Skip to the content

Capsid menu

app/enhance/theme.ts

149 lines and 17 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.

Functions

The functions of theme.ts
FunctionLinesComplexityCoveredCRAPMutation
enhanceThemeToggle7 to 221No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:8>8 to 215No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
toSearch39 to 421No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
openPalette44 to 1046No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:73>73 to 761No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:74>74 to 741No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:75>75 to 751No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:83>83 to 861No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:84>84 to 841No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:91>91 to 941No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:95>95 to 981No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
isMac110 to 1143No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
enhanceSearchTrigger116 to 1528No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:136>136 to 1405No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:143>143 to 1514No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
apply154 to 1734No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:169>169 to 1691No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code

Imports

It imports app/lib/enhance-url.mjs, app/lib/theme.ts. No module of this repo imports it.

Source

// No "apply the stored theme on load" step: the server already wrote the attribute, so a script
// that re-applied it could only agree, or race.

import { ENHANCE_URL_PREFIX } from "~/lib/enhance-url.mjs";
import { serializeThemeCookie, isWritableTheme, type WritableTheme } from "~/lib/theme";

function enhanceThemeToggle() {
  document.addEventListener("submit", (event) => {
    const form = event.target;
    if (!(form instanceof HTMLFormElement)) return;
    if (!form.matches("[data-theme-toggle]")) return;

    const submitter = (event as SubmitEvent).submitter;
    if (!(submitter instanceof HTMLButtonElement)) return;

    const choice = submitter.value;
    if (!isWritableTheme(choice)) return;

    event.preventDefault();
    apply(choice, form);
  });
}

enhanceThemeToggle();
enhanceSearchTrigger();

/**
 * A custom event rather than `import()`, because vite wraps every `import()` in a preload helper that
 * would be a large share of this bundle. 'self' allows the inserted script with no nonce.
 */
const PALETTE_OPEN = "palette:open";

let requested = false;

/**
 * The caller has already prevented the anchor's default, so a refusal or a load failure must fall
 * back to `/search`, never to nothing, and says why in the console first.
 */
function toSearch(reason: unknown) {
  console.error("palette:", reason);
  location.assign("/search");
}

function openPalette() {
  const trigger = document.querySelector<HTMLElement>("[data-palette]");
  // The URL is hashed by the app build; the `?url` import in `search-trigger.tsx` is its one statement.
  // Held to the loader's rule, because 'self' trusts whatever the loader inserts from this origin. The
  // element resolves and normalizes the URL, so `..` is gone before the check reads it, and the
  // origin is followed by the prefix's own `/`, so a longer host cannot match. Inline rather than a
  // shared function: every public page loads this bundle, and its budget has no room for one.
  const raw = trigger?.dataset.palette;
  const script = document.createElement("script");
  if (raw) script.src = raw;
  if (!script.src.startsWith(location.origin + ENHANCE_URL_PREFIX)) {
    toSearch(raw);
    return;
  }

  if (!requested) {
    requested = true;
    trigger?.setAttribute("aria-busy", "true");

    /*
     * Stylesheets are awaited because the bundle calls `showModal` as soon as it runs. A failed
     * stylesheet resolves: an unstyled dialog is better than none.
     */
    const pending = [];
    for (const href of (trigger?.dataset.paletteCss ?? "").split(",").filter(Boolean)) {
      const link = document.createElement("link");
      link.rel = "stylesheet";
      link.href = href;
      pending.push(
        new Promise((resolve) => {
          link.addEventListener("load", () => resolve(null));
          link.addEventListener("error", () => resolve(null));
        }),
      );
      document.head.appendChild(link);
    }

    script.type = "module";
    pending.push(
      new Promise((resolve, reject) => {
        script.addEventListener("load", () => resolve(null));
        script.addEventListener("error", reject);
      }),
    );
    document.head.appendChild(script);

    void Promise.all(pending).then(
      () => {
        trigger?.removeAttribute("aria-busy");
        document.dispatchEvent(new Event(PALETTE_OPEN));
      },
      (reason) => {
        trigger?.removeAttribute("aria-busy");
        toSearch(reason);
      },
    );
    return;
  }

  document.dispatchEvent(new Event(PALETTE_OPEN));
}

/**
 * The hint is set only from script, so a reader whose script did not run is never told about a key
 * that would do nothing. Platform detection is best-effort: the handler accepts meta or ctrl either way.
 */
function isMac(): boolean {
  const nav = navigator as Navigator & { userAgentData?: { platform?: string } };
  const platform = nav.userAgentData?.platform ?? navigator.platform ?? navigator.userAgent;
  return /mac/i.test(platform);
}

function enhanceSearchTrigger() {
  const mac = isMac();
  const chord = mac ? "Command-K" : "Control-K";

  // Text before unhide, so the stale server placeholder is never the announced description.
  for (const hint of document.querySelectorAll<HTMLElement>("[data-search-hint]")) {
    hint.textContent = `Press ${chord} to search`;
    hint.hidden = false;
  }

  for (const trigger of document.querySelectorAll<HTMLElement>("[data-search-trigger]")) {
    /* The header tip names the keys as key caps (Capsomer's tooltip), only now that they do something. */
    const tip = trigger.querySelector(".cap-site-header-tip");
    if (tip && !tip.querySelector(".cap-keys")) {
      const keys = document.createElement("span");
      keys.className = "cap-keys";
      for (const key of [mac ? "\u2318" : "Ctrl", "K"]) keys.appendChild(document.createElement("kbd")).textContent = key;
      tip.appendChild(keys);
    }
    trigger.dataset.shortcutHint = "shown";
    trigger.addEventListener("click", (event) => {
      if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
      event.preventDefault();
      openPalette();
    });
  }

  document.addEventListener("keydown", (event) => {
    const meta = event.metaKey || event.ctrlKey;
    if (meta && event.key.toLowerCase() === "k") {
      event.preventDefault();
      openPalette();
      return;
    }
    // Deliberately no bare-slash shortcut: it collides with find-in-page.
  });
}

function apply(choice: WritableTheme, form: HTMLFormElement) {
  const root = document.documentElement;

  root.setAttribute("data-theme", choice);

  document.cookie = serializeThemeCookie(choice);

  /*
   * Activating a button hides it via the cascade, and `display: none` drops focus to `<body>`, so move
   * focus to its twin, only when the hidden one held it. `preventScroll` because Chrome scrolls a
   * focused sticky-header descendant toward its un-stuck position.
   */
  const active = document.activeElement;
  if (active instanceof HTMLElement && form.contains(active) && active.offsetParent === null) {
    const shown = [...form.querySelectorAll<HTMLElement>("button[value]")].find(
      (button) => button.offsetParent !== null,
    );
    shown?.focus({ preventScroll: true });
  }
}