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:
+1
-1
@@ -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>
|
||||
<div class="evidence">
|
||||
<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>
|
||||
</header>
|
||||
|
||||
|
||||
+120
-6
@@ -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);
|
||||
|
||||
@@ -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}`));
|
||||
|
||||
Reference in New Issue
Block a user