PageSourceSearch

https://cobiro.com/assets/js/site.js?v=1788938211

js cobiro.com collected 2026-10-02 16:29:55 UTC 134,203 bytes, 2,912 lines download raw bytes

1/* Cobiro self-hosted page runtime.
2 * Dependency-free vanilla JS, no third-party runtime. All timings,
3 * easings and choreography mirror the original site's own configuration. */
4(function () {
5  'use strict';
6
7  var DATA = {"appear":[{"c":"cb-hveze6","f":{"o":"1","t":"none"},"dur":0.7,"ease":"cubic-bezier(.44,0,.26,.92)","th":0.5,"rev":true},{"c":"cb-9gbyet","f":{"o":"1","t":"none"},"dur":0.6,"ease":"cubic-bezier(.3,1.25,.5,1)","th":0.3,"rev":true},{"c":"cb-84617x","f":{"o":"1","t":"none"},"dur":0.6,"ease":"cubic-bezier(.3,1.25,.5,1)","th":0.3,"rev":true},{"c":"cb-10ro6tt","f":{"o":"1","t":"none"},"dur":0.6,"ease":"cubic-bezier(.3,1.25,.5,1)","th":0.3,"rev":true},{"c":"cb-1k15mnt","f":{"o":"1","t":"none"},"dur":0.6,"ease":"cubic-bezier(.3,1.25,.5,1)","th":0.3,"rev":true},{"c":"cb-1czqd6b","f":{"o":"1","t":"none"},"dur":0.8,"th":0.3},{"c":"cb-1ft7shw","f":{"o":"1","t":"none"},"dur":0.55},{"c":"cb-mfyh04","f":{"o":"1","t":"none"},"dur":0.55},{"c":"cb-pmy6oi","f":{"o":"1","t":"none"},"dur":0.55},{"c":"cb-1l5wmgs","f":{"o":"1","t":"none"},"dur":0.55},{"c":"cb-qj0rgr","f":{"o":"1","t":"none"},"dur":0.55},{"c":"cb-1u969o1","f":{"o":"1","t":"none"},"dur":0.55},{"c":"cb-t7rh9r","f":{"o":"1","t":"none"},"dur":0.55,"delay":0.2}],"studioOverlays":["cb-j3qtut","cb-gdip8d","cb-1tr5zr2","cb-9mjmm7"],"tickerSpeed":25};
8
9  var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
10  var SPRING = 'cubic-bezier(.215,.61,.355,1)';      // spring 300/60/1 approx
11  var SPRING_B02 = 'cubic-bezier(.3,1.25,.5,1)';      // spring bounce .2 approx
12  var TWEEN_INOUT = 'cubic-bezier(.44,0,.56,1)';
13  var HERO_EASE = 'cubic-bezier(.44,0,.26,.92)';
14
15  function each(sel, fn, root) {
16    var list = (root || document).querySelectorAll(sel);
17    for (var i = 0; i < list.length; i++) fn(list[i], i);
18  }
19  function byClass(cls, root) {
20    return Array.prototype.slice.call((root || document).getElementsByClassName(cls));
21  }
22  function fade(el, o, dur, ease, delay) {
23    if (!el) return;
24    el.style.transition = 'opacity ' + (dur || 0.4) + 's ' + (ease || SPRING_B02) + ' ' + (delay || 0) + 's';
25    el.style.opacity = String(o);
26  }
27  /* Has the reader arrived at this card? Half of it showing while the page is
28     still moving is not the same as being at it — the how-it-works cards each
29     play a sequence that only makes sense watched from the start. Something
30     taller than the viewport can never reach .9, so it settles for half. */
31  function landed(el, ratio) {
32    var tall = el.getBoundingClientRect().height > window.innerHeight * 0.9;
33    return ratio >= (tall ? 0.5 : 0.9);
34  }
35  /* A line that writes itself, for the how-it-works cards that open on a
36     command. Nothing is rewritten: the whole sentence stays in the DOM and the
37     part not yet written is only transparent, so it wraps where it always did
38     and the box it sits in never changes height mid-sentence. The copy is
39     whatever the document already carries, so a translated page types its own
40     translation. */
41  /* Nobody puts a full stop at the end of what they type into a prompt box.
42     Both commands are trimmed here rather than at their source: the photo
43     card's is the export's copy, which a regeneration would put back, and the
44     video card's full stop is what its dictionary entry is keyed on. */
45  function unpunctuated(text) { return text.replace(/\.$/, ''); }
46
47  var TYPE_MS = 26;   // per character
48  function typer(line) {
49    var full = line.textContent;
50    line.textContent = '';
51    var typed = document.createElement('span');
52    var caret = document.createElement('i');
53    caret.className = 'cb-caret';
54    var rest = document.createElement('span');
55    rest.className = 'cb-untyped';
56    rest.textContent = full;
57    line.appendChild(typed);
58    line.appendChild(caret);
59    line.appendChild(rest);
60    var timer = 0;
61    function at(n) {
62      typed.textContent = full.slice(0, n);
63      rest.textContent = full.slice(n);
64    }
65    return {
66      reset: function () {
67        clearTimeout(timer);
68        timer = 0;
69        at(0);
70        caret.style.display = '';
71      },
72      // Written already, nothing moving — what reduced motion should find.
73      whole: function () {
74        clearTimeout(timer);
75        timer = 0;
76        at(full.length);
77        caret.style.display = 'none';
78      },
79      run: function (done) {
80        var n = 0;
81        (function step() {
82          at(++n);
83          if (n < full.length) { timer = setTimeout(step, TYPE_MS); return; }
84          timer = 0;
85          caret.style.display = 'none';
86          done();
87        })();
88      }
89    };
90  }
91
92  /* The original page-enter fade ran inside the exported runtime, before first
93   * paint. Ours is a deferred script, so it fired *after* the page was already
94   * on screen: one blank frame, then a 0.2s fade back in — a visible flash and
95   * a Speed Index penalty for no gain. Dropped deliberately. */
96
97  /* ---------------- appear engine ---------------- */
98  function setState(el, st, animate, dur, ease, delay) {
99    if (animate) {
100      el.style.transition = 'opacity ' + dur + 's ' + ease + ' ' + (delay || 0) + 's, transform ' + dur + 's ' + ease + ' ' + (delay || 0) + 's';
101    } else {
102      el.style.transition = 'none';
103    }
104    requestAnimationFrame(function () {
105      if (st.o != null) el.style.opacity = st.o;
106      if (st.t != null) el.style.transform = st.t;
107    });
108  }
109  DATA.appear.forEach(function (item) {
110    byClass(item.c).forEach(function (el) {
111      var start = { o: el.style.opacity || '1', t: el.style.transform || 'none' };
112      if (reduced) { setState(el, item.f, false); return; }
113      var io = new IntersectionObserver(function (entries) {
114        entries.forEach(function (entry) {
115          if (entry.isIntersecting) {
116            setState(el, item.f, true, item.dur, item.ease || SPRING, item.delay);
117            if (!item.rev) io.unobserve(el);
118          } else if (item.rev) {
119            setState(el, start, true, item.dur, item.ease || SPRING, 0);
120          }
121        });
122      }, { threshold: item.th || 0 });
123      io.observe(el);
124    });
125  });
126
127  /* ---------------- logo ticker (speed 25px/s, linear, infinite) ---------- */
128  each('.cb-1d97jqv-container section', function (section) {
129    var ul = section.querySelector('ul');
130    if (!ul) return;
131    section.style.opacity = '1';
132    if (reduced) return;
133    var items = Array.prototype.slice.call(ul.children);
134    var setWidth = 0, offset = 0, running = true, last = null;
135    function tick(ts) {
136      if (last == null) last = ts;
137      if (running && setWidth > 0) {
138        offset = (offset + DATA.tickerSpeed * (ts - last) / 1000) % setWidth;
139        ul.style.transform = 'translateX(' + (-offset) + 'px)';
140      }
141      last = ts;
142      requestAnimationFrame(tick);
143    }
144    new IntersectionObserver(function (es) {
145      es.forEach(function (e) { running = e.isIntersecting && !document.hidden; });
146    }).observe(section);
147    function init() {
148      var gap = parseFloat(getComputedStyle(ul).columnGap || getComputedStyle(ul).gap) || 0;
149      setWidth = gap * items.length;
150      items.forEach(function (li) { setWidth += li.getBoundingClientRect().width; });
151      if (setWidth < 50) { setTimeout(init, 300); return; }
152      var copies = Math.min(Math.max(1, Math.round(section.getBoundingClientRect().width / setWidth * 2) + 1), 8);
153      for (var k = 0; k < copies; k++) {
154        items.forEach(function (li) {
155          var c = li.cloneNode(true);
156          c.setAttribute('aria-hidden', 'true');
157          ul.appendChild(c);
158        });
159      }
160      requestAnimationFrame(tick);
161    }
162    if (document.readyState === 'complete') init();
163    else window.addEventListener('load', init);
164  });
165
166  /* ---------------- videos: play in view, pause out ---------------- */
167  var vio = new IntersectionObserver(function (entries) {
168    entries.forEach(function (e) {
169      if (e.isIntersecting) e.target.play().catch(function () {});
170      else e.target.pause();
171    });
172    // preload="none" means the fetch starts on play(), so give it a head start.
173  }, { threshold: 0.1, rootMargin: '400px 0px' });
174  each('video', function (v) {
175    v.muted = true;
176    // #generatevideo owns its clip: the card is a claim about generating that
177    // video, so it must not already be running behind the frosted cover while
178    // the card is still saying it is being made. That block plays it once the
179    // generating animation has finished, and pauses it on the way out.
180    if (v.closest('#generatevideo')) return;
181    // Leave preload="none" from the markup alone: every video sits well below
182    // the fold, and forcing preload="auto" here pulled 4.5 MB down during load.
183    vio.observe(v);
184  });
185
186  /* ------ sticky nav: variant 2 past ticker (+user: slightly narrower) ---- */
187  var ticker = document.getElementById('ticker');
188  var root = document.documentElement;
189
190  /* ------ sticky nav: which colourway the glass wears --------------------
191     The pill has a white version and a dark one (see the glass block in
192     site.css), and which one is right depends on what is passing underneath
193     rather than on how far down the page we are — this page alternates dark
194     bands with white ones the whole way down. So read it.
195
196     Hit-test three points across the pill, walk up from whatever is under
197     each one until something paints an opaque colour, and take its luminance.
198     The vote only goes white when every readable sample is light and at least
199     two of the three were readable; everything else stays dark, because dark
200     over white is merely dull, while white over dark is a nav nobody can
201     read.
202
203     Anything drawn with an image cannot be reduced to a colour, but only some
204     of those are what the pill is really sitting on, and there are two ways
205     to be the wrong one. Too small: #ticker's logos are 6-9% of the pill's
206     width and the white behind them is what the nav reads against, while a
207     gallery card is 40% and the hero shot is wider than the pill. Too faint:
208     /about's hero grid is a gradient of 7% hairlines over white, and the
209     white is what matters. So an image is walked past unless it is both big
210     in each direction and solid enough to hide what is under it — and one
211     that is ends the vote at dark.
212
213     The two thresholds are hysteresis: the band between them holds whichever
214     colourway is already on, so a section edge sliding under the pill cannot
215     set it flickering. NAV_SETTLE is the other half of that — a reading has to
216     hold for that long before it commits, which is what keeps the 60px white
217     gutter between two dark bands from flashing the pill white on the way
218     past. Scroll slowly enough to see the gutter and it does switch.
219
220     Named apart from the how-it-works cards' own SETTLE on purpose: both are
221     var in this one function scope, so the same name was one variable, and
222     the card block further down assigns last — this reading held for 300ms
223     rather than the 150ms documented here. */
224  var LIGHT_ON = 0.62, LIGHT_OFF = 0.48;
225  var BLOCK_W = 0.25;
226  var FAINT = 0.25;
227  var NAV_SETTLE = 150;
228  var navLight = false, navPending = false, navSettle = 0, navSolid = false;
229
230  function luminance(c) {
231    var v = [c[0], c[1], c[2]].map(function (n) {
232      n /= 255;
233      return n <= 0.03928 ? n / 12.92 : Math.pow((n + 0.055) / 1.055, 2.4);
234    });
235    return 0.2126 * v[0] + 0.7152 * v[1] + 0.0722 * v[2];
236  }
237
238  /* Whether a background-image hides what is behind it. A bitmap always
239     does; a gradient only if one of its stops is more than faintly opaque,
240     which is what separates a backdrop from a grid or a hairline overlay. */
241  function opaqueImage(bg) {
242    if (bg.indexOf('url(') !== -1) return true;
243    var stops = bg.match(/rgba?\([^)]*\)/g);
244    if (!stops) return true;
245    for (var i = 0; i < stops.length; i++) {
246      var m = stops[i].match(/[\d.]+/g);
247      if ((m.length > 3 ? parseFloat(m[3]) : 1) > FAINT) return true;
248    }
249    return false;
250  }
251
252  /* One sample: the opaque background colour behind (x, y) as [r,g,b], the
253     string 'block' if something the pill's own size is painted there with an
254     image, or null if the stack ran out without either. */
255  function sample(x, y, nav, box) {
256    var stack = document.elementsFromPoint(x, y);
257    var under = null;
258    for (var i = 0; i < stack.length; i++) {
259      if (!nav.contains(stack[i])) { under = stack[i]; break; }
260    }
261
262    for (var n = under; n; n = n.parentElement) {
263      var tag = n.tagName;
264      var cs = getComputedStyle(n);
265
266      if (tag === 'IMG' || tag === 'VIDEO' || tag === 'CANVAS' || tag === 'svg'
267          || (cs.backgroundImage !== 'none' && opaqueImage(cs.backgroundImage))) {
268        var r = n.getBoundingClientRect();
269        if (r.width >= box.width * BLOCK_W && r.height >= box.height) return 'block';
270        continue;   /* small enough to see past: read what it is sitting on */
271      }
272      /* A faint image falls through to this element's own colour, which is
273         what it is tinting. */
274
275      var m = cs.backgroundColor.match(/[\d.]+/g);
276      if (!m) continue;
277      /* Below fully opaque the colour underneath still shows through enough
278         to matter, and compositing the whole stack is not worth it here. */
279      if ((m.length > 3 ? parseFloat(m[3]) : 1) >= 0.85) return [+m[0], +m[1], +m[2]];
280    }
281    return null;
282  }
283
284  /* The pill that is actually on screen: the export ships three nav variants
285     and hides two of them per breakpoint. */
286  function livePill() {
287    var pills = document.querySelectorAll('.cb-1owthep');
288    for (var i = 0; i < pills.length; i++) {
289      var r = pills[i].getBoundingClientRect();
290      if (r.width > 0 && r.height > 0) return pills[i];
291    }
292    return null;
293  }
294
295  function litBehind(pill) {
296    var nav = pill.closest('.cb-1os592c-container') || pill;
297    var r = pill.getBoundingClientRect();
298    var y = Math.round(r.top + r.height / 2);
299    var xs = [Math.round(r.left + 24), Math.round(r.left + r.width / 2), Math.round(r.right - 24)];
300    var read = 0, lit = 0;
301
302    for (var i = 0; i < xs.length; i++) {
303      var c = sample(xs[i], y, nav, r);
304      /* Big enough to be the backdrop and unreadable: keep the dark
305         colourway, which is legible over anything. */
306      if (c === 'block') return false;
307      if (!c) continue;
308      read++;
309      if (luminance(c) >= (navLight ? LIGHT_OFF : LIGHT_ON)) lit++;
310    }
311
312    return read >= 2 && lit === read;
313  }
314
315  function applyNav() {
316    root.classList.toggle('cb-nav-light', navLight);
317    root.classList.toggle('cb-nav-dark', !navLight);
318  }
319
320  function paintNav() {
321    /* Over the hero the pill keeps its exported 6% white and its white text,
322       so the colourway only has a say once the glass is solid. */
323    var solid = root.classList.contains('cb-nav-scrolled') || root.classList.contains('gc-doc');
324    var pill = solid && document.elementsFromPoint ? livePill() : null;
325    var want = pill ? litBehind(pill) : false;
326
327    /* Crossing into or out of the solid state is not a flicker risk — the
328       pill was transparent a frame ago — and settling there would paint the
329       dark colourway first and correct it afterwards, so the glass arriving
330       over a white band read as transparent, black, white. */
331    if (solid !== navSolid) {
332      navSolid = solid;
333      clearTimeout(navSettle);
334      navLight = navPending = want;
335      applyNav();
336      return;
337    }
338
339    if (want === navLight) {
340      /* back where we started, so drop anything that was on its way 
340in */
341      navPending = want;
342      clearTimeout(navSettle);
343    } else if (want !== navPending) {
344      navPending = want;
345      clearTimeout(navSettle);
346      /* On a timer rather than on the next scroll event, so that stopping
347         inside a band still lands on that band's colourway. */
348      navSettle = setTimeout(function () { navLight = navPending; applyNav(); }, NAV_SETTLE);
349    }
350
351    applyNav();
352  }
353
354  /* Where the glass arrives. The ticker sets it, but the hero runs 80px past
355     that point, and for those 80px the thing behind the pill is the hero shot
356     — which reads as dark, correctly, and then gives way to the white band a
357     few frames later. Solid there means the glass arrives black and turns
358     white almost immediately, which reads as a flash rather than as two
359     bands. So it waits for the hero to clear the pill as well. */
360  var hero = document.getElementById('hero');
361  var pillMid = 0;
362  function solidAt() {
363    var y = ticker ? ticker.offsetTop - 120 : 800;
364    if (!hero) return y;
365    if (!pillMid) {
366      /* The line the samples are taken on, so that the glass arrives exactly
367         when the reading stops being the hero. */
368      var pill = livePill();
369      var r = pill && pill.getBoundingClientRect();
370      pillMid = r ? r.top + r.height / 2 : 50;
371    }
372    var clear = hero.offsetTop + hero.offsetHeight - pillMid;
373    /* A ticker that is not the band under the hero (the front page keeps it
374       under the testimonials since 2026-09-09) says nothing about where the
375       hero ends. */
376    if (ticker && ticker.offsetTop > hero.offsetTop + hero.offsetHeight) return clear;
377    return Math.max(y, clear);
378  }
379
380  var navQueued = false;
381  function onScroll() {
382    var y = window.scrollY;
383    var past = y > solidAt();
384    var was = root.classList.contains('cb-nav-scrolled');
385    root.classList.toggle('cb-nav-scrolled', past);
386    /* The frame the glass arrives is the one frame that cannot wait for the
387       rAF below: the pill would paint one frame of the dark colourway before
388       the reading landed. */
389    if (past !== was) { paintNav(); return; }
390    /* One hit test per frame at most: three of them plus the walk up each
391       stack is more than a scroll handler should be doing per event. */
392    if (!navQueued) {
393      navQueued = true;
394      requestAnimationFrame(function () { navQueued = false; paintNav(); });
395    }
396  }
397  window.addEventListener('scroll', onScroll, { passive: true });
398  window.addEventListener('resize', function () {
399    pillMid = 0;               /* the breakpoint may have swapped the variant */
400    onScroll();
401  }, { passive: true });
402  onScroll();
403
404  /* ---------------- mobile hamburger menu ----------------
405     This menu is the one piece of copy the document does not contain, so it
406     is also the one place a translation cannot come from the markup. The page
407     hands the labels over in window.CB_I18N (PageShell writes it, English
408     pages omit it); anything missing falls back to the English below. */
409  var I18N = window.CB_I18N || {};
410  function label(en) { return I18N[en] || en; }
411
412  var burger = document.querySelector('.cb-7rc2c0');
413  if (burger) {
414    var bars = burger.querySelectorAll('div[class^="cb-"]');
415    for (var b = 0; b < bars.length && b < 4; b++) bars[b].classList.add('cb-bar-' + (b + 1));
416    var panel = document.createElement('div');
417    panel.className = 'cb-mmenu';
418    /* A page of ours is one document link the shell rewrites per language on
419       the way out. This menu is not in the document, so it does that itself —
420       otherwise it drops a German reader onto the English page. */
421    var docLang = document.documentElement.lang || 'en';
422    var langPrefix = docLang === 'en' ? '' : '/' + docLang;
423
424    /* The two app links are the same pair App\Utils\PageShell writes onto the
425       header buttons in the document; this menu is built here rather than
426       shipped, so it carries its own copy of them. Change one, change both.
427       Same for the page links: they are the nav's own list, in its order. */
428    [['How it works', '#showcase'], ['Solutions', '/solutions'],
429     ['Platform', '/platform'],
430     ['Features', '/features'],
431     ['Google CSS', '/google-css'],
432     ['Pricing', '/pricing'],
433     ['Log in', 'https://app.cobiro.com/'], ['Get started', 'https://app.cobiro.com/auth/signup']]
434      .forEach(function (l, i, arr) {
435        var a = document.createElement('a');
436        a.textContent = label(l[0]);
437        a.href = l[1].charAt(0) === '/' ? langPrefix + l[1] : l[1];
438        if (i === arr.length - 1) a.className = 'cb-mmenu-cta';
439        a.addEventListener('click', function () {
440          document.documentElement.classList.remove('cb-menu-open');
441        });
442        panel.appendChild(a);
443      });
444    document.body.appendChild(panel);
445    burger.addEventListener('click', function () {
446      document.documentElement.classList.toggle('cb-menu-open');
447    });
448  }
449
450  /* ------ showcase Studio/Lifestyle toggle (crossfade 0.4s spring) --------
451     One shared state for every breakpoint variant; driven by clicks and by
452     the #showcase scroll pin below. */
453  var LABEL_ON = 'rgb(24, 24, 27)';
454  var LABEL_OFF = 'rgba(24, 24, 27, 0.66)';   // .4 was 2.5:1 on white — below AA
455  var studioOn = false;
456
457  function setStudio(on, animate) {
458    on = !!on;
459    if (on === studioOn) return;
460    studioOn = on;
461    var dur = animate ? 0.4 : 0;
462    DATA.studioOverlays.forEach(function (cls) {
463      byClass(cls).forEach(function (el) { fade(el, on ? 1 : 0, dur); });
464    });
465    each('[data-cb-name="Select"]', function (sel) {
466      var knobWrap = sel.querySelector('[data-cb-name="Toggle"]');
467      if (knobWrap) {
468        var knob = knobWrap.firstElementChild || knobWrap;
469        var pad = parseFloat(getComputedStyle(knobWrap).paddingLeft) || 0;
470        var travel = knobWrap.clientWidth - 2 * pad - knob.offsetWidth;
471        if (!(travel > 0)) travel = 18;
472        knob.style.transition = animate ? 'transform .4s ' + SPRING_B02 : 'none';
473        knob.style.transform = 'translateX(' + (on ? travel : 0) + 'px)';
474      }
475      var labels = sel.querySelectorAll('p.cb-text'); // [Studio, Lifestyle]
476      for (var i = 0; i < labels.length; i++) {
477        labels[i].style.transition = 'color .3s ease';
478        labels[i].style.color = i === (on ? 1 : 0) ? LABEL_ON : LABEL_OFF;
479      }
480    });
481  }
482
483  each('[data-cb-name="Select"]', function (sel) {
484    sel.style.cursor = 'pointer';
485    sel.addEventListener('click', function () { setStudio(!studioOn, true); });
486  });
487
488  /* ------ #showcase scroll pin: hold the section in place while the toggle
489           flips Studio -> Lifestyle, then let the page scroll on ---------- */
490  (function () {
491    var section = document.getElementById('showcase');
492    if (!section || reduced) return;
493    if (!window.CSS || !CSS.supports || !CSS.supports('position', 'sticky')) return;
494    /* A sticky element only pins against the nearest scroll container, so one
495       clipping ancestor is enough to kill the hold and leave nothing but the
496       empty stretch behind it. The page root clips with overflow:clip, which
497       is fine — but engines without clip support fall back to hidden. */
498    for (var a = section.parentNode; a && a !== document.body; a = a.parentNode) {
499      var ov = getComputedStyle(a).overflowY;   // html/body scroll the viewport
500      if (ov !== 'visible' && ov !== 'clip') return;
501    }
502
503    /* Touch scrolling is the reason for the two sets of numbers below: a wheel
504       notch is ~100px, so a 0.75-viewport hold reads as a clear pause, but one
505       thumb flick covers 900px+ and would swallow the whole thing. On touch the
506       hold gets longer, the flip comes earlier, and a snap point catches flings
507       that die inside the hold (see .cb-pin-snap). Read per measure, not once:
508       device-mode toggles and hybrid laptops switch pointer type mid-session. */
509    var coarse = window.matchMedia('(hover: none) and (pointer: coarse)');
510
511    var wrap = document.createElement('div');
512    wrap.className = 'cb-pin';
513    section.parentNode.insertBefore(wrap, section);
514    wrap.appendChild(section);
515    // The extra scroll length lives on the wrapper, which carries the section's
516    // own white so the gap under it matches. It must NOT be a separate absolute
517    // filler: that paints over the sticky section and swallows the stats row at
518    // the bottom of it once the hold ends.
519    section.cbPinWrap = wrap;                   // anchor links target the top
520
521    /* The section is taller than most viewports, so park it on the part that
522       actually changes: heading + toggle + images, else toggle + images. */
523    function visible(sel) {
524      var list = section.querySelectorAll(sel);
525      for (var i = 0; i < list.length; i++) {
526        var r = list[i].getBoundingClientRect();
527        if (r.width > 0 && r.height > 0) return r;
528      }
529      return null;
530    }
531    function focusTop(vh, h) {
532      var top = section.getBoundingClientRect().top;
533      var toggle = visible('[data-cb-name="Select"]');
534      var images = visible('[data-cb-name="Image Wrapper"]');
535      var heading = visible('h2');
536      var t = 0;
537      if (toggle && images) {
538        var end = images.bottom - top;
539        var starts = [heading ? heading.top - top : toggle.top - top, toggle.top - top];
540        t = 16 - starts[1];                              // last resort: toggle at top
541        for (var i = 0; i < starts.length; i++) {
542          if (end - starts[i] + 40 <= vh) { t = (vh - (end - starts[i])) / 2 - starts[i]; break; }
543        }
544      } else {
545        t = vh - h;
546      }
547      return Math.round(Math.max(Math.min(0, t), Math.min(0, vh - h)));
548    }
549    // Inert unless the pointer is coarse: only then does CSS make it a snap
550    // target, so it can be built once and left alone.
551    var snap = document.createElement('div');
552    snap.className = 'cb-pin-snap';
553    wrap.appendChild(snap);
554
555    var pin = 0, stickTop = 0, flip = 0.35, flipped = null;
556    function measure() {
557      var vh = window.innerHeight;
558      var h = section.offsetHeight;
559      var touch = coarse.matches;
560      flip = touch ? 0.28 : 0.35;                  // how far into the hold it turns
561      pin = Math.round(touch ? Math.min(vh * 1.15, 900) : Math.min(vh * 0.75, 640));
562      stickTop = focusTop(vh, h);
563      section.style.position = 'sticky';           // inline: beats the section's own rule
564      section.style.top = stickTop + 'px';
565      wrap.style.height = (h + pin) + 'px';
566      wrap.style.scrollMarginTop = getComputedStyle(section).scrollMarginTop;
567      // Rest just past the flip, so a caught fling lands on the Lifestyle shot.
568      snap.style.top = Math.round(-stickTop + pin * (flip + 0.12)) + 'px';
569    }
570    function update() {
571      var p = pin > 0 ? (stickTop - wrap.getBoundingClientRect().top) / pin : 0;
572      var want = p >= flip;
573      if (want !== flipped) { flipped = want; setStudio(want, true); }
574    }
575    var pending = false;
576    function onPinScroll() {
577      if (pending) return;
578      pending = true;
579      requestAnimationFrame(function () { pending = false; update(); });
580    }
581    function remeasure() { measure(); update(); }
582    window.addEventListener('scroll', onPinScroll, { passive: true });
583    window.addEventListener('resize', remeasure);
584    window.addEventListener('load', remeasure);
585    if (window.ResizeObserver) new ResizeObserver(remeasure).observe(section);
586    remeasure();
587  })();
588
589  /* ------ #howitworks: step number over each card headline ----------------
590     "From raw data to ad-ready in just a few steps" promises an order the
591     cards never state — they stack one over the next, each opening on a
592     headline of its own, so a reader who lands mid-section cannot tell how
593     far along the walkthrough they are. Number them from the DOM rather than
594     from a list here: the five headlines are the five cards, in order, and a
595     regenerated export that adds a sixth numbers it without a code change.
596     The eyebrow is copy the document does not contain, so it goes through the
597     same CB_I18N channel as the mobile menu. */
598  each('#howitworks [data-styles-preset="aR6qZQkh1"]', function (head, i) {
599    var box = head.parentNode;                    // the RichTextContainer
600    if (!box || !box.parentNode) return;
601    var tag = document.createElement('p');
602    tag.className = 'cb-step';
603    tag.textContent = label('Step') + ' ' + (i + 1);
604    box.parentNode.insertBefore(tag, box);
605  });
606
607  /* ------ #howitworks progress dots ----------------------------------------
608     The stack is five sticky cards, and mid-stack nothing on screen says
609     which of the five is being looked at. A column of dots to the right of
610     the cards does: one per card, the current one darker.
611
612     The cards are found the same way the step numbers are — the five
613     headlines, then the ancestor whose parent holds all five in separate
614     children. That parent is the stack; nothing here names a hashed class.
615
616     Fixed against the viewport rather than absolute in the stack, because the
617     cards are sticky: what the dots stand beside is the card that is pinned,
618     which is a place on the screen, not a place in the page. The column's own
619     spot is measured off the stack's right edge rather than written as a
620     number, so it lands in the margin beside the card at every width — and is
621     pulled back inside the viewport when that margin runs out.
622
623     The dots also take the reader: clicking one scrolls to that card, which
624     unwinds the ones above it and lets the card play again on arrival — the
625     same rewind leaving and returning by hand does. The scroll target cannot
626     be read off a pinned card, whose rect is the pin rather than its place in
627     the page, so each card's place is measured while the stack is still below
628     the middle of the screen — before anything can have pinned — and its pin
629     is its own sticky `top`. */
630  (function () {
631    var heads = document.querySelectorAll('#howitworks [data-styles-preset="aR6qZQkh1"]');
632    if (heads.length < 2) return;
633    function childHolding(parent, node) {
634      for (var c = parent.firstElementChild; c; c = c.nextElementSibling) {
635        if (c.contains(node)) return c;
636      }
637      return null;
638    }
639    var stack = null, cards = null;
640    for (var a = heads[0].parentNode; a && a !== document.body; a = a.parentNode) {
641      var p = a.parentNode;
642      if (!p) break;
643      var seen = [];
644      for (var i = 0; i < heads.length; i++) {
645        var c = childHolding(p, heads[i]);
646        if (!c || seen.indexOf(c) !== -1) { seen = null; break; }
647        seen.push(c);
648      }
649      if (seen) { stack = p; cards = seen; break; }
650    }
651    if (!cards) return;
652
653    var rail = document.createElement('div');
654    rail.className = 'cb-dots';
655    cards.forEach(function (_, j) {
656      var dot = document.createElement('button');
657      dot.type = 'button';
658      dot.setAttribute('aria-label', label('Step') + ' ' + (j + 1));
659      rail.appendChild(dot);
660    });
661    document.body.appendChild(rail);
662    var dots = Array.prototype.slice.call(rail.children);
663
664    /* Where each card lives in the page, and the offset its sticky top pins
665       it at. null until the stack has been seen safely unpinned; a resize
666       drops the numbers and they are retaken the same way. */
667    var places = null;
668    function measure() {
669      var top = stack.getBoundingClientRect().top;
670      places = cards.map(function (card) {
671        return {
672          at: card.getBoundingClientRect().top - top,
673          pin: parseFloat(getComputedStyle(card).top) || 0
674        };
675      });
676    }
677    window.addEventListener('resize', function () { places = null; });
678
679    rail.addEventListener('click', function (ev) {
680      var dot = ev.target;
681      while (dot && dot.tagName !== 'BUTTON') dot = dot.parentNode;
682      var j = dots.indexOf(dot);
683      if (j < 0) return;
684      if (!places) {
685        // Never measured — a mid-stack reload. Right for an unpinned card,
686        // a no-op for a pinned one; the next pass above the stack fixes it.
687        cards[j].scrollIntoView({ behavior: reduced ? 'auto' : 'smooth', block: 'start' });
688        return;
689      }
690      var to = stack.getBoundingClientRect().top + window.scrollY
691        + places[j].at - places[j].pin + 2;
692      window.scrollTo({ top: to, behavior: reduced ? 'auto' : 'smooth' });
693    });
694
695    var shown = false, active = -1, at = -1;
696    function update() {
697      var mid = window.innerHeight * 0.5;
698      var box = stack.getBoundingClientRect();
699      // The stack below the middle of the screen means nothing is pinned yet
700      // (the pins are all near the top), so the cards stand where they live.
701      if (!places && box.top >= mid) measure();
702      var on = box.top < mid && box.bottom > mid;
703      if (on !== shown) {
704        shown = on;
705        rail.classList.toggle('cb-dots-on', on);
706      }
707      if (!on) return;
708      // In the margin beside the card: 32px off its right edge, held 25px
709      // clear of the viewport's when the margin is narrower than that.
710      var x = Math.min(Math.round(box.right) + 32, window.innerWidth - 25);
711      if (x !== at) {
712        at = x;
713        rail.style.left = x + 'px';
714      }
715      // The last card whose top has passed the middle of the screen is the
716      // one pinned under the reader — sticky tops never leave, so "the
717      // furthest one that has arrived" is the current one.
718      var now = 0;
719      cards.forEach(function (card, j) {
720        if (card.getBoundingClientRect().top <= mid) now = j;
721      });
722      if (now !== active) {
723        active = now;
724        dots.forEach(function (d, j) { d.classList.toggle('cb-dot-on', j === now); });
725      }
726    }
727    var waiting = false;
728    function onMove() {
729      if (waiting) return;
730      waiting = true;
731      requestAnimationFrame(function () { waiting = false; update(); });
732    }
733    window.addEventListener('scroll', onMove, { passive: true });
734    window.addEventListener('resize', onMove);
735    update();
736  })();
737
738  /* ------ what the three command cards share -------------------------------
739     #photoshoot, #enhancephotos and #generatevideo each open on a command and
740     answer it. The beats, the marks and the send button are held here so they
741     read as one product answering three times rather than as three
742     animations. Above all three because #photoshoot is the first of them. */
743  var HOLD = 600;      // the plate on its own before the command starts
744  var SETTLE = 300;    // command finished to send
745  var WORK = 2000;     // how long it spins before the answer arrives
746
747  // Phosphor, filled, as the export draws them.
748  var CHECK = 'M232.49,80.49l-128,128a12,12,0,0,1-17,0l-56-56a12,12,0,1,1,17-17L96,183,215.51,63.51a12'
749    + ',12,0,0,1,17,17Z';
750  var CHEVRON_DOWN = 'M216.49,104.49l-80,80a12,12,0,0,1-17,0l-80-80a12,12,0,0,1,17-17L128,159l71.51-'
751    + '71.52a12,12,0,0,1,17,17Z';
752  var PAPER_PLANE = 'M240,127.89a16,16,0,0,1-8.18,14L63.9,237.9A16.15,16.15,0,0,1,56,240a16,16,0,0,1'
753    + '-15-21.33l27-79.95A4,4,0,0,1,71.72,136H144a8,8,0,0,0,8-8.53,8.19,8.19,0,0,0-8.26-7.47h-72a4,4,'
754    + '0,0,1-3.79-2.72l-27-79.94A16,16,0,0,1,63.84,18.07l168,95.89A16,16,0,0,1,240,127.89Z';
755  var CROSS = 'M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69'
756    + ',128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z';
757
758  function askIcon(path) {
759    var svg = '<svg viewBox="0 0 256 256" aria-hidden="true" focusable="false">';
760    if (path) {
761      svg += '<path d="' + path + '"/>';
762    } else {
763      // The export carries no clock, and its icons are all filled Phosphor
764      // paths; this one is drawn from primitives rather than guessed at.
765      svg += '<circle cx="128" cy="128" r="94" fill="none" stroke="currentColor" stroke-width="22"/>'
766        + '<path d="M128,72v56h44" fill="none" stroke="currentColor" stroke-width="22"'
767        + ' stroke-linecap="round" stroke-linejoin="round"/>';
768    }
769    return svg + '</svg>';
770  }
771
772  /* The photo card's own send button, so all three cards send with the same
773     mark: a plane that swaps for a spinning ring while the work is done. */
774  function buildSend() {
775    var send = document.createElement('span');
776    send.className = 'cb-ask-send';
777    send.innerHTML = askIcon(PAPER_PLANE) + '<i class="cb-send-spin" style="opacity:0"></i>';
778    return send;
779  }
780
781  /* Taking the button: it dips, comes back, and the plane hands over to the
782     ring. `later` is the card's own timer. */
783  function pressSend(send, later) {
784    if (!send) return;
785    send.style.transition = 'transform .12s ' + TWEEN_INOUT;
786    send.style.transform = 'scale(.86)';
787    later(function () {
788      send.style.transition = 'transform .28s ' + SPRING_B02;
789      send.style.transform = 'none';
790    }, 120);
791    fade(send.getElementsByTagName('svg')[0], 0, 0.25);
792    fade(byClass('cb-send-spin', send)[0], 1, 0.25);
793  }
794
795  /* And letting it go again: the ring off, the plane back. */
796  function restSend(send, dur) {
797    if (!send) return;
798    fade(send.getElementsByTagName('svg')[0], 1, dur);
799    fade(byClass('cb-send-spin', send)[0], 0, dur);
800  }
801
802  /* ------ #photoshoot slideshow ------------------------------------------
803     Entering the section still plays the original build-up: blurred plate ->
804     Step1(1000) -> Step2(400) -> Step3(400) -> three generated shots, with
805     the thumbnail rail growing from one stacked thumb to the three-up strip.
806
807     From there the three shots play on the same timer — big image swaps, the
808     matching thumbnail lights up — and the card ends on the last one with
809     Replay offered. Thumbnails take the run over on click.
810
811     The shots used to be scrubbed against a spacer that stretched the page b
811y
812     about a viewport. #photoshoot is one of the sticky stacked cards inside
813     #howitworks, and on a tall window that spacer read as a block of empty
814     background under the card: it sat some 680px further from the next card
815     than any other pair in the stack.
816
817     In front of that build-up goes the request it answers, the same command
818     box the two cards below it carry. This one is not typed: the card's claim
819     is that a setting is picked rather than described, so the command is three
820     attachments dropping in a second apart — the product, the template, the
821     environment — then the send, then a couple of seconds of the button
822     spinning over a plate that is still blurred, and only then the shots.
823     Their thumbnails are read out of the card's own shots, so a regenerated
824     export changes them with everything else and nothing here points at an
825     asset by name.
826
827     The box stays once the shots are up. That is why the thumbnail rail moves
828     to the top of the plate: the two would otherwise want the same corner, and
829     the rail is the part a reader clicks. The export's orb badge wanted that
830     corner too, and is hidden — the spinning send button is the same signal in
831     the place the request is.
832     ---------------------------------------------------------------------- */
833  (function () {
834    var section = document.getElementById('photoshoot');
835    if (!section) return;
836
837    var START = 'cb-v-b6y9cn';       // "Animation - Start": rail collapsed to one
838    var SLIDESHOW = 'cb-v-j7ioje';   // "Slideshow": rail spread to three thumbs
839    var SHOT = 1600;                 // how long each shot holds before the next
840    var BLOB_IN = 1000;              // one attachment to the next
841
842    /* The three things the request is made of, picked in the order a person
843       would: what is being shot, then how, then where.
844
845       The thumbnails are not named here. Each is read out of a shot the card
846       already carries — the rail's tight crop of the shoes for the product,
847       the seated model for the template, the staircase for the environment —
848       so they stay right through a regeneration, they match what they are
849       labelled, and they cost no request of their own. */
850    var SHOT_ASK = [
851      { label: 'Image: White sneaker' },
852      { label: 'Template: On-model' },
853      { label: 'Environment: Outside, staircase' }
854    ];
855
856    function srcOf(el) {
857      var img = el && el.querySelector('img');
858      return img ? (img.currentSrc || img.getAttribute('src') || '') : '';
859    }
860
861    /* The command box: the two attachments and a send, in the same box the
862       two cards below this one carry. Both start out of the box. */
863    function buildShotAsk(root, thumbSrc) {
864      var box = document.createElement('div');
865      box.className = 'cb-shot-ask';
866      var row = document.createElement('div');
867      row.className = 'cb-ask-row';
868      var blobs = SHOT_ASK.map(function (item, i) {
869        var blob = document.createElement('span');
870        blob.className = 'cb-blob';
871        blob.innerHTML = '<i class="cb-blob-thumb"></i><b></b>'
872          + askIcon(CHEVRON_DOWN) + askIcon(CROSS);
873        blob.getElementsByTagName('b')[0].textContent = label(item.label);
874        var thumb = byClass('cb-blob-thumb', blob)[0];
875        if (thumbSrc[i]) thumb.style.backgroundImage = 'url("' + thumbSrc[i] + '")';
876        row.appendChild(blob);
877        return blob;
878      });
879      var send = buildSend();
880      row.appendChild(send);
881      box.appendChild(row);
882      root.appendChild(box);
883      return { box: box, blobs: blobs, send: send };
884    }
885
886    // One view per breakpoint variant in the markup; only one is ever visible,
887    // both are kept in the same state.
888    var views = [];
889    each('[data-cb-name="Animation - Start"]', function (root) {
890      var base = byClass('cb-ea3kqq', root)[0];   // Slideshow-1 (top layer)
891      if (!base) return;
892      var thumbs = ['cb-18fpaqi-container', 'cb-zvanw-container', 'cb-1kgbf0f-container']
893        .map(function (c) { return byClass(c, root)[0]; });
894      thumbs.forEach(function (t) {
895        var inner = t && byClass('cb-64si6a', t)[0];
896        if (!inner) return;             // hand the .7 / 1 state over to site.css
897        inner.style.opacity = '';
898        inner.style.boxShadow = '';
899      });
900      var mid = byClass('cb-1505hwd', root)[0];         // Slideshow-2, on-model
901      var rail = byClass('cb-17zxmjm', root)[0];
902      var replay = byClass('cb-1nhzejv-container', root)[0];
903      /* The export hangs the rail off the bottom right, which is where the
904         command box now goes. It moves to the top of the plate — the thumbs
905         are worth seeing while the shots are still being made — and Replay
906         takes the corner it leaves. Both are set here rather than in site.css
907         because the rules to outrank are keyed on hashed class names. */
908      if (rail) { rail.style.top = '20px'; rail.style.bottom = 'auto'; }
909      if (replay) { replay.style.left = '22px'; replay.style.right = 'auto'; }
910      /* The export's shader badge — the orb the rest of the page uses to mark
911         AI work in progress — sits in the corner the rail now has, and the
912         send button says the same thing better: it is on the request that
913         asked for the work, not floating over the plate. Hidden rather than
914         dropped, so a regenerated export still lines up here. */
915      var badge = byClass('cb-vwa6mw-container', root)[0];
916      if (badge) badge.style.display = 'none';
917      var ask = buildShotAsk(root, [srcOf(thumbs[0]), srcOf(mid), srcOf(base)]);
918      views.push({
919        root: root,
920        base: base,
921        mid: mid,
922        fin: byClass('cb-vmidx1', root)[0],               // Slideshow3 (bottom)
923        rail: rail,
924        replay: replay,
925        thumbs: thumbs,
926        ask: ask.box,
927        blobs: ask.blobs,
928        send: ask.send
929      });
930    });
931    if (!views.length) return;
932
933    var timers = [];
934    function later(fn, ms) { timers.push(setTimeout(fn, ms)); }
935    function clearTimers() { timers.forEach(clearTimeout); timers = []; }
936    function blurTo(el, px, dur) {
937      if (!el) return;
938      el.style.transition = (el.style.transition ? el.style.transition + ', ' : '') +
939        'filter ' + dur + 's ' + SPRING_B02;
940      el.style.filter = 'blur(' + px + 'px)';
941      el.style.webkitFilter = 'blur(' + px + 'px)';
942    }
943    /* An attachment arriving in the box, or taken back out of it. */
944    function showBlob(el, on, dur) {
945      if (!el) return;
946      var d = dur === undefined ? 0.4 : dur;
947      el.style.transition = 'opacity ' + d + 's ' + SPRING_B02
948        + ', transform ' + d + 's ' + SPRING_B02;
949      el.style.opacity = on ? '1' : '0';
950      el.style.transform = on ? 'none' : 'translateY(6px) scale(.96)';
951    }
952    /* the rail's glass panel: transparent while the shots are still being
953     * "generated", frosted once the three-up strip is there to hold */
954    function railGlass(el, on) {
955      if (!el) return;
956      el.style.transition = 'background-color .4s ' + SPRING_B02 +
957        ', backdrop-filter .4s ' + SPRING_B02;
958      el.style.backgroundColor = on ? 'rgba(255, 255, 255, 0.25)' : 'rgba(255, 255, 255, 0)';
959      el.style.backdropFilter = el.style.webkitBackdropFilter = on ? 'blur(10px)' : 'blur(0px)';
960    }
961
962    /* the slideshow is up: rail spread, thumbs and Replay in, badge out. The
963       command box stays — the shots are what it asked for, and a reader
964       arriving late should still be able to see what was asked. Only the
965       button lets go, back from the spinner to the plane. */
966    var settled = false;
967    function settle() {
968      if (settled) return;
969      settled = true;
970      clearTimers();
971      views.forEach(function (v) {
972        restSend(v.send, 0.25);
973        blurTo(v.base, 0, 0.7);
974        v.root.classList.remove(START);
975        v.root.classList.add(SLIDESHOW);
976        railGlass(v.rail, true);
977        v.thumbs.forEach(function (t, j) {
978          if (t) { t.style.transitionDelay = (j * 0.15) + 's'; fade(t, 1, 0.4); }
979        });
980        fade(v.replay, 1, 0.4);
981      });
982    }
983
984    var slide = 0;
985    function apply(n) {   // 1..3 => which of the stacked shots is on top
986      if (n === slide) return;
987      slide = n;
988      views.forEach(function (v) {
989        fade(v.base, n === 1 ? 1 : 0, 0.7);
990        fade(v.mid, n <= 2 ? 1 : 0, 0.7);
991        fade(v.fin, 1, 0.7);
992        v.thumbs.forEach(function (t, j) {
993          if (t) t.classList.toggle('cb-thumb-on', j === n - 1);
994        });
995      });
996    }
997
998    function build() {
999      if (settled) return;   // a thumbnail was picked: the card is theirs now
1000      clearTimers();
1001      views.forEach(function (v) { fade(v.base, 1, 0.7); blurTo(v.base, 0, 0.7); });   // Step1
1002      later(function () { views.forEach(function (v) { fade(v.mid, 1, 0.7); }); }, 1000);
1003      later(function () { views.forEach(function (v) { fade(v.fin, 1, 0.7); }); }, 1400);
1004      later(function () {
1005        settle();                                  // clears the build-up timers
1006        apply(1);
1007        later(function () { apply(2); }, SHOT);
1008        later(function () { apply(3); }, 2 * SHOT);
1009      }, 1800);
1010    }
1011
1012    function reset() {
1013      clearTimers();
1014      settled = false;
1015      slide = 0;
1016      views.forEach(function (v) {
1017        fade(v.ask, 1, 0);
1018        v.blobs.forEach(function (b) { showBlob(b, false, 0); });
1019        restSend(v.send, 0);
1020        if (v.send) { v.send.style.transition = ''; v.send.style.transform = ''; }
1021        fade(v.base, 0.6, 0);
1022        blurTo(v.base, 7, 0);
1023        fade(v.mid, 0, 0);
1024        fade(v.fin, 0, 0);
1025        v.root.classList.remove(SLIDESHOW);
1026        v.root.classList.add(START);
1027        railGlass(v.rail, false);
1028        v.thumbs.forEach(function (t) {
1029          if (!t) return;
1030          t.classList.remove('cb-thumb-on');
1031          fade(t, 0, 0);
1032        });
1033        fade(v.replay, 0, 0);
1034      });
1035    }
1036
1037    /* The request, then the shots that answer it. The plate is given a beat on
1038       its own first, the two attachments drop in one after the other, and the
1039       last one is allowed to settle before it is sent — the same opening beat
1040       and the same last-move-to-send pause as the two cards below. */
1041    function play() {
1042      var t = HOLD;
1043      SHOT_ASK.forEach(function (item, i) {
1044        later(function () {
1045          views.forEach(function (v) { showBlob(v.blobs[i], true); });
1046        }, t);
1047        t += BLOB_IN;
1048      });
1049      later(function () {
1050        views.forEach(function (v) { pressSend(v.send, later); });
1051        /* And then nothing for a beat. The button spins on its own with the
1052           plate still blurred behind it — the work is being done, and a card
1053           that answered the moment it was asked would not read as work at
1054           all. The same WORK the two cards below it spin for, and like them
1055           the button lets go the moment the answer starts arriving. */
1056        later(function () {
1057          build();
1058          views.forEach(function (v) { restSend(v.send, 0.25); });
1059        }, WORK);
1060      }, t - BLOB_IN + SETTLE);
1061    }
1062
1063    // A picked thumbnail owns the card from then on: the rest of the run is off.
1064    function goTo(n) { settle(); clearTimers(); apply(n); }
1065    views.forEach(function (v) {
1066      v.thumbs.forEach(function (t, j) {
1067        if (!t) return;
1068        t.style.cursor = 'pointer';
1069        t.addEventListener('click', function () { goTo(j + 1); });
1070        var hit = t.querySelector('[tabindex]');   // Framer's focusable inner box
1071        if (hit) {
1072          hit.addEventListener('keydown', function (ev) {
1073            if (ev.key !== 'Enter' && ev.key !== ' ') return;
1074            ev.preventDefault();
1075            goTo(j + 1);
1076          });
1077        }
1078      });
1079      if (v.replay) {
1080        v.replay.style.cursor = 'pointer';
1081        v.replay.addEventListener('click', function () {
1082          reset();
1083          // Loop -> 1000ms -> the request again; play() holds its own 600 of
1084          // that before the first attachment, so the wait here is the rest.
1085          later(play, 1000 - HOLD);
1086        });
1087      }
1088    });
1089
1090    if (reduced) {
1091      // Asked and answered already, with nothing moving: the request is in the
1092      // box and the shots it asked for are up.
1093      views.forEach(function (v) {
1094        v.blobs.forEach(function (b) { showBlob(b, true, 0); });
1095      });
1096      settle();
1097      apply(1);
1098      return;
1099    }
1100    var inView = false;
1101    new IntersectionObserver(function (es) {
1102      es.forEach(function (e) {
1103        if (e.isIntersecting && !inView) { inView = true; play(); }
1104        else if (!e.isIntersecting && inView) { inView = false; reset(); }
1105      });
1106    }, { threshold: 0.5 }).observe(section);
1107  })();
1108
1109  /* ------ the model chip, on the two cards that have one -------------------
1110     Both of the cards that open on a command carry a chip naming the model
1111     the work goes to, and a chip that only ever reads one name says Cobiro is
1112     one model behind a logo. The card's claim is that it is the pick of them,
1113     so the chip is opened on camera: the list drops, the run walks down it,
1114     and the name it lands on becomes the chip's.
1115
1116     It is not a control. It plays; it does not answer a click.
1117
1118     The two chips are not the same markup — #enhancephotos uses the export's
1119     own, #generatevideo's is built further down this file — so what is shared
1120     is the list, the walk, and the timing, and the chip is handed in. */
1121
1122  /* One name per line, a tick against the one the chip is carrying. */
1123  function modelMenu(names) {
1124    var menu = document.createElement('span');
1125    menu.className = 'cb-ask-menu';
1126    names.forEach(function (name, j) {
1127      var opt = document.createElement('span');
1128      opt.className = 'cb-ask-opt' + (j === 0 ? ' cb-opt-on' : '');
1129      opt.innerHTML = '<b></b>' + askIcon(CHECK);
1130      opt.getElementsByTagName('b')[0].textContent = label(name);
1131      menu.appendChild(opt);
1132    });
1133    return menu;
1134  }
1135
1136  /* Hangs the list off a chip, closed. The chevron is marked on the way — it
1137     is whatever the chip ends with before the list arrives, a built svg on one
1138     card and the export's own wrapper on the other — so both cards turn the
1139     same mark over when the list is down. */
1140  function attachMenu(chip, names) {
1141    var chevron = chip.lastElementChild;
1142    if (chevron) chevron.classList.add('cb-chip-chevron');
1143    // The export clips its own chips, and the list has to leave the box. Set
1144    // here rather than in site.css: the rule to outrank is keyed on a hashed
1145    // class name, and those change with every regeneration.
1146    chip.style.overflow = 'visible';
1147    chip.appendChild(modelMenu(names));
1148  }
1149
1150  var MENU_OPEN = 260;   // command written -> the list is down
1151  var MENU_STEP = 240;   // one name to the next as the run walks it
1152  var MENU_TAKE = 460;   // the chosen name held, lit, before the list goes
1153
1154  /* The chip played as a select: open, walk to the last name in the list, take
1155     it, close. Nothing about the walk is hardcoded to three names — it ends
1156     wherever the list ends, which is where the model to land on is put.
1157
1158     `chip` is what opens and carries the list, `name` the element whose text
1159     is the chip's own label, and `later` the card's timer — so scrolling away
1160     mid-walk drops the walk with everything else that card has running. */
1161  function chooser(chip, name, later) {
1162    if (!chip || !name) return null;
1163    var menu = byClass('cb-ask-menu', chip)[0];
1164    if (!menu) return null;
1165    var opts = byClass('cb-ask-opt', menu);
1166    if (!opts.length) return null;
1167    var last = opts.length - 1;
1168
1169    function cursor(i) {   // where the run is; -1 for nowhere
1170      opts.forEach(function (o, j) { o.classList.toggle('cb-opt-cursor', j === i); });
1171    }
1172    function take(i) {     // the tick moves and the chip takes the name
1173      opts.forEach(function (o, j) { o.classList.toggle('cb-opt-on', j === i); });
1174      name.textContent = opts[i].getElementsByTagName('b')[0].textContent;
1175    }
1176    return {
1177      reset: function () {
1178        chip.classList.remove('cb-chip-open');
1179        cursor(-1);
1180        take(0);
1181      },
1182      // Chosen already, nothing moving — what reduced motion should find.
1183      whole: function () {
1184        chip.classList.remove('cb-chip-open');
1185        cursor(-1);
1186        take(last);
1187      },
1188      run: function (done) {
1189        chip.classList.add('cb-chip-open');
1190        cursor(0);
1191        var t = MENU_OPEN;
1192        for (var i = 1; i <= last; i++) {
1193          later(function (n) { return function () { cursor(n); }; }(i), t);
1194          t += MENU_STEP;
1195        }
1196        later(function () { take(last); }, t);
1197        later(function () {
1198          chip.classList.remove('cb-chip-open');
1199          cursor(-1);
1200          done();
1201        }, t + MENU_TAKE);
1202      }
1203    };
1204  }
1205
1206  /* ------ #enhancephotos: the prompt is written, sent, and answered --------
1207     The plate stacks three shots — the raw one off a table, the same shoes
1208     cut out, the finished studio shot — and wipes the upper two in on a timer
1209     from the moment the card is half seen. Everything that would explain why
1210     was already in the markup, switched off: the command sat there finished
1211     before anything happened, and the send button and the green tick the
1212     export ships were never touched.
1213
1214     So the card is played as the exchange it describes. The command is typed
1215     over the raw shot, the model is picked out of the list the chip carries,
1216     the send button is taken and spins while the work is done, and only then
1217     do the two wipes answer it — background off, then the studio finish —
1218     with the tick arriving as the last one lands.
1219
1220     The chip is the export's own, down to the sparkle on it, which is a plain
1221     mark rather than any one model's logo — so it stands for whichever of the
1222     three the run lands on. Only the name is written here. */
1223  var PHOTO_MODELS = ['GPT Image 2', 'Seedream 5.0 Pro', 'Nano Banana Pro'];
1224  each('[data-cb-name="Variant 1"]', function (root) {
1225    var s2 = byClass('cb-1lb4ol3', root)[0];   // Step2: cut out of the table
1226    var s3 = byClass('cb-10wemww', root)[0];   // Step3: studio finish
1227    if (!s2 || !s3) return;
1228    var line = root.querySelector('.cb-ufzeyj p');
1229    if (line) line.textContent = unpunctuated(line.textContent);
1230    /* The export answers the command with a green tick at the end of the
1231       line. The shot changing IS the answer — the tick said the same thing
1232       twice, and it is gone from all three command cards. Hidden rather than
1233       dropped, so a regenerated export still lines up here. */
1234    var tick = byClass('cb-1bl0sh1-container', root)[0];
1235    if (tick) tick.style.display = 'none';
1236    var send = byClass('cb-9xylxy', root)[0];
1237    var icon = send && byClass('cb-xgt3b1-container', send)[0];
1238    /* The export ships a Replay in the top right at opacity 0 and never
1239       touches it again. It moves to the top left — where every card in the
1240       stack now keeps its Replay — and comes in once the card has answered. */
1241    var replay = byClass('cb-6o78ro-container', root)[0];
1242    if (replay) { replay.style.left = '22px'; replay.style.right = 'auto'; }
1243    /* The model chip and the name inside it. The export ships one name in the
1244       markup; the list replaces it, so what the export says only decides what
1245       a reader sees before the card has been reached. */
1246    var chip = byClass('cb-meqkzm', root)[0];
1247    var chipName = chip && chip.querySelector('.cb-6pymgq p');
1248    if (chip && chipName) attachMenu(chip, PHOTO_MODELS);
1249    var choose = chooser(chip, chipName, later);
1250    if (choose) choose.reset();
1251
1252    /* The export rebuilt a mask gradient every frame to wipe these in. Same
1253       move as a transition (see site.css): the gradient is fixed and only its
1254       position travels, which the compositor can carry on its own and which a
1255       class can start. */
1256    function hide(el) {
1257      // The export ships the hidden state as an inline mask, which no class
1258      // can outrank; clearing it hands the mask over to the rules in site.css
1259      // and leaves the rest of the inline style (the centring transform) be.
1260      el.style.mask = el.style.webkitMask = '';
1261      el.classList.add('cb-wipe');
1262      el.classList.remove('cb-wipe-run', 'cb-wipe-in');
1263    }
1264    function wipe(el) { el.classList.add('cb-wipe-run', 'cb-wipe-in'); }
1265    if (reduced) {
1266      // Drop the export's inline mask without putting one back: the finished
1267      // shot stands, answered, and nothing moves.
1268      s2.style.mask = s2.style.webkitMask = '';
1269      s3.style.mask = s3.style.webkitMask = '';
1270      if (choose) choose.whole();
1271      return;
1272    }
1273
1274    var type = line ? typer(line) : null;
1275
1276    // A plain ring rather than the orb the badges use: inside a 30px button
1277    // the orb is a smudge, and what is wanted here is only "working".
1278    var spin = null;
1279    if (send) {
1280      spin = document.createElement('i');
1281      spin.className = 'cb-send-spin';
1282      spin.style.opacity = '0';
1283      send.appendChild(spin);
1284    }
1285
1286    var etimers = [];
1287    function later(fn, ms) { etimers.push(setTimeout(fn, ms)); }
1288    function reset() {
1289      etimers.forEach(clearTimeout);
1290      etimers = [];
1291      if (type) type.reset();
1292      if (choose) choose.reset();
1293      hide(s2);
1294      hide(s3);
1295      if (spin) fade(spin, 0, 0);
1296      if (icon) fade(icon, 1, 0);
1297      if (send) { send.style.transition = ''; send.style.transform = ''; }
1298      if (replay) fade(replay, 0, 0);
1299    }
1300    function press() {
1301      if (send) {
1302        send.style.transition = 'transform .12s ' + TWEEN_INOUT;
1303        send.style.transform = 'scale(.86)';
1304        later(function () {
1305          send.style.transition = 'transform .28s ' + SPRING_B02;
1306          send.style.transform = 'none';
1307        }, 120);
1308      }
1309      fade(icon, 0, 0.25);
1310      fade(spin, 1, 0.25);
1311    }
1312    function answer() {
1313      later(function () {
1314        press();                    // and the button spins while it is worked on
1315        later(function () {
1316          fade(spin, 0, 0.25);      // the work is done — now the shot answers
1317          fade(icon, 1, 0.25);
1318          wipe(s2);                                            // off the table
1319          later(function () { wipe(s3); }, 800);               // into the studio
1320          // the studio finish has landed: done, and Replay is on offer
1321          later(function () { if (replay) fade(replay, 1, 0.35); }, 2000);
1322        }, WORK);
1323      }, SETTLE);
1324    }
1325    // Written, then the model taken out of the list, then sent.
1326    function pickModel() {
1327      if (choose) later(function () { choose.run(answer); }, SETTLE);
1328      else answer();
1329    }
1330    function play() {
1331      // The shot it is asked about is given a beat on its own first — the card
1332      // has to be looked at before it is worth watching it change.
1333      later(function () { type ? type.run(pickModel) : pickModel(); }, HOLD);
1334    }
1335
1336    reset();
1337    if (replay) {
1338      replay.style.cursor = 'pointer';
1339      replay.addEventListener('click', function () { reset(); play(); });
1340    }
1341    var card = s2.closest('[id]') || s2;
1342    var playing = false;
1343    new IntersectionObserver(function (es) {
1344      es.forEach(function (e) {
1345        if (!playing && landed(card, e.intersectionRatio)) { playing = true; play(); }
1346        else if (!e.isIntersecting && playing) { playing = false; reset(); }
1347      });
1348    }, { threshold: [0, 0.5, 0.9] }).observe(card);
1349  });
1350
1351  /* ------ #attributes scanner: bar in @+500, 2s sweep @+900, end @+2900 ---- */
1352  each('[data-cb-name="Animation2"]', function (root) {
1353    var bar = byClass('cb-18mvb6l', root)[0];
1354    var after = byClass('cb-dsszb5', root)[0];
1355    var replay = byClass('cb-4qnd2r-container', root)[0];
1356    // The export centres this one on a point near the top right; the top left
1357    // is where every card in the stack keeps its Replay now.
1358    if (replay) {
1359      replay.style.top = '22px';
1360      replay.style.left = '22px';
1361      replay.style.transform = 'none';
1362    }
1363    if (!bar || !after) return;
1364    if (reduced) {
1365      after.style.webkitMaskImage = 'none';
1366      after.style.maskImage = 'none';
1367      return;
1368    }
1369    /* The export ships the coloured plate fully visible, and run() only masks
1370       it down when the card lands — so a reader scrolling in slowly met the
1371       answer before the scan. Masked from the start instead: colour is what
1372       the scan has passed over, and there is no colour before there is a
1373       scan. */
1374    var M0 = 'linear-gradient(180deg, rgb(0,0,0) 0%, rgba(0,0,0,0) 1%)';
1375    after.style.webkitMaskImage = M0;
1376    after.style.maskImage = M0;
1377    var host = bar.parentElement;
1378    function sweep() {
1379      var t0 = null, D = 2000;
1380      function frame(ts) {
1381        if (!t0) t0 = ts;
1382        var p = Math.min((ts - t0) / D, 1);
1383        var e = p * p * (3 - 2 * p); // smoothstep ~ spring bounce 0
1384        var y = e * 100;
1385        bar.style.top = 'calc(' + y + '% - 1px)';
1386        var m = 'linear-gradient(180deg, rgb(0,0,0) ' + (y - 1) + '%, rgba(0,0,0,0) ' + (y + 0.5) + '%)';
1387        after.style.webkitMaskImage = m;
1388        after.style.maskImage = m;
1389        if (p < 1) requestAnimationFrame(frame);
1390        else setTimeout(end, 0);
1391      }
1392      requestAnimationFrame(frame);
1393    }
1394    function end() {
1395      fade(bar, 0, 0.7);
1396      var m = 'linear-gradient(180deg, rgb(0,0,0) 98.7%, rgba(0,0,0,0) 100%)';
1397      after.style.webkitMaskImage = m;
1398      after.style.maskImage = m;
1399      if (replay) fade(replay, 1, 0.7);
1400    }
1401    function run() {
1402      fade(bar, 0, 0);
1403      bar.style.top = '4px';
1404      bar.style.width = '10%';
1405      bar.style.left = '50%';
1406      bar.style.transform = 'translateX(-50%)';
1407      if (replay) fade(replay, 0, 0);
1408      after.style.webkitMaskImage = M0;
1409      after.style.maskImage = M0;
1410      setTimeout(function () {
1411        fade(bar, 1, 0.4);
1412        bar.style.transition += ', width .4s cubic-bezier(.3,.1,.25,1)';
1413        bar.style.width = '110%';  // Anim2: bar expands to 110% width
1414      }, 500);
1415      setTimeout(sweep, 900);
1416    }
1417    if (replay) {
1418      replay.style.cursor = 'pointer';
1419      replay.addEventListener('click', run);
1420    }
1421    /* Watch the card, not the plate inside it: the scan is over in under three
1422       seconds, and half a card showing while the stack is still moving spends
1423       most of that on a reader who has not arrived yet. Waiting for the card
1424       to have landed under the one before it puts the sweep in front of them,
1425       the same way the cards either side of it wait. This one is reached by
1426       its border rather than by an id — it is the only card without one. */
1427    var scanning = false;
1428    var card = root.closest('[data-border]') || root;
1429    new IntersectionObserver(function (es) {
1430      es.forEach(function (e) {
1431        if (!scanning && landed(card, e.intersectionRatio)) { scanning = true; run(); }
1432        else if (!e.isIntersecting && scanning) { scanning = false; }
1433      });
1434    }, { threshold: [0, 0.5, 0.9] }).observe(card);
1435  });
1436
1437  /* ------ #generatevideo: the same exchange as #enhancephotos --------------
1438     The plate ships with the second half of the story only — a pill that says
1439     "Generating video…" over a frosted cover, and no sign of what was asked
1440     for. The command goes in front of it: the line, the three selects that
1441     shape the clip, and a send button that spins while the work is done, just
1442     as the photo card's does. The command stays put through all of it and
1443     takes the same green tick when the clip is running, so the two cards read
1444     as one flow rather than two different ideas about the same product.
1445
1446     That leaves the export's own generating half — the line and the progress
1447     bar — with nothing to say; it stays in the pill, out of the way, so a
1448     regenerated export can still be recognised here. The two selects beside
1449     the model are the shape of the request, not a control: nothing about the
1450     resolution or the length is clickable, and neither of them moves. The
1451     model chip is the one that opens — see the model-chip block above. */
1452  var VIDEO_MODELS = ['Kling 3.0', 'Seedance 2.5', 'Veo 3.1'];
1453  var VIDEO_ASK = [
1454    { icon: 'M234.49,111.07,90.41,22.94A20,20,0,0,0,60,39.87V216.13a20,20,0,0,0,30.41,16.93l144.08-88'
1455        + '.13a19.82,19.82,0,0,0,0-33.86ZM84,208.85V47.15L216.16,128Z',
1456      label: VIDEO_MODELS[0], options: VIDEO_MODELS },
1457    { icon: 'M228,40V80a12,12,0,0,1-24,0V52H176a12,12,0,0,1,0-24h40A12,12,0,0,1,228,40ZM80,204H52V176'
1458        + 'a12,12,0,0,0-24,0v40a12,12,0,0,0,12,12H80a12,12,0,0,0,0-24Zm136-40a12,12,0,0,0-12,12v28H176'
1459        + 'a12,12,0,0,0,0,24h40a12,12,0,0,0,12-12V176A12,12,0,0,0,216,164ZM40,92A12,12,0,0,0,52,80V52H80'
1460        + 'a12,12,0,0,0,0-24H40A12,12,0,0,0,28,40V80A12,12,0,0,0,40,92ZM84,72h88a12,12,0,0,1,12,12v88'
1461        + 'a12,12,0,0,1-12,12H84a12,12,0,0,1-12-12V84A12,12,0,0,1,84,72Zm12,88h64V96H96Z', label: '1080p' },
1462    // The export carries no clock, and its icons are all filled Phosphor paths;
1463    // this one is drawn from primitives rather than guessed at as path data.
1464    { icon: null, label: '8s' }
1465  ];
1466  function buildAsk() {
1467    var ask = document.createElement('div');
1468    ask.className = 'cb-ask';
1469
1470    // No tick at the end of the line: the clip starting is the answer, the
1471    // same as on the other two command cards.
1472    var head = document.createElement('div');
1473    head.className = 'cb-ask-head';
1474    var line = document.createElement('p');
1475    line.className = 'cb-ask-line';
1476    line.textContent = unpunctuated(
1477      label('Create a video of this product, with a woman wearing the shoes in a close-up style.')
1478    );
1479    head.appendChild(line);
1480    ask.appendChild(head);
1481
1482    var row = document.createElement('div');
1483    row.className = 'cb-ask-row';
1484    VIDEO_ASK.forEach(function (sel) {
1485      var chip = document.createElement('span');
1486      chip.className = 'cb-ask-chip';
1487      chip.innerHTML = askIcon(sel.icon) + '<b></b>' + askIcon(CHEVRON_DOWN);
1488      chip.getElementsByTagName('b')[0].textContent = label(sel.label);
1489      if (sel.options) attachMenu(chip, sel.options);
1490      row.appendChild(chip);
1491    });
1492    row.appendChild(buildSend());
1493    ask.appendChild(row);
1494    return ask;
1495  }
1496
1497  each('[data-cb-name="Animation3"], .cb-HSmqV', function (root) {
1498    var cover = byClass('cb-hwivmq', root)[0];
1499    var pill = byClass('cb-l3gt3h', root)[0];
1500    // The progress bar the export drives; unused now, but it is what tells
1501    // this plate apart from the other Shader-bearing ones.
1502    if (!byClass('cb-10h9jdk', root)[0]) return;
1503    var video = root.querySelector('video');
1504    var ask = null, send = null, plane = null, spin = null, type = null, choose = null;
1505    /* The export centres its Replay on a point near the top right; the top
1506       left is where every card in the stack keeps its Replay now. It comes in
1507       once the clip is running. */
1508    var replay = byClass('cb-k459sj-container', root)[0];
1509    if (replay) {
1510      replay.style.top = '22px';
1511      replay.style.left = '22px';
1512      replay.style.transform = 'none';
1513    }
1514    if (pill) {
1515      // The export's "Generating video…" line and progress track are gathered
1516      // into one box the pill then keeps out of sight. The pill also carries
1517      // the plate's orb badge, which is positioned against the plate and is
1518      // part of neither — anything absolute stays where the export put it.
1519      var gen = document.createElement('div');
1520      gen.className = 'cb-gen';
1521      Array.prototype.slice.call(pill.children).forEach(function (kid) {
1522        if (getComputedStyle(kid).position !== 'absolute') gen.appendChild(kid);
1523      });
1524      ask = buildAsk();
1525      pill.appendChild(ask);
1526      pill.appendChild(gen);
1527      pill.classList.add('cb-pill-ask');
1528      send = ask.getElementsByClassName('cb-ask-send')[0];
1529      plane = send.getElementsByTagName('svg')[0];
1530      spin = ask.getElementsByClassName('cb-send-spin')[0];
1531      type = typer(ask.getElementsByClassName('cb-ask-line')[0]);
1532      // The model chip is the first of the three; the other two have no list.
1533      var modelChip = ask.getElementsByClassName('cb-ask-chip')[0];
1534      // Its own name, above the list's — getElementsByTagName is in DOM order.
1535      choose = chooser(modelChip, modelChip.getElementsByTagName('b')[0], later);
1536    }
1537    // The orbs are built further down this file, so look the badge up per
1538    // run rather than holding a reference from here.
1539    function orbState(state) {
1540      var orb = root.querySelector('.cb-orb');
1541      if (orb) orb.setAttribute('data-orb-state', state);
1542    }
1543    var gtimers = [];
1544    function later(fn, ms) { gtimers.push(setTimeout(fn, ms)); }
1545
1546    /* Fetch without playing. The clip is the card's whole claim, so it may not
1547       run before the button says it is made — but a plate with nothing decoded
1548       yet is a flat grey rectangle under the frost, and the file is big enough
1549       that asking for it at the end would show a gap where the loop belongs.
1550       Priming on approach gets the first frame under the cover and the rest
1551       buffered by the time it is asked to play. */
1552    function prime() {
1553      if (!video || video.preload === 'auto') return;
1554      video.preload = 'auto';
1555      video.load();
1556    }
1557    function press() {
1558      if (send) {
1559        send.style.transition = 'transform .12s ' + TWEEN_INOUT;
1560        send.style.transform = 'scale(.86)';
1561        later(function () {
1562          send.style.transition = 'transform .28s ' + SPRING_B02;
1563          send.style.transform = 'none';
1564        }, 120);
1565      }
1566      fade(spin, 1, 0.25);
1567      fade(plane, 0, 0.25);
1568    }
1569    function generate() {
1570      prime();
1571      press();
1572      later(function () {
1573        // Made: the button comes back, the cover clears and the loop starts.
1574        fade(spin, 0, 0.25);
1575        fade(plane, 1, 0.25);
1576        if (cover) fade(cover, 0, 1.2, TWEEN_INOUT);
1577        if (video) video.play().catch(function () {});
1578        orbState('idle');
1579        // the clip is running: done, and Replay is on offer
1580        later(function () { if (replay) fade(replay, 1, 0.35); }, 1400);
1581      }, WORK);
1582    }
1583    // The frozen plate is given a beat, then the command writes itself, the
1584    // model is picked out of the list, and it is sent — the same opening as
1585    // the photo card two above it, with the choice in the middle.
1586    function sendIt() { later(generate, SETTLE); }
1587    function pickModel() {
1588      if (choose) later(function () { choose.run(sendIt); }, SETTLE);
1589      else sendIt();
1590    }
1591    function run() {
1592      later(function () {
1593        if (type) type.run(pickModel);
1594        else pickModel();
1595      }, HOLD);
1596    }
1597    // Back to the frozen plate with nothing moving — what leaving the card
1598    // does, and what Replay starts over from.
1599    function rewind() {
1600      gtimers.forEach(clearTimeout);
1601      gtimers = [];
1602      if (type) type.reset();
1603      if (choose) choose.reset();
1604      if (cover) fade(cover, 1, 0);
1605      if (send) { send.style.transition = ''; send.style.transform = ''; }
1606      fade(spin, 0, 0);
1607      fade(plane, 1, 0);
1608      if (replay) fade(replay, 0, 0);
1609      if (video) { video.pause(); video.currentTime = 0; }
1610      orbState('thinking');
1611    }
1612    if (reduced) {
1613      if (type) type.whole();
1614      if (choose) choose.whole();
1615      if (cover) fade(cover, 0, 0);
1616      if (video) video.play().catch(function () {});
1617      return;
1618    }
1619    if (replay) {
1620      replay.style.cursor = 'pointer';
1621      replay.addEventListener('click', function () { rewind(); run(); });
1622    }
1623    var genPlaying = false;
1624    new IntersectionObserver(function (es) {
1625      es.forEach(function (e) {
1626        if (e.isIntersecting) prime();
1627        if (!genPlaying && landed(root, e.intersectionRatio)) { genPlaying = true; run(); }
1628        else if (!e.isIntersecting && genPlaying) {
1629          genPlaying = false;
1630          rewind();
1631        }
1632      });
1633    }, { threshold: [0, 0.5, 0.9] }).observe(root);
1634  });
1635
1636  /* ------ #distribute shopping cards: Start -> Spread (0.4s) -> Focus (0.4s)
1637     The three cards ship stacked off the right edge of the panel at opacity 0.
1638     In view they slide out into a row, then the Cobiro card scales to 1.15
1639     while the two competitor cards drop to 30% — the original runs this as two
1640     variant steps 0.45s apart, both tween in-out.
1641
1642     The appear engine can't carry this one: the panel clips the cards, so
1643     their intersection ratio never reaches a threshold and the observer never
1644     fires. And the spread positions come from a variant class, so the slide
1645     has to be measured (first/last) rather than written as fixed pixels — the
1646     panel is 530px, 400px or full-width depending on the breakpoint. */
1647  each('.cb-bnpjT', function (panel) {
1648    var START = 'cb-v-a3s2zv';
1649    var SPREAD = 'cb-v-1q32zli';   // the Focus variant is the same layout
1650    var cards = [
1651      { el: byClass('cb-18iys5i', panel)[0], to: 'translateY(-50%)', o: 1,
1652        focus: 'translateY(-50%) scale(1.15, 1.15)' },        // Cobiro, in front
1653      { el: byClass('cb-vtx6vo', panel)[0], to: 'translateX(-50%)', o: 0.3 },
1654      { el: byClass('cb-cdle14', panel)[0], to: 'none', o: 0.3 }  // half off the edge
1655    ];
1656    if (!cards.every(function (c) { return c.el; })) return;
1657
1658    function spread() {
1659      panel.classList.remove(START);
1660      panel.classList.add(SPREAD);
1661    }
1662    function end() {
1663      cards.forEach(function (c) {
1664        c.el.style.opacity = String(c.o);
1665        c.el.style.transform = c.focus || c.to;
1666      });
1667    }
1668    if (reduced) { spread(); end(); return; }
1669
1670    /* The only card in the stack the export ships no Replay for, so it
1671       borrows the photo card's — a clone keeps the translated label and the
1672       hover the others have — and stands where every card keeps its Replay
1673       now. The card, not the panel: the panel is the strip the shopping cards
1674       slide inside, halfway down the plate. */
1675    var card = panel.closest('[id]') || panel.parentNode;
1676    var replay = null;
1677    var donor = byClass('cb-6o78ro-container', document.body)[0];
1678    if (donor && card) {
1679      replay = donor.cloneNode(true);
1680      replay.style.cssText = 'position:absolute;z-index:5;top:22px;left:22px;opacity:0';
1681      if (getComputedStyle(card).position === 'static') card.style.position = 'relative';
1682      card.appendChild(replay);
1683    }
1684
1685    function run() {
1686      // first: where the stacked cards sit now
1687      var first = cards.map(function (c) { return c.el.getBoundingClientRect(); });
1688      spread();
1689      cards.forEach(function (c) {
1690        c.el.style.transition = 'none';
1691        c.el.style.transform = c.to;
1692      });
1693      // last: where the variant puts them, inverted back onto the start point
1694      var last = cards.map(function (c) { return c.el.getBoundingClientRect(); });
1695      cards.forEach(function (c, i) {
1696        c.el.style.transform = 'translate(' + (first[i].left - last[i].left) + 'px, ' +
1697          (first[i].top - last[i].top) + 'px) ' + c.to;
1698      });
1699      requestAnimationFrame(function () {
1700        requestAnimationFrame(function () {
1701          cards.forEach(function (c) {
1702            c.el.style.transition = 'opacity .4s ' + TWEEN_INOUT + ', transform .4s ' + TWEEN_INOUT;
1703            c.el.style.opacity = '1';
1704            c.el.style.transform = c.to;
1705          });
1706          setTimeout(function () {
1707            end();                 // Focus: hero scales up, the others recede
1708            if (replay) fade(replay, 1, 0.4);
1709          }, 450);
1710        });
1711      });
1712    }
1713
1714    if (replay) {
1715      replay.style.cursor = 'pointer';
1716      replay.addEventListener('click', function () {
1717        // Back to the stacked start, then the same measured slide again.
1718        fade(replay, 0, 0);
1719        panel.classList.remove(SPREAD);
1720        panel.classList.add(START);
1721        cards.forEach(function (c) {
1722          c.el.style.transition = 'none';
1723          c.el.style.opacity = '0';
1724          c.el.style.transform = '';
1725        });
1726        requestAnimationFrame(function () { requestAnimationFrame(run); });
1727      });
1728    }
1729
1730    /* Off the card rather than the panel, and off the card having landed: the
1731       slide runs once and is over in under a second, so starting it while the
1732       stack is still moving spends it on a reader who has not arrived yet —
1733       the same wait the three cards above it make. */
1734    new IntersectionObserver(function (es, io) {
1735      es.forEach(function (e) {
1736        if (!landed(card, e.intersectionRatio)) return;
1737        io.disconnect();
1738        run();
1739      });
1740    }, { threshold: [0, 0.5, 0.9] }).observe(card);
1741  });
1742
1743  /* ------ #advertising orbit: wrapper 360°/40s, chips counter-rotate ------ */
1744  byClass('cb-pxzgya').forEach(function (orbit) {
1745    if (reduced) return;
1746    orbit.classList.add('cb-orbit');
1747    each('[data-cb-name="Integration"]', function (chip) {
1748      chip.classList.add('cb-orbit-item');
1749    }, orbit);
1750    new IntersectionObserver(function (es) {
1751      es.forEach(function (e) {
1752        var st = e.isIntersecting ? 'running' : 'paused';
1753        orbit.style.animationPlayState = st;
1754        each('.cb-orbit-item', function (c) { c.style.animationPlayState = st; }, orbit);
1755      });
1756    }).observe(orbit);
1757  });
1758
1759  /* ------ showcase center glow pulse (1s linear mirror to scale .9) ------- */
1760  byClass('cb-kmsord').forEach(function (el) {
1761    if (!reduced) el.classList.add('cb-kmsord-anim');
1762  });
1763
1764  /* ------ agents scroll-linked word reveal (0.07 -> 1) --------------------
1765     The words already ship as one <span style="opacity:.07"> each, one <p> per
1766     breakpoint variant. Without this the sentence just sits at 7% forever. */
1767  each('#agents p', function (p) {
1768    var spans = [];
1769    for (var si = 0; si < p.children.length; si++) {
1770      if (p.children[si].tagName === 'SPAN') spans.push(p.children[si]);
1771    }
1772    if (spans.length < 5 || p.dataset.cbWords) return;
1773    p.dataset.cbWords = '1';
1774
1775    // Drive the ramp with filter: opacity() rather than the opacity property.
1776    // Identical rendering, but it leaves the declared text colour intact — a
1777    // static contrast check reads a scroll-linked reveal as N permanently
1778    // unreadable words, which is not what it is: every word reaches full
1779    // contrast as the section scrolls past, and prefers-reduced-motion skips
1780    // the ramp entirely.
1781    var n = spans.length;
1782    function setLevel(el, a) {
1783      el.style.opacity = '';
1784      el.style.filter = a >= 1 ? 'none' : 'opacity(' + a + ')';
1785    }
1786    if (reduced) {
1787      spans.forEach(function (s) { setLevel(s, 1); });
1788      return;
1789    }
1790    spans.forEach(function (s) { setLevel(s, 0.07); });
1791
1792    var ticking = false;
1793    function update() {
1794      ticking = false;
1795      var r = p.getBoundingClientRect();
1796      var vh = window.innerHeight;
1797      var prog = (0.9 * vh - r.top) / (r.height + 0.3 * vh);
1798      prog = Math.max(0, Math.min(1, prog));
1799      for (var i = 0; i < n; i++) {
1800        var w = Math.max(0, Math.min(1, (prog - i / n) * n));
1801        setLevel(spans[i], 0.07 + 0.93 * w);
1802      }
1803    }
1804    window.addEventListener('scroll', function () {
1805      if (!ticking) { ticking = true; requestAnimationFrame(update); }
1806    }, { passive: true });
1807    update();
1808  });
1809
1810  /* ------ shader badges -> "Aurora Mesh" orb ------------------------------
1811     The exported <canvas> is inert: nothing ever acquires a GL context for
1812     it. Drop it and build the orb from four orbiting colour blobs plus a
1813     glass shell (see site.css). Every badge marks AI work in progress, so
1814     they start in the "thinking" state; #generatevideo settles its own back
1815     to idle when the bar completes. */
1816  function orbLayer(parent, name) {
1817    var layer = document.createElement('i');
1818    layer.className = 'cb-orb-' + name;
1819    parent.appendChild(layer);
1820    return layer;
1821  }
1822  each('[data-cb-component-type="Shader"]', function (shader) {
1823    var orb = shader.firstElementChild;
1824    if (!orb) return;
1825    shader.classList.add('cb-orb-host');
1826    orb.className = 'cb-orb';
1827    orb.setAttribute('data-orb-state', 'thinking');
1828    orb.textContent = '';
1829    for (var b = 1; b <= 4; b++) orbLayer(orb, 'b' + b);
1830    orbLayer(orb, 'shell');
1831    // Blur, orbit radii, shell and glow are all fractions of the badge's own
1832    // size. The size goes on the host rather than the orb because the glow
1833    // needs it too, and custom properties only travel downwards. This runs
1834    // even under reduced motion — the orb still has to look right, it just
1835    // holds still. Badges in the breakpoint variants that are hidden here
1836    // measure 0; skip those so they keep the CSS fallback until their
1837    // breakpoint makes them visible and the observer fires again.
1838    new ResizeObserver(function (entries) {
1839      var box = entries[0].contentRect;
1840      var size = Math.round(Math.min(box.width, box.height));
1841      if (size) shader.style.setProperty('--cb-orb-s', size + 'px');
1842    }).observe(orb);
1843    if (reduced) return;
1844    new IntersectionObserver(function (entries) {
1845      entries.forEach(function (entry) {
1846        orb.classList.toggle('cb-orb-paused', !entry.isIntersecting);
1847      });
1848    }).observe(orb);
1849  });
1850
1851  /* ------ hero sky: "Sky with clouds" ------------------------------------
1852     The export ships this code component's <canvas> and all of its container
1853     styling — bottom 70% of the hero, opacity .2, a top-fade mask — but no
1854     runtime to draw into it, so the hero has been a still photo. Below is the
1855     component's own GLSL on the settings the page gives it: one flat blue for
1856     both ends of the sky (#314E9D, the same colour the hero panel behind it is
1857     painted), white clouds at coverage .2 and softness .2, scale 1, speed .2,
1858     drawn at half resolution. Read through the container's 20% those settings
1859     land as a slow drift low in the frame rather than a literal sky. */
1860  each('[aria-label="Sky with clouds shader"] canvas', function (canvas) {
1861    var SKY = [49 / 255, 78 / 255, 157 / 255];   // #314E9D, top and bottom alike
1862    var CLOUD = [1, 1, 1];
1863    var SCALE = 1, SPEED = 0.2, COVERAGE = 0.2, SOFTNESS = 0.2, DPR = 0.5;
1864
1865    var VERT = [
1866      'attribute vec2 a_position;',
1867      'varying vec2 v_uv;',
1868      'void main() {',
1869      '  v_uv = a_position * 0.5 + 0.5;',            // a_position is clip space
1870      '  gl_Position = vec4(a_position, 0.0, 1.0);',
1871      '}'
1872    ].join('\n');
1873
1874    var FRAG = [
1875      'precision highp float;',
1876      'uniform vec2 u_resolution;',
1877      'uniform float u_time;',
1878      'uniform vec3 u_skyTop;',
1879      'uniform vec3 u_skyBottom;',
1880      'uniform vec3 u_cloud;',
1881      'uniform float u_scale;',
1882      'uniform float u_speed;',
1883      'uniform float u_coverage;',
1884      'uniform float u_softness;',
1885      'varying vec2 v_uv;',
1886      // hash / noise helpers (stable, and less grid-like than a plain lattice)
1887      'float hash(vec2 p) {',
1888      '  vec3 p3 = fract(vec3(p.xyx) * 0.1031);',    // iq style
1889      '  p3 += dot(p3, p3.yzx + 33.33);',
1890      '  return fract((p3.x + p3.y) * p3.z);',
1891      '}',
1892      'float noise(vec2 p) {',
1893      '  vec2 i = floor(p);',
1894      '  vec2 f = fract(p);',
1895      '  vec2 u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0);',   // quintic
1896      '  float a = hash(i + vec2(0.0, 0.0));',
1897      '  float b = hash(i + vec2(1.0, 0.0));',
1898      '  float c = hash(i + vec2(0.0, 1.0));',
1899      '  float d = hash(i + vec2(1.0, 1.0));',
1900      '  return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);',
1901      '}',
1902      'float fbm(vec2 p) {',
1903      '  float v = 0.0;',
1904      '  float a = 0.52;',
1905      '  mat2 m = mat2(1.7, -1.3, 1.3, 1.7);',
1906      '  for (int i = 0; i < 7; i++) {',
1907      '    v += a * noise(p);',
1908      '    p = m * p;',
1909      '    a *= 0.52;',
1910      '  }',
1911      '  return v;',
1912      '}',
1913      'float fbmRidged(vec2 p) {',
1914      '  float v = 0.0;',
1915      '  float a = 0.5;',
1916      '  mat2 m = mat2(1.8, -1.2, 1.2, 1.8);',
1917      '  for (int i = 0; i < 5; i++) {',
1918      '    float n = noise(p);',
1919      '    float r = 1.0 - abs(2.0 * n - 1.0);',
1920      '    v += a * (r * r);',
1921      '    p = m * p;',
1922      '    a *= 0.55;',
1923      '  }',
1924      '  return v;',
1925      '}',
1926      'void main() {',
1927      '  vec2 uv = v_uv;',
1928      '  float aspect = u_resolution.x / max(u_resolution.y, 1.0);',
1929      '  vec2 p = (uv - 0.5);',
1930      '  p.x *= aspect;',
1931      // sky gradient + a little haze towards the horizon
1932      '  float skyT = smoothstep(0.0, 1.0, uv.y);',
1933      '  vec3 sky = mix(u_skyBottom, u_skyTop, skyT);',
1934      '  float haze = smoothstep(0.0, 0.55, uv.y) * 0.12;',
1935      '  sky = mix(sky, vec3(1.0), haze * 0.25);',
1936      '  float vignette = smoothstep(1.15, 0.25, length(p));',
1937      '  sky *= mix(0.9, 1.0, vignette);',
1938      '  float t = u_time * u_speed;',
1939      '  vec2 wind1 = normalize(vec2(1.0, 0.25));',   // one wind per layer
1940      '  vec2 wind2 = normalize(vec2(0.6, 0.12));',
1941      '  vec2 q = vec2(p.x, p.y * 1.25);',            // stretched for perspective
1942      // large-scale domain warp, so the shapes are not obviously noise
1943      '  vec2 warpBase = q * 0.55 + wind1 * (t * 0.06);',
1944      '  vec2 warp = vec2(fbm(warpBase + 17.2), fbm(warpBase - 31.4));',
1945      '  q += (warp - 0.5) * 0.55;',
1946      '  vec2 layer1 = q + wind1 * (t * 0.10);',      // billowy cumulus
1947      '  float base = fbmRidged(layer1 * u_scale * 0.85);',
1948      '  float detail = fbm(layer1 * u_scale * 2.4 + 4.2);',
1949      '  float c1 = base * 0.78 + detail * 0.32;',
1950      '  vec2 layer2 = (q * vec2(1.35, 0.75)) + wind2 * (t * 0.16);',  // wispy
1951      '  float c2 = fbm(layer2 * u_scale * 1.6 + 12.0);',
1952      '  c2 = smoothstep(0.35, 0.85, c2) * 0.55;',
1953      '  float c = clamp(c1 + c2, 0.0, 1.0);',
1954      '  float horizon = smoothstep(1.0, 0.05, uv.y);',
1955      '  c *= mix(0.88, 1.22, horizon);',
1956      '  c *= mix(1.0, 0.9, smoothstep(0.25, 0.85, uv.y));',
1957      '  float thresh = mix(0.9, 0.38, clamp(u_coverage, 0.0, 1.0));',
1958      '  float s = max(0.001, u_softness);',
1959      // low-frequency wobble in the threshold, to break up the uniformity
1960      '  float breakup = fbm(q * 0.35 + vec2(10.0, -4.0) + wind1 * (t * 0.02));',
1961      '  thresh += (breakup - 0.5) * 0.12;',
1962      '  float alpha = smoothstep(thresh - s, thresh + s, c);',
1963      // depth: a normal approximated from the noise gradient
1964      '  float eps = 0.0025;',
1965      '  float cx = clamp((fbmRidged((layer1 + vec2(eps, 0.0)) * u_scale * 0.85) - fbmRidged((layer1 - vec2(eps, 0.0)) * u_scale * 0.85)), -1.0, 1.0);',
1966      '  float cy = clamp((fbmRidged((layer1 + vec2(0.0, eps)) * u_scale * 0.85) - fbmRidged((layer1 - vec2(0.0, eps)) * u_scale * 0.85)), -1.0, 1.0);',
1967      '  vec3 nrm = normalize(vec3(cx, cy, 0.85));',
1968      '  vec3 lightDir = normalize(vec3(-0.35, 0.25, 1.0));',
1969      '  float diff = clamp(dot(nrm, lightDir), 0.0, 1.0);',
1970      '  float depth = smoothstep(0.1, 0.95, c);',
1971      '  float shade = (1.0 - diff) * 0.18 + (1.0 - depth) * 0.08;',
1972      '  vec3 cloudCol = u_cloud;',
1973      '  cloudCol = mix(cloudCol * 0.88, cloudCol, diff);',
1974      '  cloudCol -= shade;',
1975      '  vec3 col = mix(sky, cloudCol, alpha);',
1976      '  gl_FragColor = vec4(col, 1.0);',
1977      '}'
1978    ].join('\n');
1979
1980    var gl = canvas.getContext('webgl', {
1981      antialias: false, alpha: false, premultipliedAlpha: false,
1982      depth: false, stencil: false, preserveDrawingBuffer: false,
1983      powerPreference: 'high-performance', failIfMajorPerformanceCaveat: false
1984    }) || canvas.getContext('experimental-webgl');
1985    // No GL: the hero panel behind is already this exact blue, so an empty
1986    // canvas at 20% is the same picture minus the drift. Leave it be.
1987    if (!gl) return;
1988
1989    function compile(type, src) {
1990      var sh = gl.createShader(type);
1991      gl.shaderSource(sh, src);
1992      gl.compileShader(sh);
1993      if (gl.getShaderParameter(sh, gl.COMPILE_STATUS)) return sh;
1994      gl.deleteShader(sh);
1995      return null;
1996    }
1997    var vs = compile(gl.VERTEX_SHADER, VERT);
1998    var fs = compile(gl.FRAGMENT_SHADER, FRAG);
1999    if (!vs || !fs) return;
2000    var prog = gl.createProgram();
2001    gl.attachShader(prog, vs);
2002    gl.attachShader(prog, fs);
2003    gl.linkProgram(prog);
2004    gl.deleteShader(vs);
2005    gl.deleteShader(fs);
2006    if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) return;
2007    gl.useProgram(prog);
2008
2009    var pos = gl.getAttribLocation(prog, 'a_position');
2010    gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
2011    gl.bufferData(gl.ARRAY_BUFFER,
2012      new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW);
2013    gl.enableVertexAttribArray(pos);
2014    gl.vertexAttribPointer(pos, 2, gl.FLOAT, false, 0, 0);
2015
2016    function u(name) { return gl.getUniformLocation(prog, name); }
2017    var uTime = u('u_time'), uRes = u('u_resolution');
2018    gl.uniform3f(u('u_skyTop'), SKY[0], SKY[1], SKY[2]);
2019    gl.uniform3f(u('u_skyBottom'), SKY[0], SKY[1], SKY[2]);
2020    gl.uniform3f(u('u_cloud'), CLOUD[0], CLOUD[1], CLOUD[2]);
2021    gl.uniform1f(u('u_scale'), SCALE);
2022    gl.uniform1f(u('u_speed'), SPEED);
2023    gl.uniform1f(u('u_coverage'), COVERAGE);
2024    gl.uniform1f(u('u_softness'), SOFTNESS);
2025
2026    var host = canvas.parentNode;
2027    function resize() {
2028      var box = host.getBoundingClientRect();
2029      var w = Math.max(1, Math.floor(box.width * DPR));
2030      var h = Math.max(1, Math.floor(box.height * DPR));
2031      if (canvas.width === w && canvas.height === h) return;
2032      canvas.width = w;
2033      canvas.height = h;
2034      gl.viewport(0, 0, w, h);
2035      gl.uniform2f(uRes, w, h);
2036    }
2037    function draw(seconds) {
2038      resize();
2039      gl.uniform1f(uTime, seconds);
2040      gl.drawArrays(gl.TRIANGLES, 0, 6);
2041    }
2042
2043    // One frame is still worth drawing under reduced motion: the clouds are
2044    // part of the picture, it is only the drift that has to go.
2045    if (reduced) {
2046      draw(0);
2047      window.addEventListener('resize', function () { draw(0); });
2048      return;
2049    }
2050
2051    var raf = null, clock = 0, last = 0;
2052    function frame(now) {
2053      clock += (now - last) / 1000;
2054      last = now;
2055      draw(clock);
2056      raf = requestAnimationFrame(frame);
2057    }
2058    new IntersectionObserver(function (entries) {
2059      entries.forEach(function (entry) {
2060        if (entry.isIntersecting && raf == null) {
2061          last = performance.now();      // resume where the drift left off
2062          raf = requestAnimationFrame(frame);
2063        } else if (!entry.isIntersecting && raf != null) {
2064          cancelAnimationFrame(raf);
2065          raf = null;
2066        }
2067      });
2068    }, { threshold: 0.1 }).observe(host);
2069  });
2070
2071  /* ------ pricing: monthly or yearly ------------------------------------
2072     The page is written with the yearly prices in place and every element
2073     that prints one carries the other in data-pr-monthly, so the swap is a
2074     text change, not a second copy of the cards. The switch comes out of
2075     hidden here: with this file blocked the visitor gets the yearly page
2076     rather than a control that does nothing. */
2077  each('[data-pr-billing]', function (group) {
2078    var toggle = group.querySelector('[role="switch"]');
2079    var strip = group.querySelector('.pr-billing-switch');
2080    var labels = group.querySelectorAll('.pr-billing-label');
2081    var page = group.closest ? group.closest('.pr-page') : null;
2082    var prices = (page || document).querySelectorAll('[data-pr-monthly]');
2083
2084    if (!toggle || !strip || !prices.length) return;
2085
2086    function show(yearly) {
2087      toggle.setAttribute('aria-checked', yearly ? 'true' : 'false');
2088
2089      for (var n = 0; n < prices.length; n++) {
2090        prices[n].textContent = prices[n].getAttribute(yearly ? 'data-pr-yearly' : 'data-
2090pr-monthly');
2091      }
2092
2093      // Two labels, one switch: the one the prices belong to is the darker of
2094      // the pair, so the state is legible without reading the knob.
2095      for (var i = 0; i < labels.length; i++) {
2096        var isYearly = i === labels.length - 1;
2097        labels[i].classList[isYearly === yearly ? 'add' : 'remove']('is-on');
2098      }
2099    }
2100
2101    strip.hidden = false;
2102    toggle.addEventListener('click', function () {
2103      show(toggle.getAttribute('aria-checked') !== 'true');
2104    });
2105  });
2106
2107  /* ------ stat count-up ---------------------------------------------------
2108     The stat headlines in the home page's stats band (+32%, 90%, 41% → 96%)
2109     count up from zero the first time they scroll into view. They are found
2110     by their own text rather than by class: the export's class names are
2111     hashed and change on every regeneration, the numbers do not. Every digit
2112     run in the headline is eased in place while sign, unit and spacing stay
2113     as written, so both the single form ("+32%", a German "+32 %") and the
2114     range form ("41% → 96%") survive the round trip. Under reduced motion
2115     the numbers simply stand. */
2116  (function () {
2117    if (reduced || !('IntersectionObserver' in window)) return;
2118
2119    var pattern = /^[+−-]?\d+\s*[%x×]$/;
2120    var rangePattern = /^\d+\s*%\s*→\s*\d+\s*%$/;
2121
2122    each('h2.cb-text', function (h) {
2123      var text = (h.textContent || '').trim();
2124      if (!pattern.test(text) && !rangePattern.test(text)) return;
2125
2126      var started = false;
2127
2128      new IntersectionObserver(function (entries, observer) {
2129        entries.forEach(function (entry) {
2130          if (!entry.isIntersecting || started) return;
2131          started = true;
2132          observer.disconnect();
2133
2134          var t0 = performance.now();
2135          var DURATION = 1400;
2136
2137          function tick(now) {
2138            var p = Math.min((now - t0) / DURATION, 1);
2139            // ease-out cubic, so the last digits land gently
2140            var eased = 1 - Math.pow(1 - p, 3);
2141            if (p < 1) {
2142              h.textContent = text.replace(/\d+/g, function (n) {
2143                return Math.round(parseInt(n, 10) * eased);
2144              });
2145              requestAnimationFrame(tick);
2146            } else {
2147              h.textContent = text; // the exact original, spacing and all
2148            }
2149          }
2150
2151          requestAnimationFrame(tick);
2152        });
2153      }, { threshold: 0.6 }).observe(h);
2154    });
2155  })();
2156
2157  /* ------ hero swap cycle -------------------------------------------------
2158     The hero walks a ring of states, one story-length beat (~12-13.5s)
2159     at a time: the export's own
2160     red-outfit shot (a), hero2 (sunglasses, yellow wall — b), a Kling
2161     fashion film of the beret-and-blazer woman, animated from hero3 (c,
2162     video), hero4 (clipper grooming, grey studio, a KIE-generated
2163     original — d), hero5 (cream tee on red — e, and the state the page
2164     OPENS on since 2026-08-19, see BOOT below), hero6 (checked blazer —
2165     g), hero7 (Chanel N°5 in the pink neon room, 16:9 so the wide panel
2166     keeps the whole bottle — h), hero8 (frosted skeleton watch, editorial grey-blue — i; Bo tried a
2167     living-photograph wink video here and rolled it back to the still), a Kling fashion film of the red-outfit woman showing
2168     off the jacket (f, video) and a Kling product film of the Fujifilm
2169     X-T3 held out against night bokeh, from hero9 (j, video), and the
2170     Peugeot bike that rolls in from the left and settles on Bo's
2171     hero10.jpg — Kling generated the bike rolling OUT, the clip is
2172     reversed and end-frozen in ffmpeg so it lands exactly on the still
2173     (k, video, product chip only — the scene has no person). Two more
2174     showcase films from Bo's stills: The Botanist gin with bubbles and
2175     one condensation droplet, background drips Kling invented patched
2176     out with a static overlay from the clean wide still (m, video,
2177     Luxury + Still life + Botanicals moods), and the exploded dessert that
2178     assembles out of mid-air and holds on the finished cup (n, video,
2179     noloop, three ingredient chips — the cookie rides the Location slot
2180     like B's shades).
2181     Every transition tells its story in the References card BEFORE the
2182     picture moves, in Bo's second choreography (2026-08-19 evening): the
2183     finished hero holds the card for 3.5s showing the references it was
2184     "made from", then the card RESETS — chips fade out, the two fixed
2185     reference slots hollow to dashed outlines, every select empties — and
2186     the next state's inputs arrive from scratch: reference images first
2187     (product, portrait, extra, staggered 0.5s), then EVERY field fills
2188     in FIELDS order (all of them — they are empty now: the three selects
2189     through the pick menu, the three Mood pills typed in behind a
2190     blinking caret), then the Generate press, and the "generated" frame
2191     lands exactly 1s after the click. The beat's length follows the story, roughly 12-13.5s
2192     depending on how many references the state has. A state without a
2193     person or product keeps that slot hollow through its whole beat (the
2194     gin and bike films have no person); the base state a — the only one
2195     with no chip keys of its own — refills the slots with the export's
2196     own images instead. A-state texts are read from the DOM rather than
2197     hard-coded, so a translated page swaps back to its own words. Overlays
2198     are injected here, never written into the export's markup, so a
2199     pipeline regeneration cannot leave a stale overlay behind. Full-panel
2200     layers sit at z-index 2 inside the blue panel: above the gradient wash
2201     (z 1) and the cut-out photo (z 2, earlier in the tree), below the
2202     headline and the References card (both z 3). Layer stacking follows
2203     state order, so the incoming frame always fades in on top; once it is
2204     opaque the layers under it snap off without animating (.cb-noanim).
2205     The video layer preloads when its state is next in line (a whole
2206     beat's head start), plays only while shown, and if the file ever errors its state
2207     is skipped so the ring degrades to stills. Everything is lazy so it
2208     never competes with the first paint. Reduced motion gets the still
2209     page.
2210     The test pages' ?alive=1 knob (HeroVariantsController; Bo's idea of
2211     2026-08-20, on /indexNext for an hour and taken off it again the same
2212     day — "det er jo ikke på index") adds a second act to six of the image
2213     states — the ones whose still has a Seedance living-photograph clip
2214     (alive below, /assets/videos/heroN-sd25.mp4): the still lands as
2215     everywhere else, is admired, and then, with no reset, the card flips
2216     Style to Video and AI Model to a video model, Generate again, and the
2217     film fades in over the still it was made from and plays on — the
2218     photograph comes alive (comeAlive()). Nothing of it exists without the
2219     knob: no film layers, no second act. */
2220  (function () {
2221    if (reduced) return;
2222    var hero = document.getElementById('hero');
2223    var panel = hero && hero.firstElementChild;
2224    if (!panel) return;
2225
2226    var STATES = [
2227      { key: 'a' },
2228      /* alive: the Seedance 2.5 living-photograph clip made FROM this
2229         still (first frame = the still, a slow push-in, 5s). Only the test
2230         pages' ?alive=1 knob ever shows it — see comeAlive(). */
2231      { key: 'b', img: 'hero2c', nudge: 'r', product: 'pill-top', portrait: 'pill-blonde',
2232        extra: 'pill-shades', text: 'dark', alive: '/assets/videos/hero2-sd25.mp4',
2233        sel: { mood: 'Playful',  model: 'Seedream 5.0 Pro', env: 'Urban' } },
2234      { key: 'c', video: '/assets/videos/hero-fashion-k19.mp4',
2235        product: 'pill-blazer', portrait: 'pill-beret', text: 'dark',
2236        sel: { model: 'Veo 3.1', env: 'Studio', style: 'Video' } },
2237      { key: 'd', img: 'hero4e', product: 'pill-shaver', portrait: 'pill-groom', text: 'dark',
2238        alive: '/assets/videos/hero4-sd25.mp4',
2239        sel: { model: 'Seedream 5.0 Pro', env: 'Studio', mood: 'Clean' } },
2240      /* noloop like the bike: Bo's Veo pass (2026-08-19, via /cas) runs the
2241         explosion the other way — it ASSEMBLES over its 8s and ends on the
2242         composed dessert, so it rests there for the rest of the beat. A
2243         loop restart would snap back to the scattered first frame. */
2244      { key: 'n', video: '/assets/videos/hero-dessert-k25.mp4', noloop: true,
2245        product: 'pill-berry', portrait: 'pill-choc', extra: 'pill-cookie', text: 'dark',
2246        sel: { model: 'Veo 3.1', env: 'Studio', style: 'Video', mood2: 'Product', mood: 'Explode' } },
2247      { key: 'e', img: 'hero5c', nudge: 'r', product: 'pill-tee2', portrait: 'pill-crew',
2248        alive: '/assets/videos/hero5-sd25.mp4',
2249        sel: { mood: 'Vivid',  model: 'Seedream 5.0 Pro', env: 'Studio' } },
2250      { key: 'g', img: 'hero6c', nudge: 'l', product: 'pill-check', portrait: 'pill-sleek', text: 'dark',
2251        alive: '/assets/videos/hero6-sd25.mp4',
2252        sel: { model: 'Seedream 5.0 Pro', env: 'Studio' } },
2253      /* All three Mood pills are spelled out here: leaving mood2 unset let
2254         it fall back to the export's base word "Upper body", which is
2255         model-framing vocabulary on a shot with no model in it (Bo spotted
2256         it 2026-08-19). Still life is what the shot actually is, and
2257         Botanicals is both the gin's name and what is in the frame — the
2258         rosemary and lime beside the serve. */
2259      { key: 'm', video: '/assets/videos/hero-gin-k24.mp4',
2260        product: 'pill-gin',
2261        sel: { model: 'Veo 3.1', env: 'Studio', style: 'Video',
2262               mood1: 'Luxury', mood2: 'Still life', mood: 'Botanicals' } },
2263      { key: 'h', img: 'hero7g', product: 'pill-no5', portrait: 'pill-glam',
2264        alive: '/assets/videos/hero7-sd25.mp4',
2265        sel: { model: 'Seedream 5.0 Pro', env: 'Studio', mood: 'Playful' } },
2266      { key: 'i', img: 'hero8b', product: 'pill-watch', portrait: 'pill-fair', text: 'dark',
2267        alive: '/assets/videos/hero8-sd25.mp4',
2268        sel: { mood: 'Closeup',  model: 'Seedream 5.0 Pro', env: 'Studio' } },
2269      { key: 'f', video: '/assets/videos/hero-fashion-k18.mp4',
2270        product: 'pill-knit', portrait: 'pill-aussie2', text: 'dark',
2271        sel: { mood: 'Casual',  model: 'Veo 3.1', env: 'Studio', style: 'Video' } },
2272      { key: 'j', video: '/assets/videos/hero-camera-k20.mp4',
2273        product: 'pill-xt3', portrait: 'pill-street',
2274        sel: { model: 'Veo 3.1', env: 'Urban', style: 'Video', mood: 'Hand' } },
2275      /* noloop: the bike film ends ON the still it was made from and is
2276         meant to stay there; under the old fixed 10s beat its 9.5s length
2277         never wrapped, but the variable beat is longer now, and a loop
2278         restart would send the bike rolling in twice. */
2279      { key: 'k', video: '/assets/videos/hero-bike-k21.mp4', noloop: true,
2280        product: 'pill-bike',
2281        sel: { mood2: 'Industrial', mood: '',  model: 'Veo 3.1', env: 'Urban', style: 'Video' } }
2282    ];
2283    /* The state the page OPENS on (Bo 2026-08-19). Not necessarily the
2284       first in the array: the ring order is a story, the entry point is a
2285       separate choice. Its frame is lit before the first beat with no
2286       fade, and home.html preloads the same responsive set in <head>, so
2287       it — not the export's own frame — is the LCP image. */
2288    var BOOT = 'e';
2289    /* The /indexNew0… hero test pages (App\Controller\HeroVariantsController)
2290       hand a knob in through window.CB_HERO, written into <head> before
2291       this script: boot = the state to open on instead of BOOT, still =
2292       open on it and never cycle. Absent everywhere else, so the ring
2293       below is untouched on the real pages. */
2294    var knob = window.CB_HERO || {};
2295    if (typeof knob.boot === 'string' && knob.boot.length === 1) BOOT = knob.boot;
2296    /* alive = the two-step behind the test pages' ?alive=1 (comeAlive()
2297       below): every image state with a living-photograph clip lands as a
2298       still first and comes alive from the card afterwards. False
2299       everywhere else, and then no film layer is even created. */
2300    var alive = knob.alive === true;
2301
2302    var SLOTS = { product: '.cb-1kjf96l', portrait: '.cb-1pezjni' };
2303
2304    var heroLayers = [];
2305    var chipLayers = [];
2306    STATES.forEach(function (state) {
2307      if (state.video) {
2308        var vid = document.createElement('video');
2309        vid.muted = true;
2310        vid.loop = !state.noloop; // ended + no loop = the film holds its last frame
2311        vid.setAttribute('playsinline', '');
2312        vid.preload = 'none';
2313        vid.src = state.video;
2314        vid.className = 'cb-swap-img cb-swap-hero';
2315        vid.dataset.state = state.key;
2316        vid.addEventListener('error', function () { state.dead = true; });
2317        panel.appendChild(vid);
2318        heroLayers.push(vid);
2319      } else if (state.img) {
2320        var full = document.createElement('img');
2321        full.alt = '';
2322        if (state.key === BOOT) {
2323          full.setAttribute('fetchpriority', 'high');
2324          full.setAttribute('decoding', 'sync');
2325        } else {
2326          full.setAttribute('loading', 'lazy');
2327          full.setAttribute('decoding', 'async');
2328        }
2329        full.setAttribute('sizes', '100vw');
2330        full.setAttribute('srcset', [412, 824, 1004, 1310, 1536, 2000].map(function (w) {
2331          return '/assets/images/r/' + state.img + '-' + w + '.webp ' + w + 'w';
2332        }).join(','));
2333        full.setAttribute('src', '/assets/images/r/' + state.img + '-2000.webp');
2334        full.className = 'cb-swap-img cb-swap-hero';
2335        if (state.nudge) full.classList.add(state.nudge === 'l' ? 'cb-nudge-l' : 'cb-nudge-r');
2336        full.dataset.state = state.key;
2337        panel.appendChild(full);
2338        heroLayers.push(full);
2339      } else if (state.key !== 'a') {
2340        return; // nothing to show for this state
2341      }
2342
2343      /* The two-step's film (/indexNext only): the state's living-photograph
2344         clip as a second layer right above its still, so comeAlive() can
2345         fade it in over the exact frame it was made from. It carries the
2346         still's nudge and data-state, so every per-state framing rule in
2347         site.css lands on both; data-alive tells activate() the two apart.
2348         No loop: the clips are slow push-ins that end on a hold — a loop
2349         restart would snap back out — and like the bike film it rests on
2350         its last frame. */
2351      if (alive && state.img && state.alive) {
2352        var film = document.createElement('video');
2353        film.muted = true;
2354        film.setAttribute('muted', ''); // the attribute too: iOS reads it for script-started autoplay
2355        film.loop = false;
2356        film.setAttribute('playsinline', '');
2357        film.preload = 'none';
2358        film.src = state.alive;
2359        film.className = 'cb-swap-img cb-swap-hero';
2360        if (state.nudge) film.classList.add(state.nudge === 'l' ? 'cb-nudge-l' : 'cb-nudge-r');
2361        film.dataset.state = state.key;
2362        film.dataset.alive = '1';
2363        film.addEventListener('error', function () { state.aliveDead = true; });
2364        panel.appendChild(film);
2365        heroLayers.push(film);
2366      }
2367
2368      Object.keys(SLOTS).forEach(function (slot) {
2369        if (!state[slot]) return; // a state may swap only one of the two chips
2370        each(SLOTS[slot] + ' [data-cb-background-image-wrapper] img', function (img) {
2371          if (img.classList.contains('cb-swap-img')) return; // clone only the export's own img
2372          var copy = img.cloneNode(false);
2373          copy.setAttribute('loading', 'lazy');
2374          copy.setAttribute('decoding', 'async');
2375          copy.setAttribute('srcset', '/assets/images/r/' + state[slot] + '-80.webp 80w,/assets/images/r/' + state[slot] + '-160.webp 160w');
2376          copy.setAttribute('src', '/assets/images/r/' + state[slot] + '-160.webp');
2377          copy.classList.add('cb-swap-img', 'cb-swap-chip');
2378          copy.dataset.state = state.key;
2379          copy.dataset.slot = slot;
2380          img.parentNode.appendChild(copy);
2381          chipLayers.push(copy);
2382        }, hero);
2383      });
2384
2385      /* A state can fill the dashed "Location" placeholder with a third
2386         image (hero2's sunglasses); every other state leaves it dashed. */
2387      if (state.extra) {
2388        each('.cb-ba7qqd', function (box) {
2389          var im = document.createElement('img');
2390          im.alt = '';
2391          im.setAttribute('loading', 'lazy');
2392          im.setAttribute('decoding', 'async');
2393          im.setAttribute('srcset', '/assets/images/r/' + state.extra + '-80.webp 80w,/assets/images/r/' + state.extra + '-160.webp 160w');
2394          im.setAttribute('src', '/assets/images/r/' + state.extra + '-160.webp');
2395          im.className = 'cb-swap-img cb-swap-chip cb-swap-loc';
2396          im.dataset.state = state.key;
2397          im.dataset.slot = 'extra';
2398          box.appendChild(im);
2399          chipLayers.push(im);
2400        }, hero);
2401      }
2402    });
2403
2404    /* The two fixed reference slots (product, portrait), in both breakpoint
2405       variants of the card: during the reset phase they hollow out to the
2406       dashed look the Location box was born with (cb-slot-empty in
2407       site.css), and a state that has no reference for one of them leaves
2408       it hollow for its whole beat. */
2409    var slotBoxes = { product: [], portrait: [] };
2410    Object.keys(SLOTS).forEach(function (slot) {
2411      each(SLOTS[slot], function (el) { slotBoxes[slot].push(el); }, hero);
2412    });
2413
2414    /* One mock select per settings row. After the reset every select is
2415       empty, so every field picks anew each beat, in FIELDS order. The
2416       model menu's catalog (image vs video list) is chosen by the TARGET
2417       state's style, not the current label, so AI Model may show video
2418       models a beat before Style flips — as specified. base is whatever
2419       the page says now. The three Mood pills carry typed: true — they
2420       never open a menu; typeIn() writes them character by character. */
2421    var FIELDS = [
2422      { name: 'model', row: '.cb-elxgel', label: '.cb-1j0uv23 p',
2423        menus: { image: ['GPT Image 2', 'Nano Banana Pro', 'Seedream 5.0 Pro'],
2424                 video: ['Veo 3.1', 'Seedance 2.5', 'Grok Imagine'] } },
2425      { name: 'style', row: '.cb-dtcq0z', label: '.cb-1tbu3ce p', extra: ['Video'] },
2426      { name: 'env',   row: '.cb-kpen9i', label: '.cb-14xbgou p', extra: ['Urban', 'Studio'] },
2427      /* The Mood row has three pills: mood1 the FIRST (base "Premium"),
2428         mood2 the MIDDLE (base "Upper body"), mood the THIRD (base
2429         "Lean"). They are not selects (Bo): no menu ever opens on them —
2430         after the reset each pill drops its chrome and blinks a caret,
2431         and when the story reaches it the word TYPES in and the pill
2432         returns around it (typed: true, handled by typeIn()). */
2433      { name: 'mood1', row: '.cb-1rte6ed', label: '.cb-1uxr2s p', typed: true, pill: '.cb-1ueka1y' },
2434      { name: 'mood2', row: '.cb-1rte6ed', label: '.cb-1osh443 p', typed: true, pill: '.cb-3dcxg' },
2435      { name: 'mood',  row: '.cb-1rte6ed', label: '.cb-2q0hr7 p', typed: true, pill: '.cb-xleuj3' }
2436    ];
2437    FIELDS.forEach(function (f) {
2438      f.rowEl = hero.querySelector(f.row);
2439      f.labelEl = f.rowEl && f.rowEl.querySelector(f.label);
2440      f.base = f.labelEl ? f.labelEl.textContent : '';
2441      if (f.menus && f.menus.image.indexOf(f.base) === -1) f.menus.image.unshift(f.base);
2442      f.options = [f.base].concat(f.extra || []);
2443      if (f.labelEl) f.labelEl.style.transition = 'opacity .18s ease';
2444      /* The model row carries a one-line blurb under its label and an icon
2445         tile at its left edge; both empty with the reset and return with
2446         the pick, or the row would look half-reset (Bo: the AI Model row
2447         must be completely blank until something is chosen). */
2448      f.subEl = f.rowEl && f.rowEl.querySelector('.cb-w6h3zm p');
2449      f.subBase = f.subEl ? f.subEl.textContent : '';
2450      if (f.subEl) f.subEl.style.transition = 'opacity .18s ease';
2451      f.iconEl = f.name === 'model' && f.rowEl ? f.rowEl.querySelector('.cb-1qptpj6') : null;
2452      if (f.iconEl) f.iconEl.style.transition = 'opacity .18s ease';
2453      f.pillEl = f.typed && f.rowEl ? f.rowEl.querySelector(f.pill) : null;
2454    });
2455
2456    /* The card ends in a Generate button (frosted pill, like the example
2457       cards further down the page). Pressing it is the last beat of every
2458       transition's story: settings picked, chips swapped, Generate, fade. */
2459    var genBtn = null;
2460    var card = hero.querySelector('.cb-hveze6');
2461    if (card) {
2462      genBtn = document.createElement('div');
2463      genBtn.className = 'cb-gen-btn';
2464      genBtn.textContent = 'Generate';
2465      card.appendChild(genBtn);
2466    }
2467
2468    /* Every timeout of the running beat — the staggered picks, the chip
2469       swap, the Generate press, the fade, and pick()'s own menu
2470       animation — registers here, so a manual dot jump can cancel the
2471       story mid-flight without a stray callback landing afterwards. */
2472    var beatTimers = [];
2473    function schedule(fn, ms) {
2474      // self-pruning, so the registry stays small over hours of beats
2475      var id = setTimeout(function () {
2476        var at = beatTimers.indexOf(id);
2477        if (at !== -1) beatTimers.splice(at, 1);
2478        fn();
2479      }, ms);
2480      beatTimers.push(id);
2481    }
2482    function clearBeat() {
2483      beatTimers.forEach(clearTimeout);
2484      beatTimers = [];
2485    }
2486
2487    function pick(field, to, list) {
2488      if (!field.labelEl) return;
2489      var from = field.labelEl.textContent;
2490      if (from === to) return;
2491      var menu = document.createElement('div');
2492      menu.className = 'cb-sel-menu';
2493      var opts = (list || field.options).map(function (text) {
2494        var opt = document.createElement('div');
2495        opt.className = 'cb-sel-opt' + (text === from ? ' cb-sel-act' : '');
2496        opt.textContent = text;
2497        menu.appendChild(opt);
2498        return opt;
2499      });
2500      field.rowEl.classList.add('cb-sel-open');
2501      field.rowEl.appendChild(menu);
2502      /* Open upward when the drop would clip on the card's bottom edge —
2503         the card's overflow is clipped, and the docked phone card is short
2504         enough that the Style and Environment drops fell off it. Measured
2505         per open, so the desktop card keeps its downward menus. */
2506      if (card) {
2507        var cardBox = card.getBoundingClientRect();
2508        if (menu.getBoundingClientRect().bottom > cardBox.bottom - 4) {
2509          menu.classList.add('cb-sel-menu--up');
2510        }
2511      }
2512      requestAnimationFrame(function () { menu.classList.add('cb-sel-in'); });
2513      /* A tighter lifecycle than the first choreography's (380/800/1000
2514         instead of 550/1150/1450): after the reset every one of the six
2515         fields picks, and at the old pace the picks alone took nine
2516         seconds. The 1000ms cleanup also keys the pick stagger — the three
2517         Mood pills share one row, so the next menu must never open before
2518         the previous one has left it. */
2519      schedule(function () {            // the highlight moves to the incoming choice
2520        opts.forEach(function (o) { o.classList.toggle('cb-sel-act', o.textContent === to); });
2521      }, 380);
2522      schedule(function () {            // the click: menu away, label swaps
2523        menu.classList.remove('cb-sel-in');
2524        field.labelEl.style.opacity = '0';
2525        if (field.subEl) field.subEl.style.opacity = '0';
2526        schedule(function () {
2527          field.labelEl.textContent = to;
2528          field.labelEl.style.opacity = '1';
2529          if (field.subEl) {
2530            field.subEl.textContent = field.subBase;
2531            field.subEl.style.opacity = '1';
2532          }
2533          if (field.iconEl) field.iconEl.style.opacity = '1';
2534        }, 180);
2535      }, 800);
2536      schedule(function () {
2537        menu.remove();
2538        field.rowEl.classList.remove('cb-sel-open');
2539      }, 1000);
2540    }
2541
2542    function stopVideo(el) {
2543      if (el.tagName !== 'VIDEO') return;
2544      setTimeout(function () {
2545        // it may have been re-activated (a dot jump back) while this waited
2546        if (!el.classList.contains('cb-on')) el.pause();
2547      }, 1700); // let it keep moving through the fade-out
2548    }
2549
2550    /* Fade the incoming layer(s) in on top; once opaque, snap the layers
2551       under them off without animating. No incoming layer (state a) means
2552       everything fades out and the export's own images show again. */
2553    function activate(layers, key, holdMs, film) {
2554      /* film: light the state's living-photograph layer (the two-step of
2555         /indexNext) instead of its still. Chip layers and, on the real
2556         page, every hero layer are plain, so the flag is moot there. */
2557      var incoming = layers.filter(function (el) {
2558        return el.dataset.state === key && !!el.dataset.alive === !!film;
2559      });
2560      if (!incoming.length) {
2561        layers.forEach(function (el) {
2562          if (el.classList.contains('cb-on')) stopVideo(el);
2563          el.classList.remove('cb-on');
2564        });
2565        return;
2566      }
2567      incoming.forEach(function (el) {
2568        el.classList.add('cb-on');
2569        if (el.tagName === 'VIDEO') {
2570          try { el.currentTime = 0; } catch (err) { /* not seekable yet */ }
2571          var playing = el.play();
2572          if (playing && playing.catch) playing.catch(function () {});
2573        }
2574      });
2575      /* Via schedule(), not a bare timeout: a dot jump inside the holdMs
2576         window used to fire this stale pass and snap off the layer the
2577         jump had just lit. clearBeat() cancels it now, and the jump's own
2578         activate() re-runs the same cleanup with the right survivors. */
2579      schedule(function () {
2580        layers.forEach(function (el) {
2581          if (incoming.indexOf(el) !== -1 || !el.classList.contains('cb-on')) return;
2582          stopVideo(el);
2583          el.classList.add('cb-noanim');
2584          el.classList.remove('cb-on');
2585          setTimeout(function () { el.classList.remove('cb-noanim'); }, 60);
2586        });
2587      }, holdMs);
2588    }
2589
2590    var idx = 0;
2591
2592    /* There was a dot rail on the panel's right edge here (Bo 2026-08-19),
2593       one dot per state, so a state could be jumped to by hand. Bo took it
2594       out the same day: beside the hero it read as interference, not as
2595       furniture. goTo() went with it — nothing else ever called it. */
2596
2597    function bufferVideo(next) {
2598      heroLayers.forEach(function (el) {
2599        if (el.tagName === 'VIDEO' && el.dataset.state === next.key && el.preload !== 'auto') {
2600          el.preload = 'auto';
2601          el.load();
2602        }
2603      });
2604    }
2605
2606    /* Hollowing and refilling the card. usesBasePair: a state with no
2607       chip keys at all — in the current ring only a, whose references ARE
2608       the export's own red-garment pair — refills the slots with the
2609       export's own images; every other state shows exactly the chips it
2610       defines and leaves the rest hollow. */
2611    function usesBasePair(state) { return !state.product && !state.portrait; }
2612    function setSlotHollow(slot, hollow) {
2613      slotBoxes[slot].forEach(function (el) { el.classList.toggle('cb-slot-empty', hollow); });
2614    }
2615
2616    /* Everything the card should show for a state, instantly — the dot
2617       jumps land here; the automatic beat does the same work piecemeal
2618       through resetCard() and fillSlot(). */
2619    function applyCard(state) {
2620      FIELDS.forEach(function (f) {
2621        if (!f.labelEl) return;
2622        var named = state.sel && Object.prototype.hasOwnProperty.call(state.sel, f.name);
2623        var want = named ? (state.sel[f.name] || '') : f.base;
2624        f.labelEl.textContent = want || '\u00A0';
2625        if (f.pillEl) f.pillEl.classList.toggle('cb-pill-ghost', f.typed && want === '');
2626        if (f.subEl) f.subEl.textContent = f.subBase;
2627      });
2628      Object.keys(slotBoxes).forEach(function (slot) {
2629        setSlotHollow(slot, !state[slot] && !usesBasePair(state));
2630      });
2631      activate(chipLayers, state.key, 500); // chips fade .45s; snap after, not during
2632    }
2633
2634    /* Beat timings (Bo's spec, 2026-08-19 evening): hold the finished
2635       result with the references it was made from, reset the card, rebuild
2636       it input by input, press Generate — and the "generated" frame lands
2637       exactly 1s after the click. */
2638    var HOLD = 3500;         // the finished hero + its references stay readable
2639    var RESET_FADE = 600;    // the clear-out breath before the inputs return
2640    var CHIP_STAGGER = 500;
2641    var PICK_STAGGER = 1050; // pick()'s lifecycle is 1000 — menus never overlap
2642    var PICK_TAIL = 1000;    // a pick's menu is gone this long after it starts
2643    var GEN_GAP = 250;       // a breath between the last menu closing and the click
2644    var GEN_TO_SWAP = 1000;  // Bo: the new frame follows 1s after Generate
2645    /* The floor under a beat (swap to swap). Where the card is hidden —
2646       the phone treatments of the /indexNew test pages, 2026-08-20 — every
2647       field is set silently and the story collapsed to ~6.5s, cutting the
2648       gin, dessert and bike films off mid-play. 9.7s is the phone beat
2649       with the card on (three picks, the Mood row hidden), so the real
2650       page is untouched, and it lets the longest film (the bike, 9.5s)
2651       reach its end; desktop beats run 12-13.5s, far above it. */
2652    var MIN_BEAT = 9700;
2653
2654    /* The reset: every chip layer fades, both fixed slots hollow to dashed
2655       outlines, every select label empties (to a no-break space, so the
2656       line box — and with it the row's height — survives). The fill that
2657       follows visibly chooses everything anew. */
2658    function resetCard() {
2659      activate(chipLayers, '', 300); // no key matches: every chip layer off
2660      Object.keys(slotBoxes).forEach(function (slot) { setSlotHollow(slot, true); });
2661      FIELDS.forEach(function (f) {
2662        if (f.labelEl) f.labelEl.style.opacity = '0';
2663        if (f.subEl) f.subEl.style.opacity = '0';
2664        if (f.iconEl) f.iconEl.style.opacity = '0'; // back with the pick
2665      });
2666      schedule(function () {
2667        FIELDS.forEach(function (f) {
2668          if (!f.labelEl) return;
2669          /* Everything holds a no-break space so its line box \u2014 and the
2670             row's height \u2014 survives the wait. The typed pills sit bare
2671             (ghosted chrome, no caret yet): the single caret arrives with
2672             typeIn(), just before its word. */
2673          f.labelEl.textContent = '\u00A0';
2674          f.labelEl.style.opacity = '1';
2675          if (f.subEl) {
2676            f.subEl.textContent = '\u00A0';
2677            f.subEl.style.opacity = '1';
2678          }
2679          if (f.typed && f.pillEl) f.pillEl.classList.add('cb-pill-ghost');
2680        });
2681      }, 200);
2682    }
2683
2684    /* The Mood pills are typed, not picked (Bo, two rounds: no menus, and
2685       only ONE caret in the row): the caret lands on the bare word-slot
2686       0.2s before the word types in, and when the word is finished the
2687       caret leaves and the pill chrome fades back around it. */
2688    function typeIn(field, to) {
2689      if (!field.labelEl) return;
2690      field.labelEl.textContent = ''; // the caret's own box carries the line now
2691      field.labelEl.classList.add('cb-typing');
2692      var i = 0;
2693      var tick = function () {
2694        i += 1;
2695        field.labelEl.textContent = to.slice(0, i);
2696        if (i < to.length) {
2697          schedule(tick, 45);
2698        } else {
2699          schedule(function () {
2700            field.labelEl.classList.remove('cb-typing');
2701            if (field.pillEl) field.pillEl.classList.remove('cb-pill-ghost');
2702          }, 160);
2703        }
2704      };
2705      schedule(tick, 200);
2706    }
2707
2708    /* One reference landing: the incoming state's chip layer, or — in a
2709       base-pair state — the export's own image fading back into the slot.
2710       All chip layers are off after the reset, so lighting the right ones
2711       is enough; no cross-fade bookkeeping needed. */
2712    function fillSlot(slot, state) {
2713      if (slot !== 'extra') setSlotHollow(slot, false);
2714      chipLayers.forEach(function (el) {
2715        if (el.dataset.state === state.key && el.dataset.slot === slot) el.classList.add('cb-on');
2716      });
2717    }
2718
2719    function cycle() {
2720      var nextIdx = (idx + 1) % STATES.length;
2721      while (STATES[nextIdx].dead) nextIdx = (nextIdx + 1) % STATES.length;
2722      var next = STATES[nextIdx];
2723
2724      // a video that is next in line gets the whole beat to buffer
2725      bufferVideo(next);
2726
2727      var videoNext = !!(next.sel && next.sel.style === 'Video');
2728
2729      schedule(resetCard, HOLD);
2730
2731      /* References first: product, portrait, extra — one landing at a
2732         time. A slot the state has no reference for stays hollow and
2733         takes no beat of the story. */
2734      var t = HOLD + RESET_FADE;
2735      ['product', 'portrait', 'extra'].forEach(function (slot) {
2736        var lands = slot === 'extra' ? !!next.extra
2737                                     : !!(next[slot] || usesBasePair(next));
2738        if (!lands) return;
2739        schedule(function () { fillSlot(slot, next); }, t);
2740        t += CHIP_STAGGER;
2741      });
2742
2743      /* Then the settings: every field fills, they are all empty now —
2744         the three selects through pick()'s menu, the three Mood pills by
2745         typing (45ms a character, so their beat share follows the word).
2746         A field whose row is display:none (the phone layout hides the
2747         Mood block) is set silently and takes no beat time — animating
2748         it was ~3s of dead air on phones. */
2749      t += 250;
2750      var lastEnd = t;
2751      FIELDS.forEach(function (f) {
2752        if (!f.labelEl) return;
2753        var named = next.sel && Object.prototype.hasOwnProperty.call(next.sel, f.name);
2754        var want = named ? (next.sel[f.name] || '') : f.base;
2755        if (!f.rowEl || f.rowEl.offsetParent === null) {
2756          schedule(function () {
2757            f.labelEl.textContent = want || '\u00A0';
2758            f.labelEl.classList.remove('cb-typing');
2759            if (f.pillEl) f.pillEl.classList.toggle('cb-pill-ghost', f.typed && want === '');
2760            if (f.subEl) f.subEl.textContent = f.subBase;
2761            if (f.iconEl) f.iconEl.style.opacity = '1';
2762          }, t);
2763          return;
2764        }
2765        if (f.typed) {
2766          if (want === '') return; // the state leaves this pill bare — no beat spent
2767          schedule(function () { typeIn(f, want); }, t);
2768          lastEnd = t + 200 + 45 * want.length + 160;
2769          t = lastEnd + 240;
2770        } else {
2771          var list = f.menus ? f.menus[videoNext ? 'video' : 'image'] : null;
2772          schedule(function () { pick(f, want, list); }, t);
2773          lastEnd = t + PICK_TAIL;
2774          t += PICK_STAGGER;
2775        }
2776      });
2777
2778      var genAt = Math.max(lastEnd + GEN_GAP, MIN_BEAT - GEN_TO_SWAP); // see MIN_BEAT
2779      schedule(pressGenerate, genAt);
2780      schedule(function () {
2781        /* The video may have errored while this very beat was buffering
2782           it (preload starts at cycle start, the dead-skip above ran even
2783           earlier) — landing on it would fade in an empty layer. Skip the
2784           swap, keep the current frame through the next beat, move on. */
2785        if (next.dead) {
2786          idx = nextIdx;
2787          cycle();
2788          return;
2789        }
2790        hero.classList.toggle('cb-hero-ink', !!next.text);
2791        document.documentElement.classList.toggle('cb-hero-nav-ink', !!next.text);
2792        activate(heroLayers, next.key, 1700);
2793        idx = nextIdx;
2794        /* ?alive=1: a still with a film behind it gets its second act
2795           before the ring moves on — comeAlive() calls cycle() itself. */
2796        if (alive && next.alive) {
2797          comeAlive(next);
2798          return;
2799        }
2800        cycle();
2801      }, genAt + GEN_TO_SWAP);
2802    }
2803
2804    function pressGenerate() {
2805      if (!genBtn) return;
2806      genBtn.classList.add('cb-gen-press');
2807      setTimeout(function () { genBtn.classList.remove('cb-gen-press'); }, 220);
2808    }
2809
2810    /* The second act behind the test pages' ?alive=1 knob (Bo's idea,
2811       2026-08-20), for a state whose still has a living-photograph clip
2812       (STATES' alive — the Seedance 2.5 -sd25 films, first frame = the
2813       still). The still has just landed the ordinary way — image model,
2814       Generate, the picture — and holds for the usual HOLD, the surprise
2815       of the finished image. Then, with NO reset, the "user" goes back
2816       into the card: Style flips to Video through its own menu, AI Model
2817       opens the video catalogue and picks the model the clips were in fact
2818       made with, Generate again, and exactly GEN_TO_SWAP later the film
2819       fades in over the still it was made from and plays on — the
2820       photograph comes alive. It ends on its hold and rests there (no
2821       loop), and cycle() takes over: the usual HOLD with Video / Seedance
2822       2.5 in the card, then the reset into the next state. Under the test
2823       pages' still knob the film is the end of the page's story. A field
2824       whose row the layout hides is set silently, as in cycle(); a clip
2825       that errored while buffering simply leaves the still standing. */
2826    var ALIVE_MODEL = 'Seedance 2.5';
2827    function comeAlive(state) {
2828      var t = HOLD;
2829      var lastEnd = t;
2830      [['style', 'Video'], ['model', ALIVE_MODEL]].forEach(function (step) {
2831        var f = FIELDS.filter(function (x) { return x.name === step[0]; })[0];
2832        if (!f || !f.labelEl) return;
2833        if (!f.rowEl || f.rowEl.offsetParent === null) {
2834          schedule(function () { f.labelEl.textContent = step[1]; }, t);
2835          return;
2836        }
2837        schedule(function () { pick(f, step[1], f.menus ? f.menus.video : null); }, t);
2838        lastEnd = t + PICK_TAIL;
2839        t += PICK_STAGGER;
2840      });
2841      var genAt = lastEnd + GEN_GAP;
2842      schedule(pressGenerate, genAt);
2843      schedule(function () {
2844        if (!state.aliveDead) activate(heroLayers, state.key, 1700, true);
2845        if (!knob.still) cycle();
2846      }, genAt + GEN_TO_SWAP);
2847    }
2848
2849    /* Open on BOOT instead of the export's own frame. .cb-noanim first, so
2850       the layer is simply THERE rather than fading up over the markup
2851       underneath — a cross-fade here would read as a glitch on load, not
2852       as a transition. applyCard() gives the References card the same
2853       state the picture is in; its own words come back the moment the ring
2854       reaches state a. The class comes off after a frame so the ring's
2855       real transitions animate normally. */
2856    var opened = null; // the boot state, for the two-step below
2857    (function () {
2858      var at = -1;
2859      STATES.forEach(function (st, i) { if (st.key === BOOT) at = i; });
2860      if (at === -1) return;
2861      var boot = STATES[at];
2862      opened = boot;
2863      idx = at;
2864      applyCard(boot);
2865      hero.classList.toggle('cb-hero-ink', !!boot.text);
2866      document.documentElement.classList.toggle('cb-hero-nav-ink', !!boot.text);
2867      /* The film waits for comeAlive(); it starts buffering now so it is
2868         there when the card reaches for it a few seconds in. */
2869      if (alive && boot.alive) bufferVideo(boot);
2870      var lit = heroLayers.concat(chipLayers).filter(function (el) {
2871        return el.dataset.state === BOOT && !el.dataset.alive;
2872      });
2873      lit.forEach(function (el) {
2874        el.classList.add('cb-noanim', 'cb-on');
2875        /* A video layer is created preload=none and only activate() ever
2876           plays one; BOOT is an image on the real page, but the test
2877           pages' knob can open on a film, which would otherwise sit blank
2878           over the export's frame. */
2879        if (el.tagName === 'VIDEO') {
2880          el.preload = 'auto';
2881          var p = el.play();
2882          if (p && p.catch) p.catch(function () {});
2883        }
2884      });
2885      setTimeout(function () {
2886        lit.forEach(function (el) { el.classList.remove('cb-noanim'); });
2887      }, 60);
2888    })();
2889
2890    /* Under ?alive=1 the page opens the way it cycles: the boot still is
2891       admired, then the card flips it to video — and comeAlive() hands
2892       over to cycle() itself (or holds the film, under the still knob). */
2893    if (alive && opened && opened.alive) {
2894      comeAlive(opened);
2895      return;
2896    }
2897    if (knob.still) return; // the test page's still: the boot frame, held
2898    cycle();
2899  })();
2900
2901  /* ------ smooth scroll on anchor clicks (JS, like the original) ---------- */
2902  each('a[href*="#"]', function (a) {
2903    a.addEventListener('click', function (ev) {
2904      var hash = a.getAttribute('href').split('#')[1];
2905      var target = hash && document.getElementById(hash);
2906      if (!target) return;
2907      ev.preventDefault();
2908      (target.cbPinWrap || target).scrollIntoView({ behavior: reduced ? 'auto' : 'smooth', block: 'start' });
2909      history.replaceState(null, '', '#' + hash);
2910    });
2911  });
2912})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.