learning-website-nextjs1-5 Exercise 1: Tokens, a Colour for Each Site, and a Test That Reads the Real CSS ============================================================================================================== The design system is a package, @lw/ui, that every app imports: one set of stylesheets, a few components, and a function that gives each site its own accent. It is the Django project's theme (apps/theme) moved into a package. The one place a colour is decided is tokens.css (copied unchanged from the Django project): Save as packages/ui/src/tokens.css: /* tokens.css: the ONLY place a colour is decided. Everything else reads these variables. */ :root { --bg: #0f1117; --font-body: system-ui, -apple-system, "Segoe UI", sans-serif; --font-mono: Consolas, "Cascadia Mono", monospace; --radius: 8px; --max-width: 56rem; /* --accent is set per site, on the element, from config/sites_config.py */ } /* the two neutral families that exist on the site today (measured in Framework & Architecture 4) */ [data-family="language"] { --surface: #1a1d27; --border: #2a2d3a; --text: #e2e4ec; --muted: #a0a4b8; } [data-family="technical"] { --surface: #161b22; --border: #30363d; --text: #c9d1d9; --muted: #8b949e; } /* a lesson fragment keeps its own language colour; it wins inside its own wrapper */ .hu-lesson { --accent: #5aa469; } /* Hungarian */ .de-lesson { --accent: #e0a82e; } /* German */ .jp-lesson { --accent: #d64550; } /* Japanese */ .fr-lesson { --accent: #4f8ef7; } /* French */ Two neutral families exist (language and technical, measured in Learning Website: Framework & Architecture 4), and each site has ONE accent of its own. The accent is a CSS variable on the element, so one stylesheet serves every site and only that value differs. The table lives in the shared site package, next to the site map (appended to packages/sites/src/index.ts): export const SITE_STYLE: Record = { languages: { family: "language", accent: "#a78bfa" }, webdevelopment: { family: "technical", accent: "#38bdf8" }, programming: { ... "#44b78b" }, systems: { ... "#f0938a" }, ai: { ... "#fbbf24" }, humanities: { language, "#fb923c" }, lifeskills: { language, "#6ee7b7" }, creative: { technical, "#f472b6" }, }; Save as packages/ui/src/theme.ts: import { SITE_STYLE, type SiteName } from "@lw/sites"; /** * The attributes a site puts on its element. The accent is a CSS variable on that element, so one stylesheet * serves every site and only this value differs. The family picks the neutral colours (tokens.css). */ export function htmlProps(site: SiteName) { const style = SITE_STYLE[site]; return { "data-site": site, "data-family": style.family, style: { "--accent": style.accent } as Record, }; } Save as packages/ui/src/contrast.ts: /** WCAG relative luminance of a "#rrggbb" colour. */ export function luminance(hex: string): number { const h = hex.replace(/^#/, ""); if (!/^[0-9a-fA-F]{6}$/.test(h)) throw new Error(`not a #rrggbb colour: ${hex}`); const [r, g, b] = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255) as [number, number, number]; const channel = (c: number) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b); } /** WCAG contrast ratio, from 1 (none) to 21 (black on white). Text needs 4.5 to pass level AA. */ export function contrast(a: string, b: string): number { const [high, low] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number]; return (high + 0.05) / (low + 0.05); } Save as packages/ui/src/design.test.ts: import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { test } from "node:test"; import { SITES, SITE_NAMES, SITE_STYLE } from "@lw/sites"; import { contrast, luminance } from "./contrast.ts"; import { htmlProps } from "./theme.ts"; const css = (name: string) => readFileSync(new URL(`./${name}`, import.meta.url), "utf8"); /** The colours the site really uses, read from tokens.css itself (not copied into the test). */ function tokens() { const text = css("tokens.css"); const bg = /--bg:\s*(#[0-9a-f]{6})/i.exec(text)?.[1]; const surface = (family: string) => new RegExp(`\\[data-family="${family}"\\]\\s*\\{[^}]*--surface:\\s*(#[0-9a-f]{6})`, "i").exec(text)?.[1]; assert.ok(bg, "--bg not found"); const language = surface("language"); const technical = surface("technical"); assert.ok(language && technical, "family surfaces not found"); return { bg, surfaces: { language, technical } }; } test("the contrast function matches known values", () => { assert.equal(luminance("#000000"), 0); assert.equal(luminance("#ffffff"), 1); assert.equal(contrast("#000000", "#ffffff"), 21); assert.equal(contrast("#ffffff", "#ffffff"), 1); assert.equal(contrast("#777777", "#ffffff").toFixed(2), "4.48"); // the well-known "just fails AA" grey assert.throws(() => luminance("red")); assert.throws(() => luminance("#fff")); }); test("every site has a style, and its own accent", () => { assert.deepEqual(Object.keys(SITE_STYLE).sort(), [...SITE_NAMES].sort()); const accents = SITE_NAMES.map((site) => SITE_STYLE[site].accent); assert.equal(new Set(accents).size, accents.length); for (const site of SITE_NAMES) assert.ok(SITES[site].title); }); test("every accent is readable as text on its surface and on the page background", () => { const { bg, surfaces } = tokens(); for (const site of SITE_NAMES) { const { accent, family } = SITE_STYLE[site]; for (const background of [surfaces[family], bg]) { assert.ok(contrast(accent, background) >= 4.5, `${site}: ${accent} on ${background} is ${contrast(accent, background).toFixed(2)}`); } } }); test("a site's html attributes carry its accent and family", () => { assert.deepEqual(htmlProps("languages"), { "data-site": "languages", "data-family": "language", style: { "--accent": "#a78bfa" }, }); assert.equal(htmlProps("systems").style["--accent"], "#f0938a"); }); test("the shared css never forces a rule with !important", () => { for (const name of ["tokens.css", "base.css", "components.css", "SiteLayout.module.css"]) { assert.ok(!css(name).includes("!important"), name); } }); test("shared component rules stay weaker than a fragment's own scoped rules", () => { // A fragment's rule looks like ".wrapper .tip-box" (two classes). The shared rules must use no ids and at most // two parts, or they would beat it and old pages would change their look. const text = css("components.css").replace(/\/\*[\s\S]*?\*\//g, ""); const selectors = [...text.matchAll(/([^{}@]+)\{/g)].map((m) => m[1] ?? ""); assert.ok(selectors.length > 10); for (const group of selectors) { for (const part of group.split(",")) { assert.ok(!part.includes("#"), part); assert.ok(part.trim().split(/\s+/).length <= 2, part); } } }); test("fragment-facing classes are global and the site's own frame is in a module", () => { assert.ok(css("components.css").includes(".tip-box")); // fragments say class="tip-box": it must keep its name assert.ok(css("base.css").includes(".page-title")); assert.ok(!css("base.css").includes(".bar")); // the header's classes are not global assert.ok(css("SiteLayout.module.css").includes(".bar")); }); The accent-readability test does not copy the colours into the test: it READS the real background and surface colours out of tokens.css, so if someone changes a token the test sees the change. Every accent must reach a contrast of 4.5 on both neutral surfaces (the WCAG level AA for text). The contrast function itself is checked against known values (black on white is exactly 21, and #777777 on white is 4.48, the famous "just fails"). npm test ℹ tests 41 ℹ pass 41 ℹ fail 0 (34 from earlier chapters and 7 new) WHY THIS WORKS AS AN ANSWER --------------------------- The rules that keep the design consistent (every site has a style, accents differ and are readable, no !important, shared rules stay weaker than a fragment's own) are tests over the real files, not a description of intentions.