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