/* 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); });
})();