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.