Files
design/scripts/check-preference.mjs
zeekay 96c95b31ed Arabic: the companions from @hanzo/font 1.9.4, --font-arabic first in every stack, and an arabic axis; 0.5.37
Zen draws no Arabic. tokens/fonts.css now declares Noto Kufi Arabic (the
default) and Noto Sans Arabic, each under its own family with an Arabic
unicode-range, and names --font-arabic first in the sans, mono and serif
stacks. Latin passes the companion over without a download, so an
English page fetches what it did before, and the first available font a
line box is measured from is still Zen.

The preference gains an arabic axis (kufi | sans), set by reference to
--font-arabic-kufi and --font-arabic-sans. It is its own axis because a
face and an Arabic companion are two questions: serif with Kufi is two
names, not a pairing enum. check-preference holds the axis and that
every stack names the slot first.
2026-10-22 19:34:37 -07:00

314 lines
14 KiB
JavaScript

/**
* Preference contract, checked the way this package already checks tokens:
* a plain node script, no framework, run by `npm test`.
*
* The load-bearing check is the last one. The first version of preference.ts
* kept its own copy of the type ramp and the copy was WRONG (lg/xl were 16/18
* against the tokens' 15/17), so a preference of 1 — "leave it alone" — would
* have resized two rungs of the published design. Nothing caught it, because
* nothing compared the copy to the source. Now there is no copy: the knobs are
* multipliers and the ramps live only in tokens/*.css, and check 5 fails if a
* rung ever stops carrying its multiplier.
*/
import { readFileSync } from "node:fs";
import { stripTypeScriptTypes } from "node:module";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const here = dirname(fileURLToPath(import.meta.url));
const root = join(here, "..");
// Load the source directly — this package ships tokens, and the preference
// module is small enough to evaluate without a build step in the check. Node
// erases the types itself; a pile of regexes did it before and broke on every
// annotation shape it had not met yet.
// The module reads design's own inks out of the generated token table, so the
// table is evaluated ahead of it in the same scope, imports dropped.
const source = (f) =>
stripTypeScriptTypes(readFileSync(join(root, f), "utf8"))
.replace(/^import[^\n]*\n/gm, "")
.replace(/^export /gm, "");
const js = source("src/tokens.gen.ts") + "\n" + source("src/preference.ts");
const mod = new Function(`${js}; return { vars, css, isColor, ink, brightness, TYPE_MIN, TYPE_MAX };`)();
const { vars, css, isColor, ink, brightness, TYPE_MIN, TYPE_MAX } = mod;
let failed = 0;
const check = (name, fn) => {
try {
fn();
console.log(` ok ${name}`);
} catch (e) {
failed++;
console.error(` FAIL ${name}\n ${e.message}`);
}
};
const eq = (a, b, m) => {
const A = JSON.stringify(a), B = JSON.stringify(b);
if (A !== B) throw new Error(`${m || ""} got ${A} want ${B}`);
};
const ok = (v, m) => { if (!v) throw new Error(m || "expected truthy"); };
console.log("preference:");
check("an unset preference changes nothing", () => {
eq(vars({}), {});
eq(css({}), "");
});
check("type is ONE knob, not a restated ramp", () => {
const v = vars({ type: 1.2 });
eq(Object.keys(v), ["--type-scale"], "type must emit exactly one name:");
eq(v["--type-scale"], "1.2");
});
check("type is CLAMPED — a preference cannot make the UI illegible", () => {
eq(vars({ type: 0.1 })["--type-scale"], String(TYPE_MIN), "min");
eq(vars({ type: 99 })["--type-scale"], String(TYPE_MAX), "max");
// the smallest rung is 12px and the sheet clamps every rung at 12px, so the
// multiplier's floor cannot take it under that
ok(12 * TYPE_MIN >= 10, "xs falls below 10px at the multiplier's floor");
});
check("density moves spacing only, and never touches type", () => {
const v = vars({ density: "compact" });
eq(Object.keys(v), ["--density"]);
ok(Number(v["--density"]) < 1, "compact should tighten");
ok(Number(vars({ density: "comfortable" })["--density"]) > 1, "comfortable should loosen");
ok(!("--type-scale" in v), "density must not touch type");
// Spacing compounds through nesting, so the range stays tight enough that a
// 44px coarse-pointer target does not fall under the floor.
ok(Number(vars({ density: "compact" })["--density"]) >= 0.8, "compact is too tight to stay tappable");
});
check("EVERY ramp rung carries its multiplier — no rung can opt out", () => {
// This is the check that the drifting copy would have failed.
const type = readFileSync(join(root, "tokens/typography.css"), "utf8");
const space = readFileSync(join(root, "tokens/spacing.css"), "utf8");
const bare = [];
for (const [file, text, knob, re] of [
["typography.css", type, "--type-scale", /--text-[a-z0-9]+:\s*([^;]+);/g],
["spacing.css", space, "--density", /--space-\d+:\s*([^;]+);/g],
]) {
for (const m of text.matchAll(re)) {
const value = m[1].trim();
if (value === "0") continue; // zero times anything is zero
if (value.startsWith("var(")) continue; // an alias inherits its target's calc
// --text-floor / --text-ceiling are the BOUNDS every rung is clamped
// between, not rungs. A bound that scaled with the knob would move with
// the value it exists to bound and could never bind — which is the whole
// reason a floor is stated in absolute units and a ceiling in the
// viewport's.
if (/^--text-(floor|ceiling):/.test(m[0])) continue;
if (!value.includes(`var(${knob}`)) bare.push(`${file}: ${m[0].trim()}`);
}
}
eq(bare, [], "rungs that do NOT scale with their knob:");
});
check("a unitless leading stays a RATIO — scaling it would double-apply", () => {
const type = readFileSync(join(root, "tokens/typography.css"), "utf8");
const bad = [];
for (const m of type.matchAll(/--leading-[a-z0-9]+:\s*([^;]+);/g)) {
const v = m[1].trim();
// A ratio (`1.05`) already scales with the font size it multiplies.
if (/^[0-9.]+$/.test(v) === false) continue;
if (v.includes("var(")) bad.push(m[0].trim());
}
eq(bad, [], "unitless leadings must not carry a multiplier:");
});
check("a face is set by REFERENCE, so a brand's own face still wins", () => {
// Restating "Georgia, serif" here would freeze this file's idea of serif into
// every document that ever stored the preference.
eq(vars({ font: "serif" })["--font-sans"], "var(--font-serif)");
eq(vars({ font: "mono" })["--font-sans"], "var(--font-mono)");
ok(!("--font-mono" in vars({ font: "serif" })), "code must stay monospaced");
eq(vars({ font: "default" }), {}, "default is no opinion, not a self-reference:");
});
check("Arabic is its own axis, set by reference, and touches no other face", () => {
eq(vars({ arabic: "kufi" }), { "--font-arabic": "var(--font-arabic-kufi)" });
eq(vars({ arabic: "sans" }), { "--font-arabic": "var(--font-arabic-sans)" });
eq(vars({ arabic: "default" }), {}, "default is no opinion:");
eq(vars({ arabic: "naskh" }), {}, "an unknown companion is refused:");
// Orthogonal to the face: serif with Kufi is two names, neither restated.
eq(vars({ font: "serif", arabic: "sans" }), { "--font-sans": "var(--font-serif)", "--font-arabic": "var(--font-arabic-sans)" });
});
check("every stack names the Arabic slot first, so one property reaches them all", () => {
const fonts = readFileSync(join(root, "tokens/fonts.css"), "utf8");
for (const name of ["--font-sans", "--font-mono", "--font-serif"]) {
const m = new RegExp(`^\\s*${name}:([^;]+);`, "m").exec(fonts);
ok(m, `${name} is not declared`);
ok(m[1].trim().startsWith("var(--font-arabic)"), `${name} reads ${m[1].trim().slice(0, 40)}…`);
}
for (const v of Object.values({ kufi: "var(--font-arabic-kufi)", sans: "var(--font-arabic-sans)" })) {
const name = /var\((--[a-z-]+)\)/.exec(v)[1];
ok(new RegExp(`^\\s*${name}:`, "m").test(fonts), `${name} is not declared`);
}
});
check("the measure moves containers, never the column count", () => {
const v = vars({ width: "wide" });
ok(!("--grid-columns" in v), "changing the columns is a different page, not a wider one");
ok("--container-max" in v && "--container-prose" in v, Object.keys(v).join(","));
ok(parseFloat(v["--container-prose"]) > parseFloat(vars({ width: "narrow" })["--container-prose"]), "wide must exceed narrow");
eq(vars({ width: "default" }), {}, "default is no opinion:");
});
check("an unknown stored value is refused, not passed through", () => {
// A preference is user input; these axes are string unions, so a stored value
// from a future version or a hand-edited store must not reach a stylesheet.
eq(vars({ font: "comic" }), {});
eq(vars({ width: "enormous" }), {});
});
check("a modular scale regenerates the DISPLAY rungs, geometrically", () => {
const v = vars({ modular: 1.618 });
const names = Object.keys(v);
eq(names.length, 8, "a modular scale emits the eight display rungs:");
// Each rung is the one below it times the ratio — that IS the scale.
const rem = (n) => parseFloat(v[`--text-${n}`].match(/([\d.]+)rem/)[1]);
const rungs = ["2xl","3xl","4xl","5xl","6xl","7xl","8xl","9xl"];
for (let i = 1; i < rungs.length; i++) {
const r = rem(rungs[i]) / rem(rungs[i - 1]);
ok(Math.abs(r - 1.618) < 0.001, `${rungs[i]}/${rungs[i-1]} = ${r}, want the ratio`);
}
// Anchored on xl (17px), so the display half continues the interface rather
// than restarting under it.
ok(Math.abs(rem("2xl") - 1.0625 * 1.618) < 0.001, "2xl must be xl x ratio");
});
check("a modular scale leaves the INTERFACE rungs alone", () => {
// A geometric scale through xs..xl is unusable at any real ratio: the 13px nav
// label and the 15px lead simply do not exist on one.
const emitted = Object.keys(vars({ modular: 1.618 }));
for (const n of ["--text-xs", "--text-sm", "--text-base", "--text-lg", "--text-xl"]) {
ok(!emitted.includes(n), `${n} is the interface register and must not be regenerated`);
}
});
check("a modular scale still answers to the size knob", () => {
// It multiplies out with --type-scale rather than freezing a px value.
ok(vars({ modular: 1.5 })["--text-4xl"].includes("var(--type-scale, 1)"),
"a regenerated rung must carry the size knob");
});
check("the modular ratio is CLAMPED to something that is still a scale", () => {
// At 1 every display rung collapses onto the one below it.
const flat = vars({ modular: 1 });
const rem = (v, n) => parseFloat(v[`--text-${n}`].match(/([\d.]+)rem/)[1]);
ok(rem(flat, "3xl") > rem(flat, "2xl"), "a clamped ratio must still ascend");
eq(vars({ modular: 99 })["--text-2xl"], vars({ modular: 2 })["--text-2xl"], "max:");
});
check("a colour lands on both --primary and --accent", () => {
const v = vars({ accent: "#808000" });
eq(v["--primary"], "#808000");
eq(v["--accent"], "#808000");
});
check("an accent brings its own ink and hover, on both names", () => {
const v = vars({ accent: "#3b82f6" });
for (const k of ["--primary", "--primary-foreground", "--primary-hover", "--accent", "--accent-foreground", "--accent-hover"]) {
ok(k in v, `missing ${k}`);
}
eq(v["--primary-foreground"], v["--accent-foreground"], "one ink:");
eq(v["--primary-hover"], "color-mix(in oklab, #3b82f6 85%, #0a0a0a)");
});
check("the ink is whichever of design's two reads better — 4.5:1 on every preset", () => {
const lum = (hex) => brightness(hex);
const ratio = (a, b) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
for (const hue of ["#808000", "#3b82f6", "#8b5cf6", "#f43f5e", "#f59e0b", "#fafafa", "#0a0a0a"]) {
const label = ink(hue);
ok(label === "#0a0a0a" || label === "#fafafa", `${hue} -> ${label}`);
const other = label === "#0a0a0a" ? "#fafafa" : "#0a0a0a";
ok(ratio(lum(hue), lum(label)) >= ratio(lum(hue), lum(other)), `${hue}: ${label} is the weaker ink`);
}
eq(ink("#f59e0b"), "#0a0a0a", "amber:");
eq(ink("#0a0a0a"), "#fafafa", "black:");
});
check("every notation an accent is stored in reads the same brightness", () => {
const near = (a, b) => Math.abs(a - b) < 0.01;
const y = brightness("#3b82f6");
ok(near(brightness("#38f"), brightness("#3388ff")), "short hex");
ok(near(brightness("rgb(59 130 246)"), y), "rgb space");
ok(near(brightness("rgba(59, 130, 246, .5)"), y), "rgba comma");
ok(near(brightness("hsl(217 91% 60%)"), y), "hsl");
ok(Math.abs(brightness("oklch(62.3% 0.188 259.8)") - y) < 0.03, "oklch");
});
check("a colour this module cannot read is not an accent", () => {
for (const unread of ["rebeccapurple", "color(display-p3 1 0 0)", "lab(50 20 30)"]) {
eq(ink(unread), undefined, unread);
ok(!("--primary" in vars({ accent: unread })), `accepted ${unread}`);
}
});
check("corners are ONE knob, and default writes nothing", () => {
eq(vars({ radius: "sharp" }), { "--radius-scale": "0.5" });
eq(vars({ radius: "round" }), { "--radius-scale": "1.5" });
eq(vars({ radius: "default" }), {});
eq(vars({ radius: "pill" }), {}, "unknown:");
});
check("the theme is a class, so it emits no property", () => {
eq(vars({ theme: "light" }), {});
eq(vars({ theme: "system" }), {});
});
check("a NON-colour is dropped, never sanitised into something plausible", () => {
for (const bad of [
"red; background-image:url(//evil/x)",
"}html{display:none",
"url(//evil/x)",
"expression(alert(1))",
"",
" ",
]) {
const v = vars({ accent: bad });
ok(!("--primary" in v), `accepted ${JSON.stringify(bad)}`);
ok(!isColor(bad), `isColor said yes to ${JSON.stringify(bad)}`);
}
});
check("real colour notations are accepted", () => {
for (const good of ["#fff", "#ffffff", "#ffffffff", "rgb(1 2 3)", "oklch(.7 .1 120)", "rebeccapurple"]) {
ok(isColor(good), `isColor said no to ${good}`);
}
});
check("css() emits ONE block for the selector it is given", () => {
const out = css({ type: 1.1, density: "compact", accent: "#fff" }, ":root");
ok(out.startsWith(":root{") && out.endsWith("}"), out.slice(0, 40));
ok(!out.includes("}" + "{"), "emitted more than one block");
});
check("every emitted name is one the token files actually read", () => {
// A variable nothing reads is a write into another document — the exact
// mistake this package exists to prevent. The knobs are READ by the ramps
// (as var(--knob, 1)); the colours are DECLARED by colors.css.
const files = ["tokens/typography.css", "tokens/spacing.css", "tokens/grid.css", "tokens/colors.css", "tokens/radius.css", "tokens/fonts.css"];
const text = files.map((f) => readFileSync(join(root, f), "utf8")).join("\n");
const declared = new Set([...text.matchAll(/^\s*(--[a-z0-9-]+)\s*:/gm)].map((m) => m[1]));
const read = new Set([...text.matchAll(/var\((--[a-z0-9-]+)/g)].map((m) => m[1]));
// EVERY axis, not a sample — an axis missing here is an axis whose names
// nothing gates, which is how a knob starts writing into another document.
const emitted = Object.keys(
vars({ type: 1.1, ratio: 1.2, density: "compact", font: "serif", arabic: "sans", width: "wide", accent: "#fff", radius: "round", theme: "dark" })
);
const orphans = emitted.filter((k) => !declared.has(k) && !read.has(k));
eq(orphans, [], "emitted names no token file declares or reads:");
});
if (failed) {
console.error(`\n${failed} preference check(s) failed`);
process.exit(1);
}
console.log("preference: all checks passed");