scripts/check-dashboard-scale.mjs
Rama master · réplica de lectura del repositorio original.
#!/usr/bin/env node
/**
* UISpec P2 — dashboard scale gate (account + usage surfaces).
*
* The account page, the add/login modal and the usage panel each grew their own
* px values. This gate pins them to the two token scales in the :root block:
*
* --space-<n> spacing, named by its px value (2px grid)
* --text-* one dense type ladder
* --radius-* the existing radius set (+ --radius-xs for chips/swatches)
*
* It also pins the 44 px row budget: `--acct-row-pad-y` may only appear on the
* accounts table, so a later "cleanup" cannot quietly turn 5 px into 4 or 6 and
* move every row off the height 3.9.50 measured.
*
* Exit 0 = clean. Exported as lintDashboardScale() for the test suite.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { readStyleBlock, parseRules, parseDeclarations, customProperties } from './dashboard-css.mjs';
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
/** Account management, add/login modal, usage panel + their shared primitives. */
export const SCOPE = /(\.acct-|#accounts-|\.fleet-|\.add-modal|\.add-tabs|\.add-tab\b|\.add-panel|\.add-result|\.login-method|\.login-panel|\.login-steps|\.login-advanced|\.login-token-step|\.login-alt-panel|\.method-chip|\.provider-swatch|\.detail-advanced|\.note-chip|\.row-menu|\.tb-group|\.oauth-brand|\.th-sort|\.usage-|#p-accounts)/;
const SPACING_PROPS = new Set([
'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
'gap', 'row-gap', 'column-gap',
]);
/** Values that are allowed without a token, and why. */
function spacingOk(token, selector) {
if (token === '0' || token === 'auto') return true;
if (token === '1px') return true; // hairline (1 px borders/offsets)
if (/^var\(--space-\d+\)$/.test(token)) return true;
if (token === 'var(--acct-row-pad-y)') return /#accounts-table/.test(selector);
if (token.endsWith('%')) return true;
if (token.includes('calc(')) return true; // e.g. calc(-1 * var(--space-4))
return false;
}
function radiusOk(value) {
if (/^var\(--radius(-sm|-lg|-xs)?\)$/.test(value)) return true;
if (value === '999px' || value === '50%' || value === '0') return true;
const px = Number(String(value).replace('px', ''));
return Number.isFinite(px) && px <= 2; // optical radius on a 2–3 px bar
}
function pxNumber(value) {
const m = String(value).match(/^(-?\d+(?:\.\d+)?)px$/);
return m ? Number(m[1]) : null;
}
export function lintDashboardScale(html = readDashboardHtml()) {
const problems = [];
const css = readStyleBlock(html);
// ── the tokens themselves ────────────────────────────────────────────────
const root = customProperties(css, ':root');
const spaces = Object.keys(root).filter((k) => /^--space-\d+$/.test(k));
if (!spaces.length) problems.push('no --space-* tokens defined');
for (const key of spaces) {
const expected = Number(key.replace('--space-', ''));
const actual = pxNumber(root[key]);
if (actual !== expected) problems.push(`${key} must be ${expected}px, found ${root[key]}`);
if (expected % 2 !== 0) problems.push(`${key} is off the 2px grid`);
}
const texts = Object.keys(root).filter((k) => /^--fs-/.test(k));
if (!texts.length) problems.push('no --fs-* tokens defined');
let prev = 0;
for (const key of texts) {
const size = pxNumber(root[key]);
if (size === null) { problems.push(`${key} must be a px size, found ${root[key]}`); continue; }
if (size <= prev) problems.push(`${key} (${size}px) is not larger than the previous step (${prev}px)`);
prev = size;
}
if (root['--acct-row-pad-y'] !== '5px') {
problems.push(`--acct-row-pad-y must stay 5px (44px row budget), found ${root['--acct-row-pad-y']}`);
}
// ── the rules that use them ──────────────────────────────────────────────
for (const { selector, body } of parseRules(css)) {
if (!SCOPE.test(selector)) continue;
for (const { prop, value } of parseDeclarations(body)) {
if (prop === 'font-size') {
if (!/^var\(--fs-[a-z0-9]+\)$/.test(value) && value !== 'inherit') {
problems.push(`${selector} — font-size: ${value} (use var(--fs-*))`);
}
continue;
}
if (SPACING_PROPS.has(prop)) {
if (value.includes('calc(')) continue; // calc() is already token-derived
const tokens = value.split(/\s+/).filter(Boolean);
const bad = tokens.filter((t) => !spacingOk(t, selector));
if (bad.length) problems.push(`${selector} — ${prop}: ${value} (off-scale: ${bad.join(' ')})`);
continue;
}
if (prop === 'border-radius' && !radiusOk(value)) {
problems.push(`${selector} — border-radius: ${value} (use var(--radius-*))`);
}
}
}
// ── the row budget stays on the table ────────────────────────────────────
for (const { selector, body } of parseRules(css)) {
if (!body.includes('var(--acct-row-pad-y)')) continue;
if (/#accounts-table/.test(selector)) continue;
problems.push(`${selector} uses --acct-row-pad-y outside the accounts table`);
}
return { ok: problems.length === 0, problems };
}
function readDashboardHtml() {
return fs.readFileSync(path.join(ROOT, 'src/dashboard/index.html'), 'utf8');
}
const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
if (invokedDirectly) {
const { ok, problems } = lintDashboardScale();
if (ok) {
console.log('✓ dashboard scale: account/usage surfaces use the spacing + type tokens');
process.exit(0);
}
console.error(`✗ dashboard scale: ${problems.length} off-scale value(s)`);
for (const p of problems) console.error(` - ${p}`);
process.exit(1);
}