diff --git a/scripts/check-design-system-contract.mjs b/scripts/check-design-system-contract.mjs index 41b879033..5fb468719 100644 --- a/scripts/check-design-system-contract.mjs +++ b/scripts/check-design-system-contract.mjs @@ -25,6 +25,7 @@ const SOURCE_EXTENSIONS = new Set([".css", ".ts", ".tsx"]); const RAW_COLOR = /#[0-9a-f]{3,8}\b|\b(?:rgb|rgba|hsl|hsla|oklch)\(/gi; /** Whole-file backstop for literal shadow utilities the AST class-root pass can miss. */ const LITERAL_SHADOW_TEXT = /(?:^|[\s"'`])shadow-\[(?!var\()[^\]]+\]/g; +const ARBITRARY_TRACKING_TEXT = /(?:^|[\s"'`])tracking-\[(?!var\()[^\]]+\]/g; const CUSTOM_CONTROL_CLASS_PROP = /(?:closeButtonClassName|sheetCloseButtonClassName|buttonClassName|triggerClassName)\s*=\s*(?:"([^"]*)"|`([^`]*)`)/g; @@ -116,6 +117,7 @@ const metrics = { legacyPaletteUtilities: 0, darkColorOverrides: 0, legacyShadowAliases: 0, + arbitraryTracking: 0, layoutTransitionExceptions: 0, textSoftConsumers: 0, }; @@ -149,6 +151,7 @@ for (const file of files) { const classTextSource = withoutComments(source); const textLegacyTap = countMatches(classTextSource, LEGACY_TAP_CLASS); const textLiteralShadow = countMatches(classTextSource, LITERAL_SHADOW_TEXT); + const textArbitraryTracking = countMatches(classTextSource, ARBITRARY_TRACKING_TEXT); assert( classAnalysis.legacyTapClasses.length >= textLegacyTap, `${file.relativePath} has ${textLegacyTap} legacy tap class text match(es) but the AST class-root pass only saw ${classAnalysis.legacyTapClasses.length}`, @@ -157,11 +160,16 @@ for (const file of files) { classAnalysis.literalShadowClasses.length >= textLiteralShadow, `${file.relativePath} has ${textLiteralShadow} literal shadow class text match(es) but the AST class-root pass only saw ${classAnalysis.literalShadowClasses.length}`, ); + assert( + classAnalysis.arbitraryTracking.length >= textArbitraryTracking, + `${file.relativePath} has ${textArbitraryTracking} arbitrary tracking text match(es) but the AST class-root pass only saw ${classAnalysis.arbitraryTracking.length}`, + ); const fileEdgeFindings = classAnalysis.edgeOwnershipConflicts; recordDebt("edgeOwnershipConflicts", file.relativePath, fileEdgeFindings.length); recordDebt("legacyPaletteUtilities", file.relativePath, classAnalysis.legacyPaletteUtilities.length); recordDebt("darkColorOverrides", file.relativePath, classAnalysis.darkColorOverrides.length); recordDebt("legacyShadowAliases", file.relativePath, classAnalysis.legacyShadowAliases.length); + recordDebt("arbitraryTracking", file.relativePath, classAnalysis.arbitraryTracking.length); densityOverrideFindings.push(...classAnalysis.densityOverrides); hardcodedMotionClassFindings.push(...classAnalysis.hardcodedMotionClasses); layoutTransitionFindings.push(...classAnalysis.layoutTransitions); @@ -374,7 +382,7 @@ console.log( `Design-system contract passed (${files.length} production files; raw colors ${metrics.rawColorLiterals}; literal shadows ${metrics.literalShadowClasses}; legacy tap classes ${metrics.legacyTapClasses}; edge conflicts ${metrics.edgeOwnershipConflicts}; 1px shadow spreads ${metrics.onePixelShadowSpreads}).`, ); console.log( - `Motion/z/palette ratchets: hardcoded CSS durations ${metrics.hardcodedCssMotionDurations}; layout transitions ${metrics.layoutTransitionExceptions}; raw CSS z-index ${metrics.rawCssZIndices}; legacy palette utilities ${metrics.legacyPaletteUtilities}; dark color overrides ${metrics.darkColorOverrides}; legacy shadow aliases ${metrics.legacyShadowAliases}.`, + `Motion/z/palette ratchets: hardcoded CSS durations ${metrics.hardcodedCssMotionDurations}; layout transitions ${metrics.layoutTransitionExceptions}; raw CSS z-index ${metrics.rawCssZIndices}; legacy palette utilities ${metrics.legacyPaletteUtilities}; dark color overrides ${metrics.darkColorOverrides}; legacy shadow aliases ${metrics.legacyShadowAliases}; arbitrary tracking ${metrics.arbitraryTracking}.`, ); console.log(`Text-role ratchet: --text-soft consumers ${metrics.textSoftConsumers}.`); console.log(`Raw-color exemptions: ${RAW_COLOR_EXEMPTIONS.map(({ category }) => category).join(", ")}.`); diff --git a/scripts/design-system-contract-baseline.json b/scripts/design-system-contract-baseline.json index 79857b103..e95303ff0 100644 --- a/scripts/design-system-contract-baseline.json +++ b/scripts/design-system-contract-baseline.json @@ -10,6 +10,7 @@ "legacyPaletteUtilities": 2, "darkColorOverrides": 3, "legacyShadowAliases": 231, + "arbitraryTracking": 68, "layoutTransitionExceptions": 12, "textSoftConsumers": 0 }, @@ -147,6 +148,48 @@ "src/components/ui/tooltip.tsx": 1, "src/components/ui-primitives.tsx": 6 }, + "arbitraryTracking": { + "src/app/reference/colour-coding/page.tsx": 1, + "src/components/AccessibleTable.tsx": 2, + "src/components/calculators/bedside-sheet.tsx": 1, + "src/components/calculators/clinical-console.tsx": 1, + "src/components/calculators/directory-grid.tsx": 3, + "src/components/calculators/guided-flow.tsx": 4, + "src/components/calculators/search-detail.tsx": 4, + "src/components/calculators/search-page.tsx": 1, + "src/components/clinical-dashboard/account-setup-dialog.tsx": 1, + "src/components/clinical-dashboard/cross-mode-links.tsx": 1, + "src/components/clinical-dashboard/differentials-home.tsx": 3, + "src/components/clinical-dashboard/document-results.tsx": 1, + "src/components/clinical-dashboard/document-search-results.tsx": 2, + "src/components/clinical-dashboard/evidence-panels.tsx": 1, + "src/components/clinical-dashboard/favourites-command-library-page.tsx": 1, + "src/components/clinical-dashboard/library-health-strip.tsx": 1, + "src/components/clinical-dashboard/medication-prescribing-workspace.tsx": 1, + "src/components/clinical-dashboard/output-panel.tsx": 2, + "src/components/clinical-dashboard/search-pins-menu.tsx": 1, + "src/components/clinical-dashboard/settings-dialog.tsx": 1, + "src/components/clinical-dashboard/universal-search-command-surface.tsx": 1, + "src/components/ClinicalDashboard.tsx": 1, + "src/components/differentials/differential-detail-page.tsx": 1, + "src/components/differentials/differential-presentation-workflow-page.tsx": 1, + "src/components/document-viewer/document-clinical-summary.tsx": 3, + "src/components/document-viewer/document-overview-landing.tsx": 1, + "src/components/document-viewer/section-nav.tsx": 2, + "src/components/document-viewer/source-panels.tsx": 5, + "src/components/dsm/dsm-comparison-page.tsx": 1, + "src/components/factsheets/factsheet-detail-page.tsx": 6, + "src/components/factsheets/factsheets-search-page.tsx": 2, + "src/components/mode-nav/mode-nav.tsx": 1, + "src/components/patient-safety-plan.tsx": 2, + "src/components/pwa-lifecycle.tsx": 1, + "src/components/services/services-navigator-page.tsx": 2, + "src/components/specifiers/specifier-ui.tsx": 1, + "src/components/therapy-compass/screens/recommend-screen.tsx": 1, + "src/components/therapy-compass/screens/sheets-screen.tsx": 1, + "src/components/therapy-compass/therapy-card.tsx": 1, + "src/components/ui-primitives.tsx": 1 + }, "layoutTransitionExceptions": { "src/app/globals.css": 4, "src/components/calculators/guided-flow.tsx": 1, diff --git a/scripts/design-system-contract-utils.mjs b/scripts/design-system-contract-utils.mjs index 7a44da045..07122ac0e 100644 --- a/scripts/design-system-contract-utils.mjs +++ b/scripts/design-system-contract-utils.mjs @@ -201,6 +201,12 @@ const DENSITY_HEIGHT_UTILITY = /^(?:h|min-h|max-h|size)-/; const DENSITY_TEXT_UTILITY = /^text-(?:2xs|xs|sm-minus|sm|base|lg|xl|[2-9]xl|\[[^\]]*(?:px|rem|em|clamp\()[^\]]*\])$/; const HARDCODED_MOTION_UTILITY = /^(?:duration|delay)-(?:\d+|\[(?!var\(--duration-)[^\]]+\])$/; const LITERAL_SHADOW_UTILITY = /^shadow-\[(?!var\()[^\]]+\]$/; +// Same shape as the literal-shadow ratchet, for the same reason: 371 arbitrary +// letterspacing values across 31 distinct spellings, seven of them positive steps +// between 0.04 and 0.16em that no reader can tell apart. `tracking-[var(--…)]` is +// 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\()[^\]]+\]$/; 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})?$/; @@ -660,6 +666,7 @@ function uniqueTokenEntries(possibilities) { export function analyzeClassContractsInSource(relativePath, sourceText) { const analyzer = classExpressionAnalyzer(relativePath, sourceText); const result = { + arbitraryTracking: [], darkColorOverrides: [], densityOverrides: [], edgeOwnershipConflicts: [], @@ -709,6 +716,7 @@ export function analyzeClassContractsInSource(relativePath, sourceText) { result.hardcodedMotionClasses.push(`${relativePath}:${line} (${token})`); } 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 (hasLegacyTapClass(token)) result.legacyTapClasses.push(`${relativePath}:${line} (${token})`); for (const match of token.matchAll(LEGACY_SHADOW_ALIAS)) { result.legacyShadowAliases.push(`${relativePath}:${line} (${match[0]})`); diff --git a/src/app/globals.css b/src/app/globals.css index b12c04daa..08b70a5e2 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -133,10 +133,46 @@ --leading-display: 1.05; --leading-prose: 1.6; - /* Uppercase eyebrow tracking. 0.08em was spelled as `tracking-[0.08em]` at 60+ - call sites; this makes it a named utility (`tracking-eyebrow`) with the - identical computed value. */ + /* ---- Letterspacing scale ------------------------------------------------- + Measured 7 Aug 2026, production only (`*-mockups.tsx` and `mockups/` are + design scratch and gate-exempt): 110 arbitrary `tracking-[…]` call sites + across 18 distinct values, against one named token. Counting mockups gives + 372/31 — that is the number to quote about the *sprawl*, not about the debt + a gate can act on. + + The real defect is sharper than sprawl. 57 of the 110 sites are a single + value, 0.06em — and `--tracking-eyebrow` is declared at 0.08em, so the one + named token does not match the value its own call sites overwhelmingly use. + That is why it has almost no consumers: the token was named for a number + nobody writes. + + Named roles, with the measured production counts each one absorbs: + + --tracking-display -0.02em folds: -0.008 (1), -0.01 (3), -0.035 (1), -0.04 (1) + --tracking-normal 0 folds: 0.01 (1), 0.02 (2) + --tracking-label 0.06em folds: 0.04 (3), 0.05 (6), 0.06 (57) <- the mass + --tracking-eyebrow 0.08em folds: 0.08 (8), 0.09 (1) [pre-existing, value unchanged] + --tracking-kicker 0.12em folds: 0.1 (7), 0.11 (1), 0.12 (14), 0.16 (1) + + Five roles, not seven: production does not justify a second wide step or a + separate tight-display step — the negative cluster is six sites total. No + fold moves a call site by more than 0.04em, and most by 0.01–0.02em. + + `--tracking-eyebrow` keeps 0.08em deliberately. Retuning it to 0.06em would + silently move every existing consumer of the token; that is a different + change from naming the scale, and wants its own measured step. + + The `arbitraryTracking` contract ratchet holds the family at its current + per-path count (68 statically-resolvable sites across 40 files) so it cannot + regrow while the call-site migration waits on the open answer-shell and + doc-truth PRs. `tracking-[var(--…)]` is the sanctioned token form and is not + counted, exactly as `text-[color:var(--…)]` is exempt from the type-scale + check. */ + --tracking-display: -0.02em; + --tracking-normal: 0em; + --tracking-label: 0.06em; --tracking-eyebrow: 0.08em; + --tracking-kicker: 0.12em; /* Font families: bind Tailwind's font-sans / font-mono to the loaded Geist faces (variables set on by next/font). font-mono is used for