openvibers/openvibe.shared showcase-stage.js
showcase-stage.js
| 1 | ; |
| 2 | /** |
| 3 | * openvibe-shared/showcase-stage — homes v2 (plan ~/openvibe/OpenVibe_Homes_v2_Plan_2026-11-09.md; the owner, 11-09: "make |
| 4 | * the home pages more unique… show the features… custom animations and effects… infographics… a shared system, but |
| 5 | * modular… not generic… and still SEO friendly"). Show, don't tell: a home opens on the product working. |
| 6 | * |
| 7 | * const st = require('openvibe-shared/showcase-stage'); |
| 8 | * `<style>${st.CSS}</style>` once (only pages that use these: other homes do not grow), then |
| 9 | * st.stage({ title, accent, lede, actions, note, demo, frame, motif }) the hero: words + a live demo panel |
| 10 | * st.reel({ id, title, lede, items: [{ title, text, icon, anim, href }] }) features as cards with a small motion each |
| 11 | * st.numbers({ id, title, lede, items: [{ value, label, series, note, unit }] }) real numbers that roll up, with lines |
| 12 | * st.flow({ id, title, lede, steps: [{ title, text, icon }] }) how it works, the line draws as it scrolls in |
| 13 | * `<script>${st.MOTION_JS}</script>` once at the end of the page: starts each piece when it comes into view. |
| 14 | * |
| 15 | * Everything is server-rendered HTML with the real words and numbers in it (crawlers and no-JavaScript readers get the |
| 16 | * whole page); the motion only adds. `demo` is the site's own HTML (trusted: the site renders it from its data). Motion is |
| 17 | * opacity, stroke-dash and transforms on HTML elements or on an <svg> itself, never a CSS transform on an element inside |
| 18 | * an SVG (phones left those unpainted while scrolling); Calm (html[data-ov-motion=reduced]) plays it with its loops slowed, and all of |
| 19 | * it stops with motion off (html[data-ov-motion=off]). |
| 20 | */ |
| 21 | const sc = require('./showcase'); |
| 22 | const glyphs = require('./glyphs'); |
| 23 | |
| 24 | /** A glyph from the set as inline SVG (strokes in currentColor); "ov:name" and "fa-name" resolve too. */ |
| 25 | function art(name, size) { |
| 26 | const n = String(name || '').replace(/^ov:/, ''); |
| 27 | const key = glyphs.NAMES.includes(n) ? n : (glyphs.fromFa ? glyphs.fromFa(n) : null); |
| 28 | if (key && glyphs.path(key)) return glyphs.svg(key, { size }); |
| 29 | return name ? sc.icon(name, size) : ''; |
| 30 | } |
| 31 | |
| 32 | const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); |
| 33 | const has = (a) => Array.isArray(a) && a.length > 0; |
| 34 | const slug = (t) => String(t || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'section'; |
| 35 | const head = (title, lede, hid) => `${title ? `<h2 id="${hid}">${esc(title)}</h2>` : ''}${lede ? `<p class="sc-lede">${esc(lede)}</p>` : ''}`; |
| 36 | const num = (v) => (v == null || v === '' || !Number.isFinite(Number(v)) ? null : Number(v)); |
| 37 | |
| 38 | // ── Motifs: one signature drawing per site, in the theme's accents (--accent, --accent-light), behind the stage ── |
| 39 | const P = (d, cls = '') => `<path d="${d}"${cls ? ` class="${cls}"` : ''}/>`; |
| 40 | const MOTIFS = { |
| 41 | wave: () => [0, 1, 2].map((i) => P(`M-20 ${190 + i * 34}C80 ${140 + i * 34} 160 ${240 + i * 34} 260 ${190 + i * 34}S440 ${140 + i * 34} 620 ${190 + i * 34}`, `m-line m-d${i}`)).join(''), |
| 42 | planes: () => P('M-10 330C120 300 150 150 300 170S470 70 610 40', 'm-dash') + [[150, 228], [330, 162], [500, 82]].map(([x, y], i) => P(`M${x} ${y}l26-10-8 20-6-8z`, `m-dot m-d${i}`)).join(''), |
| 43 | strip: () => [60, 330].map((y) => Array.from({ length: 12 }, (_, i) => `<rect x="${i * 52 - 10}" y="${y}" width="34" height="22" rx="5" class="m-dot m-d${i % 3}"/>`).join('')).join(''), |
| 44 | shutter: () => [70, 110, 150].map((r, i) => `<circle cx="470" cy="200" r="${r}" class="m-line m-d${i}"/>`).join('') + Array.from({ length: 6 }, (_, i) => { const a = (i * Math.PI) / 3; return P(`M${470 + Math.cos(a) * 40} ${200 + Math.sin(a) * 40}L${470 + Math.cos(a + 1.2) * 150} ${200 + Math.sin(a + 1.2) * 150}`, `m-line m-d${i % 3}`); }).join(''), |
| 45 | bubbles: () => [[60, 80, 150], [330, 140, 190], [90, 230, 210], [380, 300, 160], [200, 30, 120]].map(([x, y, w], i) => `<rect x="${x}" y="${y}" width="${w}" height="46" rx="23" class="m-dot m-d${i % 3}"/>`).join(''), |
| 46 | converge: () => [40, 120, 200, 280, 360].map((y, i) => P(`M-10 ${y}C200 ${y} 300 200 520 200`, `m-dash m-d${i % 3}`)).join('') + '<circle cx="530" cy="200" r="14" class="m-dot"/>', |
| 47 | rise: () => P('M-10 340L80 300 150 318 230 250 310 262 390 180 470 196 610 70', 'm-draw') + [[80, 300], [230, 250], [390, 180], [610, 70]].map(([x, y], i) => `<circle cx="${x}" cy="${y}" r="6" class="m-dot m-d${i % 3}"/>`).join(''), |
| 48 | path: () => P('M30 340C120 340 110 230 220 230S330 120 430 120 520 40 600 40', 'm-dash') + [[30, 340], [220, 230], [430, 120], [600, 40]].map(([x, y], i) => `<circle cx="${x}" cy="${y}" r="11" class="m-dot m-d${i % 3}"/>`).join(''), |
| 49 | stickers: () => [[60, 60, 0], [480, 40, 1], [520, 280, 2], [120, 300, 1], [300, 20, 2], [380, 330, 0]].map(([x, y, i]) => (i === 0 ? `<circle cx="${x}" cy="${y}" r="22" class="m-dot m-d0"/>` : i === 1 ? `<rect x="${x - 20}" y="${y - 20}" width="40" height="40" rx="10" class="m-dot m-d1"/>` : P(`M${x} ${y - 24}l7 15 16 2-12 11 3 16-14-8-14 8 3-16-12-11 16-2z`, 'm-dot m-d2'))).join(''), |
| 50 | grid: () => Array.from({ length: 40 }, (_, i) => `<rect x="${(i % 10) * 62 + 6}" y="${Math.floor(i / 10) * 100 + 20}" width="34" height="34" rx="9" class="m-dot m-d${(i * 7) % 3}"/>`).join(''), |
| 51 | pen: () => P('M20 320C120 120 230 380 320 200S520 60 590 160', 'm-draw') + '<circle cx="590" cy="160" r="8" class="m-dot"/>', |
| 52 | orbit: () => [80, 130, 180].map((r, i) => `<circle cx="300" cy="200" r="${r}" class="m-line m-d${i}"/>`).join('') + [[380, 200], [300, 70], [140, 260]].map(([x, y], i) => `<circle cx="${x}" cy="${y}" r="9" class="m-dot m-d${i}"/>`).join(''), |
| 53 | cursor: () => [[40, 220], [40, 160], [80, 290], [80, 120], [40, 250], [120, 180]].map(([x, w], i) => `<rect x="${x}" y="${60 + i * 46}" width="${w * 1.6}" height="14" rx="7" class="m-dot m-d${i % 3}"/>`).join('') + '<rect x="440" y="290" width="12" height="26" rx="2" class="m-blink"/>', |
| 54 | }; |
| 55 | function motifSVG(name) { |
| 56 | const draw = MOTIFS[name] || MOTIFS.orbit; |
| 57 | return `<svg class="sc-motif" viewBox="0 0 600 400" preserveAspectRatio="xMidYMid slice" aria-hidden="true" focusable="false">${draw()}</svg>`; |
| 58 | } |
| 59 | |
| 60 | /** The hero: the words (one H1) and the product working in a frame beside them (above on a phone: the words first). */ |
| 61 | function stage({ title, accent, lede, actions, note, demo, frame = 'window', motif = 'orbit', label } = {}) { |
| 62 | const acts = has(actions) ? `<div class="sc-actions">${actions.map((a) => `<a class="sc-btn${a.primary ? ' sc-primary' : ''}" href="${esc(a.href)}">${esc(a.label)}</a>`).join('')}</div>` : ''; |
| 63 | const fr = ['window', 'phone', 'card'].includes(frame) ? frame : 'window'; |
| 64 | const panel = demo ? `<div class="sc-stage-demo sc-frame-${fr}"${label ? ` role="figure" aria-label="${esc(label)}"` : ''}>${fr === 'window' ? '<span class="sc-frame-bar" aria-hidden="true"><i></i><i></i><i></i></span>' : ''}<div class="sc-frame-body">${demo}</div></div>` : ''; |
| 65 | return `<section class="sc-stage sc-motif-${esc(motif)}" data-sc-in><div class="sc-stage-bg" aria-hidden="true">${motifSVG(motif)}</div>` |
| 66 | + `<div class="sc-stage-copy"><h1>${esc(title)}${accent ? `<span class="sc-accent"> ${esc(accent)}</span>` : ''}</h1>${lede ? `<p class="sc-stage-lede">${esc(lede)}</p>` : ''}${acts}${note ? `<p class="sc-stage-note">${esc(note)}</p>` : ''}</div>${panel}</section>`; |
| 67 | } |
| 68 | |
| 69 | const ANIMS = ['float', 'pulse', 'tilt', 'pop', 'glow']; |
| 70 | /** The features: cards in a row you swipe on a phone and a grid on a computer, each with its icon moving its own way. */ |
| 71 | function reel({ id, title, lede, items } = {}) { |
| 72 | if (!has(items)) return ''; |
| 73 | const hid = slug(id || title); |
| 74 | return `<section class="sc-reel" data-sc-in${title ? ` aria-labelledby="${hid}"` : ''}>${head(title, lede, hid)}<ul class="sc-reel-list">${items.map((it, i) => { |
| 75 | const anim = ANIMS.includes(it.anim) ? it.anim : ANIMS[i % ANIMS.length]; |
| 76 | const inner = `<span class="sc-reel-art" data-anim="${anim}" aria-hidden="true">${it.icon ? art(it.icon, 26) : ''}</span><h3>${esc(it.title)}</h3>${it.text ? `<p>${esc(it.text)}</p>` : ''}`; |
| 77 | return `<li class="sc-reel-card" style="--i:${i}">${it.href ? `<a href="${esc(it.href)}">${inner}</a>` : inner}</li>`; |
| 78 | }).join('')}</ul></section>`; |
| 79 | } |
| 80 | |
| 81 | function spark(series) { |
| 82 | const v = (Array.isArray(series) ? series : []).map(Number).filter(Number.isFinite).slice(-30); |
| 83 | if (v.length < 2) return ''; |
| 84 | const max = Math.max(...v, 1), w = 120, h = 28; |
| 85 | const pts = v.map((x, i) => `${((i / (v.length - 1)) * w).toFixed(1)},${(h - (x / max) * (h - 4) - 2).toFixed(1)}`).join(' '); |
| 86 | return `<svg class="sc-num-line" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" aria-hidden="true"><polyline points="${pts}"/></svg>`; |
| 87 | } |
| 88 | /** The site's real numbers as an infographic: each rolls up once when it comes into view; the text is the final number. */ |
| 89 | function numbers({ id, title, lede, items } = {}) { |
| 90 | const list = (items || []).filter((x) => x && num(x.value) != null); |
| 91 | if (!list.length) return ''; |
| 92 | const hid = slug(id || title); |
| 93 | const fmt = new Intl.NumberFormat('en-US'); |
| 94 | return `<section class="sc-numbers" data-sc-in${title ? ` aria-labelledby="${hid}"` : ''}>${head(title, lede, hid)}<dl class="sc-num-grid">${list.map((x) => { |
| 95 | const n = num(x.value); |
| 96 | return `<div class="sc-num"><dt>${esc(x.label)}</dt><dd><b data-to="${Number.isInteger(n) ? n : ''}">${esc(fmt.format(n))}</b>${x.unit ? `<span class="sc-num-unit">${esc(x.unit)}</span>` : ''}</dd>${spark(x.series)}${x.note ? `<p>${esc(x.note)}</p>` : ''}</div>`; |
| 97 | }).join('')}</dl></section>`; |
| 98 | } |
| 99 | |
| 100 | /** How it works: steps joined by a line that draws as the section comes into view. */ |
| 101 | function flow({ id, title, lede, steps } = {}) { |
| 102 | if (!has(steps)) return ''; |
| 103 | const hid = slug(id || title); |
| 104 | return `<section class="sc-flow" data-sc-in${title ? ` aria-labelledby="${hid}"` : ''}>${head(title, lede, hid)}<ol class="sc-flow-list">${steps.map((s, i) => `<li style="--i:${i}"><span class="sc-flow-dot" aria-hidden="true">${s.icon ? art(s.icon, 20) : i + 1}</span><h3>${esc(s.title)}</h3>${s.text ? `<p>${esc(s.text)}</p>` : ''}</li>`).join('')}</ol></section>`; |
| 105 | } |
| 106 | |
| 107 | const CSS = ` |
| 108 | .sc-stage{--sa:var(--accent,#3b82f6);--sb:var(--accent-light,#60a5fa);position:relative;isolation:isolate;display:grid;gap:28px;align-items:center;padding:28px 0 36px;margin:0 0 8px} |
| 109 | @media (min-width:900px){.sc-stage{grid-template-columns:minmax(0,1fr) minmax(0,1.05fr);gap:48px;padding:56px 0 64px}} |
| 110 | .sc-stage-bg{position:absolute;inset:-40px calc(-1 * var(--ovk-gutter,16px)) 0;z-index:-1;overflow:hidden;-webkit-mask-image:linear-gradient(180deg,#000 55%,transparent);mask-image:linear-gradient(180deg,#000 55%,transparent)} |
| 111 | .sc-motif{position:absolute;inset:0;width:100%;height:100%;opacity:.55} |
| 112 | @media (max-width:899px){.sc-motif{opacity:.28}.sc-stage-bg{-webkit-mask-image:linear-gradient(180deg,#000 30%,transparent 70%);mask-image:linear-gradient(180deg,#000 30%,transparent 70%)}} |
| 113 | .sc-motif .m-line,.sc-motif .m-dash,.sc-motif .m-draw{fill:none;stroke:var(--sa);stroke-width:2;stroke-linecap:round;opacity:.35} |
| 114 | .sc-motif .m-dash{stroke-dasharray:2 12;stroke:var(--sb)} |
| 115 | .sc-motif .m-draw{stroke-width:3;stroke-dasharray:1;stroke-dashoffset:1;opacity:.5} |
| 116 | .sc-motif .m-dot{fill:color-mix(in srgb,var(--sb) 28%,transparent);stroke:none} |
| 117 | .sc-motif .m-blink{fill:var(--sb);opacity:.6} |
| 118 | .sc-stage.is-in .sc-motif .m-draw{animation:sc-draw 2.4s cubic-bezier(.3,.7,.2,1) forwards} |
| 119 | .sc-stage.is-in .sc-motif .m-dash{animation:sc-flowdash 9s linear infinite} |
| 120 | .sc-stage.is-in .sc-motif .m-dot{animation:sc-breathe 5s ease-in-out infinite} |
| 121 | .sc-stage.is-in .sc-motif .m-d1{animation-delay:-1.6s}.sc-stage.is-in .sc-motif .m-d2{animation-delay:-3.2s} |
| 122 | .sc-stage.is-in .sc-motif .m-blink{animation:sc-blink 1.1s steps(1) infinite} |
| 123 | .sc-stage-copy h1{margin:0 0 14px;font-size:clamp(2rem,5.4vw,3.4rem);line-height:1.05;letter-spacing:-.03em} |
| 124 | .sc-stage-copy .sc-accent{background:linear-gradient(90deg,var(--sa),var(--sb));-webkit-background-clip:text;background-clip:text;color:transparent} |
| 125 | .sc-stage-lede{margin:0 0 20px;font-size:clamp(1.02rem,2.2vw,1.2rem);line-height:1.55;color:var(--text-secondary,#b6c0d0);max-width:38em} |
| 126 | .sc-stage-note{margin:12px 0 0;font-size:.86rem;color:var(--text-muted,#8a96a8)} |
| 127 | .sc-stage-demo{position:relative;min-width:0;border-radius:20px;background:color-mix(in srgb,var(--bg-secondary,#111827) 86%,transparent);border:1px solid color-mix(in srgb,var(--sb) 22%,var(--border,#263244));box-shadow:0 30px 70px -30px color-mix(in srgb,var(--sa) 45%,transparent),0 0 0 1px rgba(255,255,255,.02);overflow:hidden;opacity:0;transform:translateY(14px) scale(.985);transition:opacity .6s ease,transform .7s cubic-bezier(.2,.8,.2,1)} |
| 128 | .sc-stage.is-in .sc-stage-demo{opacity:1;transform:none} |
| 129 | .sc-stage-demo::before{content:'';position:absolute;inset:-30%;z-index:-1;background:radial-gradient(circle at 70% 20%,color-mix(in srgb,var(--sa) 30%,transparent),transparent 55%)} |
| 130 | .sc-frame-bar{display:flex;gap:6px;padding:10px 12px;border-bottom:1px solid var(--border,#263244)} |
| 131 | .sc-frame-bar i{width:9px;height:9px;border-radius:50%;background:var(--border-light,#334155)} |
| 132 | .sc-frame-body{padding:14px;min-width:0} |
| 133 | .sc-frame-phone{max-width:340px;margin:0 auto;border-radius:34px;padding:10px} |
| 134 | .sc-frame-phone .sc-frame-body{border-radius:26px;background:var(--bg-primary,#0b1220);min-height:420px} |
| 135 | .sc-reel,.sc-numbers,.sc-flow{margin:36px 0} |
| 136 | .sc-reel-list{list-style:none;margin:14px calc(-1 * var(--ovk-gutter,16px)) 0;padding:4px var(--ovk-gutter,16px) 10px;display:grid;grid-auto-flow:column;grid-auto-columns:min(78%,280px);gap:12px;overflow-x:auto;scroll-snap-type:x mandatory;scrollbar-width:none} |
| 137 | .sc-reel-list::-webkit-scrollbar{display:none} |
| 138 | @media (min-width:900px){.sc-reel-list{grid-auto-flow:row;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));overflow:visible;margin:14px 0 0;padding:4px 0}} |
| 139 | .sc-reel-card{scroll-snap-align:start;border-radius:18px;background:var(--bg-secondary,#111827);border:1px solid var(--border,#263244);transition:transform .25s cubic-bezier(.2,.8,.2,1),border-color .25s,box-shadow .25s;opacity:0;transform:translateY(10px)} |
| 140 | .sc-reel.is-in .sc-reel-card{opacity:1;transform:none;transition-delay:calc(var(--i) * 70ms)} |
| 141 | .sc-reel-card>a,.sc-reel-card{color:inherit;text-decoration:none} |
| 142 | .sc-reel-card>a{display:block} |
| 143 | .sc-reel-card>a{padding:16px;height:100%;box-sizing:border-box} |
| 144 | .sc-reel-card:not(:has(>a)){padding:16px} |
| 145 | .sc-reel-card h3{margin:12px 0 6px;font-size:1.02rem} |
| 146 | .sc-reel-card p{margin:0;font-size:.9rem;line-height:1.5;color:var(--text-secondary,#b6c0d0)} |
| 147 | @media (hover:hover){.sc-reel-card:hover{transform:translateY(-3px);border-color:color-mix(in srgb,var(--accent,#3b82f6) 50%,var(--border,#263244));box-shadow:0 16px 36px -20px color-mix(in srgb,var(--accent,#3b82f6) 60%,transparent)}} |
| 148 | .sc-reel-art svg,.sc-flow-dot svg{display:block} |
| 149 | .sc-reel-art{display:grid;place-items:center;width:52px;height:52px;border-radius:15px;background:color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent);color:var(--accent-light,#60a5fa)} |
| 150 | .sc-reel.is-in [data-anim=float]{animation:sc-float 4.5s ease-in-out infinite} |
| 151 | .sc-reel.is-in [data-anim=pulse]{animation:sc-pulse 3.2s ease-in-out infinite} |
| 152 | .sc-reel.is-in [data-anim=tilt]{animation:sc-tilt 5s ease-in-out infinite} |
| 153 | .sc-reel.is-in [data-anim=pop]{animation:sc-pop .6s cubic-bezier(.3,1.6,.5,1) both;animation-delay:calc(var(--i,0) * 90ms + .2s)} |
| 154 | .sc-reel.is-in [data-anim=glow]{animation:sc-glow 3.6s ease-in-out infinite} |
| 155 | .sc-num-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin:14px 0 0} |
| 156 | @media (min-width:700px){.sc-num-grid{grid-template-columns:repeat(4,minmax(0,1fr))}} |
| 157 | .sc-num{display:grid;gap:4px;align-content:start;padding:14px;border-radius:16px;background:var(--bg-secondary,#111827);border:1px solid var(--border,#263244);min-width:0} |
| 158 | .sc-num dt{order:2;font-size:.82rem;color:var(--text-muted,#8a96a8)} |
| 159 | .sc-num dd{order:1;margin:0;display:flex;align-items:baseline;gap:4px} |
| 160 | .sc-num b{font-size:clamp(1.5rem,4vw,2.1rem);font-variant-numeric:tabular-nums;letter-spacing:-.02em} |
| 161 | .sc-num-unit{font-size:.9rem;color:var(--text-muted,#8a96a8)} |
| 162 | .sc-num-line{order:3;width:100%;height:28px} |
| 163 | .sc-num-line polyline{fill:none;stroke:var(--accent,#3b82f6);stroke-width:2;vector-effect:non-scaling-stroke;stroke-linecap:round;stroke-linejoin:round} |
| 164 | .sc-num-line{opacity:0;transition:opacity .9s ease .3s}.sc-numbers.is-in .sc-num-line{opacity:1} |
| 165 | .sc-num p{order:4;margin:0;font-size:.8rem;color:var(--text-muted,#8a96a8)} |
| 166 | .sc-flow-list{list-style:none;margin:16px 0 0;padding:0;display:grid;gap:18px;position:relative} |
| 167 | @media (min-width:800px){.sc-flow-list{grid-auto-flow:column;grid-auto-columns:1fr}} |
| 168 | .sc-flow-list::before{content:'';position:absolute;left:21px;top:22px;bottom:22px;width:2px;background:linear-gradient(180deg,var(--accent,#3b82f6),var(--accent-light,#60a5fa));transform-origin:0 0;transform:scaleY(0);transition:transform 1.2s cubic-bezier(.3,.7,.2,1)} |
| 169 | @media (min-width:800px){.sc-flow-list::before{left:22px;right:22px;top:21px;bottom:auto;width:auto;height:2px;transform:scaleX(0)}} |
| 170 | .sc-flow.is-in .sc-flow-list::before{transform:none} |
| 171 | .sc-flow-list li{position:relative;padding-left:60px} |
| 172 | @media (min-width:800px){.sc-flow-list li{padding:58px 0 0}} |
| 173 | .sc-flow-dot{position:absolute;left:0;top:0;display:grid;place-items:center;width:44px;height:44px;border-radius:50%;background:var(--bg-primary,#0b1220);border:2px solid var(--accent,#3b82f6);color:var(--accent-light,#60a5fa);font-weight:800} |
| 174 | .sc-flow-list h3{margin:8px 0 4px;font-size:1.02rem} |
| 175 | .sc-flow-list p{margin:0;font-size:.9rem;line-height:1.5;color:var(--text-secondary,#b6c0d0)} |
| 176 | @keyframes sc-draw{from{stroke-dashoffset:1}to{stroke-dashoffset:0}} |
| 177 | @keyframes sc-flowdash{to{stroke-dashoffset:-280}} |
| 178 | @keyframes sc-breathe{50%{opacity:.35}} |
| 179 | @keyframes sc-blink{50%{opacity:0}} |
| 180 | @keyframes sc-float{50%{transform:translateY(-5px)}} |
| 181 | @keyframes sc-pulse{50%{box-shadow:0 0 0 8px color-mix(in srgb,var(--accent,#3b82f6) 12%,transparent)}} |
| 182 | @keyframes sc-tilt{25%{transform:rotate(-4deg)}75%{transform:rotate(4deg)}} |
| 183 | @keyframes sc-pop{from{transform:scale(.6);opacity:0}} |
| 184 | @keyframes sc-glow{50%{background:color-mix(in srgb,var(--accent,#3b82f6) 26%,transparent)}} |
| 185 | html[data-ov-motion=off] .sc-stage-demo,html[data-ov-motion=off] .sc-reel-card,html[data-ov-motion=off] .sc-num-line{opacity:1;transform:none}html[data-ov-motion=off] .sc-flow-list::before{transform:none}html[data-ov-motion=off] .sc-motif .m-draw{stroke-dashoffset:0} |
| 186 | html[data-ov-motion=off] :is(.sc-stage,.sc-reel,.sc-numbers,.sc-flow) *,html[data-ov-motion=off] :is(.sc-stage-demo,.sc-reel-card){opacity:1;transform:none} |
| 187 | html[data-ov-motion=off] .sc-flow-list::before{transform:none} |
| 188 | `; |
| 189 | |
| 190 | // Starts each piece when it comes into view; rolls the numbers up from zero once. Without IntersectionObserver, or with |
| 191 | // motion off, everything is shown as it is (the server already wrote the final numbers). |
| 192 | const MOTION_JS = "(function(){try{var d=document,h=d.documentElement,still=h.getAttribute('data-ov-motion')==='off',els=[].slice.call(d.querySelectorAll('[data-sc-in]'));" |
| 193 | + "function roll(e){if(still)return;[].slice.call(e.querySelectorAll('[data-to]')).forEach(function(n){var to=+n.getAttribute('data-to');if(!(to>0))return;var f=new Intl.NumberFormat('en-US'),t0=performance.now();(function s(t){var k=Math.min(1,(t-t0)/900);n.textContent=f.format(Math.round(to*(1-Math.pow(1-k,3))));if(k<1)requestAnimationFrame(s)})(t0)})}" |
| 194 | + "if(!('IntersectionObserver' in window)){els.forEach(function(e){e.classList.add('is-in')});return}" |
| 195 | + "var io=new IntersectionObserver(function(es){es.forEach(function(x){if(!x.isIntersecting)return;x.target.classList.add('is-in');roll(x.target);io.unobserve(x.target)})},{threshold:.15});els.forEach(function(e){io.observe(e)})}catch(e){}})();"; |
| 196 | |
| 197 | module.exports = { stage, reel, numbers, flow, motifSVG, MOTIFS: Object.keys(MOTIFS), ANIMS, CSS, MOTION_JS }; |
| 198 |