app.ts (18050B)
1 // Progressive enhancement, re-run after every (view-transition) navigation. 2 3 import { initFuzz } from './fuzz'; 4 import { initHero } from './hero'; 5 import { initDmn } from './dmn/init'; 6 7 /* The OS setting or the site's own switch (`<html data-motion="reduce">`, 8 set from the catalog's display panel) both count as reduced motion. */ 9 function reducedMotion(): boolean { 10 return ( 11 window.matchMedia('(prefers-reduced-motion: reduce)').matches || 12 document.documentElement.dataset.motion === 'reduce' 13 ); 14 } 15 16 function applyTheme(next: 'light' | 'dark'): void { 17 document.documentElement.setAttribute('data-theme', next); 18 try { localStorage.setItem('theme', next); } catch {} 19 document.documentElement.style.colorScheme = next; 20 // A canvas bitmap does not inherit the cascade, so the banners' signal 21 // field has no way to notice the mode changed. It listens for this. 22 dispatchEvent(new Event('daemonmodechange')); 23 } 24 25 function initTheme(): void { 26 const toggles = document.querySelectorAll<HTMLButtonElement>('[data-theme-toggle]'); 27 toggles.forEach((btn) => { 28 if (btn.dataset.bound) return; 29 btn.dataset.bound = '1'; 30 btn.addEventListener('click', () => { 31 const cur = document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark'; 32 const next: 'light' | 'dark' = cur === 'light' ? 'dark' : 'light'; 33 const root = document.documentElement; 34 35 // Feature/consent gate: no View Transitions or reduced-motion -> flip instantly. 36 const startVT = (document as unknown as { 37 startViewTransition?: (cb: () => void) => { finished: Promise<void> }; 38 }).startViewTransition; 39 const reduce = reducedMotion(); 40 if (!startVT || reduce) { applyTheme(next); return; } 41 42 // Origin + radius for the circular reveal: expand from the toggle's centre 43 // out to the farthest viewport corner so the circle always covers the screen. 44 const r = btn.getBoundingClientRect(); 45 const x = r.left + r.width / 2; 46 const y = r.top + r.height / 2; 47 const endR = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y)); 48 root.style.setProperty('--vt-x', `${x}px`); 49 root.style.setProperty('--vt-y', `${y}px`); 50 root.style.setProperty('--vt-r', `${endR}px`); 51 52 root.classList.add('theme-vt'); 53 const t = startVT.call(document, () => applyTheme(next)); 54 t.finished.finally(() => root.classList.remove('theme-vt')); 55 }); 56 }); 57 } 58 59 function initMobileNav(): void { 60 const toggles = document.querySelectorAll<HTMLButtonElement>('[data-mobile-nav-toggle]'); 61 toggles.forEach((toggle) => { 62 if (toggle.dataset.bound) return; 63 const menuId = toggle.getAttribute('aria-controls'); 64 const menu = menuId ? document.getElementById(menuId) : null; 65 if (!menu) return; 66 67 const setOpen = (open: boolean): void => { 68 toggle.setAttribute('aria-expanded', String(open)); 69 toggle.setAttribute('aria-label', open ? 'Close navigation' : 'Open navigation'); 70 menu.hidden = !open; 71 }; 72 73 toggle.dataset.bound = '1'; 74 toggle.addEventListener('click', () => { 75 setOpen(toggle.getAttribute('aria-expanded') !== 'true'); 76 }); 77 78 menu.addEventListener('click', (event) => { 79 if ((event.target as Element).closest('a')) setOpen(false); 80 }); 81 82 toggle.closest('.site-header')?.addEventListener('keydown', (event) => { 83 if ((event as KeyboardEvent).key !== 'Escape' || toggle.getAttribute('aria-expanded') !== 'true') return; 84 setOpen(false); 85 toggle.focus(); 86 }); 87 }); 88 } 89 90 /* The retired site resolved labels left-to-right through this exact ASCII 91 pool over nine 32ms steps. Keeping the source string in data-unscramble 92 means the page is correct before JS, remains searchable, and exposes the 93 final label to assistive technology while the visual text briefly settles. */ 94 const UNSCRAMBLE_POOL = '/\\|<>=+*·#%'; 95 const UNSCRAMBLE_STEPS = 9; 96 97 function scrambled(text: string, step: number): string { 98 const settled = Math.ceil((text.length * step) / UNSCRAMBLE_STEPS); 99 let value = text.slice(0, settled); 100 for (let i = settled; i < text.length; i += 1) { 101 const char = text[i]; 102 value += /\s/.test(char) 103 ? char 104 : UNSCRAMBLE_POOL[(i * 7 + step * 3) % UNSCRAMBLE_POOL.length]; 105 } 106 return value; 107 } 108 109 function initUnscramble(): void { 110 document.querySelectorAll<HTMLElement>('.sheet-title, .subcat h2, .download-library__head h2, .prose > h2').forEach((node) => { 111 if (!node.hasAttribute('data-unscramble')) node.dataset.unscramble = node.textContent ?? ''; 112 }); 113 114 const nodes = document.querySelectorAll<HTMLElement>('[data-unscramble]:not([data-unscramble-bound])'); 115 const reduced = reducedMotion(); 116 117 const resolve = (node: HTMLElement, finalText: string): void => { 118 let step = 0; 119 node.textContent = scrambled(finalText, step); 120 const timer = window.setInterval(() => { 121 step += 1; 122 if (step >= UNSCRAMBLE_STEPS) { 123 clearInterval(timer); 124 node.textContent = finalText; 125 return; 126 } 127 node.textContent = scrambled(finalText, step); 128 }, 32); 129 }; 130 131 const observer = !reduced && 'IntersectionObserver' in window 132 ? new window.IntersectionObserver((entries, current) => { 133 entries.forEach((entry) => { 134 if (!entry.isIntersecting) return; 135 const node = entry.target as HTMLElement; 136 resolve(node, node.dataset.unscramble ?? node.textContent ?? ''); 137 current.unobserve(node); 138 }); 139 }, { rootMargin: '0px 0px -8% 0px', threshold: 0.25 }) 140 : null; 141 142 nodes.forEach((node) => { 143 node.dataset.unscrambleBound = '1'; 144 const finalText = node.dataset.unscramble ?? node.textContent ?? ''; 145 node.setAttribute('aria-label', finalText); 146 if (reduced || !finalText) { 147 node.textContent = finalText; 148 return; 149 } 150 151 const rect = node.getBoundingClientRect(); 152 if (!observer || rect.top < window.innerHeight * 0.92) resolve(node, finalText); 153 else observer.observe(node); 154 }); 155 } 156 157 /** 158 * Bring the archive into view in short, staggered runs as the reader moves 159 * down a page. The hidden state is applied by script rather than baked into 160 * the stylesheet, so every word remains visible if JavaScript is unavailable. 161 */ 162 function initScrollReveal(): void { 163 const reduced = reducedMotion(); 164 const nodes = document.querySelectorAll<HTMLElement>([ 165 '[data-reveal]:not([data-reveal-bound])', 166 '.subcat:not([data-reveal-bound])', 167 '.download-library__head:not([data-reveal-bound])', 168 '.record:not([data-reveal-bound])', 169 '.download-row:not([data-reveal-bound])', 170 '.prose > h2:not([data-reveal-bound])', 171 '.prose > h3:not([data-reveal-bound])', 172 '.prose > p:not([data-reveal-bound])', 173 '.prose > blockquote:not([data-reveal-bound])', 174 '.prose > figure:not([data-reveal-bound])', 175 '.prose > .table-scroll:not([data-reveal-bound])', 176 ].join(',')); 177 178 if (!nodes.length) return; 179 180 nodes.forEach((node, index) => { 181 node.dataset.revealBound = '1'; 182 // Only nearby siblings cascade; long indexes should not accumulate delay. 183 node.style.setProperty('--reveal-delay', `${(index % 6) * 70}ms`); 184 if (reduced) node.classList.add('is-revealed'); 185 else node.classList.add('scroll-reveal'); 186 }); 187 188 if (reduced || !('IntersectionObserver' in window)) { 189 nodes.forEach((node) => node.classList.add('is-revealed')); 190 return; 191 } 192 193 const observer = new window.IntersectionObserver((entries, current) => { 194 entries.forEach((entry) => { 195 if (!entry.isIntersecting) return; 196 (entry.target as HTMLElement).classList.add('is-revealed'); 197 current.unobserve(entry.target); 198 }); 199 }, { rootMargin: '0px 0px -9% 0px', threshold: 0.06 }); 200 201 nodes.forEach((node) => { 202 const rect = node.getBoundingClientRect(); 203 if (rect.top < window.innerHeight * 0.94) node.classList.add('is-revealed'); 204 else observer.observe(node); 205 }); 206 } 207 208 /** 209 * Roll every `[data-count]` number up from zero to its target the first time it 210 * scrolls into view. The final value is server-rendered as the element's text, 211 * so with JS off (or under reduced motion) the real number is always shown; the 212 * animation only replaces it briefly. Used by the hero stats and the platform 213 * ledger. 214 */ 215 function initCounters(): void { 216 const nodes = document.querySelectorAll<HTMLElement>('[data-count]:not([data-count-bound])'); 217 if (!nodes.length) return; 218 const reduced = reducedMotion(); 219 220 const run = (el: HTMLElement): void => { 221 const target = parseInt(el.dataset.count || '0', 10) || 0; 222 if (reduced) { el.textContent = target.toLocaleString(); return; } 223 const dur = 1100; 224 const start = performance.now(); 225 const tick = (now: number): void => { 226 const p = Math.min(1, (now - start) / dur); 227 const eased = 1 - Math.pow(1 - p, 3); 228 el.textContent = Math.round(target * eased).toLocaleString(); 229 if (p < 1) requestAnimationFrame(tick); 230 else el.textContent = target.toLocaleString(); 231 }; 232 requestAnimationFrame(tick); 233 }; 234 235 const observer = !reduced && 'IntersectionObserver' in window 236 ? new window.IntersectionObserver((entries, current) => { 237 entries.forEach((entry) => { 238 if (!entry.isIntersecting) return; 239 run(entry.target as HTMLElement); 240 current.unobserve(entry.target); 241 }); 242 }, { rootMargin: '0px 0px -8% 0px', threshold: 0.4 }) 243 : null; 244 245 nodes.forEach((node) => { 246 node.dataset.countBound = '1'; 247 const rect = node.getBoundingClientRect(); 248 if (reduced || !observer || rect.top < window.innerHeight * 0.95) run(node); 249 else observer.observe(node); 250 }); 251 } 252 253 /** A two-pixel signal line makes page position readable on the longer CPTS sheets. */ 254 function initScrollProgress(): void { 255 let progress = document.querySelector<HTMLElement>('[data-scroll-progress]'); 256 if (!progress) { 257 progress = document.createElement('div'); 258 progress.className = 'scroll-progress'; 259 progress.dataset.scrollProgress = '1'; 260 progress.setAttribute('aria-hidden', 'true'); 261 document.body.append(progress); 262 } 263 if (progress.dataset.bound) return; 264 progress.dataset.bound = '1'; 265 266 let ticking = false; 267 const update = (): void => { 268 const range = Math.max(1, document.documentElement.scrollHeight - window.innerHeight); 269 progress!.style.setProperty('--scroll-progress', String(Math.min(1, Math.max(0, window.scrollY / range)))); 270 ticking = false; 271 }; 272 const requestUpdate = (): void => { 273 if (ticking) return; 274 ticking = true; 275 window.requestAnimationFrame(update); 276 }; 277 278 window.addEventListener('scroll', requestUpdate, { passive: true }); 279 window.addEventListener('resize', requestUpdate, { passive: true }); 280 update(); 281 } 282 283 /** 284 * Every code block becomes the archive's terminal block: the language at 285 * the left of a caption bar, the copy control at the right, both in the 286 * 9.5px mono the rest of the site labels things in. 287 * 288 * No window chrome and no traffic-light dots — this is a printed listing 289 * of terminal output, not a simulated window. The plate under it stays 290 * dark in both modes (see prose.css). 291 */ 292 const COPY_ICON = 293 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="12" height="12"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg>'; 294 const CHECK_ICON = 295 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>'; 296 297 function enhanceCode(): void { 298 const blocks = document.querySelectorAll<HTMLPreElement>('.prose pre.astro-code:not([data-enhanced])'); 299 blocks.forEach((pre) => { 300 pre.dataset.enhanced = '1'; 301 const lang = pre.getAttribute('data-language') || 'text'; 302 303 const pane = document.createElement('figure'); 304 /* `.plate` pins the night palette for the pane's subtree so the caption 305 tokens and corner brackets read as a terminal on the cream page too; 306 `.corners` adds the cyberdeck brackets; the pane is `position: relative` 307 in prose.css, so both anchor without further help. */ 308 pane.className = 'code-pane plate corners'; 309 const bar = document.createElement('figcaption'); 310 bar.className = 'code-pane__bar'; 311 312 const langEl = document.createElement('span'); 313 langEl.className = 'code-pane__lang'; 314 langEl.textContent = lang.replace(/[^a-z0-9+#-]/gi, '').slice(0, 16) || 'text'; 315 316 const copy = document.createElement('button'); 317 copy.className = 'code-copy'; 318 copy.type = 'button'; 319 const setLabel = (icon: string, word: string) => { 320 copy.innerHTML = icon; 321 copy.append(document.createTextNode(word)); 322 }; 323 setLabel(COPY_ICON, 'Copy'); 324 325 // The feedback lives on the button for 1400ms rather than in a toast 326 // across the page — you copied this block, not the document. 327 copy.addEventListener('click', async () => { 328 const text = pre.innerText; 329 try { 330 await navigator.clipboard.writeText(text); 331 } catch { 332 const ta = document.createElement('textarea'); 333 ta.value = text; document.body.appendChild(ta); ta.select(); 334 try { document.execCommand('copy'); } catch {} 335 ta.remove(); 336 } 337 setLabel(CHECK_ICON, 'Copied'); 338 copy.classList.add('copied'); 339 setTimeout(() => { setLabel(COPY_ICON, 'Copy'); copy.classList.remove('copied'); }, 1400); 340 }); 341 342 bar.append(langEl, copy); 343 pre.parentNode?.insertBefore(pane, pre); 344 pane.append(bar, pre); 345 346 // The CRT film. Its own element rather than a pseudo, because `.corners` 347 // has already spent both of the pane's pseudo-elements. 348 const film = document.createElement('div'); 349 film.className = 'scanlines'; 350 film.setAttribute('aria-hidden', 'true'); 351 pane.append(film); 352 }); 353 } 354 355 /** 356 * Obsidian/GitHub `> [!type]` callouts. The markdown pipeline renders these 357 * as plain blockquotes whose first line is the literal `[!type]` marker (no 358 * rehype plugin is in the build, by design). Here every blockquote is framed 359 * as a terminal callout (.callout + .corners, styled in prose.css); when a 360 * marker is present its type keys the accent and a mono label replaces the 361 * marker text. Plain blockquotes are left with the frame and no label, so the 362 * page reads right with JS off too. 363 */ 364 const CALLOUT_ALIASES: Record<string, string> = { 365 note: 'note', abstract: 'note', summary: 'note', tldr: 'note', 366 info: 'info', todo: 'info', 367 tip: 'tip', hint: 'tip', important: 'tip', 368 success: 'success', check: 'success', done: 'success', 369 question: 'question', help: 'question', faq: 'question', 370 warning: 'warning', caution: 'warning', attention: 'warning', 371 failure: 'danger', fail: 'danger', missing: 'danger', 372 danger: 'danger', error: 'danger', bug: 'danger', 373 example: 'example', quote: 'quote', cite: 'quote', 374 }; 375 const CALLOUT_LABEL: Record<string, string> = { 376 note: 'Note', info: 'Info', tip: 'Tip', success: 'Success', 377 question: 'FAQ', warning: 'Warning', danger: 'Danger', 378 example: 'Example', quote: 'Quote', 379 }; 380 381 function firstTextNode(el: Element): Text | null { 382 const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); 383 let n = walker.nextNode(); 384 while (n && !(n.textContent ?? '').trim()) n = walker.nextNode(); 385 return (n as Text) ?? null; 386 } 387 388 function enhanceCallouts(): void { 389 document.querySelectorAll<HTMLQuoteElement>('.prose blockquote:not([data-callout-done])').forEach((bq) => { 390 bq.dataset.calloutDone = '1'; 391 bq.classList.add('callout', 'corners'); 392 393 const first = bq.firstElementChild; 394 if (!first) return; 395 const node = firstTextNode(first); 396 if (!node) return; 397 const m = (node.textContent ?? '').match(/^\s*\[!(\w+)\]([+-]?)\s?/); 398 if (!m) return; 399 400 const variant = CALLOUT_ALIASES[m[1].toLowerCase()] ?? 'note'; 401 bq.setAttribute('data-callout', variant); 402 node.textContent = (node.textContent ?? '').slice(m[0].length); 403 // If the marker was the whole first line, drop the now-empty node so the 404 // label is not trailed by a blank paragraph. 405 if (!(first.textContent ?? '').trim() && !first.querySelector('*')) first.remove(); 406 407 const label = document.createElement('div'); 408 label.className = 'callout__label'; 409 label.textContent = CALLOUT_LABEL[variant] ?? variant; 410 bq.insertBefore(label, bq.firstChild); 411 }); 412 } 413 414 function wrapTables(): void { 415 document.querySelectorAll<HTMLTableElement>('.prose table:not([data-wrapped])').forEach((t) => { 416 t.dataset.wrapped = '1'; 417 const scroll = document.createElement('div'); 418 scroll.className = 'table-scroll'; 419 t.parentNode?.insertBefore(scroll, t); 420 scroll.appendChild(t); 421 }); 422 } 423 424 function initTOC(): void { 425 const toc = document.querySelector('.toc'); 426 if (!toc) return; 427 const links = Array.from(toc.querySelectorAll<HTMLAnchorElement>('a[href^="#"]')); 428 if (!links.length) return; 429 const byId = new Map(links.map((l) => [decodeURIComponent(l.getAttribute('href')!.slice(1)), l])); 430 const heads = Array.from(document.querySelectorAll<HTMLElement>('.prose h2[id], .prose h3[id]')); 431 const obs = new IntersectionObserver( 432 (entries) => { 433 entries.forEach((e) => { 434 if (!e.isIntersecting) return; 435 const id = (e.target as HTMLElement).id; 436 links.forEach((l) => l.classList.remove('active')); 437 byId.get(id)?.classList.add('active'); 438 }); 439 }, 440 { rootMargin: '-70px 0px -70% 0px', threshold: 0 } 441 ); 442 heads.forEach((h) => obs.observe(h)); 443 } 444 445 function init(): void { 446 initTheme(); 447 initMobileNav(); 448 initFuzz(); 449 initHero(); 450 initCounters(); 451 initUnscramble(); 452 enhanceCode(); 453 enhanceCallouts(); 454 wrapTables(); 455 initScrollReveal(); 456 initScrollProgress(); 457 initTOC(); 458 initDmn(); 459 } 460 461 document.addEventListener('astro:page-load', init); 462 if (document.readyState !== 'loading') init(); 463 else document.addEventListener('DOMContentLoaded', init);