From 5e254cb8510f4a5bf3261bdd713838d2f0818266 Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Thu, 22 Oct 2026 09:39:16 +0800 Subject: [PATCH] fix(01-design-tokens-rams-foundation): WR-04 audit spacing + typography numerics per D-13 (token scales, font-face exempt) --- theme/gallery.html | 2 +- theme/scripts/audit.mjs | 126 +++++++++++++++++++++++++++++++++-- theme/scripts/audit.test.mjs | 42 ++++++++++++ 3 files changed, 163 insertions(+), 7 deletions(-) diff --git a/theme/gallery.html b/theme/gallery.html index f5404d3..8910c90 100644 --- a/theme/gallery.html +++ b/theme/gallery.html @@ -152,7 +152,7 @@

Rams implementation vs mockups — side-by-side, token by token. Left columns are the mockup reference renders; right columns are the implementation component sheet rendered from the token package.

AUTOMATED EVIDENCE (D-13)

-

`npm run audit` exits 0 — zero off-token values across every rendered declaration (colors, typography, radius, shadows, blur all derive from theme/src/tokens.css). Re-run it from the repo root; the gallery is the human side of the same gate.

+

`npm run audit` exits 0 — zero off-token values across every rendered declaration (colors, spacing, typography, radius, shadows, blur all derive from theme/src/tokens.css). Re-run it from the repo root; the gallery is the human side of the same gate.

diff --git a/theme/scripts/audit.mjs b/theme/scripts/audit.mjs index 97e9552..a13af22 100644 --- a/theme/scripts/audit.mjs +++ b/theme/scripts/audit.mjs @@ -4,8 +4,8 @@ * ------------------------------------------------------------------ * "Zero off-token values" is evidence, not an eyeball claim: this * script scans RENDERED CSS — every declaration the implementation - * actually wears — and flags any color / typography / radius / - * shadow / blur value that does not derive from + * actually wears — and flags any color / spacing / typography / + * radius / shadow / blur value that does not derive from * theme/src/tokens.css (the single source of truth, D-07). * * Usage: @@ -64,6 +64,22 @@ * oklch()/oklab()/lab()/lch()/hwb()/color()/color-mix(). var() * references are always token paths and pass by construction. * + * 6. Spacing numerics (WR-04) are audited on the spacing-scale + * properties — the padding/margin/gap families, logical variants + * included: literals must equal a --spacing-* token value, zero, + * or auto. Dimensional geometry — width/height, positioning + * offsets, border widths, the 18x18 checkbox square — is spec + * geometry per the components.css header, not spacing-scale + * consumption. + * + * 7. Typography numerics (WR-04) — font-size/font-weight/line-height/ + * letter-spacing — are audited against the --type-* scale; the + * normal/bold keywords ≡ initial/≡ the 700 scale weight and stay + * sanctioned. @font-face blocks describe the self-hosted font + * binaries (D-16): their font-weight values are asset metadata, + * not rendered typography, and are exempt from the numeric scale + * (color and font-family checks still apply). + * * Reuse (D-14 lineage): later UI phases extend IMPLEMENTATION_PAGES * with their pages and keep running `npm run audit` in CI/local * gates — the DSGN-01 evidence command for every phase. @@ -192,7 +208,39 @@ function parseTokens(path) { } if (fonts.size === 0) fail(2, 'no --font-* stacks parsed from the token sheet'); - return { colors, colorCount, radiusCount, fonts }; + /* WR-04: spacing values — literals on the spacing-scale properties + * must equal a --spacing-* token value (or be zero/auto). */ + const spacings = new Set(); + for (const m of text.matchAll(/--spacing-[a-z0-9-]+\s*:\s*([^;}]+?)\s*;/g)) { + for (const v of m[1].split(/[\s,]+/)) { + if (v) spacings.add(v.toLowerCase()); + } + } + if (spacings.size === 0) fail(2, 'no --spacing-* values parsed from the token sheet'); + + /* WR-04: the --type scale — weight, size/line-height, and tracking + * values, parsed from the compound token grammar + * `weight size/line-height family…` plus the -tracking companions. */ + const typeSizes = new Set(); + const typeWeights = new Set(); + const typeLineHeights = new Set(); + const typeTrackings = new Set(); + for (const m of text.matchAll(/--type-([a-z0-9-]+)\s*:\s*([^;}]+?)\s*;/g)) { + const value = m[2].trim(); + if (m[1].endsWith('-tracking')) { + typeTrackings.add(value.toLowerCase()); + continue; + } + const t = /^([0-9]{3})\s+([0-9]*\.?[0-9]+[a-z]+)\/([0-9]*\.?[0-9]+[a-z]+)\s/.exec(value); + if (t) { + typeWeights.add(t[1]); + typeSizes.add(t[2].toLowerCase()); + typeLineHeights.add(t[3].toLowerCase()); + } + } + if (typeSizes.size === 0) fail(2, 'no --type-* scale parsed from the token sheet'); + + return { colors, colorCount, radiusCount, fonts, spacings, typeSizes, typeWeights, typeLineHeights, typeTrackings }; } function stripFamily(part) { @@ -515,7 +563,65 @@ function shadowViolations(prop, value) { const RADIUS_PROPS = /^border(-(top|bottom)-(left|right))?-radius$/; -function checkDeclaration(prop, value, tokens) { +/* ————— WR-04: spacing + typography numeric scales ————— */ + +/* Spacing-scale properties: the padding/margin/gap families (logical + * variants included). Dimensional geometry — width/height, positioning + * offsets, border widths, the 18x18 checkbox square — is spec geometry + * per the components.css header, not spacing-scale consumption + * (adjudication 6). */ +const SPACING_PROPS = new Set([ + 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', + 'padding-block', 'padding-block-start', 'padding-block-end', + 'padding-inline', 'padding-inline-start', 'padding-inline-end', + 'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', + 'margin-block', 'margin-block-start', 'margin-block-end', + 'margin-inline', 'margin-inline-start', 'margin-inline-end', + 'gap', 'row-gap', 'column-gap', +]); + +/* Typography numeric properties → the --type scale set that owns + * their values (adjudication 7). */ +const TYPE_SCALE_PROPS = { + 'font-size': 'typeSizes', + 'font-weight': 'typeWeights', + 'line-height': 'typeLineHeights', + 'letter-spacing': 'typeTrackings', +}; + +const CSS_WIDE_KEYWORDS = new Set(['inherit', 'initial', 'unset', 'revert', 'revert-layer']); + +function spacingViolations(value, tokens) { + const bad = []; + for (const part of splitTopLevel(value, ' ')) { + const v = part.trim().toLowerCase().replace(/,+$/, ''); + if (!v) continue; + if (/^var\(/.test(v)) continue; // token path + if (v.startsWith('calc(') && /var\(/i.test(v)) continue; // token math (Tailwind's spacing utilities) + if (v === 'auto' || /^-?0(?:\.0+)?(?:px|rem|em|%)?$/.test(v)) continue; // zero / auto + if (tokens.spacings.has(v)) continue; // literal equal to a --spacing-* token value + bad.push(`off-token spacing literal ${part.trim()} — derive from theme/src/tokens.css --spacing-* values (D-07)`); + } + return bad; +} + +function typeScaleViolations(prop, value, tokens) { + if (!(prop in TYPE_SCALE_PROPS)) return []; + const allowed = tokens[TYPE_SCALE_PROPS[prop]]; + const bad = []; + for (const part of splitTopLevel(value, ',')) { + const v = part.trim().toLowerCase(); + if (!v) continue; + if (/^var\(/.test(v)) continue; // token path + if (CSS_WIDE_KEYWORDS.has(v)) continue; // pass-through keywords + if (v === 'normal' || v === 'bold') continue; // initial / ≡ the 700 scale weight + if (allowed.has(v)) continue; // literal equal to a --type-* scale value + bad.push(`off-token ${prop} ${part.trim()} — derive from theme/src/tokens.css --type-* scale (D-07)`); + } + return bad; +} + +function checkDeclaration(prop, value, tokens, selector = '') { const violations = []; if (prop.startsWith('--tw-')) return violations; // framework plumbing registration violations.push(...literalHexViolations(value, tokens)); @@ -537,6 +643,13 @@ function checkDeclaration(prop, value, tokens) { } else if (prop === 'font-family' && !/var\(/i.test(value)) { violations.push(...fontViolations(value, tokens)); } + /* @font-face blocks describe the self-hosted font binaries (D-16): + * their font-weight values are asset metadata, not rendered + * typography — the numeric scales do not apply there (adjudication 7). */ + if (!/^@font-face\b/i.test(selector)) { + if (SPACING_PROPS.has(prop)) violations.push(...spacingViolations(value, tokens)); + violations.push(...typeScaleViolations(prop, value, tokens)); + } if (RADIUS_PROPS.test(prop) && !radiusAllowed(value)) { violations.push('non-zero border-radius (Rams Shapes: absolute zero roundedness)'); } @@ -593,7 +706,7 @@ function auditCss({ text, display, baseOffset, lineText }) { const decl = splitDecl(d.text); if (!decl) continue; stats.decls++; - for (const reason of checkDeclaration(decl.prop, decl.value, state.tokens)) { + for (const reason of checkDeclaration(decl.prop, decl.value, state.tokens, rule.selector)) { const offset = baseOffset + rule.bodyStart + d.offset; state.violations.push( `${display}:${lineAt(lineText, offset)}: ${rule.selector} { ${decl.prop}: ${decl.value} } — ${reason}`, @@ -695,6 +808,7 @@ console.log( + `${state.usedClasses.size} classes harvested from ${pages.length} implementation page(s)) — 0 off-token values.`, ); console.log( - `token sheet: ${state.tokens.colorCount} colors / ${state.tokens.radiusCount} radius tokens (all zero) / ${state.tokens.fonts.size} sanctioned font families`, + `token sheet: ${state.tokens.colorCount} colors / ${state.tokens.radiusCount} radius tokens (all zero) / ${state.tokens.fonts.size} sanctioned font families / ` + + `${state.tokens.spacings.size} spacing values / ${state.tokens.typeSizes.size} type sizes / ${state.tokens.typeTrackings.size} trackings`, ); process.exit(0); diff --git a/theme/scripts/audit.test.mjs b/theme/scripts/audit.test.mjs index d18f94d..1722686 100644 --- a/theme/scripts/audit.test.mjs +++ b/theme/scripts/audit.test.mjs @@ -41,6 +41,10 @@ * hwb()/color()/color-mix(), text-shadow → flagged * (exit 1); currentcolor/inherit/transparent stay * sanctioned + * T12 spacing + typography numerics audited against + * the --spacing and --type scale sets; token-value + * literals, var(), zero/auto pass; @font-face asset + * metadata exempt → flagged (exit 1) */ import { spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs'; @@ -200,6 +204,25 @@ writeFileSync(colorEvasions, `/* every flagged declaration must be listed; sanct .t-ok { outline: 2px solid inherit; } `); +/* T12 — D-13 spacing + typography numerics (WR-04): off-scale + * literals flagged; token-value literals, var(), zero/auto, and + * @font-face asset metadata pass. */ +const numerics = join(FIX, 'numerics.css'); +writeFileSync(numerics, `/* off-scale numerics — every flagged declaration must be listed */ +.a-spacing { padding: 13px; } +.b-spacing { gap: 7px; } +.c-type { font-size: 17px; } +.d-type { font-weight: 650; } +.e-type { line-height: 1.7; } +.f-track { letter-spacing: 0.05em; } +/* on-token literal forms + sanctioned values pass */ +.g-ok { padding: 1rem 0.25rem; gap: 3rem; margin: 0 auto; } +.h-ok { font-size: 14px; font-weight: 700; line-height: 20px; letter-spacing: -0.04em; } +.i-ok-var { padding: var(--spacing-space-md); margin: var(--spacing-margin); } +/* @font-face asset metadata (D-16) is not rendered typography */ +@font-face { font-family: "Geist"; font-style: normal; font-weight: 500; src: url("Geist-Medium.woff2") format("woff2"); } +`); + /* ————— tests ————— */ // T1 — clean stylesheet exits 0 @@ -355,6 +378,25 @@ writeFileSync(colorEvasions, `/* every flagged declaration must be listed; sanct `out=${r.out.slice(0, 300)}`); } +// T12 — spacing + typography numerics audited against the token scales +{ + const r = runAudit([numerics]); + const markers = ['padding: 13px', 'gap: 7px', 'font-size: 17px', 'font-weight: 650', + 'line-height: 1.7', 'letter-spacing: 0.05em']; + const missing = markers.filter((m) => !r.out.includes(m)); + check('T12 off-scale numerics → exit 1', r.status === 1, + `status=${r.status} error=${r.error}`); + check('T12 every off-scale numeric listed', missing.length === 0, + `missing markers: ${missing.join(', ')}`); + check('T12 token-value literals pass (1rem/0.25rem/14px/700/20px/-0.04em)', + !/1rem 0\.25rem/.test(r.out) && !/font-size: 14px/.test(r.out) && !/letter-spacing: -0\.04em/.test(r.out), + `out=${r.out.slice(0, 400)}`); + check('T12 zero/auto margin passes', !/margin: 0 auto/.test(r.out), + `out=${r.out.slice(0, 400)}`); + check('T12 @font-face asset metadata (font-weight 500) passes', !/font-weight: 500/.test(r.out), + `out=${r.out.slice(0, 400)}`); +} + /* ————— report ————— */ console.log(`audit.test: ${passes.length} passed, ${failures.length} failed`); passes.forEach((p) => console.log(` PASS ${p}`));