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
+120 -6
View File
@@ -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);