lib/check-css.ts reads style.css, every template's {% css %} blocks and style attributes, and the guide's CSS, and fails on a colour, font size, weight or family written outside the tokens, a rounded corner, capitals by CSS, letter-spacing, or a middle dot anywhere in site/, lib/ or guide/.- commit
- f35ab42d61d748717d452199526596f9d93daaf9
- parent
- 150d28b4bf72a7bcd82c62569ea3879bfa881e7e
- author
- cat <admin@dev.site>
- ci
- ✓ passed in 77s, signed by cat
- kind
- build risk score 1.4 of 3, Jev's estimate
- review
- #13 A design system: tokens, three fonts, no caps, real controls
ci attestation
forge-attestation 1 type ci commit f35ab42d61d748717d452199526596f9d93daaf9 tree d38a48c63b5af5387670212128b32ff108d8e2b8 script .forge/ci ba20ec15392fe0c74a07ee735a5cb0194d5e5381 result pass exit 0 started 2026-10-19T03:00:26Z duration 77s runner (not shown here; refs/notes/ci has it) system Darwin 25.5.0 arm64 env host log sha256:f99079cb382573a19069453c4b0906373f7b228fa880d93c3b8e3f2a8ce7b591 258 lines |pushed wip/i18n |81a18a7 running .forge/ci: greet.sh: say what it is | | sh: /bin/sh | | ok greet |81a18a7 pass in 0s, signed |pushed main |remote: review #4: /dami/greet/reviews/4/ |pushed wip/i18n |1194dc3 running .forge/ci: wave at the world | | sh: /bin/sh | | FAIL greet : got 'hello, world o/', want 'hello, world' |1194dc3 fail in 0s, signed |forge: 1 commit(s) did not pass |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |92f1da1 running .forge/ci: update the test for the wave | | sh: /bin/sh | | ok greet |92f1da1 pass in 0s, signed |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |warning: You appear to have cloned an empty repository. |site home: deployed 209f004 -> /sites/ana/dotfiles/home/ |created ana/notes |remote: review #1: /ana/notes/reviews/1/ |pushed ana/todo |landing #1 A todo list: ana/todo -> main |098ff73 running .forge/ci: web, server and Python versions of greet | | sh: /bin/sh | | ok greet |098ff73 pass in 0s, signed |pushed main |58dfcaa running .forge/ci: messages: notes for the later regions | | sh: /bin/sh | | ok greet |58dfcaa pass in 0s, signed |remote: review #7: /dami/greet/reviews/7/ |pushed dami/regions |created dami/forge |warning: You appear to have cloned an empty repository. |site guide: mkdir -p guide/fonts && cp site/assets/fonts/*.woff2 guide/fonts/ |site guide: deployed d6fad93 -> /sites/dami/forge/guide/ |(node:94850) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built ana/dotfiles in 0s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.CZtyPs/wt/demo/root/.forge/www |(node:94975) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built ana/notes in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.CZtyPs/wt/demo/root/.forge/www |(node:95113) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/forge in 0s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.CZtyPs/wt/demo/root/.forge/www |(node:95245) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/greet in 2s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.CZtyPs/wt/demo/root/.forge/www |(node:95911) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built - in 0s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.CZtyPs/wt/demo/root/.forge/www |233 of 233 pages render the same without 11ty |guide: 8 files, links ok |ok
Signature: good, cat
| M | .forge/ | 3 | + |
| A | lib/ | 225 | ++++++++++++++++++++++++++++++ |
| A | test/ | 45 | ++++++ |
| A | test/ | 19 | +++ |
| A | test/ | 24 | +++ |
@@ -4,6 +4,9 @@ set -e
44 sh -n bin/forge demo/make-demo.sh55 npm ci --silent --no-audit --no-fund66 npx tsc -p .7+# The design rules (DESIGN.md): tokens only, no capitals, tracking, round8+# corners or middle dots in the forge's CSS, templates and guide.9+node lib/check-css.ts710 # Scenarios: one behavior each, in a throwaway forge of its own (test/lib.sh).811 sh test/run.sh912 # And in the forge's container, as root, when FORGE_CI_CONTAINER=1 asks@@ -0,0 +1,225 @@
1+// The design rules (DESIGN.md), checked: CI runs this over the forge's CSS,2+// which is site/assets/style.css, the {% css %} blocks and style=""3+// attributes in site/'s templates, and the guide's guide.css. Outside a4+// stylesheet's token block (between "== tokens ==" and "== end tokens =="5+// comments) a declaration may not name a colour, a font size, weight or6+// family: those are tokens. Nowhere may anything be rounded, capitalised7+// by CSS, or letter-spaced. And no file in site/, lib/ or guide/ holds a8+// middle dot (U+00B7).9+//10+// node lib/check-css.ts the forge's files11+// node lib/check-css.ts <path>... these files, or what is in these12+// directories (.css, .njk; other files13+// are checked for the middle dot only)14+import fs from 'node:fs';15+import path from 'node:path';16+import { fileURLToPath } from 'node:url';17+18+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');19+20+export interface Problem {21+ file: string;22+ line: number;23+ message: string;24+}25+26+// CSS named colours; transparent, currentColor and system colours are fine.27+const NAMED = new Set(28+ ('aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown burlywood cadetblue chartreuse chocolate coral ' +29+ 'cornflowerblue cornsilk crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange ' +30+ 'darkorchid darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue dimgray dimgrey ' +31+ 'dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ' +32+ 'ivory khaki lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow lightgray lightgreen lightgrey lightpink ' +33+ 'lightsalmon lightseagreen lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen magenta maroon mediumaquamarine ' +34+ 'mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream mistyrose ' +35+ 'moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru ' +36+ 'pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown seagreen seashell sienna silver skyblue slateblue ' +37+ 'slategray slategrey snow springgreen steelblue tan teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen').split(' '),38+);39+const COLOUR_FN = /\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color|color-mix)\(/i;40+const HEX = /#[0-9a-f]{3,8}\b/i;41+const DOT = String.fromCharCode(0xb7);42+43+// var(...) taken out, nested ones too, so what is left is what the44+// declaration says itself.45+const withoutVars = (v: string): string => {46+ let out = v;47+ for (let was = ''; was !== out; ) {48+ was = out;49+ out = out.replace(/var\([^()]*(?:\([^()]*\)[^()]*)*\)/g, ' ');50+ }51+ return out;52+};53+54+const namesColour = (v: string): string | null => {55+ const bare = v.replace(/url\([^)]*\)/g, ' ');56+ const m = HEX.exec(bare) ?? COLOUR_FN.exec(bare);57+ if (m) return m[0];58+ for (const w of bare.toLowerCase().match(/[a-z-]+/g) ?? []) if (NAMED.has(w)) return w;59+ return null;60+};61+62+// One declaration's problems. tokens: it is in a token block.63+function declaration(prop: string, value: string, tokens: boolean): string[] {64+ const p = prop.toLowerCase();65+ const v = value.trim();66+ const bad: string[] = [];67+ if (p === 'letter-spacing') bad.push(`letter-spacing (${v}): no tracking; hierarchy comes from weight, size and colour`);68+ if (p === 'text-transform' && /\b(uppercase|capitalize)\b/i.test(v)) bad.push(`text-transform: ${v}: write chrome lowercase instead`);69+ if (/(^|-)radius$/.test(p) && !/^(0(px)?|var\(--radius\))$/.test(v)) bad.push(`${p}: ${v}: corners are square`);70+ if (tokens) return bad;71+ const c = namesColour(v);72+ if (c) bad.push(`${p}: a colour literal (${c}) outside the tokens; use a colour token`);73+ const own = withoutVars(v).trim();74+ if (p === 'font-size' && own && own !== 'inherit') bad.push(`font-size: ${v}: use a --text-* token`);75+ if (p === 'font-weight' && own && own !== 'inherit') bad.push(`font-weight: ${v}: use a --fw-* token`);76+ if (p === 'font-family' && own && own !== 'inherit') bad.push(`font-family: ${v}: use --sans, --mono or --serif`);77+ if (p === 'font') {78+ // What may be left of the shorthand: a line height after the slash.79+ const rest = own.replace(/\/\s*[\d.]+(?:px|em|rem|%)?/g, ' ').trim();80+ if (rest && rest !== 'inherit') bad.push(`font: ${v}: name the weight, size and family with tokens`);81+ }82+ return bad;83+}84+85+// A stylesheet's problems. offset: the line it starts on in its file;86+// tokensAllowed: it may have a token block (a stylesheet, not a template).87+export function checkCss(css: string, file: string, offset = 1, tokensAllowed = true): Problem[] {88+ const out: Problem[] = [];89+ // Comments become spaces (newlines kept, for line numbers); the token90+ // block is where its markers say.91+ let text = '';92+ const inTokens: boolean[] = [];93+ let tokens = false;94+ for (let i = 0; i < css.length; ) {95+ if (css.startsWith('/*', i)) {96+ const end = css.indexOf('*/', i + 2);97+ const c = css.slice(i, end < 0 ? css.length : end + 2);98+ if (/==\s*end tokens\s*==/.test(c)) tokens = false;99+ else if (/==\s*tokens\s*==/.test(c)) {100+ if (!tokensAllowed) out.push({ file, line: offset + (css.slice(0, i).match(/\n/g)?.length ?? 0), message: 'a token block belongs in style.css, not a template' });101+ tokens = tokensAllowed;102+ }103+ for (const ch of c) {104+ text += ch === '\n' ? '\n' : ' ';105+ inTokens.push(tokens);106+ }107+ i += c.length;108+ } else {109+ text += css[i];110+ inTokens.push(tokens);111+ i++;112+ }113+ }114+ // Declarations are what comes before a ; or }, after the last { ; or };115+ // what comes before a { is a selector or an at-rule's prelude.116+ let start = 0;117+ let quote = '';118+ let paren = 0;119+ for (let i = 0; i < text.length; i++) {120+ const ch = text[i];121+ if (quote) {122+ if (ch === '\\') i++;123+ else if (ch === quote) quote = '';124+ continue;125+ }126+ if (ch === '"' || ch === "'") quote = ch;127+ else if (ch === '(') paren++;128+ else if (ch === ')') paren--;129+ else if (paren === 0 && (ch === '{' || ch === '}' || ch === ';')) {130+ const seg = text.slice(start, i);131+ if (ch !== '{') {132+ const m = /^\s*(-?-?[a-zA-Z][\w-]*)\s*:([\s\S]*)$/.exec(seg);133+ if (m) {134+ const at = start + seg.search(/\S/);135+ const line = offset + (text.slice(0, at).match(/\n/g)?.length ?? 0);136+ for (const message of declaration(m[1], m[2], inTokens[at])) out.push({ file, line, message });137+ }138+ }139+ start = i + 1;140+ }141+ }142+ return out;143+}144+145+// A template's CSS: its {% css %} blocks and style="" attributes.146+export function checkTemplate(src: string, file: string): Problem[] {147+ const out: Problem[] = [];148+ const lineAt = (i: number): number => 1 + (src.slice(0, i).match(/\n/g)?.length ?? 0);149+ for (const m of src.matchAll(/\{%-?\s*css\s*-?%\}([\s\S]*?)\{%-?\s*endcss\s*-?%\}/g)) {150+ const body = m[1];151+ out.push(...checkCss(body, file, lineAt(m.index + m[0].indexOf(body)), false));152+ }153+ for (const m of src.matchAll(/\sstyle="([^"]*)"/g)) out.push(...checkCss(m[1] + ';', file, lineAt(m.index), false));154+ return out;155+}156+157+export function checkDot(text: string, file: string): Problem[] {158+ const out: Problem[] = [];159+ text.split('\n').forEach((l, n) => {160+ if (l.includes(DOT)) out.push({ file, line: n + 1, message: 'a middle dot (U+00B7): use a hyphen, comma or pipe' });161+ });162+ return out;163+}164+165+export interface Token {166+ name: string;167+ light: string;168+ dark: string | null;169+}170+171+// The tokens in a stylesheet's token block, as the style guide (/design/)172+// shows them: in groups, each titled by the comment above it, with each173+// token's value and its dark-mode value if it has one.174+export function tokens(css: string): { title: string; tokens: Token[] }[] {175+ const block = /==\s*tokens\s*==[\s\S]*?\*\/([\s\S]*?)\/\*\s*==\s*end tokens\s*==/.exec(css)?.[1] ?? '';176+ const light = /(?:^|\n):root\s*\{([\s\S]*?)\n\}/.exec(block)?.[1] ?? '';177+ const darkBlock = /prefers-color-scheme:\s*dark\)\s*\{\s*:root\s*\{([\s\S]*?)\}/.exec(block)?.[1] ?? '';178+ const dark = new Map([...darkBlock.matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)].map((m) => [m[1], m[2].trim()]));179+ const groups: { title: string; tokens: Token[] }[] = [];180+ for (const m of light.matchAll(/\/\*([\s\S]*?)\*\/|(--[\w-]+)\s*:\s*([^;]+);/g)) {181+ if (m[1] != null) groups.push({ title: m[1].replace(/\s+/g, ' ').trim(), tokens: [] });182+ else {183+ if (!groups.length) groups.push({ title: '', tokens: [] });184+ groups[groups.length - 1].tokens.push({ name: m[2], light: m[3].trim(), dark: dark.get(m[2]) ?? null });185+ }186+ }187+ return groups.filter((g) => g.tokens.length);188+}189+190+// The forge's own, from style.css.191+export const forgeTokens = (): { title: string; tokens: Token[] }[] => tokens(fs.readFileSync(path.join(root, 'site', 'assets', 'style.css'), 'utf8'));192+193+const BINARY = /\.(woff2?|ttf|otf|webp|png|jpe?g|gif|avif|ico|wasm|db|parquet)$/i;194+195+function walk(dir: string): string[] {196+ return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {197+ const p = path.join(dir, e.name);198+ return e.isDirectory() ? (e.name === 'node_modules' ? [] : walk(p)) : [p];199+ });200+}201+202+export function check(files: string[]): Problem[] {203+ const out: Problem[] = [];204+ for (const f of files) {205+ if (BINARY.test(f)) continue;206+ const text = fs.readFileSync(f, 'utf8');207+ const name = path.relative(root, f) || f;208+ out.push(...checkDot(text, name));209+ if (f.endsWith('.css')) out.push(...checkCss(text, name));210+ else if (f.endsWith('.njk')) out.push(...checkTemplate(text, name));211+ }212+ return out;213+}214+215+if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {216+ const args = process.argv.slice(2);217+ const files = (args.length ? args : ['site', 'lib', 'guide'].map((d) => path.join(root, d))).flatMap((a) => (fs.statSync(a).isDirectory() ? walk(path.resolve(a)) : [path.resolve(a)]));218+ const problems = check(files);219+ for (const p of problems) console.error(`${p.file}:${p.line}: ${p.message}`);220+ if (problems.length) {221+ console.error(`${problems.length} problem${problems.length === 1 ? '' : 's'}; the rules are in DESIGN.md`);222+ process.exit(1);223+ }224+ console.log(`css: ${files.filter((f) => /\.(css|njk)$/.test(f)).length} stylesheets and templates keep to DESIGN.md`);225+}@@ -0,0 +1,45 @@
1+# The design rules hold for the forge's own CSS and templates, and2+# lib/check-css.ts fails on each kind of break: a colour, size, weight or3+# family written outside the tokens, a rounded corner, capitals by CSS,4+# letter-spacing, a token block in a template, and a middle dot anywhere.5+. "$(dirname "$0")/../lib.sh"6+check() { node "$T_HOME/lib/check-css.ts" "$@"; }7+ok check8+d=$T/lint9+mkdir -p "$d"10+cp "$T_HOME/site/assets/style.css" "$d/style.css"11+ok check "$d/style.css"12+plant() { # plant <name> <ext> <text> <expected message>13+ f=$d/$1.$214+ if [ "$2" = css ]; then { cat "$T_HOME/site/assets/style.css"; printf '%s\n' "$3"; } >"$f"15+ else printf '%s\n' "$3" >"$f"; fi16+ fails_with "$4" check "$f"17+}18+plant caps css '.x { text-transform: uppercase; }' 'text-transform: uppercase'19+plant tracking css '.x { letter-spacing: 0.08em; }' 'letter-spacing'20+plant round css '.x { border-radius: 4px; }' 'corners are square'21+plant hex css '.x { color: #fff; }' 'colour literal \(#fff\)'22+plant rgba css '.x { box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); }' 'colour literal'23+plant named css '.x { border: 1px solid white; }' 'colour literal \(white\)'24+plant size css '.x { font-size: 11px; }' 'use a --text-\* token'25+plant weight css '.x { font-weight: 600; }' 'use a --fw-\* token'26+plant family css '.x { font-family: Georgia, serif; }' 'use --sans, --mono or --serif'27+plant short css '.x { font: 600 11px/1.4 var(--mono); }' 'name the weight, size and family'28+plant block njk '{% css %}29+.x { color: var(--fg); letter-spacing: 1px; }30+{% endcss %}31+<p class="x">x</p>' 'block.njk:2: letter-spacing'32+plant inline njk '<p style="color: #c00">x</p>' 'colour literal'33+plant tokens njk '{% css %}34+/* == tokens == */35+:root { --x: #fff; }36+/* == end tokens == */37+{% endcss %}' 'a token block belongs in style.css'38+plant dot txt "$(printf 'a \302\267 b')" 'a middle dot'39+# What may stand: tokens, a line height in the shorthand, var() in a40+# template's style attribute.41+printf '%s\n' '{% css %}42+.x { font: var(--fw-strong) var(--text-sm)/1.4 var(--sans); color: var(--muted); border-radius: var(--radius); }43+{% endcss %}44+<p class="x" style="--depth: 2; width: var(--space-4)">x</p>' >"$d/fine.njk"45+ok check "$d/fine.njk"@@ -0,0 +1,19 @@
1+# No page is set in capitals or letter-spaced: none of the CSS a build2+# ships (the stylesheet and the pages' bundles) has text-transform3+# uppercase or letter-spacing, and none names a rounded corner.4+. "$(dirname "$0")/../lib.sh"5+user dami6+repo dami/greet7+clone dami dami/greet "$T/w/g"8+printf '# greet\n' >"$T/w/g/README.md"9+at "$T/w/g" commit "first" README.md10+push_as dami "$T/w/g" origin main11+build12+n=013+for f in "$FORGE_WWW"/assets/*.css; do14+ n=$((n + 1))15+ lacks "$f" 'text-transform: *(uppercase|capitalize)'16+ lacks "$f" 'letter-spacing'17+ lacks "$f" 'border-radius: *[1-9]'18+done19+[ "$n" -gt 2 ] || fail "only $n stylesheets in $FORGE_WWW/assets"@@ -0,0 +1,24 @@
1+# The index's "has a site" filter is a control, not words: a globe icon2+# toggle (the globe repo rows show beside a site) with aria-pressed, a3+# label and a title; the sort is a segmented control of two icon toggles;4+# shelves are tabs, "all" first and current. ui.ts makes them work.5+. "$(dirname "$0")/../lib.sh"6+user dami7+for r in dami/chess dami/notes; do8+ repo "$r"9+ clone dami "$r" "$T/w/${r#*/}"10+ at "$T/w/${r#*/}" commit "first"11+ push_as dami "$T/w/${r#*/}" origin main12+done13+printf 'games\n' >"$FORGE_ROOT/dami/chess.git/category"14+export FORGE_STRICT=115+build16+i=$FORGE_WWW/index.html17+has "$i" '<button type="button" class="icon-btn" aria-pressed="false" aria-label="only repos with a site" title="only repos with a site" data-has-site><svg class="icon"'18+has "$i" '<span class="segmented" role="group" aria-label="sort">'19+has "$i" 'aria-label="newest first" title="newest first" aria-pressed="true" data-sort="date"'20+has "$i" 'aria-label="by name, a to z" title="by name, a to z" aria-pressed="false" data-sort="name"'21+has "$i" '<nav class="tabs" aria-label="shelves">'22+has "$i" '<a href="#shelves" class="cur" aria-current="page" data-shelf="">all<span class="n">2</span></a>'23+has "$i" '<a href="#games" data-shelf="games">games<span class="n">1</span></a>'24+lacks "$i" 'class="chip"'