Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 49 additions & 45 deletions docs/branch-review-ledger.md

Large diffs are not rendered by default.

52 changes: 34 additions & 18 deletions docs/design-system/GATES.md

Large diffs are not rendered by default.

76 changes: 38 additions & 38 deletions docs/outstanding-issues.md

Large diffs are not rendered by default.

72 changes: 72 additions & 0 deletions scripts/check-design-system-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
findDebtPathRegressions,
findInteractiveTapLiteralsInSource,
findTextSoftConsumersInSource,
findTypeStepCssUsagesInSource,
LEGACY_TAP_CLASS,
hasLegacyTapClass,
jsxClassText,
Expand Down Expand Up @@ -120,6 +121,9 @@ const metrics = {
darkColorOverrides: 0,
legacyShadowAliases: 0,
arbitraryTracking: 0,
rawPaddingLiterals: 0,
rawRadiusLiterals: 0,
rawLineHeightLiterals: 0,
layoutTransitionExceptions: 0,
textSoftConsumers: 0,
};
Expand All @@ -129,7 +133,21 @@ const recordDebt = (metric, relativePath, count) => {
if (count > 0) debtByPath[metric][relativePath] = (debtByPath[metric][relativePath] ?? 0) + count;
};

/**
* Type steps that are declared but deliberately unconsumed, with the reason.
*
* Retiring a step edits the `@theme` block, so it belongs in its own revertible
* change rather than riding along with a gate. Anything listed here is recorded
* debt with a ledger row, not a permanent licence — an entry should leave this
* list by being deleted from `globals.css`, not by being forgotten.
*/
const UNUSED_TYPE_STEP_EXEMPTIONS = new Map([
["2xl-compact", "no consumer since it was added; retirement tracked as docs/outstanding-issues.md #297"],
]);

const densityOverrideFindings = [];
const typeStepUsage = new Set();
const typeStepCssUsage = new Set();
const hardcodedMotionClassFindings = [];
const imageInversionFindings = [];
const layoutTransitionFindings = [];
Expand Down Expand Up @@ -176,6 +194,11 @@ for (const file of files) {
recordDebt("darkColorOverrides", file.relativePath, classAnalysis.darkColorOverrides.length);
recordDebt("legacyShadowAliases", file.relativePath, classAnalysis.legacyShadowAliases.length);
recordDebt("arbitraryTracking", file.relativePath, classAnalysis.arbitraryTracking.length);
recordDebt("rawPaddingLiterals", file.relativePath, classAnalysis.rawPaddingLiterals.length);
recordDebt("rawRadiusLiterals", file.relativePath, classAnalysis.rawRadiusLiterals.length);
recordDebt("rawLineHeightLiterals", file.relativePath, classAnalysis.rawLineHeightLiterals.length);
for (const step of classAnalysis.typeStepUsages) typeStepUsage.add(step);
for (const step of findTypeStepCssUsagesInSource(source, file.relativePath)) typeStepCssUsage.add(step);
densityOverrideFindings.push(...classAnalysis.densityOverrides);
hardcodedMotionClassFindings.push(...classAnalysis.hardcodedMotionClasses);
layoutTransitionFindings.push(...classAnalysis.layoutTransitions);
Expand All @@ -186,6 +209,9 @@ for (const file of files) {
recordDebt("hardcodedCssMotionDurations", file.relativePath, cssAnalysis.hardcodedMotionDurations.length);
recordDebt("rawCssZIndices", file.relativePath, cssAnalysis.rawZIndices.length);
recordDebt("legacyShadowAliases", file.relativePath, cssAnalysis.legacyShadowAliases.length);
recordDebt("rawPaddingLiterals", file.relativePath, cssAnalysis.rawPaddingLiterals.length);
recordDebt("rawRadiusLiterals", file.relativePath, cssAnalysis.rawRadiusLiterals.length);
recordDebt("rawLineHeightLiterals", file.relativePath, cssAnalysis.rawLineHeightLiterals.length);
imageInversionFindings.push(...cssAnalysis.imageInversions);
layoutTransitionFindings.push(...cssAnalysis.layoutTransitions);
}
Expand Down Expand Up @@ -335,6 +361,49 @@ assert(

const globals = textAt("src/app/globals.css");
assert(!/^\s*--space-\d+\s*:/m.test(globals), "unused --space-* tokens returned");

// Step SELECTION, the half `check:type-scale` cannot cover. That gate blocks
// arbitrary `text-[12px]` values; nothing has stopped the scale itself growing
// a step no surface ever picks. A declared-but-unconsumed step is the shape of
// that drift which IS mechanically decidable — whether a heading should have
// picked `text-sm` over `text-sm-minus` is not, and no lint here pretends
// otherwise.
const themeBlockStart = globals.indexOf("@theme {");
assert(themeBlockStart >= 0, "globals.css @theme block is missing");
if (themeBlockStart >= 0) {
const themeBlock = globals.slice(themeBlockStart, globals.indexOf("\n}", themeBlockStart));
const declaredTypeSteps = [...themeBlock.matchAll(/^\s*--text-([a-z0-9-]+)\s*:/gm)]
.map((match) => match[1])
// `--text-<step>--line-height` and `--text-<step>-tr` are companions that
// mark and accompany a step; they are not steps and generate no utility.
.filter((step) => !step.includes("--") && !step.endsWith("-tr"));
assert(declaredTypeSteps.length > 0, "no --text-* type steps found in the globals.css @theme block");

const typeStepIsConsumed = (step) => typeStepUsage.has(step) || typeStepCssUsage.has(step);
const unusedTypeSteps = declaredTypeSteps.filter(
(step) => !typeStepIsConsumed(step) && !UNUSED_TYPE_STEP_EXEMPTIONS.has(step),
);
assert(
unusedTypeSteps.length === 0,
`type steps are declared in globals.css @theme but no production surface selects them: ${unusedTypeSteps
.map((step) => `--text-${step} (text-${step})`)
.join(", ")}. Retire the step or use it; do not leave the scale carrying a step nobody picks.`,
);
// An exemption for a step that has since gained a consumer is stale, and a
// stale exemption is how a list like this starts lying to the next reader.
// Class utilities and direct `var(--text-*)` consumers both count — the same
// predicate as the unused-step filter above.
for (const [step, reason] of UNUSED_TYPE_STEP_EXEMPTIONS) {
assert(
declaredTypeSteps.includes(step),
`--text-${step} is exempted as unused but is no longer declared in @theme — drop the exemption (${reason})`,
);
assert(
!typeStepIsConsumed(step),
`--text-${step} is exempted as unused but production now selects text-${step} / var(--text-${step}) — drop the exemption (${reason})`,
);
}
}
const primitives = textAt("src/components/ui-primitives.tsx");
assert(
primitives.includes('export const chatComposerInput = "chat-composer-input"'),
Expand Down Expand Up @@ -406,5 +475,8 @@ console.log(
console.log(
`Status-colour boundary: colour-only status indicators ${metrics.colourOnlyStatusIndicators}; status-coloured numerals ${metrics.statusColouredNumerals}; image inversions ${imageInversionFindings.length}.`,
);
console.log(
`Scale ratchets: raw padding literals ${metrics.rawPaddingLiterals}; raw radius literals ${metrics.rawRadiusLiterals}; raw line-height literals ${metrics.rawLineHeightLiterals}.`,
);
console.log(`Text-role ratchet: --text-soft consumers ${metrics.textSoftConsumers}.`);
console.log(`Raw-color exemptions: ${RAW_COLOR_EXEMPTIONS.map(({ category }) => category).join(", ")}.`);
28 changes: 28 additions & 0 deletions scripts/design-system-contract-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
"darkColorOverrides": 0,
"legacyShadowAliases": 220,
"arbitraryTracking": 0,
"rawPaddingLiterals": 67,
"rawRadiusLiterals": 24,
"rawLineHeightLiterals": 3,
"layoutTransitionExceptions": 12,
"textSoftConsumers": 0
},
Expand Down Expand Up @@ -145,6 +148,31 @@
"src/components/ui-primitives.tsx": 6
},
"arbitraryTracking": {},
"rawPaddingLiterals": {
"src/app/globals.css": 17,
"src/components/clinical-dashboard/document-search-results.tsx": 1,
"src/components/clinical-dashboard/result-filter-control.tsx": 1,
"src/components/clinical-record-panels.tsx": 2,
"src/components/differentials/differential-detail-page.tsx": 1,
"src/components/services/service-detail-page.tsx": 1,
"src/components/therapy-compass/bindings.tsx": 1,
"src/components/therapy-compass/controls.ts": 2,
"src/components/therapy-compass/screens/brief-screen.tsx": 7,
"src/components/therapy-compass/screens/compare-screen.tsx": 5,
"src/components/therapy-compass/screens/detail-screen.tsx": 7,
"src/components/therapy-compass/screens/pathways-screen.tsx": 4,
"src/components/therapy-compass/screens/recommend-screen.tsx": 6,
"src/components/therapy-compass/screens/sheets-screen.tsx": 9,
"src/components/therapy-compass/therapy-card.tsx": 3
},
"rawRadiusLiterals": {
"src/app/globals.css": 22,
"src/components/clinical-dashboard/search-results-header-band.tsx": 1,
"src/components/mode-nav/mode-nav.tsx": 1
},
"rawLineHeightLiterals": {
"src/app/globals.css": 3
},
"layoutTransitionExceptions": {
"src/app/globals.css": 4,
"src/components/calculators/guided-flow.tsx": 1,
Expand Down
152 changes: 152 additions & 0 deletions scripts/design-system-contract-utils.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,69 @@ const INVERSION_FUNCTION = /(?:invert|hue-rotate)\(/;
// the sanctioned token form and is deliberately NOT counted, exactly as
// `text-[color:var(--…)]` is exempt from the type-scale check.
const ARBITRARY_TRACKING_UTILITY = /^tracking-\[(?!var\()[^\]]+\]$/;
/**
* Spacing, radius and line-height written as a bare literal — `px-[22px]`,
* `rounded-[7px]`, `leading-[1.15]` — rather than picked off the scale.
*
* These deliberately exempt any arbitrary value containing a CSS *function*,
* not just `var(`. `tracking-[var(--…)]` above can use the narrower `(?!var\()`
* because letterspacing is only ever a token or a literal, but padding is not:
* production carries `pb-[env(safe-area-inset-bottom)]`,
* `pt-[max(0.75rem,var(--safe-area-top))]`, `pt-[clamp(1.5rem,5vh,3rem)]` and
* `pb-[calc(7rem+env(safe-area-inset-bottom))]`. Those are computed from the
* viewport or the safe-area inset, cannot be spelled as a scale step, and are
* sanctioned. A `(?!var\()` lookahead would flag every one of them, because
* they open with `max(`, `clamp(`, `env(` or `calc(` rather than `var(`.
*
* So the rule is: a value with no function call in it at all is a raw literal.
* That keeps the sanctioned computed forms out without enumerating them.
*/
const RAW_LITERAL_VALUE = String.raw`\[(?![^\]]*\w\()[^\]]+\]`;
const RAW_PADDING_UTILITY = new RegExp(String.raw`^p[xytrbles]?-${RAW_LITERAL_VALUE}$`);
const RAW_RADIUS_UTILITY = new RegExp(
String.raw`^rounded(?:-(?:[trblse]|[tb][lr]|ss|se|ee|es))?-${RAW_LITERAL_VALUE}$`,
);
const RAW_LINE_HEIGHT_UTILITY = new RegExp(String.raw`^leading-${RAW_LITERAL_VALUE}$`);
Comment thread
BigSimmo marked this conversation as resolved.
Comment thread
BigSimmo marked this conversation as resolved.
/**
* The CSS-declaration half of the same three rules, so a literal cannot simply
* move from a class into `globals.css` to escape the ratchet — the same reason
* `legacyShadowAliases` and the colour ratchet count both sides.
*
* `0` in any unit carries no design decision and is exempt, as are the CSS-wide
* keywords and `line-height: normal`. The zero matcher takes any CSS unit
* identifier (`0dvh`, `0svw`, `0cqw`, `0lh`, …), not a finite allowlist — a
* closed list falsely counted those as raw debt.
*
* Tailwind's arbitrary-property spelling (`[padding:22px]`,
* `[border-radius:7px]`, `[line-height:1.35]`) reaches the same properties as
* the named utilities above and is counted with the same raw-literal predicate,
* or the ratchet could be bypassed by changing syntax alone.
*/
const RAW_PADDING_PROPERTY = /^padding(?:-(?:top|right|bottom|left|inline|block)(?:-(?:start|end))?)?$/;
const RAW_RADIUS_PROPERTY = /^border(?:-(?:top|bottom)-(?:left|right)|-(?:start|end)-(?:start|end))?-radius$/;
const CSS_WIDE_KEYWORD = /^(?:inherit|initial|unset|revert|revert-layer|normal|auto)$/;
const CSS_ZERO_VALUE = /^-?0(?:\.0+)?(?:[a-z%]+)?$/i;
const ARBITRARY_PROPERTY_UTILITY = /^\[([a-z-]+):([^\]]+)\]$/i;

function isRawScaleLiteralValue(value) {
const trimmed = value.trim();
if (!trimmed || /\w\(/.test(trimmed) || CSS_WIDE_KEYWORD.test(trimmed)) return false;
return !trimmed.split(/\s+/).every((part) => CSS_ZERO_VALUE.test(part));
}

function recordRawScaleLiteralProperty(result, relativePath, line, prop, value, token) {
if (!isRawScaleLiteralValue(value)) return;
const label = token ?? `${prop}: ${value}`;
if (RAW_PADDING_PROPERTY.test(prop)) {
result.rawPaddingLiterals.push(`${relativePath}:${line} (${label})`);
}
if (RAW_RADIUS_PROPERTY.test(prop)) {
result.rawRadiusLiterals.push(`${relativePath}:${line} (${label})`);
}
if (prop === "line-height") {
result.rawLineHeightLiterals.push(`${relativePath}:${line} (${label})`);
}
}
const LEGACY_SHADOW_ALIAS = /var\(--shadow-(?:tight|card|soft|hover|elevated|lux|lift)\)/g;
const LEGACY_PALETTE_UTILITY =
/^(?:bg|text|border|ring|outline|fill|stroke|placeholder|from|via|to)-(?:white|black|(?:slate|gray|zinc|neutral|stone)-\d{2,3})(?:\/\d{1,3})?$/;
Expand Down Expand Up @@ -974,7 +1037,11 @@ export function analyzeClassContractsInSource(relativePath, sourceText) {
legacyTapClasses: [],
legacyPaletteUtilities: [],
literalShadowClasses: [],
rawLineHeightLiterals: [],
rawPaddingLiterals: [],
rawRadiusLiterals: [],
statusColouredNumerals: [],
typeStepUsages: [],
unapprovedZIndices: [],
};
if (!analyzer) return result;
Expand Down Expand Up @@ -1057,6 +1124,27 @@ export function analyzeClassContractsInSource(relativePath, sourceText) {
}
if (LITERAL_SHADOW_UTILITY.test(base)) result.literalShadowClasses.push(`${relativePath}:${line} (${token})`);
if (ARBITRARY_TRACKING_UTILITY.test(base)) result.arbitraryTracking.push(`${relativePath}:${line} (${token})`);
if (RAW_PADDING_UTILITY.test(base)) result.rawPaddingLiterals.push(`${relativePath}:${line} (${token})`);
if (RAW_RADIUS_UTILITY.test(base)) result.rawRadiusLiterals.push(`${relativePath}:${line} (${token})`);
if (RAW_LINE_HEIGHT_UTILITY.test(base)) result.rawLineHeightLiterals.push(`${relativePath}:${line} (${token})`);
const arbitraryProperty = base.match(ARBITRARY_PROPERTY_UTILITY);
if (arbitraryProperty) {
recordRawScaleLiteralProperty(
result,
relativePath,
line,
arbitraryProperty[1].toLowerCase(),
arbitraryProperty[2],
token,
);
}
// Every bare `text-<name>` this file uses, whatever `<name>` turns out to
// mean. The caller decides which of these are type steps by reading the
// `@theme` block, so the scale is never spelled out twice — writing the
// step list here would make this module a second source of truth that
// globals.css could drift away from silently.
const bareTextUtility = base.match(/^text-([a-z0-9][a-z0-9-]*)$/);
if (bareTextUtility) result.typeStepUsages.push(bareTextUtility[1]);
if (hasLegacyTapClass(token)) result.legacyTapClasses.push(`${relativePath}:${line} (${token})`);
for (const match of token.matchAll(LEGACY_SHADOW_ALIAS)) {
result.legacyShadowAliases.push(`${relativePath}:${line} (${match[0]})`);
Expand Down Expand Up @@ -1186,11 +1274,19 @@ export function analyzeCssContractsInSource(relativePath, sourceText) {
layoutTransitions: [],
legacyShadowAliases: [],
onePixelShadowSpreads: [],
rawLineHeightLiterals: [],
rawPaddingLiterals: [],
rawRadiusLiterals: [],
rawZIndices: [],
};
for (const declaration of cssDeclarations(sourceText)) {
const line = declaration.source?.start?.line ?? 1;
const prop = declaration.prop.toLowerCase();
// Custom-property declarations are the token definitions themselves — the
// scale has to be written down somewhere — so only real properties count.
if (!prop.startsWith("--")) {
recordRawScaleLiteralProperty(result, relativePath, line, prop, declaration.value);
}
if (/^(?:-webkit-)?(?:backdrop-)?filter$/.test(prop)) {
for (const match of declaration.value.matchAll(/\b(invert|hue-rotate)\(/g)) {
result.imageInversions.push(`${relativePath}:${line} (${prop}: ${match[1]}())`);
Expand Down Expand Up @@ -1237,6 +1333,62 @@ export function countRawCssZIndicesInSource(sourceText) {
return analyzeCssContractsInSource("source.css", sourceText).rawZIndices.length;
}

export function findRawScaleLiteralClassesInSource(relativePath, sourceText) {
const analysis = analyzeClassContractsInSource(relativePath, sourceText);
return {
padding: analysis.rawPaddingLiterals,
radius: analysis.rawRadiusLiterals,
lineHeight: analysis.rawLineHeightLiterals,
};
}

export function findTypeStepUsagesInSource(relativePath, sourceText) {
return analyzeClassContractsInSource(relativePath, sourceText).typeStepUsages;
}

/**
* Direct `var(--text-<step>)` consumers in any production source. The unused-step
* gate and its exemption anti-rot check must share this predicate — a class-only
* check lets an exemption survive once a CSS consumer appears.
*
* CSS sources are walked declaration-by-declaration so a quoted `content:`
* string cannot fake a consumer. Non-CSS sources strip comments first, then
* match `var(--text-*)` in remaining text (covers inline style strings).
*/
export function findTypeStepCssUsagesInSource(sourceText, relativePath = "source.css") {
const steps = [];
const record = (step) => {
if (!step.includes("--") && !step.endsWith("-tr")) steps.push(step);
};

if (relativePath.endsWith(".css")) {
for (const declaration of cssDeclarations(sourceText)) {
if (declaration.prop.startsWith("--")) continue;
// Drop CSS string tokens so `content:"var(--text-…)"` cannot count.
const value = declaration.value.replace(/"(?:\\.|[^"\\])*"/g, '""').replace(/'(?:\\.|[^'\\])*'/g, "''");
for (const match of value.matchAll(/var\(\s*--text-([a-z0-9-]+)\s*[,)]/g)) {
record(match[1]);
}
}
return steps;
}

const withoutComments = sourceText.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
for (const match of withoutComments.matchAll(/var\(\s*--text-([a-z0-9-]+)\s*[,)]/g)) {
record(match[1]);
}
return steps;
}

export function findRawScaleLiteralDeclarationsInSource(sourceText) {
const analysis = analyzeCssContractsInSource("source.css", sourceText);
return {
padding: analysis.rawPaddingLiterals,
radius: analysis.rawRadiusLiterals,
lineHeight: analysis.rawLineHeightLiterals,
};
}

export function findDebtPathRegressions(metric, currentByPath, baselineByPath) {
return Object.entries(currentByPath)
.filter(([relativePath, count]) => count > (baselineByPath?.[relativePath] ?? 0))
Expand Down
Loading
Loading