daemon-sec-lotl

DÆMONBins: GTFOBins × LOLBAS × WADComs × LOOBins in one filterable catalog
git clone https://git.daemon-sec.xyz/daemon-sec-lotl.git
Log | Files | Refs | Submodules | README | LICENSE

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);