HeroLoop.astro (29189B)
1 --- 2 import SignalField from './SignalField.astro'; 3 4 interface Shelf { n: string; label: string; path: string; accent: string; desc: string } 5 interface Props { 6 shelves: Shelf[]; 7 name?: string; 8 tagline?: string; 9 } 10 const { shelves, name = 'DΛΣMӨП-RΣF', tagline = 'DÆMON-REF · Cheatsheet vault' } = Astro.props; 11 const data = JSON.stringify({ shelves, name, tagline }); 12 --- 13 <section class="hero-loop bleed signal-hero" data-hero-loop data-hero={data} aria-label="Shelves"> 14 <SignalField /> 15 <noscript> 16 <div style="padding:56px 44px;max-width:1560px;margin:0 auto;"> 17 <h1 style="margin:0;font-family:var(--font-wordmark);font-weight:800;font-size:clamp(40px,9vw,120px);font-stretch:88%;letter-spacing:-0.02em;line-height:1;color:var(--text);text-transform:uppercase;">{name}</h1> 18 <p style="margin-top:14px;font-family:var(--font-plex);font-size:13px;letter-spacing:0.1em;text-transform:uppercase;color:var(--muted);">Six shelves. One vault.</p> 19 <ul style="margin-top:28px;list-style:none;padding:0;border-top:1px solid var(--line);"> 20 {shelves.map((s) => ( 21 <li><a href={s.path} style={`display:flex;justify-content:space-between;gap:16px;padding:16px 0;border-bottom:1px solid var(--line);font-family:var(--font-archivo);font-weight:600;font-size:24px;text-transform:uppercase;color:var(--${s.accent});`}>{s.label}<span style="font-family:var(--font-plex);font-size:13px;color:var(--subtle);">{s.path}</span></a></li> 22 ))} 23 </ul> 24 </div> 25 </noscript> 26 </section> 27 28 <script> 29 // Hero loop — a 1:1 vanilla port of the handoff's hero-loop.js (itself the 30 // main site's hero-loop.tsx): one 21.4s rAF clock, an authored 1920×1080 31 // canvas scaled to fit at >=1280px, a fluid flow shape below, pointer/focus 32 // takeover with an eased `mix`, glyph decode until +0.55s, and the plate as 33 // the inverse of the page (theme-aware P/Q). Pauses when hidden or scrolled 34 // out; prefers-reduced-motion holds T=12.0 with no scramble. 35 36 import { mountParticleField, type LitShelf, type ShelfSource } from '../scripts/dmn/particle-field'; 37 import { SHELF_ICONS } from '../scripts/dmn/shelf-icons'; 38 39 const W = 1920, H = 1080, PLATE_W = 784, PLATE_H = 780, MIN_HEIGHT = 560, MARQUEE = 92; 40 const CANVAS_MIN = 1280, FLOW_PLATE_MIN = 860; 41 const BOOT = 0, CLOSE = 18.8, AUTHORED = 21.4, HELD_FRAME = 12.0; 42 const STARTS = [3.2, 5.8, 8.4, 11.0, 13.6, 16.2]; 43 const ENDS = STARTS.map((s, i) => (i < 5 ? STARTS[i + 1] : CLOSE)); 44 const GLYPH_POOL = 'ΛΣӨПΔΞΨΩ01#/\\_'; 45 46 const DAWN = { base: 'var(--color-dawn-base)', surface: 'var(--color-dawn-surface)', ink: 'var(--color-dawn-ink)', subtle: 'var(--color-dawn-subtle)', muted: 'var(--color-dawn-muted)', line: 'color-mix(in srgb, var(--color-dawn-ink) 16%, transparent)', pine: 'var(--color-dawn-pine)', foam: 'var(--color-dawn-foam)', gold: 'var(--color-dawn-gold)', love: 'var(--color-dawn-love)', iris: 'var(--color-dawn-iris)', rose: 'var(--color-dawn-rose)' }; 47 const NIGHT = { base: 'var(--color-night-base)', surface: 'var(--color-night-surface)', ink: 'var(--color-night-text)', subtle: 'var(--color-night-subtle)', muted: 'var(--color-night-muted)', line: 'color-mix(in srgb, var(--color-night-text) 16%, transparent)', pine: 'var(--color-night-pine)', foam: 'var(--color-night-foam)', gold: 'var(--color-night-gold)', love: 'var(--color-night-love)', iris: 'var(--color-night-iris)', rose: 'var(--color-night-rose)' }; 48 const MONO = 'var(--font-plex)', ARCHIVO = 'var(--font-archivo)', WORDMARK_FACE = 'var(--font-wordmark)'; 49 50 const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); 51 const easeOutCubic = (p: number) => 1 - Math.pow(1 - p, 3); 52 const easeInOutCubic = (p: number) => (p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2); 53 const enter = (T: number, start: number, dur = 0.6) => { const o = easeOutCubic(clamp((T - start) / dur, 0, 1)); return { o, y: (1 - o) * 24 }; }; 54 const draw = (T: number, start: number, dur = 0.8) => easeInOutCubic(clamp((T - start) / dur, 0, 1)); 55 const hold = (T: number, a: number, b: number, ramp = 0.35) => Math.min(easeOutCubic(clamp((T - a) / ramp, 0, 1)), easeOutCubic(clamp((b - T) / ramp, 0, 1))); 56 const hash = (i: number, k: number) => { const s = Math.sin(i * 12.9898 + k * 78.233) * 43758.5453; return s - Math.floor(s); }; 57 const activeAt = (T: number) => { for (let i = 0; i < 6; i++) if (T >= STARTS[i] && T < ENDS[i]) return i; return -1; }; 58 const timecode = (T: number) => `T+${String(Math.floor(T / 60)).padStart(2, '0')}:${String(Math.floor(T % 60)).padStart(2, '0')}.${String(Math.floor((T % 1) * 24)).padStart(2, '0')}`; 59 60 type Shelf = { n: string; label: string; path: string; accent: string; desc: string }; 61 62 function shelfState(T: number, mix: number, held: number | null, shelves: Shelf[]) { 63 const act: number[] = [], plateAct: number[] = [], plateY: number[] = [], plateLine: number[] = []; 64 for (let i = 0; i < shelves.length; i++) { 65 const hv = i === held ? 1 : 0; 66 act.push(hold(T, STARTS[i], ENDS[i]) * (1 - mix) + hv * mix); 67 plateAct.push(hold(T, STARTS[i], ENDS[i], 0.4) * (1 - mix) + hv * mix); 68 plateY.push((1 - enter(T, STARTS[i], 0.5).o) * 40 * (1 - mix)); 69 const line = draw(T, STARTS[i] + 0.15, 0.7) * (1 - draw(T, ENDS[i] - 0.4, 0.4)); 70 plateLine.push(line * (1 - mix) + hv * mix); 71 } 72 const fromClock = activeAt(T); 73 const active = mix > 0.5 ? (held ?? fromClock) : fromClock; 74 const standing = Math.max(hold(T, BOOT + 1.6, STARTS[0]), hold(T, CLOSE + 0.35, AUTHORED + 1)) * (1 - mix); 75 return { act, plateAct, plateY, plateLine, standing, active, accent: active >= 0 ? shelves[active].accent : 'love' }; 76 } 77 78 // Tiny DOM helper. Numeric style values need no unit here — every length is 79 // passed as an explicit px string. 80 function el(tag: string, style?: Record<string, string>, kids?: any): HTMLElement { 81 const n = document.createElement(tag); 82 if (style) Object.assign(n.style, style); 83 if (kids != null) (Array.isArray(kids) ? kids : [kids]).forEach((k) => { if (k == null || k === false) return; n.append(k.nodeType ? k : document.createTextNode(String(k))); }); 84 return n; 85 } 86 87 function build(hostW: number, mode: 'canvas' | 'flow', withPlate: boolean, shelves: Shelf[], name: string, tagline: string, P: any, Q: any, onHold: (i: number) => void, onRelease: () => void) { 88 // Row metrics differ by shape. 89 const M = mode === 'canvas' 90 ? { height: '76px', columns: '64px 1fr auto', padLeft: '14px', chipSize: '14px', chipPad: '3px 7px', labelSize: '30px', pathSize: '15px', pathPad: '16px', shift: 10, arrow: true } 91 : { height: '56px', columns: '44px 1fr auto', padLeft: '8px', chipSize: '11px', chipPad: '3px 6px', labelSize: 'clamp(17px, 2.2vw, 26px)', pathSize: 'clamp(10px, 1.1vw, 13px)', pathPad: '0px', shift: 8, arrow: false }; 92 93 // ── Shelf row ── 94 const rows = shelves.map((s) => { 95 const acc = P[s.accent]; 96 const wash = el('span', { position: 'absolute', inset: '0', background: P.surface, opacity: '0', borderLeft: `3px solid ${acc}`, transformOrigin: 'left', transform: 'scaleX(0)' }); 97 const chip = el('span', { fontFamily: MONO, fontSize: M.chipSize, letterSpacing: '0.06em', padding: M.chipPad, color: P.muted, background: 'transparent', boxShadow: `inset 0 0 0 1px ${P.line}` }, s.n); 98 const chipWrap = el('span', { position: 'relative', display: 'flex', alignItems: 'center', paddingLeft: M.padLeft }, chip); 99 const label = el('span', { position: 'relative', fontFamily: ARCHIVO, fontWeight: '600', fontSize: M.labelSize, letterSpacing: '0.04em', textTransform: 'uppercase', color: P.ink, opacity: '1', transform: 'translateX(0)' }, s.label); 100 const arrow = M.arrow ? el('span', { display: 'inline-block', marginLeft: '12px', opacity: '0', transform: 'translateX(-8px)' }, '→') : null; 101 const path = el('span', { position: 'relative', fontFamily: MONO, fontSize: M.pathSize, letterSpacing: '0.06em', color: P.subtle, paddingRight: M.pathPad, opacity: '0.6' }, [s.path, arrow]); 102 const a = el('a', { position: 'relative', display: 'grid', gridTemplateColumns: M.columns, alignItems: 'center', height: M.height, borderTop: `1px solid ${P.line}`, opacity: '1', transform: 'translateY(0)', textDecoration: 'none', color: P.ink }, [wash, chipWrap, label, path]) as HTMLAnchorElement; 103 a.href = s.path; 104 const idx = shelves.indexOf(s); 105 a.addEventListener('mouseenter', () => onHold(idx)); 106 a.addEventListener('mouseleave', onRelease); 107 a.addEventListener('focus', () => onHold(idx)); 108 a.addEventListener('blur', onRelease); 109 return { a, wash, chip, label, path, arrow }; 110 }); 111 const nav = el('nav', mode === 'canvas' 112 ? { position: 'absolute', left: '72px', top: '424px', width: '916px' } 113 : { marginTop: '32px' }, [...rows.map((r) => r.a), el('div', { borderTop: `1px solid ${P.line}` })]); 114 nav.setAttribute('aria-label', 'Shelves'); 115 nav.setAttribute('data-index-zone', ''); 116 117 // ── Plate ── 118 // The plate's dots: spread across it at rest and gathering into the 119 // hovered shelf's icon (src/scripts/dmn). They replace the old random 120 // grid, so the dots on the card are the ones that form the icon. The 121 // anchor is the open upper half, between the chrome and the shelf block. 122 const plateCanvas = el('canvas', { position: 'absolute', inset: '0', width: '100%', height: '100%', display: 'block' }) as HTMLCanvasElement; 123 plateCanvas.className = 'dmn-plate-dots'; 124 const iconAnchor = el('div', { position: 'absolute', left: '40px', right: '40px', top: '92px', height: '300px' }); 125 iconAnchor.setAttribute('data-field-icon', ''); 126 const dotField = el('div', { position: 'absolute', left: '0', top: '0', width: PLATE_W + 'px', height: PLATE_H + 'px' }, [iconAnchor, plateCanvas]); 127 const plateMeta = { fontFamily: MONO, fontSize: '14px', letterSpacing: '0.10em', textTransform: 'uppercase', color: Q.subtle }; 128 const chromeRight = el('span', { color: P.love }, '— / 06'); 129 const chrome = el('div', { position: 'absolute', left: '40px', right: '40px', top: '36px', display: 'flex', justifyContent: 'space-between', whiteSpace: 'nowrap', ...plateMeta }, [el('span', undefined, 'Plate · index'), chromeRight]); 130 const standing = el('div', { position: 'absolute', left: '40px', right: '40px', bottom: '48px', opacity: '0', transform: 'translateY(16px)' }, [ 131 el('div', { ...plateMeta, marginBottom: '14px' }, 'Standing by'), 132 el('div', { fontFamily: ARCHIVO, fontWeight: '700', fontSize: '64px', lineHeight: '0.98', letterSpacing: '-0.02em', color: Q.ink }, ['Security lives', el('br'), 'in the details.']), 133 el('div', { marginTop: '22px', fontFamily: MONO, fontSize: '15px', letterSpacing: '0.04em', color: Q.muted }, '06 shelves · select an entry'), 134 ]); 135 const sections = shelves.map((s) => { 136 const acc = Q[s.accent]; 137 const rule = el('div', { height: '2px', background: acc, marginTop: '26px', transformOrigin: 'left', transform: 'scaleX(0)' }); 138 const block = el('div', { position: 'absolute', left: '40px', right: '40px', bottom: '48px', opacity: '0', transform: 'translateY(40px)' }, [ 139 el('div', { display: 'flex', alignItems: 'center', gap: '14px', marginBottom: '20px' }, [ 140 el('span', { fontFamily: MONO, fontSize: '14px', letterSpacing: '0.08em', padding: '4px 8px', background: acc, color: Q.base }, s.n), 141 el('span', { fontFamily: MONO, fontSize: '15px', letterSpacing: '0.08em', color: acc }, s.path), 142 ]), 143 el('div', { fontFamily: ARCHIVO, fontWeight: '800', fontSize: (s.label.length > 5 ? 118 : 148) + 'px', lineHeight: '0.92', letterSpacing: '-0.03em', textTransform: 'uppercase', color: Q.ink }, s.label), 144 rule, 145 el('div', { marginTop: '22px', fontFamily: ARCHIVO, fontSize: '27px', lineHeight: '1.3', color: Q.subtle, maxWidth: '620px' }, s.desc), 146 el('div', { marginTop: '28px', fontFamily: MONO, fontSize: '15px', letterSpacing: '0.10em', textTransform: 'uppercase', color: Q.muted, display: 'flex', gap: '12px', alignItems: 'center' }, [el('span', { color: acc }, '▸'), ' Enter shelf']), 147 ]); 148 return { block, rule }; 149 }); 150 const plateSurface = el('div', { position: 'absolute', left: '0', top: '0', width: PLATE_W + 'px', height: PLATE_H + 'px', background: Q.base, color: Q.ink, overflow: 'hidden', clipPath: 'inset(0 100% 0 0)' }, [ 151 el('div', { position: 'absolute', inset: '0', background: `radial-gradient(120% 80% at 90% 100%, ${Q.surface} 0%, ${Q.base} 60%)` }), 152 dotField, chrome, 153 el('div', { position: 'absolute', left: '40px', right: '40px', top: '66px', height: '1px', background: Q.line }), 154 standing, ...sections.map((s) => s.block), 155 ]); 156 plateSurface.setAttribute('aria-hidden', 'true'); 157 plateSurface.setAttribute('data-field-host', ''); 158 159 // Plate host — absolute box on canvas, aspect-ratio fitted box on flow. 160 let plateHost: HTMLElement, plateScaleK = 1; 161 if (mode === 'canvas') { 162 plateHost = el('div', { position: 'absolute', left: '1064px', top: '150px', width: PLATE_W + 'px', height: PLATE_H + 'px' }, plateSurface); 163 } else { 164 const inner = el('div', { position: 'absolute', left: '0', top: '0', width: PLATE_W + 'px', height: PLATE_H + 'px', transformOrigin: '0 0' }, plateSurface); 165 plateHost = el('div', { position: 'relative', width: '100%', aspectRatio: `${PLATE_W} / ${PLATE_H}`, overflow: 'hidden' }, inner); 166 plateHost.setAttribute('aria-hidden', 'true'); 167 (plateHost as any)._inner = inner; 168 } 169 170 // ── Wordmark ── 171 const wm = name.split('').map((g) => el('span', { display: 'inline-block', opacity: '0', transform: 'translateY(24px)', color: g === '-' ? P.love : P.muted, fontFamily: MONO, fontWeight: '500' }, g)); 172 // The page's one heading; the glyphs scramble on arrival, so the 173 // accessible name is the settled spelling. 174 const wordmark = el('h1', { margin: '0', fontFamily: WORDMARK_FACE, fontWeight: '700', lineHeight: '1', letterSpacing: '-0.02em', color: P.ink, display: 'flex', whiteSpace: 'pre' }, wm); 175 wordmark.setAttribute('aria-label', name); 176 177 // ── Meta rows + subline ── 178 const metaBase = (x?: Record<string, string>) => ({ fontFamily: MONO, fontSize: '15px', letterSpacing: '0.10em', textTransform: 'uppercase', color: P.subtle, whiteSpace: 'nowrap', ...(x || {}) }); 179 const recDot = el('div', { width: '8px', height: '8px', borderRadius: '4px', background: P.love, opacity: '1' }); 180 const timecodeEl = el('div', metaBase({ fontVariantNumeric: 'tabular-nums' }), timecode(0)); 181 const sublinePath = el('span', { color: P[shelves[0]?.accent] || P.love }, '/'); 182 let topMeta: HTMLElement, subline: HTMLElement, hairline: HTMLElement, bottomMeta: HTMLElement, root: HTMLElement; 183 184 if (mode === 'canvas') { 185 hairline = el('div', { position: 'absolute', left: '72px', right: '72px', top: '96px', height: '1px', background: P.ink, transformOrigin: 'left', transform: 'scaleX(0)', opacity: '0.9' }); 186 topMeta = el('div', { position: 'absolute', left: '72px', right: '72px', top: '56px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', opacity: '0' }, [ 187 el('div', metaBase(), tagline), 188 el('div', { display: 'flex', alignItems: 'center', gap: '10px' }, [recDot, el('div', metaBase({ color: P.love }), 'REC')]), 189 timecodeEl, 190 ]); 191 wordmark.style.fontSize = '138px'; 192 subline = el('div', metaBase({ marginTop: '18px', marginLeft: '4px', color: P.muted, opacity: '0', transform: 'translateY(24px)' }), ['Six shelves. One vault. ', sublinePath]); 193 const wmBlock = el('div', { position: 'absolute', left: '68px', top: '178px' }, [wordmark, subline]); 194 bottomMeta = el('div', { position: 'absolute', left: '72px', right: '72px', bottom: '56px', display: 'flex', justifyContent: 'space-between', opacity: '0' }, [ 195 el('div', metaBase({ color: P.muted }), 'Rosé Pine · Dawn · plates inverted'), 196 el('div', metaBase({ color: P.muted }), 'Scroll ↓'), 197 ]); 198 root = el('div', { position: 'absolute', inset: '0', color: P.ink, opacity: '0', overflow: 'hidden', fontFamily: ARCHIVO }, [hairline, topMeta, wmBlock, nav, plateHost, bottomMeta]); 199 } else { 200 wordmark.style.fontSize = withPlate ? 'clamp(36px, 6.4vw, 88px)' : 'clamp(28px, 10vw, 80px)'; 201 hairline = el('div', { height: '1px', marginTop: '14px', background: P.ink, transformOrigin: 'left', transform: 'scaleX(0)', opacity: '0.9' }); 202 topMeta = el('div', { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '12px', opacity: '0' }, [ 203 el('div', metaBase({ fontSize: 'clamp(10px, 1.05vw, 13px)' }), tagline), 204 el('div', { display: 'flex', alignItems: 'center', gap: '8px' }, [recDot, timecodeEl]), 205 ]); 206 subline = el('div', metaBase({ fontSize: 'clamp(10px, 1.05vw, 13px)', marginTop: '14px', color: P.muted, whiteSpace: 'normal', opacity: '0', transform: 'translateY(24px)' }), ['Six shelves. One vault. ', sublinePath]); 207 const left = el('div', undefined, [wordmark, subline, nav]); 208 const center = el('div', { display: 'grid', gridTemplateColumns: withPlate ? 'minmax(0,1fr) clamp(300px, 44%, 560px)' : 'minmax(0,1fr)', gap: 'clamp(28px, 3.4vw, 56px)', alignItems: 'center' }, withPlate ? [left, plateHost] : [left]); 209 const centerWrap = el('div', { flex: '1', display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: 'clamp(28px, 4vw, 56px) 0' }, center); 210 bottomMeta = el('div', { paddingTop: '32px', display: 'flex', justifyContent: 'space-between', gap: '12px', opacity: '0' }, [ 211 el('div', metaBase({ fontSize: 'clamp(10px, 1.05vw, 13px)', color: P.muted }), 'Rosé Pine · Dawn'), 212 el('div', metaBase({ fontSize: 'clamp(10px, 1.05vw, 13px)', color: P.muted }), 'Scroll ↓'), 213 ]); 214 root = el('div', { display: 'flex', flexDirection: 'column', minHeight: `min(calc(100svh - ${MARQUEE}px), 620px)`, padding: 'clamp(28px, 4vw, 56px) clamp(20px, 3.8vw, 72px)', color: P.ink, opacity: '0', fontFamily: ARCHIVO }, [topMeta, hairline, centerWrap, bottomMeta]); 215 } 216 217 function paintPlate(T: number, st: any, reveal: number) { 218 plateSurface.style.clipPath = `inset(0 ${(1 - reveal) * 100}% 0 0)`; 219 const accentName = st.active >= 0 ? shelves[st.active].accent : 'love'; 220 const accentCol = Q[accentName]; 221 chromeRight.textContent = st.active >= 0 ? `${shelves[st.active].n} / 06` : '— / 06'; 222 chromeRight.style.color = accentCol; 223 standing.style.opacity = String(st.standing); 224 standing.style.transform = `translateY(${(1 - st.standing) * 16}px)`; 225 for (let i = 0; i < sections.length; i++) { 226 sections[i].block.style.opacity = String(st.plateAct[i]); 227 sections[i].block.style.transform = `translateY(${st.plateY[i]}px)`; 228 sections[i].rule.style.transform = `scaleX(${st.plateLine[i]})`; 229 } 230 } 231 232 function paint(T: number, st: any) { 233 const master = Math.min(enter(T, BOOT, 0.5).o, easeOutCubic(clamp((AUTHORED - T) / 0.5, 0, 1))); 234 const reveal = draw(T, BOOT + 0.9, 1.0) * (1 - draw(T, CLOSE + 1.2, 0.8)); 235 const rule = draw(T, BOOT + 0.25, 0.9), pulse = 0.55 + 0.45 * Math.sin(T * 5), sub = enter(T, BOOT + 2.1); 236 root.style.opacity = String(master); 237 hairline.style.transform = `scaleX(${rule})`; 238 topMeta.style.opacity = String(enter(T, BOOT + 0.2).o); 239 recDot.style.opacity = String(pulse); 240 timecodeEl.textContent = timecode(st.E ?? T); 241 bottomMeta.style.opacity = String(enter(T, BOOT + 2.4).o); 242 // Wordmark decode 243 const roll = Math.floor(T * 18); 244 for (let i = 0; i < wm.length; i++) { 245 const g = name[i], at = BOOT + 0.35 + i * 0.16, revealed = T >= at + 0.55, e = enter(T, at, 0.5); 246 const ch = revealed ? g : GLYPH_POOL[Math.floor(hash(i, roll) * GLYPH_POOL.length)]; 247 const node = wm[i]; 248 if (node.textContent !== ch) node.textContent = ch; 249 node.style.opacity = String(e.o); 250 node.style.transform = `translateY(${e.y}px)`; 251 node.style.color = g === '-' ? P.love : revealed ? P.ink : P.muted; 252 node.style.fontFamily = revealed ? WORDMARK_FACE : MONO; 253 node.style.fontWeight = revealed ? '700' : '500'; 254 } 255 // Subline 256 subline.style.opacity = String(sub.o); 257 subline.style.transform = `translateY(${sub.y}px)`; 258 sublinePath.textContent = st.active >= 0 ? shelves[st.active].path : '/'; 259 sublinePath.style.color = P[st.accent]; 260 // Rows 261 for (let i = 0; i < rows.length; i++) { 262 const a = st.act[i], s = shelves[i], acc = P[s.accent], lit = a > 0.5; 263 const en = enter(T, BOOT + 1.3 + i * 0.13, 0.55); 264 const R = rows[i]; 265 R.a.style.opacity = String(en.o); 266 R.a.style.transform = `translateY(${en.y}px)`; 267 R.wash.style.opacity = String(a); 268 R.wash.style.transform = `scaleX(${a})`; 269 R.chip.style.color = lit ? P.base : P.muted; 270 R.chip.style.background = lit ? acc : 'transparent'; 271 R.chip.style.boxShadow = `inset 0 0 0 1px ${lit ? acc : P.line}`; 272 R.label.style.color = a > 0.01 ? acc : P.ink; 273 R.label.style.opacity = String(0.55 + 0.45 * Math.max(a, st.active >= 0 ? 0 : 1)); 274 R.label.style.transform = `translateX(${a * M.shift}px)`; 275 R.path.style.opacity = String(0.6 + 0.4 * a); 276 if (R.arrow) { R.arrow.style.opacity = String(a); R.arrow.style.transform = `translateX(${(1 - a) * -8}px)`; } 277 } 278 paintPlate(T, st, reveal); 279 if (mode === 'flow' && withPlate) { 280 const k = plateHost.clientWidth / PLATE_W; 281 if (k > 0 && Math.abs(k - plateScaleK) > 0.001) { plateScaleK = k; (plateHost as any)._inner.style.transform = `scale(${k})`; } 282 } 283 } 284 return { root, paint, plateCanvas }; 285 } 286 287 function initHero() { 288 const host = document.querySelector('[data-hero-loop]') as HTMLElement | null; 289 if (!host || host.dataset.bound) return; 290 host.dataset.bound = '1'; 291 let cfg: { shelves: Shelf[]; name: string; tagline: string }; 292 try { cfg = JSON.parse(host.dataset.hero || '{}'); } catch { return; } 293 const shelves = cfg.shelves || []; 294 if (!shelves.length) return; 295 296 const reduced = matchMedia('(prefers-reduced-motion: reduce)'); 297 let hover: { idx: number | null; last: number | null } = { idx: null, last: null }; 298 299 // Which shelf a hand is holding, for the plate's icon. Hover only: the 300 // tour cycles the rows and the plate's copy, the icon answers a pointer. 301 let lit: LitShelf = { index: null, accent: null, held: false }; 302 const litSubs = new Set<(s: LitShelf) => void>(); 303 const shelfSource: ShelfSource = { 304 get: () => lit, 305 subscribe(f) { litSubs.add(f); return () => { litSubs.delete(f); }; }, 306 }; 307 const setLit = (i: number | null) => { 308 const next: LitShelf = i === null ? { index: null, accent: null, held: false } : { index: i, accent: shelves[i].accent, held: true }; 309 if (next.index === lit.index && next.held === lit.held) return; 310 lit = next; 311 litSubs.forEach((f) => f(lit)); 312 }; 313 const onHold = (i: number) => { hover = { idx: i, last: i }; setLit(i); }; 314 const onRelease = () => { hover = { idx: null, last: hover.last }; setLit(null); }; 315 let destroyPlate: (() => void) | null = null; 316 const mountPlate = (cv: HTMLCanvasElement, qFam: 'dawn' | 'night') => { 317 destroyPlate?.(); 318 destroyPlate = mountParticleField(cv, { 319 icons: SHELF_ICONS, 320 shelf: shelfSource, 321 cloud: false, 322 restFill: true, 323 tone: 'plate', 324 // The plate is the page's inverse, painted from the fixed poles. 325 vars: { 326 ink: qFam === 'night' ? '--color-night-text' : '--color-dawn-ink', 327 base: `--color-${qFam}-base`, 328 iris: `--color-${qFam}-iris`, 329 love: `--color-${qFam}-love`, 330 }, 331 accentVar: (a) => `--color-${qFam}-${a}`, 332 }); 333 }; 334 335 let current: { root: HTMLElement; paint: (T: number, st: any) => void } | null = null; 336 let mode: 'canvas' | 'flow' | null = null, withPlate = false, dark = document.documentElement.getAttribute('data-theme') === 'dark'; 337 let carriedT = 0, carriedE = 0, carriedMix = 0, raf = 0, prev = 0, visible = !document.hidden, inView = true; 338 339 function rebuild() { 340 const w = host.clientWidth || window.innerWidth; 341 const canvas = window.innerWidth >= CANVAS_MIN; 342 mode = canvas ? 'canvas' : 'flow'; 343 withPlate = !canvas && window.innerWidth >= FLOW_PLATE_MIN; 344 dark = document.documentElement.getAttribute('data-theme') === 'dark'; 345 const P = dark ? NIGHT : DAWN, Q = dark ? DAWN : NIGHT; 346 // Preserve the field when the hero rebuilds for theme or viewport changes. 347 for (const child of Array.from(host.children)) { 348 if (!child.hasAttribute('data-signal')) child.remove(); 349 } 350 if (canvas) { 351 const stage = el('div', { position: 'absolute', left: '0', top: '0', transformOrigin: '0 0' }); 352 host.style.height = `max(${MIN_HEIGHT}px, min(calc(100vh - ${MARQUEE}px), ${(H / W) * 100}vw))`; 353 const built = build(w, 'canvas', false, shelves, cfg.name, cfg.tagline, P, Q, onHold, onRelease); 354 stage.append(built.root); 355 host.append(stage); 356 mountPlate(built.plateCanvas, dark ? 'dawn' : 'night'); 357 const fit = () => { 358 const bw = host.clientWidth, bh = host.clientHeight, scale = bw > 0 && bh > 0 ? Math.min(bw / W, bh / H) : 0; 359 stage.style.width = W + 'px'; stage.style.height = H + 'px'; 360 stage.style.left = ((bw - W * scale) / 2) + 'px'; stage.style.top = ((bh - H * scale) / 2) + 'px'; 361 stage.style.transform = `scale(${scale})`; 362 }; 363 fit(); 364 current = built; 365 (host as any)._fit = fit; 366 } else { 367 host.style.height = ''; 368 const built = build(w, 'flow', withPlate, shelves, cfg.name, cfg.tagline, P, Q, onHold, onRelease); 369 host.append(built.root); 370 if (withPlate) mountPlate(built.plateCanvas, dark ? 'dawn' : 'night'); 371 else { destroyPlate?.(); destroyPlate = null; } 372 current = built; 373 (host as any)._fit = null; 374 } 375 // Paint one frame immediately so a paused/first render is correct. 376 paintFrame(); 377 } 378 379 function paintFrame() { 380 if (!current) return; 381 const T = reduced.matches ? HELD_FRAME : carriedT; 382 const st = { ...shelfState(T, carriedMix, hover.last, shelves), E: reduced.matches ? HELD_FRAME : carriedE }; 383 current.paint(T, st); 384 } 385 386 const running = () => !reduced.matches && visible && inView; 387 function tick(now: number) { 388 if (!running()) { raf = 0; return; } 389 const dt = Math.min(0.05, (now - prev) / 1000); prev = now; 390 // The boot plays once; the tour then wraps from the last shelf 391 // straight back to the first instead of closing and booting again. 392 // Every entrance has finished by STARTS[0], so the seam is invisible. 393 carriedT += dt; 394 carriedE += dt; 395 if (carriedT >= CLOSE) carriedT = STARTS[0] + (carriedT - CLOSE); 396 const target = hover.idx !== null ? 1 : 0; 397 carriedMix += (target - carriedMix) * Math.min(1, dt * 14); 398 paintFrame(); 399 raf = requestAnimationFrame(tick); 400 } 401 function start() { if (raf || !running()) { if (!running()) paintFrame(); return; } prev = performance.now(); raf = requestAnimationFrame(tick); } 402 function stop() { if (raf) cancelAnimationFrame(raf); raf = 0; } 403 404 rebuild(); 405 start(); 406 407 // Reactivity: breakpoint + theme rebuild, visibility + in-view pause. 408 const canvasMq = matchMedia(`(min-width: ${CANVAS_MIN}px)`), plateMq = matchMedia(`(min-width: ${FLOW_PLATE_MIN}px)`); 409 const onBp = () => { stop(); rebuild(); start(); }; 410 canvasMq.addEventListener('change', onBp); 411 plateMq.addEventListener('change', onBp); 412 reduced.addEventListener('change', () => { stop(); paintFrame(); start(); }); 413 window.addEventListener('daemonmodechange', () => { stop(); rebuild(); start(); }); 414 const ro = new ResizeObserver(() => { const f = (host as any)._fit; if (f) f(); }); 415 ro.observe(host); 416 const onVis = () => { visible = !document.hidden; visible ? start() : stop(); }; 417 document.addEventListener('visibilitychange', onVis); 418 const io = new IntersectionObserver(([e]) => { inView = e.isIntersecting; inView ? start() : stop(); }); 419 io.observe(host); 420 421 document.addEventListener('astro:before-swap', () => { 422 stop(); ro.disconnect(); io.disconnect(); 423 destroyPlate?.(); destroyPlate = null; 424 document.removeEventListener('visibilitychange', onVis); 425 canvasMq.removeEventListener('change', onBp); plateMq.removeEventListener('change', onBp); 426 }, { once: true }); 427 } 428 429 document.addEventListener('astro:page-load', initHero); 430 if (document.readyState !== 'loading') initHero(); 431 else document.addEventListener('DOMContentLoaded', initHero); 432 </script>