Skip to the content

Capsid menu

tokens/sites.mjs

256 lines and 21 functions in DrDustinEdwards/capsomer, at commit d3966cc on 2026-10-24. This file on GitHub.

Functions

The functions of sites.mjs
FunctionLinesComplexityCoveredCRAPMutation
selectors160 to 1652No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:163>163 to 1631No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:164>164 to 1641No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
block167 to 1701No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:168>168 to 1681No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
renderSite174 to 19511No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
renderBarrel197 to 2051No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:202>202 to 2021No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
files208 to 2101No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:209>209 to 2091No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
path212 to 2121No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
check214 to 25228No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:232>232 to 2321No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
waived234 to 2341No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:234>234 to 2341No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:235>235 to 2351No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:235>235 to 2351No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:236>236 to 2361No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:239>239 to 2391No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:262>262 to 2621No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet
<anonymous:265>265 to 2651No data: no coverage report in this mapNo data: no coverage report in this mapNo data: mutation testing has not run on this repo yet

Imports

It imports tokens/palette.mjs. No module of this repo imports it.

Source

// The per-site layer: what one site differs on from the family defaults, scoped to
// [data-cap-site="<site>"]. `node tokens/sites.mjs` writes tokens/site-<name>.css for each
// entry in SITES and the barrel css/sites.css; `--check` regenerates them in memory, refuses a
// difference from the committed files, a missing package export and a stale site file, and
// runs palette.mjs's WCAG pairs on the site's colours merged into its family, in both themes.
//
// What goes in: only what a site differs on (faces, the display face, neutrals, the on state,
// type sizes, space and radius, the hover touches in scale.mjs's `touch` group, --enarratio-*), as literal values, never a var() chain, so the file works on <html> or on
// any wrapper. A brand colour is never a site setting: it is a family (FAMILIES in
// palette.mjs), which the site keeps choosing with data-family. A site on the defaults has no
// entry. The selectors repeat the family files' shapes (plain, system dark, pinned dark), so a
// site rule ties a family rule on specificity and wins on order, and each also matches a
// [data-family] or [data-density] wrapper inside the site, so css/enarratio.css's wrapper
// block cannot put Capsomer's own values back there.
import { readFileSync, readdirSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { FAMILIES, failures, themes } from "./palette.mjs";

// site -> { family (whose tokens the colours are checked against), on (where the attribute
// goes), note, all (both themes), light, dark, defaultTheme, phone, phoneMax }. A site whose
// defaultTheme is "dark" (as its family's is) gets its dark values in the plain block and its
// light values only where light is pinned (data-theme="light" on the root, or
// data-cap-theme="light"); its dark values are also written under the family's dark selectors,
// so they tie them. phone applies under phoneMax, Capsomer's phone width (767.98px) unless the
// site's own layout breaks elsewhere. waive names a WCAG pair the site's own colours already
// fail where it renders today ({ scheme, what, fg, why }); the check refuses a waiver that no
// longer matches a failure, so it goes once the site's colour is fixed.
const SITES = {
  dustinedwards: {
    family: "purple",
    on: "<html>",
    note: "the public charts keep their look from before Enarratio 0.3 (Enarratio's own literals)",
    all: {
      "enarratio-font-size": "1rem",
      "enarratio-font-size-small": "0.875em",
      "enarratio-line-height-tooltip": "1.35",
      "enarratio-space": "0.5em",
      "enarratio-gap": "1em",
      "enarratio-gap-row": "0.25em",
      "enarratio-gap-inline": "0.4em",
      "enarratio-padding-button": "0.125em 0.25em",
      "enarratio-cell-padding": "0.2em 0.75em 0.2em 0",
      "enarratio-radius": "0.25em",
      "enarratio-radius-swatch": "0.15em",
      "enarratio-radius-tooltip": "0.3em",
      "enarratio-swatch-size": "0.8em",
      "enarratio-tooltip-padding": "0.35em 0.6em",
      "enarratio-tooltip-shadow": "0 2px 8px rgb(0 0 0 / 15%)",
      "enarratio-z-tooltip": "10",
      "enarratio-duration-hover": "150ms",
      "enarratio-duration-grow": "600ms",
      "enarratio-duration-fade": "500ms",
      "enarratio-duration-draw": "800ms",
      "enarratio-ease-grow": "cubic-bezier(0.2, 0.7, 0.3, 1)",
      "enarratio-ease-out": "ease-out",
    },
  },
  foxhound: {
    family: "teal",
    on: "each .fh-cap wrapper, never <html> (Foxhound's unlayered :root would beat it there) or the admin",
    note: "Foxhound's own neutrals and DM faces under the teal family's accents, dark by default",
    defaultTheme: "dark",
    all: {
      ui: '"DM Sans", sans-serif, Arial, sans-serif',
      "font-ui": '"DM Sans", sans-serif, Arial, sans-serif',
      mono: '"DM Mono", monospace',
    },
    dark: {
      ground: "#111210",
      surface: "#181917",
      raised: "#222420",
      sunken: "#0d0e0c",
      text: "#e5e8e2",
      muted: "#a8a49e",
      dim: "#928d87",
      line: "#2a2b28",
      "line-strong": "#4a4c44",
    },
    light: {
      ground: "#f7f6f2",
      surface: "#ffffff",
      raised: "#ffffff",
      sunken: "#eeede8",
      text: "#14150f",
      muted: "#2f312d",
      dim: "#595c56",
      line: "#cccbc3",
      "line-strong": "#8a8a7e",
    },
    waive: [
      {
        scheme: "dark",
        what: "control border",
        fg: "line-strong",
        why: "Foxhound's dark --border-strong #4a4c44 is legacy's value (prime directive); the bridge this file replaces already drew it",
      },
    ],
  },
  germomics: {
    family: "purple",
    on: "the admin's [data-cap-theme] element",
    note: "the admin's numbers keep the site's JetBrains Mono",
    all: {
      mono: '"JetBrains Mono", ui-monospace, Menlo, monospace',
    },
  },
  capsid: {
    family: "purple",
    on: "the Portal's <html>",
    note: "secondary text is 13px at the least on a phone (the Portal UX audit's m4), at the Portal's own 820px",
    phoneMax: "820px",
    phone: {
      "fs-label": "13px",
      "fs-detail": "13px",
    },
  },
  txasm: {
    family: "navy",
    on: "<html>",
    note: "the UI face stays Inter, which txasm loads",
    all: {
      ui: '"Inter", "Segoe UI", "Helvetica Neue", Arial, sans-serif',
    },
  },
  foxing: {
    family: "fox",
    on: "<html>",
    note: "Foxing's cream and charcoal neutrals (design-system.md), its faces (DM Sans for the UI, DM Mono, Fraunces to display), which the site self-hosts, and the sky-blue on state of its Rule 05",
    all: {
      ui: '"DM Sans", system-ui, sans-serif',
      "font-ui": '"DM Sans", system-ui, sans-serif',
      mono: '"DM Mono", ui-monospace, monospace',
      display: '"Fraunces", Georgia, serif',
    },
    // Rule 05's sky blue #43a1d8 is 2.7:1 on the cream ground, under the 3:1 a checked box needs,
    // so the light theme takes Foxing's own hover blue for the fill and a step darker for hover.
    light: { ground: "#faf6ee", surface: "#ffffff", raised: "#ffffff", sunken: "#f2ead8", text: "#16100b", muted: "#3e3028", dim: "#815e41", line: "#e5d8bf", "line-strong": "#815e41", on: "#2b87bc", "on-hover": "#1f7fb8" },
    dark: { ground: "#16100b", surface: "#1f1710", raised: "#2a1f16", sunken: "#100d08", text: "#f5edde", muted: "#c4ae96", dim: "#a28269", line: "#3a2c22", "line-strong": "#a28269", on: "#43a1d8", "on-hover": "#5aafe0" },
  },
  julieedwards: {
    family: "forest",
    on: "<html>",
    note: "Open Sans to read and Montserrat for the header and headings, which the site self-hosts; the old site's hover touches (Dustin, 2026-10-23)",
    all: {
      ui: '"Open Sans", Arial, sans-serif',
      "font-ui": '"Montserrat", Helvetica, Arial, sans-serif',
      "link-rule": "1px",
      "icon-radius": "999px",
      "icon-border": "1px",
      "theme-turn": "20deg",
      "arrow-rest": "0",
      "arrow-shift": "-0.3em",
    },
  },
};

const ORDER = "@layer cap.reset, cap.tokens, cap.base, cap.components, cap.utilities;";
const BRAND = /^(accent|primary|ring|series|ramp)/;

function selectors(name, prefixes, indent) {
  // The repeated attribute outranks tokens.css's :root, so a bundler loading the site file first still wins.
  const s = `[data-cap-site="${name}"][data-cap-site]`;
  const bases = prefixes ? prefixes.flatMap((p) => [`${p} ${s}`, `${p}${s}`]) : [s];
  return bases.flatMap((b) => [b, `${b} :is([data-family], [data-density])`]).join(`,\n${indent}`);
}

function block(name, values, prefixes, indent) {
  const body = Object.entries(values).map(([k, v]) => `${indent}  --${k}: ${v};`).join("\n");
  return `${indent}${selectors(name, prefixes, indent)} {\n${body}\n${indent}}\n`;
}

const PHONE_MAX = "767.98px";

function renderSite(name, site) {
  const darkFirst = site.defaultTheme === "dark";
  const plain = { ...site.all, ...(darkFirst ? site.dark : site.light) };
  let css = Object.keys(plain).length ? block(name, plain, null, "  ") : "";
  if (site.dark && Object.keys(site.dark).length) {
    css += `  @media (prefers-color-scheme: dark) {\n${block(name, site.dark, [':root:not([data-theme="light"])'], "    ")}  }\n`;
    css += block(name, site.dark, [':root[data-theme="dark"]', '[data-cap-theme="dark"]'], "  ");
  }
  if (darkFirst && site.light && Object.keys(site.light).length) {
    css += block(name, site.light, [':root[data-theme="light"]', '[data-cap-theme="light"]'], "  ");
  }
  if (site.phone && Object.keys(site.phone).length) {
    css += `  @media (max-width: ${site.phoneMax ?? PHONE_MAX}) {\n${block(name, site.phone, null, "    ")}  }\n`;
  }
  return `/* Generated by tokens/sites.mjs: the ${name} site on the ${site.family} family; ${site.note}.
   Put data-cap-site="${name}" on ${site.on}. Import after tokens.css and families.css.
   Do not edit: change SITES in tokens/sites.mjs and run \`npm run tokens\`. */
${ORDER}
@layer cap.tokens {
${css}}
`;
}

function renderBarrel() {
  return `/* Every per-site layer (generated by tokens/sites.mjs), each scoped to [data-cap-site].
   Import after tokens.css and families.css: \`import "capsomer/sites.css"\`. Or take one:
   "capsomer/site-<name>.css". Do not edit: change SITES in tokens/sites.mjs. */
@import "./layers.css";
${Object.keys(SITES).map((n) => `@import "../tokens/site-${n}.css";`).join("\n")}
${ORDER}
`;
}

// The files this script owns: [path relative to the package root, text].
function files() {
  return [...Object.entries(SITES).map(([n, s]) => [`tokens/site-${n}.css`, renderSite(n, s)]), ["css/sites.css", renderBarrel()]];
}

const path = (rel) => fileURLToPath(new URL(`../${rel}`, import.meta.url));

function check() {
  const problems = [];
  const exports = JSON.parse(readFileSync(path("package.json"), "utf8")).exports;
  if (exports["./sites.css"] !== "./css/sites.css") problems.push(`package.json needs "./sites.css": "./css/sites.css" in exports`);
  for (const [name, site] of Object.entries(SITES)) {
    const family = FAMILIES[site.family];
    if (!family) problems.push(`${name}: family "${site.family}" is not in FAMILIES`);
    for (const [k, v] of Object.entries({ ...site.all, ...site.light, ...site.dark, ...site.phone })) {
      if (BRAND.test(k)) problems.push(`${name}: --${k} is a brand colour; add a family in palette.mjs instead`);
      if (/var\(/.test(v)) problems.push(`${name}: --${k} must be a literal, not ${v}`);
    }
    for (const k of Object.keys(site.light ?? {})) if (!(k in (site.dark ?? {}))) problems.push(`${name}: --${k} is set for light only; give its dark value too`);
    if (site.defaultTheme === "dark") for (const k of Object.keys(site.dark ?? {})) if (!(k in (site.light ?? {}))) problems.push(`${name}: --${k} is set for dark only on a dark-first site; give its light value too`);
    if (exports[`./site-${name}.css`] !== `./tokens/site-${name}.css`) problems.push(`package.json needs "./site-${name}.css": "./tokens/site-${name}.css" in exports`);
    if (!family) continue;
    const t = themes(family);
    for (const scheme of ["light", "dark"]) {
      const merged = { ...t[scheme].tokens, ...site.all, ...site[scheme] };
      const waivers = (site.waive ?? []).filter((w) => w.scheme === scheme);
      const fails = failures(merged);
      const waived = (f) => waivers.some((w) => f.startsWith(`${w.what}: --${w.fg} `));
      problems.push(...fails.filter((f) => !waived(f)).map((p) => `${name} ${scheme}: ${p}`));
      for (const w of waivers) if (!fails.some((f) => f.startsWith(`${w.what}: --${w.fg} `))) problems.push(`${name} ${scheme}: the waiver for ${w.what} --${w.fg} matches no failure; remove it`);
    }
  }
  for (const f of readdirSync(path("tokens")).filter((f) => /^site-.+\.css$/.test(f))) {
    if (!SITES[f.slice(5, -4)]) problems.push(`tokens/${f} has no entry in SITES; delete it`);
  }
  for (const [rel, want] of files()) {
    let committed = null;
    try {
      committed = readFileSync(path(rel), "utf8");
    } catch {
      problems.push(`${rel} is missing; run npm run tokens`);
    }
    if (committed !== null && committed !== want) problems.push(`${rel} differs from what tokens/sites.mjs writes; run npm run tokens`);
  }
  return problems;
}

const invoked = process.argv[1] && fileURLToPath(import.meta.url).replace(/\\/g, "/").toLowerCase() === process.argv[1].replace(/\\/g, "/").toLowerCase();
if (invoked) {
  if (process.argv.includes("--check")) {
    const problems = check();
    if (problems.length) {
      console.error(`sites: ${problems.length} problem(s)\n  ${problems.join("\n  ")}`);
      process.exit(1);
    }
    console.log(`sites: ${files().map(([r]) => r).join(", ")} match the script and every pair meets WCAG 2.2 in both themes`);
  } else {
    for (const [rel, text] of files()) writeFileSync(path(rel), text);
    console.log(`sites: wrote ${files().map(([r]) => r).join(", ")}`);
  }
}