/* Home hero — the marquee (desktop, 2026-10-10) + phone-video controller (phones, 2026-10-08) + reveal-on-scroll, one file (Claude, 2026-10-07; the name carries the content hash; source of truth: website/home-hero-v2/{copy-motion.js,controller-side.js,reveal.js}). */ /* Generated by remotion/build-copy-motion.mjs. Source: remotion/copy-motion.mjs. * Opacity and transform only; sampled from Remotion, driven by the painted demo time. */ (()=>{'use strict';const curves={"entrance":{"start":0,"end":500,"keys":["titleOpacity","titleY","bodyOpacity","bodyY"],"rows":[[0,16,0,10],[0.0744917,14.8081332,0,10],[0.1462608,13.659828,0,10],[0.2150335,12.5594642,0,10],[0.2805623,11.5110032,0,10],[0.3426374,10.5178018,0,10],[0.4010969,9.5824497,0,10],[0.4558342,8.7066523,0,10],[0.5068019,7.8911703,0,10],[0.554011,7.1358238,0,10],[0.5975277,6.4395561,0,10],[0.6374659,5.8005461,0,10],[0.6739781,5.2163508,0,10],[0.7072461,4.6840628,0,10],[0.737471,4.2004638,0,10],[0.7648647,3.7621645,0,10],[0.7896423,3.3657231,0,10],[0.8120163,3.0077398,0,10],[0.8321921,2.6849271,0,10],[0.8503651,2.3941586,0.0744917,9.2550833],[0.8667187,2.1325002,0.1462608,8.5373925],[0.8814234,1.8972263,0.2150335,7.8496651],[0.8946359,1.6858257,0.2805623,7.194377],[0.9065001,1.4959988,0.3426374,6.5736261],[0.9171469,1.3256503,0.4010969,5.989031],[0.9266951,1.1728777,0.4558342,5.4416577],[0.9352526,1.0359586,0.5068019,4.9319814],[0.9429165,0.9133363,0.554011,4.4598899],[0.9497746,0.8036061,0.5975277,4.0247226],[0.9559062,0.7055011,0.6374659,3.6253413],[0.9613825,0.6178792,0.6739781,3.2602193],[0.966268,0.5397112,0.7072461,2.9275393],[0.9706207,0.4700691,0.737471,2.6252899],[0.9744928,0.4081157,0.7648647,2.3513528],[0.9779315,0.3530959,0.7896423,2.1035769],[0.9809795,0.3043274,0.8120163,1.8798374],[0.9836754,0.2611938,0.8321921,1.6780794],[0.9860539,0.2231374,0.8503651,1.4963491],[0.9881467,0.1896533,0.8667187,1.3328126],[0.9899823,0.1602837,0.8814234,1.1857665],[0.9915866,0.1346137,0.8946359,1.0536411],[0.9929834,0.1122662,0.9065001,0.9349993],[0.9941938,0.0928985,0.9171469,0.8285314],[0.9952376,0.0761988,0.9266951,0.7330485],[0.9961323,0.0618831,0.9352526,0.6474741],[0.9968942,0.0496925,0.9429165,0.5708352],[0.9975381,0.0393908,0.9497746,0.5022538],[0.9980774,0.0307621,0.9559062,0.4409382],[0.9985244,0.0236092,0.9613825,0.3861745],[0.9988905,0.0177517,0.966268,0.3373195],[0.999186,0.0130242,0.9706207,0.2937932],[0.9994203,0.0092753,0.9744928,0.2550723],[0.9996021,0.0063662,0.9779315,0.2206849],[0.9997394,0.0041693,0.9809795,0.1902046],[0.9998395,0.0025676,0.9836754,0.1632461],[0.9999092,0.0014535,0.9860539,0.1394609],[0.9999545,0.0007283,0.9881467,0.1185333],[0.9999812,0.0003007,0.9899823,0.1001773],[0.9999945,0.0000873,0.9915866,0.0841336],[0.9999993,0.0000107,0.9929834,0.0701664],[1,0,0.9941938,0.0580616],[1,0,0.9952376,0.0476242],[1,0,0.9961323,0.0386769],[1,0,0.9968942,0.0310578],[1,0,0.9975381,0.0246192],[1,0,0.9980774,0.0192263],[1,0,0.9985244,0.0147558],[1,0,0.9988905,0.0110948],[1,0,0.999186,0.0081401],[1,0,0.9994203,0.0057971],[1,0,0.9996021,0.0039789],[1,0,0.9997394,0.0026058],[1,0,0.9998395,0.0016047],[1,0,0.9999092,0.0009085],[1,0,0.9999545,0.0004552],[1,0,0.9999812,0.000188],[1,0,0.9999945,0.0000545],[1,0,0.9999993,0.0000067],[1,0,1,0],[1,0,1,0],[1,0,1,0],[1,0,1,0],[1,0,1,0],[1,0,1,0],[1,0,1,0],[1,0,1,0],[1,0,1,0],[1,0,1,0],[1,0,1,0],[1,0,1,0],[1,0,1,0],[1,0,1,0],[1,0,1,0],[1,0,1,0],[1,0,1,0],[1,0,1,0],[1,0,1,0],[1,0,1,0],[1,0,1,0],[1,0,1,0],[1,0,1,0]]},"pair":{"start":-300,"end":500,"keys":["expansion","connectorOpacity","connectorY","alternativeOpacity","alternativeY"],"rows":[[0,0,7,0,14],[0.0000477,0,7,0,14],[0.0001925,0,7,0,14],[0.0004373,0,7,0,14],[0.000785,0,7,0,14],[0.0012384,0,7,0,14],[0.0018008,0,7,0,14],[0.0024755,0,7,0,14],[0.0032657,0,7,0,14],[0.004175,0,7,0,14],[0.005207,0,7,0,14],[0.0063656,0,7,0,14],[0.0076546,0,7,0,14],[0.0090782,0,7,0,14],[0.0106408,0,7,0,14],[0.0123466,0,7,0,14],[0.0142003,0,7,0,14],[0.0162068,0,7,0,14],[0.0183711,0,7,0,14],[0.0206982,0,7,0,14],[0.0231937,0,7,0,14],[0.025863,0,7,0,14],[0.0287121,0,7,0,14],[0.0317468,0,7,0,14],[0.0349735,0,7,0,14],[0.0383985,0,7,0,14],[0.0420284,0,7,0,14],[0.0458702,0,7,0,14],[0.0499308,0,7,0,14],[0.0542174,0,7,0,14],[0.0587375,0,7,0,14],[0.0634985,0,7,0,14],[0.068508,0,7,0,14],[0.0737739,0,7,0,14],[0.0793038,0,7,0,14],[0.0851055,0,7,0,14],[0.0911867,0,7,0,14],[0.0975551,0,7,0,14],[0.1042178,0,7,0,14],[0.1111821,0,7,0,14],[0.1184544,0,7,0,14],[0.1260408,0,7,0,14],[0.1339468,0,7,0,14],[0.1421767,0,7,0,14],[0.1507342,0,7,0,14],[0.1596215,0,7,0,14],[0.1688395,0,7,0,14],[0.1783875,0,7,0,14],[0.1882632,0,7,0,14],[0.1984621,0,7,0,14],[0.2089775,0,7,0,14],[0.2198007,0,7,0,14],[0.2309205,0,7,0,14],[0.242323,0,7,0,14],[0.2539922,0,7,0,14],[0.2659093,0,7,0,14],[0.2780535,0,7,0,14],[0.2904018,0,7,0,14],[0.3029292,0,7,0,14],[0.3156093,0,7,0,14],[0.3284145,0,7,0,14],[0.3413164,0,7,0,14],[0.3542864,0,7,0,14],[0.3672957,0,7,0,14],[0.3803163,0,7,0,14],[0.393321,0,7,0,14],[0.4062836,0,7,0,14],[0.4191798,0,7,0,14],[0.4319868,0,7,0,14],[0.4446838,0,7,0,14],[0.4572517,0,7,0,14],[0.469674,0.0499496,6.6503529,0,14],[0.4819357,0.0987311,6.3088821,0,14],[0.4940242,0.1462608,5.9761747,0,14],[0.5059286,0.1924581,5.6527931,0,14],[0.5176399,0.2372481,5.3392633,0,14],[0.5291507,0.2805623,5.0360639,0,14],[0.5404554,0.3223405,4.7436164,0,14],[0.5515496,0.3625321,4.4622755,0,14],[0.5624301,0.4010969,4.1923217,0,14],[0.5730952,0.4380062,3.9339567,0,14],[0.5835439,0.4732429,3.6872995,0,14],[0.5937761,0.5068019,3.452387,0,14],[0.6037926,0.5386892,3.2291755,0,14],[0.6135947,0.5689221,3.017545,0,14],[0.6231845,0.5975277,2.8173058,0,14],[0.6325642,0.6245421,2.6282056,0,14],[0.6417366,0.6500088,2.4499384,0,14],[0.650705,0.6739781,2.2821535,0,14],[0.6594725,0.6965051,2.1244644,0,14],[0.6680429,0.7176489,1.976458,0,14],[0.6764197,0.737471,1.8377029,0,14],[0.6846069,0.7560348,1.7077565,0,14],[0.6926083,0.773404,1.586172,0,14],[0.7004279,0.7896423,1.4725039,0,14],[0.7080696,0.8048125,1.3663126,0,14],[0.7155375,0.8189759,1.2671688,0,14],[0.7228354,0.8321921,1.1746556,0,14],[0.7299674,0.8445184,1.0883715,0,14],[0.7369372,0.8560098,1.0079316,0,14],[0.7437488,0.8667187,0.9329688,0,14],[0.7504058,0.8766951,0.8631345,0.0375684,13.474043],[0.756912,0.885986,0.7980983,0.0744917,12.9571166],[0.763271,0.8946359,0.7375488,0.1107341,12.4497224],[0.7694863,0.9026868,0.6811922,0.1462608,11.9523495],[0.7755613,0.9101782,0.6287529,0.1810379,11.4654695],[0.7814995,0.9171469,0.579972,0.2150335,10.9895312],[0.7873041,0.9236276,0.534607,0.2482175,10.5249549],[0.7929782,0.9296527,0.492431,0.2805623,10.0721278],[0.7985251,0.9352526,0.4532319,0.3120429,9.6313994],[0.8039478,0.9404555,0.4168113,0.3426374,9.2030765],[0.8092491,0.945288,0.3829842,0.3723271,8.7874204],[0.814432,0.9497746,0.3515777,0.4010969,8.3846435],[0.8194991,0.9539385,0.3224305,0.4289352,7.9949071],[0.8244533,0.9578011,0.2953921,0.4558342,7.6183207],[0.8292971,0.9613825,0.2703222,0.4817899,7.2549413],[0.834033,0.9647015,0.2470895,0.5068019,6.904774],[0.8386636,0.9677754,0.2255719,0.5308733,6.5677734],[0.8431912,0.9706207,0.2056552,0.554011,6.2438458],[0.8476181,0.9732525,0.1872328,0.5762248,5.9328523],[0.8519465,0.975685,0.1702052,0.5975277,5.6346116],[0.8561787,0.9779315,0.1544795,0.6179354,5.3489046],[0.8603168,0.9800045,0.1399686,0.6374659,5.0754778],[0.8643628,0.9819155,0.1265916,0.6561394,4.8140481],[0.8683187,0.9836754,0.1142723,0.6739781,4.564307],[0.8721865,0.9852943,0.1029398,0.6910054,4.3259248],[0.8759679,0.9867818,0.0925275,0.7072461,4.098555],[0.8796649,0.9881467,0.0829733,0.7227258,3.8818382],[0.8832792,0.9893973,0.0742187,0.737471,3.6754058],[0.8868124,0.9905415,0.0662092,0.7515083,3.4788835],[0.8902664,0.9915866,0.0588935,0.7648647,3.2918939],[0.8936426,0.9925395,0.0522235,0.7775671,3.1140602],[0.8969427,0.9934065,0.0461542,0.7896423,2.9450077],[0.9001682,0.9941938,0.0406431,0.8011167,2.7843664],[0.9033205,0.9949071,0.0356506,0.8120163,2.6317724],[0.9064011,0.9955515,0.0311392,0.8223665,2.4868697],[0.9094113,0.9961323,0.0270739,0.8321921,2.3493112],[0.9123526,0.9966541,0.0234215,0.8415172,2.2187598],[0.9152263,0.9971213,0.0201512,0.8503651,2.0948887],[0.9180336,0.9975381,0.0172335,0.8587584,1.9773827],[0.9207758,0.9979084,0.0146409,0.8667187,1.8659376],[0.9234541,0.9982361,0.0123476,0.8742671,1.7602613],[0.9260696,0.9985244,0.010329,0.8814234,1.660073],[0.9286236,0.9987768,0.0085622,0.8882068,1.5651042],[0.9311171,0.9989964,0.0070254,0.8946359,1.4750975],[0.9335512,0.999186,0.0056981,0.9007281,1.3898073],[0.935927,0.9993484,0.004561,0.9065001,1.308999],[0.9382455,0.9994863,0.0035957,0.9119679,1.2324489],[0.9405077,0.9996021,0.0027852,0.9171469,1.159944],[0.9427146,0.9996981,0.0021131,0.9220513,1.0912813],[0.9448671,0.9997766,0.0015639,0.9266951,1.026268],[0.9469662,0.9998395,0.0011233,0.9310914,0.9647203],[0.9490127,0.9998889,0.0007774,0.9352526,0.9064637],[0.9510076,0.9999267,0.0005133,0.9391905,0.8513326],[0.9529517,0.9999545,0.0003186,0.9429165,0.7991693],[0.9548458,0.999974,0.0001818,0.9464411,0.7498242],[0.9566907,0.9999869,0.0000918,0.9497746,0.7031553],[0.9584873,0.9999945,0.0000382,0.9529266,0.6590278],[0.9602363,0.9999984,0.0000112,0.9559062,0.6173134],[0.9619385,0.9999998,0.0000014,0.9587221,0.5778908],[0.9635947,1,0,0.9613825,0.5406443],[0.9652054,1,0,0.9638954,0.5054645],[0.9667715,1,0,0.966268,0.4722473],[0.9682936,1,0,0.9685076,0.4408939],[0.9697724,1,0,0.9706207,0.4113104],[0.9712086,1,0,0.9726137,0.3834078],[0.9726027,1,0,0.9744928,0.3571013],[0.9739555,1,0,0.9762635,0.3323105],[0.9752675,1,0,0.9779315,0.3089589],[0.9765394,1,0,0.9795019,0.2869739],[0.9777716,1,0,0.9809795,0.2662865],[0.9789648,1,0,0.9823692,0.2468309],[0.9801196,1,0,0.9836754,0.2285446],[0.9812365,1,0,0.9849023,0.2113682],[0.982316,1,0,0.9860539,0.1952452],[0.9833586,1,0,0.9871342,0.1801218],[0.9843649,1,0,0.9881467,0.1659466],[0.9853354,1,0,0.9890949,0.1526709],[0.9862705,1,0,0.9899823,0.1402483],[0.9871707,1,0,0.9908118,0.1286344],[0.9880366,1,0,0.9915866,0.117787],[0.9888685,1,0,0.9923096,0.107666],[0.9896669,1,0,0.9929834,0.0982329],[0.9904322,1,0,0.9936106,0.0894513],[0.991165,1,0,0.9941938,0.0812862],[0.9918655,1,0,0.9947354,0.0737043],[0.9925343,1,0,0.9952376,0.0666739],[0.9931717,1,0,0.9957025,0.0601647],[0.9937781,1,0,0.9961323,0.0541477],[0.9943539,1,0,0.9965289,0.0485953],[0.9948996,1,0,0.9968942,0.043481],[0.9954154,1,0,0.99723,0.0387796],[0.9959017,1,0,0.9975381,0.0344669],[0.996359,1,0,0.99782,0.0305201],[0.9967875,1,0,0.9980774,0.0269169],[0.9971876,1,0,0.9983117,0.0236363],[0.9975597,1,0,0.9985244,0.0206581],[0.9979041,1,0,0.9987169,0.0179631],[0.998221,1,0,0.9988905,0.0155327],[0.9985109,1,0,0.9990465,0.0133494],[0.9987741,1,0,0.999186,0.0113962],[0.9990108,1,0,0.9993102,0.0096568],[0.9992214,1,0,0.9994203,0.0081159],[0.9994061,1,0,0.9995172,0.0067585],[0.9995653,1,0,0.9996021,0.0055704],[0.9996993,1,0,0.9996759,0.004538],[0.9998082,1,0,0.9997394,0.0036481],[0.9998925,1,0,0.9997937,0.0028883],[0.9999524,1,0,0.9998395,0.0022466],[0.9999881,1,0,0.9998778,0.0017115],[1,1,0,0.9999092,0.0012719],[1,1,0,0.9999345,0.0009172],[1,1,0,0.9999545,0.0006372],[1,1,0,0.9999698,0.0004223],[1,1,0,0.9999812,0.0002631],[1,1,0,0.9999892,0.0001507],[1,1,0,0.9999945,0.0000763],[1,1,0,0.9999977,0.0000319],[1,1,0,0.9999993,0.0000093],[1,1,0,0.9999999,0.0000012],[1,1,0,1,0],[1,1,0,1,0],[1,1,0,1,0],[1,1,0,1,0],[1,1,0,1,0],[1,1,0,1,0],[1,1,0,1,0],[1,1,0,1,0],[1,1,0,1,0],[1,1,0,1,0],[1,1,0,1,0],[1,1,0,1,0],[1,1,0,1,0],[1,1,0,1,0],[1,1,0,1,0],[1,1,0,1,0],[1,1,0,1,0],[1,1,0,1,0],[1,1,0,1,0],[1,1,0,1,0],[1,1,0,1,0]]},"exit":{"start":0,"end":1,"keys":["opacity"],"rows":[[1],[0.9997752],[0.9991062],[0.9980004],[0.9964654],[0.9945087],[0.9921376],[0.9893591],[0.9861805],[0.9826087],[0.9786505],[0.9743128],[0.9696023],[0.9645257],[0.9590894],[0.9533001],[0.947164],[0.9406876],[0.9338772],[0.926739],[0.9192793],[0.9115041],[0.9034197],[0.8950321],[0.8863473],[0.8773715],[0.8681105],[0.8585704],[0.8487572],[0.8386768],[0.8283352],[0.8177383],[0.8068922],[0.7958029],[0.7844762],[0.7729184],[0.7611353],[0.7491331],[0.736918],[0.7244961],[0.7118737],[0.699057],[0.6860525],[0.6728665],[0.6595056],[0.6459765],[0.6322857],[0.6184403],[0.6044471],[0.5903133],[0.5760461],[0.5616529],[0.5471412],[0.5325189],[0.5177939],[0.5029745],[0.488069],[0.4730861],[0.4580348],[0.4429245],[0.4277646],[0.4125651],[0.3973365],[0.3820894],[0.3668351],[0.3515854],[0.3363524],[0.3211491],[0.3059889],[0.2908861],[0.2758557],[0.2609134],[0.246076],[0.2313613],[0.2167883],[0.2023771],[0.1881493],[0.174128],[0.1603379],[0.146806],[0.1335612],[0.1206347],[0.1080608],[0.0958768],[0.0841236],[0.072846],[0.0620939],[0.0519224],[0.0423933],[0.0335755],[0.0255472],[0.0183971],[0.0122267],[0.0071529],[0.0033121],[0.0008643],[0]]}}; const sample=(name,value)=>{const curve=curves[name],position=Math.max(0,Math.min(1,(value-curve.start)/(curve.end-curve.start)))*(curve.rows.length-1),i=Math.floor(position),j=Math.min(i+1,curve.rows.length-1),p=position-i;return Object.fromEntries(curve.keys.map((key,k)=>[key,curve.rows[i][k]+(curve.rows[j][k]-curve.rows[i][k])*p]));}; window.helpAliveCopyMotion={entrance:ms=>sample('entrance',ms),pair:ms=>sample('pair',ms),exit:p=>sample('exit',p).opacity};})(); /* Home hero controller (Claude) — desktop: Codex's approved wide marquee (2026-10-10); phones: the still video layout. * Desktop (≥ 992 × 600 px): the page's own hero (title, subtitle, buttons) on top; under it the demo, centred, at 72 % * of its final size. The first scroll grows it to 100 %. What grows is the embed's poster — the parked frame as an * image (poster v5 since 2026-10-11: the question being typed, 2.1 s, the brushes at their resting size; v4 was the * empty box at 0.7 s) — never the live player: Chrome re-renders a cross-site frame at every new scale (its compositing * scale factor), and the brushes and the widget's glass panels flickered while the old version scaled the live demo * (Deniz, 2026-10-10 13:20; the earlier side-by-side layout only moved it). At the resting position the live player, * parked on that same frame, takes over in one step; it is never scaled. A scroll that stops within 400 px above the * resting position (the first time; 120 px once it has been reached) or 80 px below it settles the page there after 220 * ms of quiet — the first approach eases in over 650–800 ms (quintic), later ones over 420–760 ms (cubic). Wheel, * touch, pointer or a scroll key cancels it; held input is never fought. Copy first (since 2026-10-11): the * introduction ("Meet HelpAlive, an AI agent in your product that can:"), the scene heading and the five rails are on * the page from the start (they used to fade in at rest, after a reading pause). Play where seen (since 2026-10-11): * one gate owns playback; the demo plays wherever it is at full size and at least half of it on screen, once nothing * has moved for 150 ms and three frames agree, keeps playing while the page scrolls, and pauses below 40 % on screen or * in a hidden tab (it used to play only at the resting position and pause the moment anything moved). A scene change * does not pause it: the new heading fades in while it plays. Rails: 6 px rounded bars in a centred 408 px group (2 px * lines across the width until 2026-10-10 23:16), 40 px above / 30 px below click targets, progress smoothed between * the player's reports (never ahead by more than 150 ms; it never drives the story). The copy is centred * (styles-v2.css). * Ported from Codex's website/hero-preview-playback-2026-10-09/{scroll-snap,guard,title-typing}.js and * hero-marquee-2026-10-09/{marquee,rail-progress}.js onto this page's markup and the player's v1 protocol; one change * of ours: a new heading stays hidden until its reveal (the preview showed it for ~3 frames, then faded it in). * Phones (< 992 px): the 2026-10-08 still layout with the pre-rendered video and its iPhone hardening (2026-10-10); the * marquee's copy and rails without its motion (2026-10-10 evening); a page narrowed from desktop attaches the video * itself (2026-10-11). Reduced motion: desktop keeps the marquee without movement (full size, no glide, no fades; the * gate's order still holds); phones get the stacked layout. * Player protocol: ha-scene-ready / timeupdate / copytick / state / ended in; ha-scene-control play / pause / seekTime and * ha-marquee-presentation out; ha-scene-init asks a player that is already running to announce itself again. */ (function () { 'use strict'; var root = document.querySelector('[data-hc-transition]'); if (!root || root.dataset.hcInitialized === 'true') return; var track = root.querySelector('.hc-track'); var stage = root.querySelector('.hc-stage'); var intro = root.querySelector('.hc-intro'); var demo = root.querySelector('[data-hc-demo]'); var media = root.querySelector('[data-hc-demo-media]'); var explanation = root.querySelector('.hc-explanation'); var frame = media && media.querySelector('iframe'); if (!track || !stage || !intro || !demo || !media || !explanation || !frame) return; root.dataset.hcInitialized = 'true'; root.style.overflowAnchor = 'none'; // the page never moves the scroll position under the hero if (media.querySelector('.hc-scene-embed')) root.classList.add('hc-reviewed-scenes'); var DEFAULTS = { // desktop: the marquee (Codex's brief, 2026-10-10 — the numbers are the approved ones; change them only with Deniz) growFrom: 0.72, // the demo's size before the first scroll, as a fraction of its final size snapFirstAbove: 400, // the first approach from above settles from this far above the resting position (inclusive)… snapAbove: 120, // …and, once the resting position has been reached, from this far snapBelow: 80, // from below, always this far snapIdleMs: 220, // the scroll must be quiet this long before the page settles firstSnapMinMs: 650, firstSnapMaxMs: 800, firstSnapMsPerPx: 2, // the first approach from above (quintic smootherstep) snapMinMs: 420, snapMaxMs: 760, snapMsPerPx: 0.55, // every other settle (cubic ease-in-out) introMs: 190, introRise: 5, // the introduction fades in and rises this many px readMs: 1200, // the heading and the rails start this long after the introduction did… titleMs: 180, titleRise: 6, // …and take this long (later headings: this fade only) playAfterMs: 1410, // the first entrance hands playback over this long after it began titleFallbackMs: 300, // a new heading the gate cannot reveal soon (the page is not in place) shows after this long settleMs: 300, // nothing may have moved for this long before playback starts (then three stable frames) railJumpMs: 500, // a rail click seeks this far into its scene railProjectMs: 150, // the rail's progress runs at most this far ahead of the player's last report // the copy: the introduction (a native element with data-hc-copy-lead inside .hc-explanation wins over this text) // and the phrase of each heading painted in the mint → blue gradient (found in the Webflow heading's own text) copyLead: 'Meet HelpAlive, an AI agent in your product that can:', copyBrand: 'HelpAlive', copyGradient: { knows: 'what your users see', answers: 'plan', does: 'action', shows: 'step by step', asks: 'with' }, loop: true, // after the last scene the player starts over (its final frame is clear, so the cut is clean) parkMs: 2100, // desktop: the frame the player waits on before the story starts — the question being typed // (poster v5). 700 until 2026-10-11 (the empty question box; Deniz chose the livelier still). phoneParkMs: 700, // phones: the video's resting frame, the phone poster's 0.7 s frame (unchanged) // Copy first and play where seen: the 2026-10-10 15:30 previews ("the titles and the rails appear too late … visitors // think it is a static image"), the default since 2026-10-11 (Deniz: "implement all 3 of these suggestions"). // HC_CONFIG.playSeen = false and copyFirst = false, set before the footer, bring back the entrance and the strict gate. copyFirst: true, // the introduction, the heading and the rails are on the page from the start (no entrance fade, // no reading pause); the demo plays as soon as the page has settled (settle 150 ms) copyFirstSettleMs: 150, playSeen: true, // (implies copyFirst) the demo plays wherever it is at full size and at least seenStart of it is // on screen after a short stillness, keeps playing while the page scrolls, pauses below seenStop seenStart: 0.5, seenStop: 0.4, // 0.6 in the preview: a PageDown in Firefox at 1440 × 900 left 58 % on screen and waited // phones (the video; a still layout — nothing follows the scroll): phoneMaxWidth: 400, // the window (the video's 358 × 440 UI frame) never grows past this many CSS px phoneMinWidth: 230, // narrower than this, window + copy do not fit the viewport: the stacked layout instead phoneGapTop: 12, // window ↔ navbar once the visitor has scrolled the window up to it (part of the fit's budget) phoneGapCopy: 22, // window ↔ copy (the video's white 5% bleed sits in this gap; styles-v2.css uses the same 22 px) phoneGapBottom: 14, // rail ↔ the bottom of the viewport (more when Webflow's badge is showing: it sits over the last pill) phoneVisibleRatio: 0.6, // the video plays while at least this much of the window is on screen, and pauses below it phoneCoverHoldMs: 1000, // under the still of a reload or a fresh load: the heading and rails keep what they showed this long, // then follow the still (scene 1), the rail of the time it loads at pulsing (2026-10-14) phoneNetWaitMs: 30000, // a stall that waits on a network still delivering data is waited out this long (no nudge, no reload) loopFadeMs: 600 // the video's loop fades in from clear like the player's first 600 ms (the video's own last frames fade out) }; var USER_CFG = window.HC_CONFIG || {}; var CFG = window.HC_CONFIG = Object.assign({}, DEFAULTS, USER_CFG); (function () { if (CFG.playSeen) CFG.copyFirst = true; if (CFG.copyFirst && !('settleMs' in USER_CFG)) CFG.settleMs = CFG.copyFirstSettleMs; })(); var LW = Number(frame.getAttribute('data-hc-logical-width')) || 1152; var LH = Number(frame.getAttribute('data-hc-logical-height')) || 720; var reduced = window.matchMedia('(prefers-reduced-motion: reduce)'); var desktop = window.matchMedia('(min-width: 992px) and (min-height: 600px)'); var desktopWidth = window.matchMedia('(min-width: 992px)'); // narrower: a phone or a tablet in portrait — the video, never the player // The phone video lives in the same embed; its sources and poster are attached only off desktop (the embed's loader). var phoneBox = media.querySelector('[data-hc-phone]'); var video = phoneBox ? phoneBox.querySelector('video') : null; var PHONE = null; if (phoneBox && video) { try { PHONE = { scenes: JSON.parse(phoneBox.getAttribute('data-hc-scenes') || '[]'), durationMs: Number(phoneBox.getAttribute('data-hc-duration-ms')) || 0, fps: Number(phoneBox.getAttribute('data-hc-fps')) || 30, uiWidth: Number(phoneBox.getAttribute('data-hc-ui-width')) || 358, uiHeight: Number(phoneBox.getAttribute('data-hc-ui-height')) || 440, // the video's own opening reveal from white (2026-10-12: Codex's 240 ms "for graceful loop restart"); the // loop then needs no fade of ours, and a tap on rail 1 lands where the reveal ends (0 shows a white frame) revealMs: Math.max(0, Number(phoneBox.getAttribute('data-hc-reveal-ms')) || 0) }; if (!Array.isArray(PHONE.scenes) || !PHONE.scenes.length || !PHONE.durationMs) PHONE = null; } catch (_) { PHONE = null; } } // The embed attaches the phone video's still and source only when the page opens below 992 px (desktops never fetch // them). A page opened on desktop and narrowed later — a window snapped to half a laptop screen — reached the phone // layout with an empty window (Codex's note in hero-flow-proposal-2026-10-10, confirmed on staging 2026-10-11). The // controller now attaches them itself the first time the video is needed: the embed's choice, made once (the same flag). function bootPhoneMedia() { if (!phoneBox || !video || phoneBox.dataset.hcBooted === 'true') return; phoneBox.dataset.hcBooted = 'true'; var still = phoneBox.querySelector('.hc-phone-still'), poster = video.getAttribute('data-poster'); if (poster) { if (still) still.setAttribute('src', poster); else video.setAttribute('poster', poster); } var sources = video.querySelectorAll('source[data-src]'), pick = ''; Array.prototype.some.call(sources, function (s) { var t = s.getAttribute('type'); if (!t || video.canPlayType(t)) { pick = s.getAttribute('data-src'); return true; } return false; }); if (!pick && sources.length) pick = sources[sources.length - 1].getAttribute('data-src'); if (pick) video.setAttribute('src', pick); // preload="none" stays: no load() (see the embed) stats.phoneBoots = (stats.phoneBoots || 0) + 1; } var panels = Array.prototype.slice.call(explanation.querySelectorAll('.hc-scene-panel[data-hc-scene]')); var rail = explanation.querySelector('.hc-chapter-rail'); var note = explanation.querySelector('.hc-rail-note'); if (note) note.hidden = true; // ---- the copy: scenes from the player (or the video's timeline), words from the Webflow panels ------------------- // Five scenes, five headings; no subtitles (2026-10-10). The heading's gradient phrase is the CFG.copyGradient entry // for its scene id, found in the panel's own text — edit a heading in the Designer and the gradient follows the // phrase if it is still there (or disappears quietly if it is not). var copyScenes = []; // [{id, title, startMs, endMs, panel}] function panelText(panel, sel) { var el = panel.querySelector(sel); return el ? el.textContent.replace(/\s+/g, ' ').trim() : ''; } function buildCopyScenes(playerScenes) { copyScenes = playerScenes.map(function (s, i) { var panel = null; for (var k = 0; k < panels.length; k++) if (panels[k].getAttribute('data-hc-scene') === s.id) { panel = panels[k]; break; } if (!panel && panels.length === playerScenes.length) panel = panels[i]; return { id: s.id, startMs: s.startMs, endMs: s.endMs, panel: panel, title: panel ? (panelText(panel, '.hc-heading:not(.hc-heading--carried)') || panelText(panel, '.hc-heading')) : (s.title || '') }; }); } function escapeHTML(text) { return String(text).replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); } function headingHTML(scene) { var text = scene.title, phrase = CFG.copyGradient && CFG.copyGradient[scene.id]; if (phrase) { var at = -1, re = new RegExp('(^|[^\\w])(' + phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')(?![\\w])', 'i'), m = re.exec(text); if (m) at = m.index + m[1].length; if (at >= 0) return escapeHTML(text.slice(0, at)) + '' + escapeHTML(text.slice(at, at + phrase.length)) + '' + escapeHTML(text.slice(at + phrase.length)); } return escapeHTML(text); } function leadHTML(text) { // "Meet HelpAlive," in ink (HelpAlive in the wordmark's Chillax), the rest in grey var comma = text.indexOf(','), brand = CFG.copyBrand || ''; var opening = comma >= 0 ? text.slice(0, comma + 1) : text, rest = comma >= 0 ? text.slice(comma + 1).trim() : ''; rest = rest.replace(/ (\S+)$/, '\u00a0$1'); // the last two words stay together (no lone "can:" on a phone's second line) var b = brand ? opening.indexOf(brand) : -1; var openingHTML = b >= 0 ? escapeHTML(opening.slice(0, b)) + '' + escapeHTML(brand) + '' + escapeHTML(opening.slice(b + brand.length)) : escapeHTML(opening); return '' + openingHTML + '' + (rest ? ' ' + escapeHTML(rest) + '' : ''); } var view = null, viewMain = null, viewLead = null, viewTitle = null, segments = []; function buildCopyView() { if (view) return; view = document.createElement('div'); view.className = 'hc-copy-view'; var native = explanation.querySelector('[data-hc-copy-lead]'); var leadText = native ? native.textContent.replace(/\s+/g, ' ').trim() : String(CFG.copyLead || '').trim(); if (native) native.hidden = true; if (leadText) { viewLead = document.createElement('p'); viewLead.className = 'hc-copy-lead'; viewLead.innerHTML = leadHTML(leadText); view.appendChild(viewLead); view.classList.add('has-lead'); } viewMain = document.createElement('div'); viewMain.className = 'hc-copy-main'; viewTitle = document.createElement('h2'); viewTitle.className = 'hc-copy-title'; viewMain.appendChild(viewTitle); view.appendChild(viewMain); if (!rail) { rail = document.createElement('div'); rail.className = 'hc-chapter-rail'; rail.setAttribute('role', 'group'); rail.setAttribute('aria-label', 'Scenes'); } rail.setAttribute('aria-label', 'Demo scenes'); var wrapper = rail.parentNode && rail.parentNode.classList && rail.parentNode.classList.contains('w-embed') ? rail.parentNode : null; view.appendChild(rail); // the embed's rail moves into the view (the note stays behind, hidden) if (wrapper && !wrapper.children.length) wrapper.hidden = true; explanation.appendChild(view); ensureSegments(); } function ensureSegments() { var dot = rail.querySelector('.hc-rail-dot'); segments = Array.prototype.slice.call(rail.querySelectorAll('.hc-rail-seg')); var want = copyScenes.length || segments.length; // before the player has announced its scenes, keep the embed's buttons while (segments.length < want) { var b = document.createElement('button'); b.type = 'button'; b.className = 'hc-rail-seg'; b.dataset.state = 'todo'; b.appendChild(document.createElement('i')); rail.insertBefore(b, dot || null); segments.push(b); } while (segments.length > want) rail.removeChild(segments.pop()); segments.forEach(function (seg, i) { seg.removeAttribute('aria-pressed'); if (!seg.querySelector('i')) seg.appendChild(document.createElement('i')); seg.setAttribute('aria-label', 'Scene ' + (i + 1) + ' of ' + segments.length + (copyScenes[i] && copyScenes[i].title ? ': ' + copyScenes[i].title : '')); if (!seg.dataset.hcWired) { seg.dataset.hcWired = 'true'; seg.addEventListener('click', function (e) { e.preventDefault(); jumpTo(i); if (e.detail) seg.blur(); }); } }); } function sceneIndexAt(at) { if (!copyScenes.length) return -1; if (at >= child.durationMs) return copyScenes.length - 1; for (var i = 0; i < copyScenes.length; i++) if (at < copyScenes[i].endMs) return i; return copyScenes.length - 1; } function markScene(active) { // rails and panels follow the scene (done / current / todo) segments.forEach(function (seg, i) { var state = i < active ? 'done' : i === active ? 'current' : 'todo'; if (seg.dataset.state !== state) seg.dataset.state = state; if (i === active) { if (seg.getAttribute('aria-current') !== 'step') seg.setAttribute('aria-current', 'step'); } else if (seg.hasAttribute('aria-current')) seg.removeAttribute('aria-current'); }); panels.forEach(function (panel, i) { var on = i === active; if ((panel.getAttribute('aria-current') === 'step') !== on) { if (on) panel.setAttribute('aria-current', 'step'); else panel.removeAttribute('aria-current'); } }); var scene = copyScenes[active]; if (scene) { diagnose('hcScene', scene.id); diagnose('hcSceneIndex', active); } } // Phones: since 2026-10-10 evening the heading does not move — Deniz: "apply only the rails to the mobile form as well, none // of the animations though". It changes with the scene, whole and at once (it entered and left on Codex's Remotion copy // curves, copy-motion.js, from 2026-10-08; at the parked frame or with a video still loading, that left the introduction // without its heading). The rails' progress is the only thing that moves under the video. var phoneActive = -1; function paintCopy(at) { // phones (the time the window shows: screenMs) if (!view || !copyScenes.length || mode !== 'phone') return; var active = sceneIndexAt(at); if (active < 0) return; var scene = copyScenes[active]; if (pendingRail >= 0 && active === pendingRail && (revealed || videoBroken)) setPendingRail(-1); // the tapped scene is on screen (not the still), or nothing can be if (active !== phoneActive) { phoneActive = active; viewTitle.innerHTML = headingHTML(scene); markScene(active); } if (viewTitle.style.opacity || viewTitle.style.transform) { viewTitle.style.removeProperty('opacity'); viewTitle.style.removeProperty('transform'); } setProgress(clamp((at - scene.startMs) / Math.max(1, scene.endMs - scene.startMs))); } var lastProgress = ''; function setProgress(value) { if (!rail) return; var v = clamp(value).toFixed(4); if (v !== lastProgress) { lastProgress = v; rail.style.setProperty('--hc-scene-progress', v); } } // ---- state ------------------------------------------------------------------------------------ var frameChild = { ready: false, scenes: null, timeMs: 0, sceneId: null, playing: false, durationMs: 0, rate: 1 }; // the desktop player (iframe) var videoChild = { ready: !!PHONE, scenes: PHONE ? PHONE.scenes : null, timeMs: 0, sceneId: null, playing: false, durationMs: PHONE ? PHONE.durationMs : 0 }; // the phone video var child = frameChild; // the one the current mode drives var mode = null; // 'marquee' | 'phone' | 'stacked' | 'static' var geometry = null; var arrived = false, settled = false; // phones: always true once in the phone layout (nothing to arrive at) var measureRaf = 0, tickRaf = 0; var running = false, paused = false; // phones: the story's run state (desktop: the gate below owns playback) var inView = !('IntersectionObserver' in window), loops = 0, lastRatio = 0; var forceMeasure = false, svhProbe = null, phoneSize = null; // phones: the layout is sized once per width × small-viewport height var clock = { timeMs: 0, at: 0, playing: false }; var stats = { seeks: 0, clicks: 0, snaps: 0, plays: 0, phoneMeasures: 0, stale: 0 }; function clamp(v) { return Math.max(0, Math.min(1, v)); } function smooth(t) { t = clamp(t); return t * t * (3 - 2 * t); } function setVar(name, value) { if (root.style.getPropertyValue(name) !== String(value)) root.style.setProperty(name, value); } function style(el, name, value) { if (!el) return; value = String(value); if (el.style[name] !== value) el.style[name] = value; } function inert(el, value) { if (el && el.inert !== value) el.inert = value; } function diagnose(name, value) { value = String(value); if (root.dataset[name] !== value) root.dataset[name] = value; } // ---- child control: the desktop player (postMessage) or the phone video ---------------------------------------- function send(message) { if (!frameChild.ready) return; try { frame.contentWindow.postMessage(Object.assign({ type: 'ha-scene-control', v: 1 }, message), '*'); } catch (_) {} } function presentation(progress) { // player v13: the brushes' size relative to the window (1 → 0.64) try { frame.contentWindow.postMessage({ type: 'ha-marquee-presentation', v: 1, progress: progress }, '*'); } catch (_) {} } // desktop playback goes through the gate; these are its only ways to reach the player function playerPlay() { stats.plays++; send({ action: 'play' }); } function playerPause() { railProgress.pause(); send({ action: 'pause' }); } // A seek is answered by a report at (or just after) its time. Reports the player posted before it received the seek // (an old scene's time, an old 'ended') still arrive after it — they would flip the heading and rails back for a frame // or, near the end, restart the loop over a rail click — so until the player confirms the seek (or 1 s passes) // reports from elsewhere on the timeline are ignored. var seekGuard = null; function playerSeek(timeMs) { stats.seeks++; railProgress.reset(); timeMs = Math.max(0, timeMs); if (frameChild.ready) seekGuard = { timeMs: timeMs, until: performance.now() + 1000 }; send({ action: 'seekTime', timeMs: timeMs }); } function staleReport(d) { if (!seekGuard) return false; if (performance.now() > seekGuard.until) { seekGuard = null; return false; } if (typeof d.timeMs !== 'number' || !isFinite(d.timeMs)) return true; var at = Math.min(seekGuard.timeMs, (frameChild.durationMs || Infinity)); // a seek past the end lands on the end if (d.timeMs >= at - 1 && d.timeMs <= at + 1000) { seekGuard = null; return false; } // the seek has landed return true; } function playFrom(timeMs) { // phones stats.seeks++; if (child === videoChild) { videoPlay(timeMs); return; } if (typeof timeMs === 'number') send({ action: 'seekTime', timeMs: Math.max(0, timeMs) }); send({ action: 'play' }); } function pauseChild() { if (child === videoChild) videoPause(); else playerPause(); } function parkOf(c) { return c === videoChild ? CFG.phoneParkMs : CFG.parkMs; } // desktop 2.1 s (poster v5), phones 0.7 s (their poster) function parkChild() { if (child === videoChild) { videoPause(); videoSeek(frameMs(CFG.phoneParkMs)); return; } // a guarded seek (2026-10-10): reports from where it was playing that arrive after this are dropped, so a return to the // marquee shows the parked scene's heading at once (it showed the old scene's for a moment, then switched) playerPause(); playerSeek(CFG.parkMs); frameChild.timeMs = Math.max(0, CFG.parkMs); } function childPlaying() { return child === videoChild ? !!(video && !video.paused && !video.ended) : clock.playing; } function nowMs() { if (child === videoChild) return video ? (pendingSeek !== null ? pendingSeek : video.currentTime) * 1000 : 0; // a seek waiting for the metadata: where it is heading return clock.timeMs + (clock.playing ? performance.now() - clock.at : 0); } window.addEventListener('message', function (event) { if (event.source !== frame.contentWindow || event.origin !== 'null' || !event.data || event.data.v !== 1) return; var d = event.data; if (typeof d.type !== 'string' || d.type.indexOf('ha-scene-') !== 0) return; if (d.type === 'ha-scene-ready') { if (!Array.isArray(d.scenes) || !d.scenes.length) return; var again = frameChild.ready; // a second announcement (the handshake below): keep the state frameChild.ready = true; frameChild.scenes = d.scenes; frameChild.durationMs = d.durationMs; frame.dataset.hcReady = 'true'; diagnose('hcPlayer', 'ready'); lastBrush = -1; // tell this document the brushes' size (a fallback copy starts fresh) if (child !== frameChild) { if (!again) send({ action: 'pause' }); return; } // the phone video is driving; the player waits if (!again) { buildCopyScenes(d.scenes); ensureSegments(); playerSeek(CFG.parkMs); // the parked first frame (= the poster) until the gate plays if (mode === 'marquee') { paintMarquee(CFG.parkMs, false, 1); measureMarquee(); } } if (mode === 'marquee') { scheduleGrow(); snapper.schedule(); gate.onReady(); } return; } if (['ha-scene-timeupdate', 'ha-scene-copytick', 'ha-scene-chapterchange', 'ha-scene-state', 'ha-scene-rangeend', 'ha-scene-ended'].indexOf(d.type) === -1) return; if (staleReport(d)) { stats.stale++; return; } // posted before our seek reached the player if (typeof d.timeMs === 'number' && isFinite(d.timeMs)) frameChild.timeMs = d.timeMs; if (typeof d.playing === 'boolean') frameChild.playing = d.playing; if (typeof d.playbackRate === 'number' && d.playbackRate > 0) frameChild.rate = d.playbackRate; if (d.sceneId) frameChild.sceneId = d.sceneId; frame.dataset.hcScene = frameChild.sceneId || ''; frame.dataset.hcElapsed = String(Math.round(frameChild.timeMs)); frame.dataset.hcPlayback = frameChild.playing ? 'playing' : 'paused'; if (child !== frameChild) return; clock = { timeMs: frameChild.timeMs, at: performance.now(), playing: frameChild.playing }; if (mode !== 'marquee') return; paintMarquee(frameChild.timeMs, frameChild.playing, frameChild.rate); if (typeof d.playing === 'boolean') gate.onPlayerState(d.playing); if (d.type === 'ha-scene-ended' && CFG.loop && gate.wanted()) { // the loop: back to the start; the gate plays it again (the new heading first) loops++; diagnose('hcLoops', loops); playerSeek(0); paintMarquee(0, false, frameChild.rate); gate.arm(); } }); // The handshake: a player that announced itself before this script ran (a cached demo boots faster than the bundle // arrives) announces again when asked; a player still loading ignores this and announces by itself. try { frame.contentWindow.postMessage({ type: 'ha-scene-init', v: 1 }, '*'); } catch (_) {} function tick() { // phones: the copy follows what the window shows (screenMs) if (tickRaf) { cancelAnimationFrame(tickRaf); tickRaf = 0; } // whenever the video plays (2026-10-14: also when the story is paused — iOS can start a video by itself — so the copy // never stands still under a picture that moves) if (mode !== 'phone' || child !== videoChild || !childPlaying() || document.hidden) return; var end = child.durationMs || Infinity; videoChild.timeMs = Math.min(end, nowMs()); // the clock (where the video is, or is heading) paintCopy(Math.min(end, screenMs())); // the picture tickRaf = requestAnimationFrame(tick); } // ---- the phone video -------------------------------------------------------------------------------------- // iPhone hardening (2026-10-10; Deniz: "just before the video starts playing there is a flicker", and after the lock // screen "the demo is stopped and nothing makes it resume"): // · No seek before the video has its metadata. A currentTime set that early makes WebKit present frame 0 first (the // clear first frame of the loop's fade-in: a white window between the poster and the video) and, in WebKit's own // build, stall on the parked frame. The seek now waits for loadedmetadata. // · The poster is an image above the video (the embed's .hc-phone-still), not the video's poster attribute. It fades // out only once the video has presented a frame at the time it is meant to show (requestVideoFrameCallback's // mediaTime; where that is missing, a timeupdate a few frames later), so whatever the browser paints before that // stays under it. // · A pause we did not make (iOS: the lock screen, another app, a call) is undone as soon as the page is visible again, // with retries; a 1 Hz watchdog does the same, nudges a video whose picture stopped moving and, if that does not // help, reloads it at the same time (at most twice per page view). A refusal right after the page wakes is retried; // a refusal on a page that has been visible for a while (Low Power Mode) shows the play button, as before. var tap = null, fadeTimer = 0; var rvfc = !!(video && typeof video.requestVideoFrameCallback === 'function'); var pendingSeek = null; // a seek asked for before the metadata: applied on loadedmetadata var revealFrom = 0, revealed = false; // the still hides once a frame at ≥ revealFrom (s) has been presented var shownMedia = -1, shownAt = 0, everRevealed = false; // the frame on screen (its mediaTime, s) and when it came; the still has lifted once var coverPlaySince = 0; // the still covers a video that presents frames short of revealFrom: since when var pendingRail = -1, pendingTimer = 0; // a tapped rail whose scene is not on screen yet (it pulses); desktop: its time limit var coverAt = 0, coverHold = null; // the still went back over the video (a reload, a fresh load): when, and the time the copy holds var videoBroken = false; // the video cannot show anything (errors past the reload budget, or a tap given up on before any frame): the copy answers taps by itself var lastDataAt = -1e9, lastBuffered = 0; // when media data last arrived ('progress', or the buffer grew), the buffer's length then (s) var frameCbPending = false, frameCount = 0, frameCbWorks = false; var frameCbId = 0, frameCbArmedAt = 0, frameGen = 0, frameRearms = 0; // the pending frame callback: its id, when it was asked for, its generation; re-arms so far var ownPause = false; // the next 'pause' event is ours (sync, the observer, a park) var kickTimer = 0, kickTries = 0, interruptions = [], visibleSince = -1e9, userStarted = false; // visibleSince: set when the page comes back var blocked = false; // the play button is up because playback was refused: no automatic retries until a tap or a wake var dog = { frames: -1, time: -1, since: 0, nudgedAt: -1e9, ended: 0, rescues: 0, lostAt: 0, rearms: 0, lastTick: 0, slowSince: 0 }, reloads = 0; // Back from a long absence (2026-10-11; Deniz and Ege: on iPhone, after leaving Safari for a while, a rail tap did not // move the video; nothing in the container reproduces it). Whatever iOS left behind — a seek it ignores, reverts or never // finishes, a media error, a picture that does not come back, a frame callback that never fires — is now met by: a rail // tap followed until its scene is on screen (watchJump), a seek that never finishes counted as a stall (the watchdog used // to wait on one for good), a media error reloaded at once, the frame callback asked for again (armFrames), and, right // after a long absence (on the wall clock, or a gap in the watchdog's own ticks), a short fuse and a fresh reload budget. var reloadCap = 2; // reloads allowed (the cap grows by 2 after each long absence) var seekingSince = 0; // when the video's current seek began (0: none) var hiddenAt = 0, fastUntil = 0; // when the page was hidden (Date.now(): it runs on while the phone sleeps); the watchdog's short fuse runs until var hide = null, refreshes = 0, ownLoadAt = -1e9, emptiedAt = -1e9; // the video's time when the page was last hidden; fresh loads so far; our last load() var jump = null, jumpTimer = 0, jumpReloads = 0; // the last rail tap on a phone, until its scene is on screen var trace = []; // the last few video events, for ?hcdebug function vlog(what) { trace.push(((performance.now() / 1000).toFixed(1)) + ' ' + what); if (trace.length > 12) trace.shift(); } function frameMs(ms) { // the middle of the first video frame at or after ms (scene starts fall between frames) var fps = PHONE ? PHONE.fps : 30; return (Math.ceil(ms * fps / 1000 - 1e-6) + 0.5) * 1000 / fps; } function videoSeek(ms) { if (!video) return; var t = Math.max(0, Math.min(ms, (videoChild.durationMs || ms + 1) - 1)) / 1000; videoChild.timeMs = t * 1000; if (!revealed) revealFrom = Math.max(0, t - 0.05); if (video.readyState < 1) { pendingSeek = t; return; } // no metadata yet: applied on loadedmetadata (see above) pendingSeek = null; if (Math.abs(video.currentTime - t) < 0.004) return; try { video.currentTime = t; } catch (_) {} } // A deferred seek is applied once the video knows its metadata: on loadedmetadata, and (2026-10-11) on the events after it // too, for a video whose readyState fell back without a new loadedmetadata (a media element iOS restored after an absence). function applyPendingSeek() { if (pendingSeek === null || !video || video.readyState < 1) return; var t = pendingSeek; pendingSeek = null; try { video.currentTime = t; } catch (_) {} } function warmVideo() { if (video && video.getAttribute('preload') !== 'auto') video.setAttribute('preload', 'auto'); } function playVideo() { var promise = null; try { promise = video.play(); } catch (_) {} armFrames(); if (!promise || typeof promise.then !== 'function') return; promise.then(hideTap, function (err) { if (!err || err.name !== 'NotAllowedError') return; // AbortError & co: a pause, a seek or a load overtook it vlog('refused'); // Refused while hidden, or right after the page came back (iOS hands playback back a moment after the page is // visible): the retries get another go. Refused on a page that has been visible a while (Low Power Mode): one tap. if (document.hidden || performance.now() - visibleSince < 1500) return; showTap('blocked'); }); } function videoPlay(ms) { if (!video) return; if (typeof ms === 'number') videoSeek(frameMs(ms)); warmVideo(); playVideo(); } function videoPause() { if (video && !video.paused) { ownPause = true; video.pause(); } } // The frame callback: one at a time; a fresh one replaces the pending one, whose callback is then ignored (2026-10-11). // iOS can drop a pending callback with the media player it belonged to (after an absence WebKit may build the player // again), and WebKit starts following frames again only when a callback is asked for with none pending — so a fresh one // cancels the old one first. Asked for fresh: on waking, at a reload, when playing restarts after a while, and when the // clock runs but no frame comes (the watchdog, a rail tap). function armFrames(fresh) { // follow the presented frames (the reveal, the watchdog, a rail tap's landing) if (!rvfc || (frameCbPending && !fresh)) return; if (frameCbPending) { try { video.cancelVideoFrameCallback(frameCbId); } catch (_) {} } var gen = ++frameGen; frameCbPending = true; frameCbArmedAt = performance.now(); try { frameCbId = video.requestVideoFrameCallback(function (now, meta) { if (gen === frameGen) onFrame(now, meta); }); } catch (_) { frameCbPending = false; rvfc = false; frameCbWorks = false; } } function rearmFrames(why) { frameRearms++; armFrames(true); vlog('frames re-armed (' + why + ')'); diagnose('hcFrameRearm', frameRearms); } function onFrame(now, meta) { frameCbPending = false; frameCount++; if (!frameCbWorks) { frameCbWorks = true; diagnose('hcFrames', 'on'); } var media = meta && typeof meta.mediaTime === 'number' ? meta.mediaTime : null; if (media !== null) { shownMedia = media; shownAt = performance.now(); } if (videoBroken && media !== null && revealed && !video.paused) { videoBroken = false; diagnose('hcVideoBroken', 'no'); } // frames again if (!revealed && media !== null && media >= revealFrom) reveal(); else if (!revealed && media !== null && !video.paused && !jump) { // The still has covered a video that plays an earlier time than it was sent to (a seek iOS did not carry out) for // 2.5 s: lift it — the heading and rails follow what plays (see screenMs), so the two agree, and nothing waits on a // poster for good (2026-10-14). if (!coverPlaySince) coverPlaySince = performance.now(); else if (performance.now() - coverPlaySince > 2500) { vlog('still lifted (plays ' + media.toFixed(2) + ', sent to ' + revealFrom.toFixed(2) + ')'); reveal(); } } if (jump && !jump.framed && media !== null && media >= jump.t - 0.06 && media < jump.t + 10) { jump.framed = true; vlog('frame ' + media.toFixed(2)); } if (!video.paused || !revealed) armFrames(); if (mode === 'phone' && child === videoChild && !childPlaying()) paintCopy(screenMs()); // a paused seek has landed: the copy follows it } // What the window shows (2026-10-14; Ege's iPhone, on first load: the rails "disconnected" from the demo). The heading and // rails follow the picture, not the clock: while a frame is on its way — a rail tap's seek waiting on the network, a file // still loading — currentTime already reads the new time, but the window still shows the old frame (or the still), so the // copy stays with the frame on screen until the new one is presented. Under the still (before the first frame, or a reload or // fresh load that takes more than a second): the poster's moment, scene 1, with the rail of the time it loads at pulsing; // a quicker reload keeps what the copy showed. Where frames are not reported (no requestVideoFrameCallback, or none for // 400 ms): the clock, as before. function screenMs() { if (!video) return 0; var clockMs = nowMs(); if (videoBroken) return clockMs; // nothing to follow: the copy is the rails' own (a tap shows its scene's heading, as before) if (!revealed) { if (everRevealed && coverHold !== null && performance.now() - coverAt < CFG.phoneCoverHoldMs) return coverHold; // a quick reload changes nothing return frameMs(CFG.phoneParkMs); // the still: the poster's moment (scene 1); a rail tapped meanwhile pulses } if (!rvfc || !frameCbWorks || shownMedia < 0) return clockMs; if (video.seeking || pendingSeek !== null) return shownMedia * 1000; var age = performance.now() - shownAt; // no frame reported for 400 ms: frames not reported while the clock runs on (the clock), or a stall (the frame on screen; // at a scene's first frame the clock can read a few ms short of it, 2026-10-14's S24) if (age > 400) return Math.abs(clockMs - shownMedia * 1000) > 250 ? clockMs : shownMedia * 1000; return shownMedia * 1000 + (childPlaying() ? Math.min(age, 100) : 0); } // A video that cannot show anything (2026-10-14): media errors past the reload budget, or a rail tap given up on before the // first frame. The still stays; the heading and rails answer taps by themselves (as they did before the copy followed the // picture), until a frame does come. function markBroken(why) { if (videoBroken) return; videoBroken = true; vlog('no picture (' + why + '): the rails answer by themselves'); diagnose('hcVideoBroken', why); if (mode === 'phone') paintCopy(screenMs()); } function setPendingRail(i) { // the tapped rail pulses until its scene is on screen (styles-v2.css) if (i === pendingRail) return; if (pendingRail >= 0 && segments[pendingRail]) segments[pendingRail].removeAttribute('data-pending'); pendingRail = i; if (i >= 0 && segments[i]) segments[i].setAttribute('data-pending', ''); diagnose('hcRailPending', i >= 0 ? i + 1 : '-'); } function reveal() { // the video shows the right frame: the still above it fades out if (revealed) return; revealed = true; everRevealed = true; coverPlaySince = 0; coverHold = null; vlog('frame ' + video.currentTime.toFixed(2)); if (videoBroken) { videoBroken = false; diagnose('hcVideoBroken', 'no'); } requestAnimationFrame(function () { requestAnimationFrame(function () { if (revealed && phoneBox) phoneBox.setAttribute('data-hc-frame', 'on'); diagnose('hcVideoFrame', 'on'); }); }); } function cover(holdMs) { // the still over the video again; the copy holds holdMs for a moment (screenMs) if (typeof holdMs === 'number') { coverAt = performance.now(); coverHold = holdMs; } else if (revealed) { coverAt = performance.now(); coverHold = null; } revealed = false; shownMedia = -1; coverPlaySince = 0; if (phoneBox) phoneBox.removeAttribute('data-hc-frame'); diagnose('hcVideoFrame', 'off'); } // A slow network (2026-10-14, the first-load sweep): data that still arrives — 'progress', or a buffer that grew since the // last look — means the download is alive, however slow. A video that stops while it is alive and says it lacks data // (readyState under HAVE_FUTURE_DATA) is waiting for data — WebKit and iOS wait until they expect to play on without // stalling again, also with seconds already buffered: it is waited out (no nudge: a seek restarts that wait), and a rail // tap's seek that waits on it is not retried. A seek in place or a reload would start the download over, and a reload's // still covers the window meanwhile. A picture that stops with data enough to play (readyState 3–4) is still a stall (a // frozen picture), and so is our own seek in place that does not finish (its data was there). function noteData() { var b = 0; try { var r = video.buffered; for (var i = 0; i < r.length; i++) b += r.end(i) - r.start(i); } catch (_) {} if (b > lastBuffered + 0.02) lastDataAt = performance.now(); lastBuffered = b; } function delivering() { return performance.now() - lastDataAt < 4000; } // data arrived in the last 4 s // (Not in the first 12 s after a long absence, fastUntil: there the 2026-10-11 short fuse acts first — iOS after an absence.) function waitsOnNetwork() { return performance.now() >= fastUntil && delivering() && video.readyState < 3; } // stopped short of data while data still arrives function wantVideo() { // the video should be playing now if (!video || document.hidden || child !== videoChild) return false; if (mode === 'phone') return running && !paused; if (mode === 'stacked' || mode === 'static') return userStarted; // the stacked/static layouts: a video the visitor started keeps playing return false; } function kickSoon(ms) { clearTimeout(kickTimer); kickTimer = setTimeout(kick, ms || 0); } function kick() { // play again, with a few retries spaced out clearTimeout(kickTimer); kickTimer = 0; if (!wantVideo() || !video.paused || video.ended || blocked) { kickTries = 0; return; } kickTries++; vlog('resume #' + kickTries); diagnose('hcVideoKick', kickTries); playVideo(); if (kickTries < 6) kickSoon([400, 900, 1600, 2600, 4000][kickTries - 1]); else showTap('blocked'); // still paused after six tries: one tap plays } function interrupted() { // a pause we did not make: the system's var t = performance.now(); interruptions = interruptions.filter(function (x) { return t - x < 10000; }); interruptions.push(t); diagnose('hcVideoInterrupted', interruptions.length); if (interruptions.length > 6) { showTap('interrupted'); return; } // something keeps stopping it: leave it to a tap if (!document.hidden) kickSoon(250); // hidden: onWake resumes it when the page is back } function longWake(away, how) { // back after ≥ 20 s: a short fuse for a picture that does not come back, 2 more reloads fastUntil = performance.now() + 12000; reloadCap = reloads + 2; dog.since = 0; dog.nudgedAt = -1e9; dog.lostAt = 0; dog.rearms = 0; if (how) vlog('away ' + Math.round(away / 1000) + ' s (' + how + ')'); diagnose('hcLongWake', Math.round(away / 1000)); } function noteHidden() { // the page goes away (another app or tab, the lock screen, another page): the video's time then if (!hiddenAt) hiddenAt = Date.now(); if (!hide || hide.checked) hide = { t: video ? (pendingSeek !== null ? pendingSeek : video.currentTime) : -1, checked: false, done: false }; } // The page is back. From the back/forward cache, after 20 s or more away, or with the video's time moved while the page was // away (iOS rebuilt or reset the element), what the video shows may be a frame from before: 2026-10-11 evening, Deniz's // screenshot, back from the Demos page — scene 4's picture under scene 1's heading and rails, which follow the video's clock // (reset to 0.5 s). The video is then loaded afresh at the time it had, under the still, which lifts only once a frame from // that time has been shown; heading and rails show that time at once. A short absence with nothing moved changes nothing. function onWake(restored) { // the page is visible again (unlock, back from another app or tab, bfcache) if (document.hidden) return; var now = performance.now(), away = hiddenAt ? Date.now() - hiddenAt : 0; hiddenAt = 0; visibleSince = now; blocked = false; vlog('visible' + (away >= 1000 ? ' after ' + Math.round(away / 1000) + ' s' : '') + (restored === true ? ' (bfcache)' : '')); if (away >= 20000) longWake(away); var why = ''; if (hide && !hide.done && video && child === videoChild && hide.t >= 0) { why = restored === true ? 'bfcache' : hide.checked ? '' : away >= 20000 ? 'away' : Math.abs(video.currentTime - hide.t) > 1 ? 'moved' : ''; hide.checked = true; } if (why) refreshVideo(hide.t, why); else if (video && rvfc && frameCbPending) armFrames(true); // a callback asked for before the absence may never come (see armFrames) sync(); tick(); if (!wantVideo()) return; if (video.error && reloads < reloadCap) reloadVideo(jump ? jump.t : undefined); // the file failed while the page was away else if (video.paused && !video.ended) kickSoon(0); } function watchdog() { // Timers do not run while iOS holds the page suspended: a gap of 20 s between two ticks is an absence, also when the // page's own events did not report one (the phone slept; the app was left before 'visibilitychange' could run). var wall = Date.now(), gap = dog.lastTick ? wall - dog.lastTick : 0; dog.lastTick = wall; if (gap >= 20000 && !document.hidden && performance.now() >= fastUntil) { longWake(gap, 'timers'); if (video && rvfc && frameCbPending) armFrames(true); } if (video) noteData(); if (!wantVideo()) { dog.since = 0; dog.ended = 0; dog.slowSince = 0; return; } var clockMoved = video.currentTime !== dog.time, framesMoved = frameCount !== dog.frames; var moved = frameCbWorks ? framesMoved : clockMoved; dog.frames = frameCount; dog.time = video.currentTime; // At the end while it should play, and no loop came: loop it now (on the next tick, so the controller's own loop goes first). // In WebKit on staging (2026-10-10 evening) the video sometimes stayed on its last frame: either 'ended' never arrived, or // the playhead stopped at the end without it (the stall wait below would then hold the loop for 15 s). var atEnd = video.ended || (!moved && !video.seeking && video.duration > 0 && video.currentTime >= video.duration - 0.1); if (atEnd) { if (mode === 'phone' && CFG.loop) { if (dog.ended) { dog.ended = 0; dog.since = 0; dog.rescues++; vlog('loop (watchdog)'); diagnose('hcLoopRescue', dog.rescues); loopVideo(); } else dog.ended = 1; } return; } dog.ended = 0; if (video.paused) { if (!kickTimer && !blocked) kick(); return; } var t = performance.now(); // No frame reported while the clock runs (2026-10-11): the frame callback may be what was lost (see armFrames), so it is // asked for again — twice in a spell, without holding up the stall handling below (a picture that did freeze still gets // its nudge and reloads on time). Frames still missing after the nudge and the last reload: the clock is followed // instead, as where frame callbacks do not exist, so a video that does play is not nudged every 17 s for good. if (frameCbWorks && clockMoved && !framesMoved && !video.seeking) { if (!dog.lostAt) dog.lostAt = t; if ((!frameCbPending || t - frameCbArmedAt > 900) && dog.rearms < 2) { dog.rearms++; rearmFrames('watchdog'); } else if (dog.rearms >= 2 && reloads >= reloadCap && dog.nudgedAt > dog.lostAt && t - dog.lostAt > 8000) { frameCbWorks = false; dog.since = 0; dog.lostAt = 0; dog.rearms = 0; vlog('no frames: the clock is followed'); diagnose('hcFrames', 'clock'); return; } } else if (framesMoved) { dog.lostAt = 0; dog.rearms = 0; } // A seek that has not finished after 4 s is a stall, not progress (2026-10-11): the watchdog used to wait on one for // good, so a seek iOS left hanging kept the old picture on screen however long the visitor waited. Unless its data is on // its way (2026-10-14): on a slow network a seek takes as long as the data it needs. var nudgeSeek = video.seeking && dog.nudgedAt > 0 && seekingSince >= dog.nudgedAt - 50 && seekingSince <= dog.nudgedAt + 1000; // the seek under way is our nudge (in place: its data was there) var slow = !nudgeSeek && waitsOnNetwork(); var hung = video.seeking && seekingSince > 0 && t - seekingSince > 4000 && !slow; if (moved || (video.seeking && !hung)) { dog.since = 0; dog.slowSince = 0; return; } // Stopped short of data while data still arrives (a slow network, see noteData): waited out, up to phoneNetWaitMs per stall. if (slow) { if (!dog.slowSince) { dog.slowSince = t; vlog('waits on the network'); } if (t - dog.slowSince < CFG.phoneNetWaitMs) { dog.since = 0; diagnose('hcNetWait', Math.round((t - dog.slowSince) / 1000)); return; } } else dog.slowSince = 0; if (!dog.since) { dog.since = hung ? Math.max(seekingSince, dog.nudgedAt) : t; if (!hung) return; } // Not moving for 2.5 s while it should play (15 s while it is still downloading what it needs): first a seek in // place; stuck again right after that (within that wait + 15 s of the seek), a reload. The window includes the wait so // that a reload stays reachable while the video downloads: with 15 s from the seek, shorter than the 15 s wait itself, // a stalled download was only ever nudged, every 16 s (found 2026-10-10 evening, WebKit on staging). Just after a long // absence (fastUntil) the waits are 1.5 s, 4 s while downloading. Downloading = the second ahead of the playhead is not // buffered yet (2026-10-11): a low readyState alone also reads 2 in WebKit's own build while it plays, and with the data // there a picture that does not move is a stall, not a wait for the network. var stuck = t - dog.since, fetching = video.networkState === 2 && video.readyState < 3 && !bufferedAhead(1); var wait = t < fastUntil ? (fetching ? 4000 : 1500) : fetching ? 15000 : 2500; if (stuck < wait) return; if (t - dog.nudgedAt > wait + 15000) { dog.nudgedAt = t; dog.since = 0; vlog('nudge' + (hung ? ' (hung seek)' : '')); diagnose('hcVideoNudge', Math.round(video.currentTime * 1000)); try { video.currentTime = video.currentTime + 0.001; } catch (_) {} return; } if (!nudgeSeek && waitsOnNetwork()) return; // data still coming for a video short of it: never reloaded (a reload starts the download over) if (reloads < reloadCap) reloadVideo(); } function bufferedAhead(s) { // the video holds the next s seconds from the playhead (or up to the end) try { var b = video.buffered, t = video.currentTime, end = Math.min(t + s, (video.duration || Infinity) - 0.05); for (var i = 0; i < b.length; i++) if (b.start(i) <= t + 0.05 && b.end(i) >= end) return true; } catch (_) {} return false; } function reloadVideo(atSeconds) { // the last resort: the same file again, at the same time (or the given one), under the still reloads++; vlog('reload'); diagnose('hcVideoReload', reloads); var at = typeof atSeconds === 'number' ? atSeconds : pendingSeek !== null ? pendingSeek : video.currentTime; cover(revealed ? screenMs() : undefined); dog.since = 0; seekingSince = 0; if (mode === 'phone' && everRevealed) setPendingRail(sceneIndexAt(at * 1000)); // under the still: the rail of the time it loads at pulses ownLoadAt = performance.now(); try { video.load(); } catch (_) {} videoSeek(at * 1000); // readyState is 0 again: applied on loadedmetadata armFrames(true); // the pending callback belonged to the old player (see armFrames) playVideo(); } // The video loaded afresh at t under the still (see onWake): not a stall reload, so it does not count against the cap. It // plays when the story does (sync), and the still lifts at a frame from t. function refreshVideo(t, why) { refreshes++; vlog('refresh (' + why + ') at ' + t.toFixed(2)); diagnose('hcVideoRefresh', why + ' ' + refreshes); if (hide) hide.done = true; if (jump) { clearTimeout(jumpTimer); jumpTimer = 0; jump = null; diagnose('hcJump', 'off'); } cover(t * 1000); dog.since = 0; dog.lostAt = 0; dog.rearms = 0; seekingSince = 0; ownLoadAt = performance.now(); try { video.load(); } catch (_) {} videoSeek(t * 1000); // readyState is 0 again: applied once the metadata is there armFrames(true); setPendingRail(mode === 'phone' ? sceneIndexAt(t * 1000) : -1); // its rail pulses until the picture is back if (mode === 'phone') paintCopy(screenMs()); // under the still: the time it loads at (for a second, then the still's) if (wantVideo()) playVideo(); } // A rail tap on a phone must show its scene (2026-10-11). It is followed until the video's clock runs on from the tap's // time over two looks 0.7 s apart (a seek that snaps back is caught too): a seek on its way gets up to 6 s; otherwise the // seek again, then pause + seek + play, then the file reloaded at that time. A pause (the page scrolled away, hidden) ends // the watch: the next play continues from the time the tap gave. function watchJump(t) { if (!video) return; clearTimeout(jumpTimer); jump = { t: t, since: performance.now(), actedAt: performance.now(), tries: 0, lastT: -1, framed: false, rearmed: false }; coverPlaySince = 0; diagnose('hcJump', 'watching'); jumpTimer = setTimeout(checkJump, 900); } function jumpLanded(how) { if (!jump) return; vlog('landed ' + how + (jump.framed ? '' : ' (no frame seen)')); diagnose('hcJump', 'landed'); clearTimeout(jumpTimer); jumpTimer = 0; jump = null; } function seekTo(t) { if (video.readyState < 1) { pendingSeek = t; return; } try { video.currentTime = t; } catch (_) {} } function checkJump() { jumpTimer = 0; if (!jump) return; if (!video || mode !== 'phone' || child !== videoChild || !wantVideo() || blocked) { jump = null; diagnose('hcJump', 'off'); return; } // paused, left, or waiting for a tap on the play button // No metadata yet (a first load on a slow network): the seek waits for it (pendingSeek), and the clock still reads 0. var waitingMeta = pendingSeek !== null && video.readyState < 1; var now = performance.now(), t = waitingMeta ? pendingSeek : video.currentTime; var near = t >= jump.t - 0.06 && t < jump.t + 10; // Landed: two looks near its time with the clock running and, where frames can be followed, a frame at its time on // screen (a clock that runs under a frozen picture, which iOS can leave after an absence, is not a landing). var clockOn = near && !video.seeking && !video.paused && video.readyState >= 2 && jump.lastT >= jump.t - 0.06 && t > jump.lastT + 0.05; if (clockOn && (jump.framed || !frameCbWorks)) { jumpLanded('clock ' + t.toFixed(2)); return; } // The clock runs at its time but no frame has been reported at all for a while: it may be the frame callback that was // lost, not the picture (see armFrames). Asked for again once; if none comes after that either, the clock is enough here // — a picture that really froze is the watchdog's (a nudge, then a reload at its time), and seeking again would only // make a scene that plays start over. if (clockOn && frameCbPending && now - frameCbArmedAt > 600) { if (!jump.rearmed) { jump.rearmed = true; jump.lastT = t; rearmFrames('rail'); jumpTimer = setTimeout(checkJump, 700); return; } jumpLanded('clock ' + t.toFixed(2)); return; } // Seeking, buffering or starting at its time: give it time. While its data is arriving (a slow network, see noteData) the // wait starts over at each look, for up to phoneNetWaitMs from the tap; while the file is downloading without news, as // long as the watchdog waits on a download (15 s; 4 s just after a long absence). A retry's seek or reload would only // start that download over (2026-10-14, the first-load sweep: a slow network, a rail tapped before the first frame). var slow = waitsOnNetwork(); var hung = video.seeking && seekingSince > 0 && now - seekingSince > 4000 && !slow; if (near && slow && !video.error && now - jump.since < CFG.phoneNetWaitMs) jump.actedAt = now; var fetching = video.networkState === 2 && video.readyState < 3 && !bufferedAhead(1); var patience = clockOn ? 2500 : fetching ? (now < fastUntil ? 4000 : 15000) : 6000; var onItsWay = near && !hung && !video.error && now - jump.actedAt < patience; jump.lastT = t; if (onItsWay) { jumpTimer = setTimeout(checkJump, 700); return; } if (jump.tries === 2 && !video.error && waitsOnNetwork()) { jumpTimer = setTimeout(checkJump, 1500); return; } // the next step reloads: not while data still comes (see waitsOnNetwork) jump.tries = video.error ? Math.max(jump.tries, 2) + 1 : jump.tries + 1; // a media error: straight to the reload vlog('jump retry ' + jump.tries + ' at ' + t.toFixed(2) + (video.seeking ? ' seeking' : '') + ' rs' + video.readyState + (video.error ? ' err' + video.error.code : '')); diagnose('hcJumpRetry', jump.tries); jump.actedAt = now; if (jump.tries === 1) { seekTo(jump.t); playVideo(); jumpTimer = setTimeout(checkJump, 1500); } else if (jump.tries === 2) { videoPause(); seekTo(jump.t); playVideo(); jumpTimer = setTimeout(checkJump, 1500); } else if (jump.tries === 3 && jumpReloads < 3) { jumpReloads++; reloadVideo(jump.t); jumpTimer = setTimeout(checkJump, 1500); } else { vlog('jump: left to the watchdog'); diagnose('hcJump', 'gave-up'); jump = null; setPendingRail(-1); if (!everRevealed) markBroken('no frame'); // not one frame since the page opened: the rails answer by themselves } } function showTap(reason) { if (!phoneBox || !video) return; if (!tap) { tap = document.createElement('button'); tap.type = 'button'; tap.className = 'hc-phone-play'; tap.setAttribute('aria-label', 'Play the demo'); tap.innerHTML = ''; tap.addEventListener('click', function (e) { e.preventDefault(); if (mode !== 'phone') video.loop = true; // the stacked layout: a plain looping video, the copy is listed below it userStarted = true; interruptions = []; kickTries = 0; visibleSince = 0; // a tap: start afresh, and a refusal now means the tap is needed again videoPlay(null); }); phoneBox.appendChild(tap); } tap.hidden = false; diagnose('hcVideo', reason || 'tap'); if (reason === 'blocked' || reason === 'interrupted') blocked = true; } function hideTap() { blocked = false; if (tap) tap.hidden = true; } function loopVideo() { // back to 0 from clear: scene 1's heading and fresh rails as the window clears if (jump) { clearTimeout(jumpTimer); jumpTimer = 0; jump = null; diagnose('hcJump', 'off'); } // the loop moves on from a tap still being followed loops++; diagnose('hcLoops', loops); setPendingRail(-1); fadeInVideo(); playFrom(0); videoChild.timeMs = 0; paintCopy(screenMs()); // scene 1 once its first frame is on screen } function fadeInVideo() { // the loop starts from clear, like the player's first 600 ms (the video's last frames are clear) if (!video || reduced.matches || !CFG.loopFadeMs || (PHONE && PHONE.revealMs)) return; // a video with its own reveal starts from clear by itself clearTimeout(fadeTimer); video.style.transition = 'none'; video.style.opacity = '0'; void video.offsetWidth; video.style.transition = 'opacity ' + CFG.loopFadeMs + 'ms ease'; video.style.opacity = '1'; fadeTimer = setTimeout(function () { video.style.removeProperty('transition'); video.style.removeProperty('opacity'); }, CFG.loopFadeMs + 60); } if (video) { video.muted = true; video.defaultMuted = true; video.playsInline = true; video.loop = false; // muted + inline: iOS plays it without a tap if (phoneBox.querySelector('.hc-phone-still')) video.removeAttribute('poster'); // the still above the video is the poster (an older embed keeps its own) video.addEventListener('playing', function () { applyPendingSeek(); videoChild.playing = true; kickTries = 0; hideTap(); vlog('playing ' + video.currentTime.toFixed(2)); diagnose('hcVideo', 'playing'); armFrames(frameCbPending && performance.now() - frameCbArmedAt > 1000); tick(); }); video.addEventListener('pause', function () { videoChild.playing = false; coverPlaySince = 0; // the still's 2.5 s (onFrame) counts playing time only var mine = ownPause; ownPause = false; if (video.ended) return; vlog(mine ? 'pause' : 'pause (system)'); diagnose('hcVideo', 'paused'); if (!mine) interrupted(); }); video.addEventListener('waiting', function () { vlog('waiting'); diagnose('hcVideo', 'buffering'); }); video.addEventListener('loadedmetadata', function () { vlog('metadata'); applyPendingSeek(); }); video.addEventListener('loadeddata', applyPendingSeek); video.addEventListener('seeking', function () { if (!seekingSince) seekingSince = performance.now(); vlog('seeking ' + video.currentTime.toFixed(2)); }); video.addEventListener('stalled', function () { vlog('stalled'); }); video.addEventListener('progress', function () { lastDataAt = performance.now(); noteData(); }); // data arrived (see noteData) video.addEventListener('emptied', function () { // the element starts over: ours (a reload), or the browser's (iOS rebuilding it) var now = performance.now(), ours = now - ownLoadAt < 1500; vlog('emptied' + (ours ? '' : ' (not ours)')); if (ours || document.hidden || child !== videoChild || now - emptiedAt < 10000) return; emptiedAt = now; refreshVideo(videoChild.timeMs / 1000, 'emptied'); // at the time the heading and rails show, under the still }); video.addEventListener('timeupdate', function () { // the reveal where requestVideoFrameCallback is missing or silent applyPendingSeek(); if (video.paused) return; diagnose('hcVideo', 'playing'); // WebKit does not always fire 'playing' again after 'waiting', if (!tickRaf) tick(); // so the copy's clock restarts from here too if (!revealed && video.currentTime >= revealFrom + (frameCbWorks ? 0.6 : 0.15)) reveal(); }); video.addEventListener('seeked', function () { seekingSince = 0; vlog('seeked ' + video.currentTime.toFixed(2)); videoChild.timeMs = video.currentTime * 1000; if (child === videoChild && mode === 'phone' && !childPlaying()) paintCopy(screenMs()); }); video.addEventListener('canplay', function () { applyPendingSeek(); if (!video.paused) diagnose('hcVideo', 'playing'); }); // after a 'waiting' WebKit may skip 'playing' video.addEventListener('ended', function () { videoChild.playing = false; vlog('ended'); diagnose('hcVideo', 'ended'); if (child !== videoChild || mode !== 'phone' || !running || paused) return; if (CFG.loop) loopVideo(); else diagnose('hcPlayback', 'done'); }); video.addEventListener('error', function () { vlog('error ' + (video.error ? video.error.code : '?')); diagnose('hcVideo', 'error'); // A media error while it should play (2026-10-11; after a long absence iOS may have dropped the download): the file // again at the time it had (a rail tap's time if one is pending), instead of the watchdog's waits. if (reloads >= reloadCap) { markBroken('error'); return; } if (!wantVideo()) return; setTimeout(function () { if (video.error && wantVideo() && reloads < reloadCap) reloadVideo(jump ? jump.t : undefined); }, 400); }); if (PHONE) setInterval(watchdog, 1000); } // ---- desktop: the marquee ------------------------------------------------------------------------------------------ // The demo's layout box is its final size; only one outer transform (scale about its top centre, so its top edge never // moves) changes while the page scrolls. The resting position: the demo's top edge sits scroll-margin-top below the // top of the viewport (styles: clamp(32px, 6vh, 72px) + the marquee's top padding), so a little of the hero stays // visible above it — every snap distance below is measured from there, in CSS scroll pixels. var M = { p: 0, scale: -1, active: -1, entered: false, margin: null }; var growQueued = false, lastBrush = -1; // ?hcfx=livegrow (diagnostics): the 2026-10-10 morning behaviour — the live player itself grows (it flickers in Chrome) var liveGrow = document.documentElement.classList.contains('hcfx-livegrow'); function posterEl() { return liveGrow ? null : media.querySelector('.hc-demo-poster'); } function snapTarget() { var max = Math.max(0, document.documentElement.scrollHeight - window.innerHeight); if (M.margin === null) M.margin = parseFloat(getComputedStyle(demo).scrollMarginTop) || 0; // it depends on the viewport only: re-read per measure return Math.max(0, Math.min(window.scrollY + demo.getBoundingClientRect().top - M.margin, max)); } function measureMarquee() { // the 1152 × 720 canvas scaled to the demo's width: per size, never per frame if (mode !== 'marquee') return; M.margin = null; var w = demo.offsetWidth; if (w > 0) setVar('--hc-demo-scale', String(w / LW)); // exact: a rounded 0.66667 makes the 1152 px canvas 768.004 px wide in a 768 px box, and WebKit draws a hairline seam in the demo's margin geometry = { width: w, height: Math.round(w * LH / LW) }; scheduleGrow(); } function scheduleGrow() { if (!growQueued) { growQueued = true; requestAnimationFrame(grow); } } function grow() { growQueued = false; if (mode !== 'marquee') return; var end = Math.max(1, snapTarget()); var p = M.entered || reduced.matches || end - window.scrollY <= 2 ? 1 : clamp(window.scrollY / end); var ease = smooth(p); var scale = reduced.matches || p === 1 ? 1 : +(CFG.growFrom + (1 - CFG.growFrom) * ease).toFixed(5); var poster = posterEl(); if (scale !== M.scale) { // the element's own transform: no inherited custom property to invalidate per frame M.scale = scale; if (poster) { style(poster, 'transform', 'scale(' + scale + ')'); if (demo.style.transform) demo.style.removeProperty('transform'); } else style(demo, 'transform', 'translateZ(0) scale(' + scale + ')'); // an embed without a poster (or ?hcfx=livegrow): the demo itself } diagnose('hcGrow', poster && scale < 1 ? 'on' : 'off'); // on: the poster shows (head CSS), the live player waits unscaled and hidden M.p = p; diagnose('hcExpansion', p.toFixed(3)); // The player's brushes: at their resting size (0.64 relative to the window, as poster v4) from the start — the live // player only ever shows at full size. ?hcfx=livegrow keeps the old 1 → 0.64 during the growth. var brush = reduced.matches || !liveGrow ? 1 : ease; if (frameChild.ready && Math.abs(brush - lastBrush) > 0.0001) { lastBrush = brush; presentation(brush); } snapper.geometryChanged(); gate.onPositioned(); } function enteredNow() { // the first entrance completed: the component stays as it is from now on if (M.entered) return; M.entered = true; diagnose('hcEntrance', 'done'); inert(rail, false); if (rail) rail.removeAttribute('aria-hidden'); scheduleGrow(); } function paintMarquee(at, playing, rate) { // the heading and the rails follow the player's own time if (mode !== 'marquee' || !view || !copyScenes.length) return; var i = sceneIndexAt(at); if (i < 0) return; var scene = copyScenes[i]; if (pendingRail >= 0 && i === pendingRail) { setPendingRail(-1); clearTimeout(pendingTimer); } // the clicked scene is painted if (i !== M.active) { M.active = i; viewTitle.innerHTML = headingHTML(scene); markScene(i); copyReveal.prepare(); } railProgress.update({ time: at, start: scene.startMs, end: scene.endMs, playing: !!playing, rate: rate || 1 }); } // The rails' progress, smoothed between the player's ~100 ms reports (Codex's rail-progress.js): projected at most // railProjectMs past the last report, clamped to the scene, frozen when paused or hidden, reset on a seek. It only // paints — the player's reports remain the story's clock. var railProgress = (function () { var raf = 0, playing = false, reported = 0, received = 0, rate = 1, start = 0, end = 1, drawn = 0, resetNext = true; function within(value) { return Math.max(start, Math.min(end, value)); } function draw(time) { drawn = within(time); setProgress((drawn - start) / Math.max(1, end - start)); } function pause() { cancelAnimationFrame(raf); raf = 0; playing = false; } function step(now) { raf = 0; if (!playing || document.hidden) return; var estimated = reported + Math.min(CFG.railProjectMs, Math.max(0, now - received)) * rate; draw(Math.max(drawn, estimated)); if (drawn < end && now - received < CFG.railProjectMs) raf = requestAnimationFrame(step); } function update(state) { var changed = state.start !== start || state.end !== end; var reset = resetNext || changed || !state.playing || !playing || state.time < reported - CFG.railProjectMs; start = state.start; end = state.end; reported = state.time; received = performance.now(); if (isFinite(state.rate) && state.rate > 0) rate = state.rate; playing = state.playing && !document.hidden; resetNext = false; if (reset) draw(reported); if (!playing) pause(); else if (!raf) raf = requestAnimationFrame(step); } document.addEventListener('visibilitychange', function () { pause(); resetNext = true; }); return { update: update, pause: pause, reset: function () { pause(); resetNext = true; } }; })(); // The settle (Codex's scroll-snap.js): one interruptible capture toward one anchor. After snapIdleMs of quiet, a stop // inside the range (400 px above until the resting position has been reached once — an interrupted approach keeps // that allowance, a return to the top does not reset it — then 120 px above; 80 px below; all inclusive) glides to // it. Fresh wheel / touch / pointer / scroll-key input cancels a glide in flight; held input is never fought and is // re-evaluated on release. Resizes, late layout, hidden tabs, focus and back/forward restores re-measure the anchor // and recover. CSS smooth scrolling is switched off while the glide owns the scroll. One addition: the very top of // the page is always a resting place. var snapper = (function () { var idle = 0, raf = 0, animating = false, previousBehavior = '', state = ''; // '' until the first signal, so it is always published var pointerHeld = false, touchHeld = false, keyHeld = false, lastKeyAt = 0, lastTarget = null, recover = false, hasArrived = false; function enabled() { return mode === 'marquee'; } function held() { return pointerHeld || touchHeld || keyHeld; } function centered() { return Math.abs(window.scrollY - snapTarget()) <= 2; } function signal(next) { if (next === 'settled') hasArrived = true; if (state === next) return; state = next; diagnose('hcSnap', next); gate.onSnap(next); } function eligible() { if (!enabled() || document.hidden || held() || centered()) return false; if (recover) return true; if (window.scrollY < 1) return false; var distance = window.scrollY - snapTarget(); var above = hasArrived ? CFG.snapAbove : CFG.snapFirstAbove; // the wider first approach lasts until the anchor is actually reached return distance >= -above && distance <= CFG.snapBelow; } function interrupt() { clearTimeout(idle); idle = 0; if (animating) { document.documentElement.style.scrollBehavior = previousBehavior; cancelAnimationFrame(raf); raf = 0; animating = false; } signal('idle'); } function finish() { document.documentElement.style.scrollBehavior = previousBehavior; raf = 0; animating = false; if (centered()) { recover = false; signal('settled'); } else { recover = true; schedule(); } } function settle() { idle = 0; if (!eligible()) { signal(centered() && !held() && !document.hidden ? 'settled' : 'idle'); return; } var from = window.scrollY, to = snapTarget(), distance = to - from; lastTarget = to; animating = true; stats.snaps++; previousBehavior = document.documentElement.style.scrollBehavior; document.documentElement.style.scrollBehavior = 'auto'; signal('snapping'); if (reduced.matches) { window.scrollTo({ top: to, behavior: 'auto' }); finish(); return; } // the longer first approach arrives gently (quintic smootherstep); later corrections stay quick (cubic) var first = !hasArrived && distance > 0; var start = performance.now(); var duration = first ? Math.min(CFG.firstSnapMaxMs, Math.max(CFG.firstSnapMinMs, Math.abs(distance) * CFG.firstSnapMsPerPx)) : Math.min(CFG.snapMaxMs, Math.max(CFG.snapMinMs, Math.abs(distance) * CFG.snapMsPerPx)); function step(now) { if (document.hidden || held()) { interrupt(); return; } var t = Math.min(1, (now - start) / duration); var ease = first ? t * t * t * (t * (6 * t - 15) + 10) : t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; window.scrollTo({ top: from + distance * ease, behavior: 'auto' }); if (t < 1) raf = requestAnimationFrame(step); else finish(); } raf = requestAnimationFrame(step); } function schedule() { if (!enabled() || animating) return; clearTimeout(idle); idle = 0; if (document.hidden || held()) return; if (centered()) { recover = false; signal('settled'); return; } if (!eligible()) { signal('idle'); return; } signal('pending'); idle = setTimeout(settle, CFG.snapIdleMs); } function input() { if (!enabled()) return; recover = false; interrupt(); schedule(); } function geometryChanged() { if (!enabled()) return; var next = snapTarget(); if (lastTarget === null) { lastTarget = next; return; } if (Math.abs(next - lastTarget) <= 2) return; // keep a centred (or interrupted) component centred after a layout change recover = recover || animating || Math.abs(window.scrollY - lastTarget) <= 2; lastTarget = next; interrupt(); schedule(); } window.addEventListener('scroll', schedule, { passive: true }); window.addEventListener('wheel', input, { passive: true, capture: true }); // Held input counts only while the page can see its release (2026-10-10 14:00: the rails sometimes never appeared — // a hold whose release never arrives blocks the settle, the entrance and playback until the next click). So: a // secondary mouse button (a context menu, autoscroll) is not a hold; a context menu or a drag ends one (macOS opens // the menu on the press and keeps the release); any mouse move with no button down ends one that was missed; a key // pressed with ⌘ is not held (macOS sends no keyup for it: ⌘↑ / ⌘↓ left the hero waiting for good), and a key hold // with no key event for 2 s ends at the next mouse move. window.addEventListener('pointerdown', function (e) { if (!enabled() || (e.pointerType === 'mouse' && e.button > 0)) return; pointerHeld = true; interrupt(); }, { passive: true, capture: true }); function releasePointer() { if (!pointerHeld) return; pointerHeld = false; schedule(); } window.addEventListener('pointerup', releasePointer, { passive: true, capture: true }); window.addEventListener('pointercancel', releasePointer, { passive: true, capture: true }); window.addEventListener('contextmenu', releasePointer, { passive: true, capture: true }); window.addEventListener('dragend', releasePointer, { passive: true, capture: true }); window.addEventListener('pointermove', function (e) { if (pointerHeld && e.pointerType === 'mouse' && e.buttons === 0) releasePointer(); if (keyHeld && performance.now() - lastKeyAt > 2000) { keyHeld = false; schedule(); } }, { passive: true, capture: true }); window.addEventListener('touchstart', function () { if (!enabled()) return; touchHeld = true; interrupt(); }, { passive: true, capture: true }); window.addEventListener('touchmove', input, { passive: true, capture: true }); function releaseTouch() { if (!touchHeld) return; touchHeld = false; schedule(); } window.addEventListener('touchend', releaseTouch, { passive: true, capture: true }); window.addEventListener('touchcancel', releaseTouch, { passive: true, capture: true }); var scrollKeys = ['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' ', 'Escape']; function typing(e) { return e.target && e.target.closest && e.target.closest('input,textarea,select,[contenteditable]'); } window.addEventListener('keydown', function (e) { if (!enabled() || scrollKeys.indexOf(e.key) === -1 || typing(e)) return; recover = false; if (e.metaKey) { interrupt(); schedule(); return; } // ⌘↑ / ⌘↓ jump at once and their keyup never comes keyHeld = true; lastKeyAt = performance.now(); interrupt(); }, { capture: true }); window.addEventListener('keyup', function (e) { if (keyHeld && (scrollKeys.indexOf(e.key) !== -1 || e.key === 'Meta')) { keyHeld = false; schedule(); } }, { capture: true }); window.addEventListener('resize', function () { if (!enabled()) return; recover = recover || animating || centered(); interrupt(); geometryChanged(); schedule(); }); function resumed() { pointerHeld = touchHeld = keyHeld = false; if (!enabled()) return; geometryChanged(); schedule(); } document.addEventListener('visibilitychange', function () { if (document.hidden) { recover = recover || animating; if (enabled()) interrupt(); } else resumed(); }); window.addEventListener('blur', function () { recover = recover || animating; pointerHeld = touchHeld = keyHeld = false; if (enabled()) interrupt(); }); window.addEventListener('focus', resumed); window.addEventListener('pageshow', resumed); return { schedule: schedule, interrupt: interrupt, geometryChanged: geometryChanged, centered: centered, blocked: function () { return held() || animating || document.hidden; }, reset: function () { lastTarget = null; recover = false; }, get state() { return state; }, get hasArrived() { return hasArrived; } }; })(); // The heading's reveal (Codex's title-typing.js — no typing any more: whole words, so Safari's gradient text stays // intact). The first entrance: the introduction fades in and rises 5 px (0–190 ms), stays alone to be read, then the // heading (rising 6 px) and the rails fade in (1200–1380 ms); playback may start at 1410 ms. Later headings fade in // over 180 ms. Once complete, the introduction and rails never fade again. A cancelled reveal resolves false, so no // stale timer can start playback; the first entrance then replays in full (two refinements of ours: an interrupted // first entrance drops the heading and rails at once instead of freezing them half-visible, and its retry continues // the introduction from where it is instead of blinking it back to clear). Reduced motion: everything at once. var copyReveal = (function () { var complete = false, groupComplete = false, raf = 0, finish = null, fallback = 0; function enabled() { return mode === 'marquee' && !!viewTitle; } function companions() { return [viewLead, rail].filter(Boolean); } function paint(elements, p) { elements.forEach(function (el) { el.style.opacity = String(p); var rise = el === viewTitle ? CFG.titleRise : el === viewLead ? CFG.introRise : 0; el.style.transform = rise ? 'translateY(' + rise * (1 - p) + 'px)' : 'none'; }); } function shown(el) { var o = el ? parseFloat(el.style.opacity) : 0; return o > 0 ? Math.min(1, o) : 0; } function showHeading() { clearTimeout(fallback); fallback = 0; paint([viewTitle], 1); complete = true; viewTitle.dataset.revealed = 'true'; } function cancel() { cancelAnimationFrame(raf); raf = 0; if (!finish) return; var f = finish; finish = null; f(false); if (groupComplete || CFG.copyFirst) showHeading(); else paint([viewTitle, rail].filter(Boolean), 0); // an interrupted first entrance: the heading and rails go (the introduction stays) } // Back in the marquee after the entrance (the window went through the stacked or phone layout — a resize, a docked // DevTools, a zoom — and leaveMarquee cleared the inline styles): the introduction and the rails return as they were. // (2026-10-10 14:00, Deniz: "the rails sometimes never appear" — before this they stayed at the stylesheet's opacity 0.) function restore() { if (!groupComplete || !enabled()) return; paint(companions(), 1); inert(rail, false); if (rail) rail.removeAttribute('aria-hidden'); } function reset() { // before the first entrance: everything hidden again (Codex's reset) if (groupComplete || !enabled()) return; cancel(); complete = false; if (CFG.copyFirst) { // preview: on the page from the start; the rails take clicks from the first settle paint([viewTitle].concat(companions()), 1); inert(rail, true); if (rail) rail.setAttribute('aria-hidden', 'true'); viewTitle.dataset.revealed = 'true'; return; } paint([viewTitle].concat(companions()), 0); inert(rail, true); viewTitle.dataset.revealed = 'false'; } function prepare() { // the heading has new words (a scene change) if (!enabled()) return; clearTimeout(fallback); fallback = 0; cancel(); complete = false; if (CFG.copyFirst && !groupComplete) { // preview: before the first settle the first heading is simply there paint([viewTitle], 1); viewTitle.dataset.revealed = 'true'; gate.onTitleChange(); return; } paint([viewTitle], 0); // hidden until its own reveal (no flash of the new words first) if (!groupComplete) { paint(companions(), 0); inert(rail, true); } else armFallback(); viewTitle.dataset.revealed = 'false'; gate.onTitleChange(); } // A new heading the gate cannot reveal soon (the page is not in place, or moving) shows anyway after titleFallbackMs; // while the gate is on its way to a reveal (settling after a rail click, sampling frames) the fallback waits for it, // so the heading still fades in rather than popping in a moment before its reveal would have started. function armFallback() { clearTimeout(fallback); fallback = setTimeout(function () { fallback = 0; if (complete || raf) return; if (gate.busy()) { armFallback(); return; } showHeading(); }, CFG.titleFallbackMs); } function revealHeading() { if (!enabled() || complete) return Promise.resolve(true); clearTimeout(fallback); fallback = 0; cancel(); var introduction = !groupComplete ? viewLead : null; var elements = [viewTitle].concat(groupComplete ? [] : companions()); var followers = elements.filter(function (el) { return el !== introduction; }); // A retry after an interrupted first entrance continues the introduction from where it is (no blink back to // clear); its reading pause starts over in full. var introFrom = introduction ? shown(introduction) : 0; paint(followers, 0); if (introduction) paint([introduction], introFrom); var duration = introduction ? CFG.playAfterMs : CFG.titleMs; function done() { paint(elements, 1); complete = true; groupComplete = true; raf = 0; finish = null; viewTitle.dataset.revealed = 'true'; enteredNow(); } if (reduced.matches || (CFG.copyFirst && introduction)) { done(); return Promise.resolve(true); } // preview: no entrance choreography return new Promise(function (resolve) { finish = resolve; var start = performance.now(); function progress(elapsed, delay, length) { return 1 - Math.pow(1 - clamp((elapsed - delay) / length), 3); } function step(now) { var elapsed = now - start; if (introduction) { paint([introduction], introFrom + (1 - introFrom) * progress(elapsed, 0, CFG.introMs)); paint(followers, progress(elapsed, CFG.readMs, CFG.titleMs)); } else paint(elements, progress(elapsed, 0, CFG.titleMs)); if (elapsed < duration) raf = requestAnimationFrame(step); else { done(); resolve(true); } } raf = requestAnimationFrame(step); }); } return { prepare: prepare, reveal: revealHeading, cancel: cancel, reset: reset, restore: restore, get complete() { return complete; }, get groupComplete() { return groupComplete; } }; })(); // The gate (Codex's guard.js): the only owner of play permission on desktop. It plays only when the player is ready, // the tab visible, no input held, no settle pending or in flight, the page at the resting position, the demo at full // size and inside the viewport, nothing moved for settleMs and three animation frames agree on the demo's box — then // it awaits the reveal and checks all of it again before sending play. Anything that moves pauses at once; a scene // change pauses for its heading; a start it did not ask for is undone. One addition of ours: while it is already // arming (settling, sampling or revealing), the passive signals — a position check after a layout change elsewhere on // the page, the intersection observer, a repeated ready — leave it alone, so they cannot restart a reveal in flight; // real motion still cancels it at once. var gate = (function () { var manuallyPaused = false, wanted = false, playingNow = false, commanded = false, timer = 0, epoch = 0, busy = 0, lastMotion = performance.now(); function enabled() { return mode === 'marquee'; } function phase(value) { if (enabled()) diagnose('hcMotion', value); } function cancel() { clearTimeout(timer); timer = 0; epoch++; busy = 0; copyReveal.cancel(); } function stop() { if (playingNow || commanded) { playerPause(); playingNow = false; commanded = false; } } function visibleRatio() { var r = demo.getBoundingClientRect(); return r.height > 0 ? Math.max(0, Math.min(r.bottom, window.innerHeight) - Math.max(r.top, 0)) / r.height : 0; } function positioned() { if (CFG.playSeen) { // preview: at full size and enough of it on screen, wherever the page is if (!enabled() || document.hidden || !frameChild.ready || M.scale !== 1) return false; return visibleRatio() >= (playingNow || commanded ? CFG.seenStop : CFG.seenStart); } if (!enabled() || document.hidden || snapper.blocked() || snapper.state === 'pending' || snapper.state === 'snapping' || !frameChild.ready) return false; if (!snapper.centered()) return false; var r = demo.getBoundingClientRect(); return M.p === 1 && M.scale === 1 && r.top >= -2 && r.bottom <= window.innerHeight + 2; } function motionActive() { return [demo, stage].some(function (el) { return typeof el.getAnimations === 'function' && el.getAnimations().some(function (a) { return a.playState === 'running'; }); }); } function box() { var r = demo.getBoundingClientRect(); return [r.x, r.y, r.width, r.height]; } function arm() { cancel(); if (!wanted || !enabled()) return; if (!positioned()) { phase('waiting'); return; } var token = busy = epoch; phase('settling'); function current() { return token === epoch && wanted && positioned(); } // still this arming, still in place function abandon() { if (token === epoch) { busy = 0; phase('waiting'); } } // out of place without an event: the next check re-arms timer = setTimeout(function () { timer = 0; if (!current()) { abandon(); return; } if (performance.now() - lastMotion < CFG.settleMs || motionActive()) { arm(); return; } var before = box(), samples = 0; function sample() { if (!current()) { abandon(); return; } if (performance.now() - lastMotion < CFG.settleMs || motionActive() || box().some(function (n, i) { return Math.abs(n - before[i]) > 0.1; })) { arm(); return; } if (++samples < 3) { requestAnimationFrame(sample); return; } phase('revealing'); copyReveal.reveal().then(function (completed) { if (completed === false) return; // cancelled: whatever cancelled it re-arms or stops if (!current()) { abandon(); return; } if (performance.now() - lastMotion < CFG.settleMs || motionActive()) { arm(); return; } busy = 0; commanded = true; phase('playing'); playerPlay(); }); } requestAnimationFrame(sample); }, Math.max(0, CFG.settleMs + 20 - (performance.now() - lastMotion))); } function idle() { return wanted && !playingNow && !commanded && !busy; } // wants to play, is not playing and is not on its way function moving() { if (!enabled()) return; lastMotion = performance.now(); if (CFG.playSeen && (playingNow || commanded)) { if (!positioned()) { cancel(); stop(); phase('waiting'); arm(); } return; } // preview: motion alone never pauses it cancel(); stop(); phase('moving'); arm(); } function reset() { cancel(); stop(); wanted = false; manuallyPaused = false; } window.addEventListener('wheel', moving, { capture: true, passive: true }); window.addEventListener('touchmove', moving, { capture: true, passive: true }); // the page's own scroll only: a scroll inside some other element (a carousel, a widget) does not move the demo window.addEventListener('scroll', function (e) { var t = e && e.target; if (t && t !== document && t !== document.documentElement && t !== document.body && t !== window) return; moving(); }, { capture: true, passive: true }); window.addEventListener('resize', moving, { passive: true }); window.addEventListener('keydown', function (e) { if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '].indexOf(e.key) !== -1 && !(e.target && e.target.closest && e.target.closest('input,textarea,select,[contenteditable]'))) moving(); }, { capture: true }); document.addEventListener('visibilitychange', function () { if (!enabled()) return; if (document.hidden) { cancel(); stop(); } else { lastMotion = performance.now(); arm(); } }); if ('ResizeObserver' in window) new ResizeObserver(function () { moving(); }).observe(demo); if ('IntersectionObserver' in window) new IntersectionObserver(function () { if (!enabled()) return; if (!positioned()) { cancel(); stop(); phase('waiting'); } else if (idle()) arm(); }, { threshold: [0, CFG.seenStop, CFG.seenStart, 0.6, 0.65, 1] }).observe(demo); return { arm: arm, reset: reset, positioned: positioned, wanted: function () { return wanted; }, busy: function () { return !!busy; }, request: function (action) { // a visitor's own play / pause (no control does this today; kept for the protocol) if (action === 'play') { manuallyPaused = false; wanted = true; arm(); } else if (action === 'pause') { manuallyPaused = true; wanted = false; cancel(); stop(); phase('paused'); } }, onTitleChange: function () { if (!enabled()) return; if (CFG.playSeen && (playingNow || commanded)) { copyReveal.reveal(); return; } // preview: the new heading fades in while the demo plays on cancel(); stop(); arm(); }, onSnap: function (state) { if (!enabled()) return; lastMotion = performance.now(); if (state === 'settled' && !manuallyPaused) wanted = true; if (CFG.playSeen && (playingNow || commanded)) { if (!positioned()) { cancel(); stop(); arm(); } return; } cancel(); stop(); arm(); }, onPositioned: function () { if (!enabled()) return; if (!positioned()) { cancel(); stop(); phase('waiting'); } else if (idle()) arm(); }, onReady: function () { if (!enabled()) return; if (!manuallyPaused) wanted = true; if (idle()) arm(); }, onPlayerState: function (playing) { if (!enabled()) return; playingNow = playing; if (playingNow && (!wanted || !positioned() || (!CFG.playSeen && performance.now() - lastMotion < CFG.settleMs))) { stop(); arm(); } }, get state() { return { wanted: wanted, running: playingNow, commanded: commanded, busy: !!busy, manuallyPaused: manuallyPaused, sinceMotion: Math.round(performance.now() - lastMotion) }; } }; })(); // ---- phones: the story — continuous playback while most of the window is on screen, looped ----------------------- function shouldRun() { return mode === 'phone' && arrived && settled && inView && !document.hidden; } function sync() { var want = shouldRun(); if (want && !running) { running = true; paused = false; diagnose('hcPlayback', 'playing'); if (child.ready) playFrom(child.timeMs >= (child.durationMs || Infinity) - 1 ? 0 : child.timeMs < parkOf(child) ? parkOf(child) : null); } else if (want && running && paused) { paused = false; diagnose('hcPlayback', 'playing'); if (child.ready) { if (child === videoChild && video && video.ended && CFG.loop) loopVideo(); else playFrom(null); } } // it ended while paused: the loop it missed else if (!want && running && !paused) { paused = true; pauseChild(); diagnose('hcPlayback', 'paused'); if (tickRaf) { cancelAnimationFrame(tickRaf); tickRaf = 0; } } } function jumpTo(index) { if (!copyScenes[index]) return; if (mode === 'marquee') { // desktop: seek into the scene; the gate decides whether it plays if (!M.entered) return; stats.clicks++; var t = copyScenes[index].startMs + CFG.railJumpMs; playerSeek(t); frameChild.timeMs = t; // 2026-10-14 (the desktop sweep): the heading and rails switch with the player's first report from t — its reports // carry the time it has painted — not at the click: on a slow computer they ran a second ahead of the picture. Until // then the clicked rail pulses, as on phones (6 s at most). setPendingRail(index); clearTimeout(pendingTimer); pendingTimer = setTimeout(function () { if (mode === 'marquee') setPendingRail(-1); }, 6000); return; } if (mode !== 'phone') return; stats.clicks++; vlog('rail ' + (index + 1)); settled = true; running = true; paused = false; diagnose('hcPlayback', 'playing'); var at = copyScenes[index].startMs; if (child === videoChild && PHONE && at < PHONE.revealMs) at = PHONE.revealMs; // rail 1: past the video's opening reveal (its first frames are white) playFrom(at); setPendingRail(index); // the heading and rails switch when its scene is on screen (screenMs); until then the rail pulses paintCopy(screenMs()); if (child === videoChild) watchJump(frameMs(at) / 1000); // until its scene is on screen (see watchJump) } function stopStory() { // leaving a layout: the child parks on its first frame if (tickRaf) { cancelAnimationFrame(tickRaf); tickRaf = 0; } running = false; paused = false; parkChild(); diagnose('hcPlayback', 'idle'); } // ---- phones: the still layout's geometry ----------------------------------------------------------------------- // The window (the video's 358 × 440 UI frame) as wide as the content column allows (≤ phoneMaxWidth) while window + // copy + rail still fit under the navbar in one screen once the visitor has scrolled the window up to it; the copy box // is as tall as its tallest heading, so nothing under it moves when the scene changes. The height used is the small // viewport (100svh — the browser's bars showing), which the iOS toolbar collapsing on scroll does not change. function smallViewportHeight() { if (!svhProbe) { svhProbe = document.createElement('div'); svhProbe.className = 'hc-svh-probe'; svhProbe.setAttribute('aria-hidden', 'true'); svhProbe.style.cssText = 'position:absolute;left:0;top:0;width:1px;height:100vh;height:100svh;visibility:hidden;pointer-events:none'; // 100vh where svh is unknown root.appendChild(svhProbe); } return svhProbe.offsetHeight || window.innerHeight; } // The copy at a given width: the introduction (with its margin; phones show it since 2026-10-10 evening) and the tallest of // the five headings. The heading box's min-height is the latter, so the rails never move between scenes. function measureCopyHeight(width) { if (!view || !copyScenes.length) return { lead: 0, main: 120 }; var probe = view.cloneNode(true); probe.className = 'hc-copy-view hc-copy-probe'; probe.removeAttribute('style'); probe.style.cssText = 'position:absolute;left:0;top:0;width:' + width + 'px;visibility:hidden;pointer-events:none;transform:none;opacity:1'; Array.prototype.forEach.call(probe.querySelectorAll('[style]'), function (el) { el.removeAttribute('style'); }); var rl = probe.querySelector('.hc-chapter-rail'); if (rl) rl.parentNode.removeChild(rl); explanation.appendChild(probe); var main = probe.querySelector('.hc-copy-main'), t = probe.querySelector('.hc-copy-title'), lead = probe.querySelector('.hc-copy-lead'); main.style.minHeight = '0px'; // the copy box's min-height is this measurement: never floor it with the last one var max = 0; for (var i = 0; i < copyScenes.length; i++) { t.innerHTML = headingHTML(copyScenes[i]); max = Math.max(max, main.getBoundingClientRect().height); } var leadH = 0; if (lead && getComputedStyle(lead).display !== 'none') leadH = lead.getBoundingClientRect().height + (parseFloat(getComputedStyle(lead).marginBottom) || 0); explanation.removeChild(probe); return { lead: Math.ceil(leadH), main: Math.ceil(max) }; } function measurePhone() { if (!PHONE || !video) return null; stats.phoneMeasures++; var width = stage.clientWidth, height = smallViewportHeight(); if (width < 1 || height < 1) return null; var stageStyle = getComputedStyle(stage); var column = width - (parseFloat(stageStyle.paddingLeft) || 0) - (parseFloat(stageStyle.paddingRight) || 0); // the stage keeps its native side padding var navbar = document.querySelector('.navbar.w-nav'); var navRect = navbar ? navbar.getBoundingClientRect() : null; var navBottom = navRect && navRect.top <= 1 && navRect.bottom > 0 ? navRect.bottom : 64; var ratio = PHONE.uiHeight / PHONE.uiWidth; var railRoom = 29; // down to the rails' 6 px bar (the rail's 4 px margin + 19 + 6); its tap area may run on below var gapBottom = CFG.phoneGapBottom, badge = document.querySelector('.w-webflow-badge'); if (badge && badge.getClientRects().length) gapBottom = Math.max(gapBottom, Math.ceil(window.innerHeight - badge.getBoundingClientRect().top) + 8); // keep the pills clear of a 'Made in Webflow' badge var fixed = navBottom + CFG.phoneGapTop + CFG.phoneGapCopy + railRoom + gapBottom; var textH = 0, leadH = 0; function fitWindow() { var w = Math.floor(Math.min(column, CFG.phoneMaxWidth)); var wide = root.classList.contains('hc-phone-compact'); // short phones: the copy spans the column (fewer lines), the window may be narrower for (var pass = 0; pass < 3; pass++) { // a narrower window wraps the copy more: settle in a pass or two var copy = measureCopyHeight(wide ? Math.floor(column) : w); textH = copy.main; leadH = copy.lead; var fit = Math.floor((height - fixed - leadH - textH) / ratio); if (fit >= w) break; w = fit; } return w; } root.classList.remove('hc-phone-compact'); var w = fitWindow(); if (w < CFG.phoneMinWidth + 60) { // a short phone (iPhone SE): smaller headings, so the window keeps its size root.classList.add('hc-phone-compact'); var compactW = fitWindow(); if (compactW > w) w = compactW; else { root.classList.remove('hc-phone-compact'); w = fitWindow(); } } if (w < CFG.phoneMinWidth) { root.classList.remove('hc-phone-compact'); return null; } var h = Math.round(w * ratio); setVar('--hc-phone-w', w + 'px'); setVar('--hc-phone-h', h + 'px'); setVar('--hc-phone-text-h', Math.ceil(textH) + 'px'); setVar('--hc-phone-lead-h', Math.ceil(leadH) + 'px'); return { phone: true, width: width, height: height, column: column, navBottom: navBottom, w: w, h: h, textH: Math.ceil(textH), leadH: Math.ceil(leadH), compact: root.classList.contains('hc-phone-compact') }; } // ---- layouts -------------------------------------------------------------------------------------------------- function useChild(next) { // switching between the player and the video: the one left behind parks if (next === videoChild) bootPhoneMedia(); if (child === next) return; stopStory(); child = next; phoneActive = -1; M.active = -1; arrived = false; settled = false; } function leaveMarquee() { if (mode !== 'marquee') return; snapper.interrupt(); gate.reset(); copyReveal.cancel(); setPendingRail(-1); clearTimeout(pendingTimer); if (viewTitle) [viewLead, viewTitle, rail].forEach(function (el) { if (el) { el.style.removeProperty('opacity'); el.style.removeProperty('transform'); } }); inert(rail, false); demo.style.removeProperty('transform'); var poster = posterEl(); if (poster) poster.style.removeProperty('transform'); diagnose('hcGrow', 'off'); } function enterMarquee() { useChild(frameChild); mode = 'marquee'; diagnose('hcMode', 'marquee'); diagnose('hcPhase', 'marquee'); buildCopyView(); if (frameChild.scenes) { buildCopyScenes(frameChild.scenes); ensureSegments(); } M.active = -1; M.scale = -1; lastBrush = -1; if (!M.entered) copyReveal.reset(); // before the first entrance: introduction, heading and rails hidden else copyReveal.restore(); // after it: the introduction and rails as they were (the heading reveals) if (frameChild.ready) paintMarquee(frameChild.timeMs || CFG.parkMs, frameChild.playing, frameChild.rate); measureMarquee(); grow(); root.classList.remove('hc-opening'); snapper.reset(); snapper.schedule(); if (frameChild.ready) gate.onReady(); } // Phones: a still layout. Title, window and copy sit in the page's normal flow (styles-v2.css); nothing here listens to // the scroll. The story runs whenever most of the window is on screen (the IntersectionObserver below) and pauses // otherwise — no opening, no beat, no settle. function enterPhone() { var wasPhone = mode === 'phone'; leaveMarquee(); useChild(videoChild); video.loop = false; // the controller loops it (and replays the fade-in) [demo, intro, explanation].forEach(function (el) { el.style.removeProperty('transform'); el.style.removeProperty('opacity'); el.style.removeProperty('pointer-events'); inert(el, false); }); mode = 'phone'; diagnose('hcMode', 'phone'); buildCopyView(); buildCopyScenes(PHONE.scenes); ensureSegments(); geometry = measurePhone(); if (!geometry) { enterStacked(false); return; } // too short for window + copy (a landscape phone): the stacked layout hideTap(); if (!wasPhone) parkChild(); arrived = true; settled = true; // nothing to arrive at on a phone diagnose('hcPhase', 'story'); paintCopy(screenMs()); root.classList.remove('hc-opening'); updateInView(); sync(); } function enterStacked(staticLayout) { leaveMarquee(); root.classList.remove('hc-phone', 'hc-phone-compact', 'hc-marquee'); useChild(!desktopWidth.matches && PHONE && video ? videoChild : frameChild); mode = staticLayout ? 'static' : 'stacked'; arrived = false; settled = false; geometry = null; root.classList.remove('hc-opening'); [demo, intro, explanation].forEach(function (el) { el.style.removeProperty('transform'); el.style.removeProperty('opacity'); el.style.removeProperty('pointer-events'); inert(el, false); }); stopStory(); panels.forEach(function (panel) { panel.removeAttribute('aria-current'); panel.removeAttribute('aria-hidden'); }); diagnose('hcMode', mode); diagnose('hcPhase', mode); setPendingRail(-1); if (child === videoChild) showTap('tap'); // nothing plays by itself here: the poster and a play button } function measure() { if (measureRaf) { cancelAnimationFrame(measureRaf); measureRaf = 0; } var force = forceMeasure; forceMeasure = false; var marquee = desktop.matches; // desktop: the marquee, with or without motion var phone = !desktopWidth.matches && !reduced.matches && !!PHONE && !!video; // Phones: only the width and the small-viewport height size the layout. The iOS toolbar collapsing as the visitor // scrolls fires resize with neither changed — nothing to do (a fonts/load re-measure is forced). if (phone && !force && (mode === 'phone' || mode === 'stacked') && phoneSize && phoneSize.w === stage.clientWidth && phoneSize.h === smallViewportHeight()) return; root.classList.toggle('hc-enhanced', marquee || !reduced.matches); root.classList.toggle('hc-marquee', marquee); root.classList.toggle('hc-phone', phone); root.classList.remove('hc-side'); root.classList.add('hc-ready'); setVar('--hc-logical-width', LW + 'px'); setVar('--hc-logical-height', LH + 'px'); setVar('--hc-logical-ratio', LW / LH); if (phone) { phoneSize = { w: stage.clientWidth, h: smallViewportHeight() }; if (mode !== 'phone') { enterPhone(); return; } geometry = measurePhone(); if (!geometry) { enterStacked(false); return; } if (!childPlaying()) paintCopy(screenMs()); return; } phoneSize = null; if (!marquee) { setVar('--hc-mobile-gap', '32px'); setVar('--hc-demo-scale', String(media.clientWidth / LW)); if (mode !== (reduced.matches ? 'static' : 'stacked')) enterStacked(reduced.matches); return; } if (mode !== 'marquee') { enterMarquee(); return; } measureMarquee(); } function scheduleMeasure(force) { if (force === true) forceMeasure = true; if (!measureRaf) measureRaf = requestAnimationFrame(measure); } // ---- wiring ------------------------------------------------------------------------------- // Phones: on screen = at least phoneVisibleRatio of the window (the video starts once most of it is in view and // pauses once most of it has left). Thresholds every 5 %, so a mode change can re-judge the last ratio at once. // (Desktop: the gate has its own observer and conditions.) function updateInView() { if (!('IntersectionObserver' in window)) return; inView = lastRatio >= CFG.phoneVisibleRatio - 0.01; diagnose('hcInView', inView); } if ('IntersectionObserver' in window) { var steps = []; for (var k = 0; k <= 20; k++) steps.push(k / 20); new IntersectionObserver(function (entries) { lastRatio = entries[entries.length - 1].intersectionRatio; updateInView(); sync(); }, { threshold: steps }).observe(media); } document.addEventListener('visibilitychange', function () { if (document.hidden) { noteHidden(); vlog('hidden'); sync(); tick(); } else onWake(); }); window.addEventListener('pagehide', function () { noteHidden(); vlog('pagehide'); }); window.addEventListener('pageshow', function (e) { onWake(e.persisted === true); if (mode === 'marquee') { scheduleGrow(); } }); // back/forward cache, iOS app switch window.addEventListener('focus', onWake); window.addEventListener('scroll', function () { if (mode === 'phone') warmVideo(); else if (mode === 'marquee') scheduleGrow(); }, { passive: true }); // phones: the first scroll only starts fetching the video window.addEventListener('resize', function () { scheduleMeasure(false); }, { passive: true }); // Desktop: a late reflow (fonts, content above the demo, the page's own scripts) can move the resting position without // a resize — the hero's stage and the page's body are watched and the growth and snap target re-measured (Codex's // marquee.js watched its opening and masthead the same way). Cheap: one read per frame at most, and only on a change. if ('ResizeObserver' in window) { var reflow = new ResizeObserver(function () { if (mode === 'marquee') scheduleGrow(); }); reflow.observe(stage); if (document.body) reflow.observe(document.body); } window.addEventListener('load', function () { forceMeasure = true; measure(); }, { once: true }); reduced.addEventListener('change', measure); desktop.addEventListener('change', measure); desktopWidth.addEventListener('change', measure); if (document.fonts && document.fonts.ready) document.fonts.ready.then(function () { scheduleMeasure(true); }); // ?hcdebug: a small read-out of the hero's state in the corner (for checks on a real device; nothing otherwise) if (/[?&]hcdebug\b/.test(location.search)) { var debugBox = document.createElement('div'); debugBox.style.cssText = 'position:fixed;left:8px;bottom:8px;z-index:2147483647;max-width:calc(100vw - 16px);padding:6px 8px;border-radius:8px;background:rgba(18,20,38,.86);color:#fff;font:10px/1.35 ui-monospace,Menlo,monospace;white-space:pre;pointer-events:none'; document.body.appendChild(debugBox); setInterval(function () { var lines = []; if (mode === 'marquee') { var g = gate.state; lines.push('marquee y ' + Math.round(window.scrollY) + ' target ' + Math.round(snapTarget()) + ' snap ' + snapper.state + ' arrived ' + snapper.hasArrived + ' motion ' + (root.dataset.hcMotion || '-'), 'grow p ' + M.p.toFixed(3) + ' scale ' + M.scale + ' entered ' + M.entered + ' scene ' + M.active + ' heading ' + (viewTitle ? viewTitle.dataset.revealed : '-'), 'player ' + (frameChild.ready ? 'ready' : 'loading') + ' ' + (frame.dataset.hcLoader || 'hosted') + ' t ' + Math.round(frameChild.timeMs) + ' playing ' + frameChild.playing + ' | gate wanted ' + g.wanted + ' running ' + g.running + ' cmd ' + g.commanded + ' still ' + g.sinceMotion + 'ms', 'plays ' + stats.plays + ' snaps ' + stats.snaps + ' seeks ' + stats.seeks + ' loops ' + loops); } else if (video) { lines.push('mode ' + mode + ' run ' + running + ' paused ' + paused + ' inView ' + inView + ' hidden ' + document.hidden, 'video t ' + video.currentTime.toFixed(2) + ' paused ' + video.paused + ' rs ' + video.readyState + ' ns ' + video.networkState + ' frames ' + frameCount + (frameCbWorks ? '' : !rvfc ? ' (no rVFC)' : frameCount ? ' (clock)' : ' (none yet)') + (frameRearms ? ' rearms ' + frameRearms : ''), 'still ' + (revealed ? 'off' : 'on') + ' kicks ' + kickTries + ' interrupts ' + interruptions.length + ' reloads ' + reloads + '/' + reloadCap + (refreshes ? ' fresh ' + refreshes : '') + ' ' + (video.currentSrc || '').split('_').pop(), 'seeking ' + video.seeking + (seekingSince ? ' ' + ((performance.now() - seekingSince) / 1000).toFixed(1) + ' s' : '') + ' err ' + (video.error ? video.error.code : '-') + ' taps ' + stats.clicks + (jump ? ' jump ' + jump.t.toFixed(2) + ' try ' + jump.tries : '') + (performance.now() < fastUntil ? ' fast' : ''), 'shown ' + (shownMedia >= 0 ? shownMedia.toFixed(2) : '-') + ' copy ' + (screenMs() / 1000).toFixed(2) + (pendingRail >= 0 ? ' rail ' + (pendingRail + 1) + ' pending' : '') + ' data ' + (lastDataAt > 0 ? ((performance.now() - lastDataAt) / 1000).toFixed(1) + ' s ago' : '-') + ' buf ' + lastBuffered.toFixed(1) + ' s'); lines = lines.concat(trace); } else lines.push('mode ' + mode); debugBox.textContent = lines.join('\n'); }, 500); } window.HC_HERO = { jumpTo: jumpTo, stats: stats, get trace() { return trace.slice(); }, get state() { return { mode: mode, arrived: arrived, settled: settled, running: running, paused: paused, inView: inView, loops: loops, child: child, backend: child === videoChild ? 'video' : 'player', marquee: mode === 'marquee' ? { p: M.p, scale: M.scale, entered: M.entered, active: M.active, snap: snapper.state, hasArrived: snapper.hasArrived, target: snapTarget(), centered: snapper.centered(), positioned: gate.positioned(), gate: gate.state, heading: viewTitle ? viewTitle.dataset.revealed : null, revealComplete: copyReveal.complete } : null, player: { ready: frameChild.ready, timeMs: frameChild.timeMs, playing: frameChild.playing, loader: frame.dataset.hcLoader || 'hosted' }, video: video ? { time: video.currentTime, paused: video.paused, ended: video.ended, readyState: video.readyState, src: video.currentSrc, preload: video.getAttribute('preload') } : null, phone: geometry && geometry.phone ? { w: geometry.w, h: geometry.h, column: geometry.column, height: geometry.height, navBottom: geometry.navBottom, textH: geometry.textH, leadH: geometry.leadH, compact: geometry.compact } : null, ratio: lastRatio, scenes: copyScenes.map(function (s) { return { id: s.id, startMs: s.startMs, endMs: s.endMs, title: s.title }; }) }; } }; measure(); })(); /* Home Copy — reveal on scroll (Claude, 2026-10-04). The components below the hero fade and rise into place as they * enter the viewport, once, staggered within their group; the sections' own animations (Astra's start theirs at * 25–45 % visibility) follow the entrance. Web Animations API rather than CSS transitions, so no section's own * `transition` list is ever overridden; `translate` rather than `transform`, so no section's own transform is touched. * Nothing is hidden when the browser lacks the APIs or the visitor prefers reduced motion. */ (function () { 'use strict'; var SELECTORS = [ '#ha-problem-proposal .pp-header', '#ha-problem-proposal .pp-card', '#ht-two-ways .ht-two-heading', '#ht-two-ways .ht-two-card', '#ha-nudge-proposal .np-art', '#ha-nudge-proposal .np-copy', '#ha-install-proposal .ip-copy', '#ha-install-proposal .ip-art', '#hs-showcase .hs-showcase-header', '#hs-showcase .hs-showcase-player', '#hs-showcase .hs-showcase-picker', '#ha-faq .hf-intro', '#ha-faq .hf-item', '#ha-faq .hf-cta', '.footer .footer-box' ]; var DURATION = 700, STAGGER = 90, MAX_STAGGER_INDEX = 4, RISE = '0 24px', EASING = 'cubic-bezier(.2,.65,.2,1)'; if (!('IntersectionObserver' in window) || !Element.prototype.animate || !(window.CSS && CSS.supports && CSS.supports('translate', '0 1px'))) return; if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; var items = [], seen = []; SELECTORS.forEach(function (sel) { Array.prototype.forEach.call(document.querySelectorAll(sel), function (el) { if (seen.indexOf(el) !== -1 || el.closest('[data-hc-transition]')) return; seen.push(el); items.push(el); }); }); if (!items.length) return; // the stagger: an element's index among the revealed siblings before it (capped), so cards and FAQ items ripple items.forEach(function (el) { var i = 0, n = el.previousElementSibling; while (n) { if (seen.indexOf(n) !== -1) i++; n = n.previousElementSibling; } el.dataset.hcRevealIndex = String(Math.min(i, MAX_STAGGER_INDEX)); el.classList.add('hc-reveal'); }); var initial = true; var io = new IntersectionObserver(function (entries) { entries.forEach(function (entry) { if (!entry.isIntersecting) return; var el = entry.target; io.unobserve(el); var index = Number(el.dataset.hcRevealIndex) || 0; el.classList.remove('hc-reveal'); // already on screen when the page opened (a reload further down): no entrance, just show it if (initial && entry.boundingClientRect.top < window.innerHeight * 0.6) return; el.animate([{ opacity: 0, translate: RISE }, { opacity: 1, translate: '0 0' }], { duration: DURATION, delay: index * STAGGER, easing: EASING, fill: 'backwards' }); }); initial = false; }, { rootMargin: '0px 0px -8% 0px', threshold: 0.08 }); items.forEach(function (el) { io.observe(el); }); })();