fix(01-design-tokens-rams-foundation): WR-04 audit spacing + typography numerics per D-13 (token scales, font-face exempt)

This commit is contained in:
araharah-executor
2026-10-22 09:39:16 +08:00
parent ee48e760f8
commit 5e254cb851
3 changed files with 163 additions and 7 deletions
+1 -1
View File
@@ -152,7 +152,7 @@
<p class="text-body-md">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.</p> <p class="text-body-md">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.</p>
<div class="evidence"> <div class="evidence">
<p class="text-label-md">AUTOMATED EVIDENCE (D-13)</p> <p class="text-label-md">AUTOMATED EVIDENCE (D-13)</p>
<p class="text-body-sm">`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.</p> <p class="text-body-sm">`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.</p>
</div> </div>
</header> </header>
+120 -6
View File
@@ -4,8 +4,8 @@
* ------------------------------------------------------------------ * ------------------------------------------------------------------
* "Zero off-token values" is evidence, not an eyeball claim: this * "Zero off-token values" is evidence, not an eyeball claim: this
* script scans RENDERED CSS — every declaration the implementation * script scans RENDERED CSS — every declaration the implementation
* actually wears — and flags any color / typography / radius / * actually wears — and flags any color / spacing / typography /
* shadow / blur value that does not derive from * radius / shadow / blur value that does not derive from
* theme/src/tokens.css (the single source of truth, D-07). * theme/src/tokens.css (the single source of truth, D-07).
* *
* Usage: * Usage:
@@ -64,6 +64,22 @@
* oklch()/oklab()/lab()/lch()/hwb()/color()/color-mix(). var() * oklch()/oklab()/lab()/lch()/hwb()/color()/color-mix(). var()
* references are always token paths and pass by construction. * 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 * Reuse (D-14 lineage): later UI phases extend IMPLEMENTATION_PAGES
* with their pages and keep running `npm run audit` in CI/local * with their pages and keep running `npm run audit` in CI/local
* gates — the DSGN-01 evidence command for every phase. * 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'); 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) { function stripFamily(part) {
@@ -515,7 +563,65 @@ function shadowViolations(prop, value) {
const RADIUS_PROPS = /^border(-(top|bottom)-(left|right))?-radius$/; 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 = []; const violations = [];
if (prop.startsWith('--tw-')) return violations; // framework plumbing registration if (prop.startsWith('--tw-')) return violations; // framework plumbing registration
violations.push(...literalHexViolations(value, tokens)); violations.push(...literalHexViolations(value, tokens));
@@ -537,6 +643,13 @@ function checkDeclaration(prop, value, tokens) {
} else if (prop === 'font-family' && !/var\(/i.test(value)) { } else if (prop === 'font-family' && !/var\(/i.test(value)) {
violations.push(...fontViolations(value, tokens)); 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)) { if (RADIUS_PROPS.test(prop) && !radiusAllowed(value)) {
violations.push('non-zero border-radius (Rams Shapes: absolute zero roundedness)'); 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); const decl = splitDecl(d.text);
if (!decl) continue; if (!decl) continue;
stats.decls++; 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; const offset = baseOffset + rule.bodyStart + d.offset;
state.violations.push( state.violations.push(
`${display}:${lineAt(lineText, offset)}: ${rule.selector} { ${decl.prop}: ${decl.value} } — ${reason}`, `${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.`, + `${state.usedClasses.size} classes harvested from ${pages.length} implementation page(s)) — 0 off-token values.`,
); );
console.log( 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); process.exit(0);
+42
View File
@@ -41,6 +41,10 @@
* hwb()/color()/color-mix(), text-shadow → flagged * hwb()/color()/color-mix(), text-shadow → flagged
* (exit 1); currentcolor/inherit/transparent stay * (exit 1); currentcolor/inherit/transparent stay
* sanctioned * 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 { spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs'; 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; } .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 ————— */ /* ————— tests ————— */
// T1 — clean stylesheet exits 0 // T1 — clean stylesheet exits 0
@@ -355,6 +378,25 @@ writeFileSync(colorEvasions, `/* every flagged declaration must be listed; sanct
`out=${r.out.slice(0, 300)}`); `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 ————— */ /* ————— report ————— */
console.log(`audit.test: ${passes.length} passed, ${failures.length} failed`); console.log(`audit.test: ${passes.length} passed, ${failures.length} failed`);
passes.forEach((p) => console.log(` PASS ${p}`)); passes.forEach((p) => console.log(` PASS ${p}`));