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
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
| 2026-08-15 | claude/ds-gates-265 | a1e5c9e9926c59ea9a0a1875ca2ea5ba49c064f2 | review-and-fix | Fixed two P2 gate bypasses: comparable arbitrary min-heights and reachable conditional/composed branches now fail below 48px; merged latest main | focused Vitest 36/36; design-system contract; format:changed; changed-file ESLint; source typecheck; gate-manifest; outstanding-issues and ledger guards |
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
| 2026-08-15 | claude/ds-gates-265 | 6de09c409645cd854438c819250aa668b661568e | DS gate 2: interactiveTapFloorDeclarations ratchet closing the h-10 case, plus GATES.md figure corrections (#265) | Gate 2 closed for new use; gate 8 stopped deliberately with the reason recorded; gate 7 untouched | verify:pr-local all 17 selected gates passed — unit suite 607 files / 6585 passed, 4 skipped; check:design-system-contract mutation-verified (interactiveTapFloorDeclarations increased from 41 to 42 plus the per-path line); check:gate-manifest OK at 35 gates / 32 static; no Chromium available (chromium-1194 vs pinned 1234, #255/#312) so no browser gate was claimed |
82 changes: 41 additions & 41 deletions docs/design-system/GATES.md

Large diffs are not rendered by default.

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
{
"version": 1,
"id": "8c133c4e-23e7-427f-b1b8-6d6a64dfd381",
"createdOn": "2026-08-15",
"action": "update",
"payload": {
"id": "#265",
"detail": "GATE 2 CLOSED 2026-08-15 by PR #1984; gates 7 and 8 remain, and gate 8 is materially larger than this row said. Corrections first, because two figures here were stale: the debt for gate 8 is 25 conflicts across 12 files, not 27 across 15 — PR #1942 paid three files down without updating the prose, and both GATES.md section 3 and scripts/design-system-contract-baseline.json already said 25, which outranks the prose per docs/design-system/README.md. GATES.md carries 9 implemented-partial rows, not 10; the tenth grep hit is the legend. And #293 is resolved: commit 885c613 (PR #1962) IS on main — PR #1977 is already open to close that row, so do not queue another. GATE 2, what closed it: the row named two gaps, the rendered enumeration and the fixed-height h-10 case. The first landed in #1962. The second is closed by a new interactiveTapFloorDeclarations metric in check:design-system-contract — an interactive element (a, button, input, select, summary, textarea) declaring its OWN unprefixed min-h-* below the 48px token, ratcheted at 43 across 17 files with per-path pins; comparable arbitrary lengths and mutually exclusive/composed branches are evaluated independently, so a new sub-floor control anywhere in src/** fails today. Mutation-verified: lowering one shortlist button to min-h-9 produced both the total and matching per-path regression. Scoped to min-h-* and NOT h-*/size-* on purpose: a short h-4 on an interactive element is routinely the visible box of a control whose hit area belongs to a tap-sized wrapper (SelectionCheckbox in differentials-home.tsx, whose label ui-smoke asserts still meets the floor), and flagging those would pad the baseline with non-defects — the exact section 5 failure mode. One remaining limit recorded rather than hidden: the walker sees intrinsic lowercase tags only, so a floor on <Link> or another component wrapper is invisible to it (the pre-existing legacyTapClasses check shares that blind spot). The 43 recorded sites are real debt still owed. Also tightened legacyShadowAliases 119 to its measured 118 — one unit of stale slack found while working #163. GATE 8 — STOPPED DELIBERATELY, and the reason matters for whoever picks it up. The 25 conflicts are not 25 mechanical one-line edits. Inspected every site: the large majority are focus:ring-4 focus indicators co-existing with a border on inputs, selects and textareas (master-search-header 5, formulation-builder 3, and singles across formulation-compare, dashboard-nav, favourites, specifiers, DocumentTagCloud, ui-primitives), and only pwa-lifecycle's shared cardClassName is the decorative persistent double edge the rule was written for — one recipe counted 5 times. Retiring the focus-ring class means restyling focus indicators from ring to outline across roughly 17 controls, which is an accessibility-visible change needing focus-state proof in a browser. Three further constraints: this container cannot run Chromium (ships chromium-1194 against a pinned 1234, #255/#312) so that proof was unavailable; 8 of the 25 sit in files that open PRs #1976, #1982 and #1983 are editing; and pinning at zero without first widening the onePixelShadowSpreads property filter (design-system-contract-utils.mjs:1320 matches --e[0-4] and --shadow-* but not --glow-primary, --glow-soft or --ring-hairline, all of which carry 0 0 0 1px) would be a partly false close. Next for gate 8: decide the focus-indicator question first as a design-system ruling (ring vs outline for focus), then widen the spread filter, then retire and pin — with browser focus proof. GATE 7 untouched, unchanged from this row's description: no child/parent elevation check exists and no shared render-tree traversal helper exists to build one on; every spec inlines its own page.evaluate walk. The template is #1962's determinism scaffolding plus a computed-box-shadow to --e0..--e4 tier lookup built with the probe technique at ui-style-contract.spec.ts:265-289.",
"source": "session 2026-08-15; PR #1984; GATES.md; scripts/design-system-contract-baseline.json"
}
}
10 changes: 9 additions & 1 deletion scripts/check-design-system-contract.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,6 +10,7 @@ import {
findDebtPathRegressions,
findErrorStateCountPropsInSource,
findFailedStateResultCountsInSource,
findInteractiveTapFloorDeclarationsInSource,
findInteractiveTapLiteralsInSource,
findTextSoftConsumersInSource,
findTypeStepCssUsagesInSource,
Expand DownExpand Up@@ -65,6 +66,11 @@ function findInteractiveTapLiterals(file) {
return findInteractiveTapLiteralsInSource(file.relativePath, sourceText);
}

function findInteractiveTapFloorDeclarations(file) {
const sourceText = fs.readFileSync(file.absolutePath, "utf8");
return findInteractiveTapFloorDeclarationsInSource(file.relativePath, sourceText);
}

function findTherapyButtonsWithoutBaseClass(file) {
if (!file.relativePath.startsWith("src/components/therapy-compass/") || !file.relativePath.endsWith(".tsx"))
return [];
Expand DownExpand Up@@ -113,6 +119,7 @@ const metrics = {
rawColorLiterals: 0,
literalShadowClasses: 0,
legacyTapClasses: 0,
interactiveTapFloorDeclarations: 0,
colourOnlyStatusIndicators: 0,
statusColouredNumerals: 0,
edgeOwnershipConflicts: 0,
Expand DownExpand Up@@ -184,6 +191,7 @@ for (const file of files) {
const classAnalysis = analyzeClassContractsInSource(file.relativePath, source);
recordDebt("literalShadowClasses", file.relativePath, classAnalysis.literalShadowClasses.length);
recordDebt("legacyTapClasses", file.relativePath, classAnalysis.legacyTapClasses.length);
recordDebt("interactiveTapFloorDeclarations", file.relativePath, findInteractiveTapFloorDeclarations(file).length);
// Fail closed when a whole-file text scan finds debt the AST class-root pass
// cannot see (unresolved identifiers, odd expression shapes). Baselines are 0,
// so any miss would otherwise silently weaken the ratchet.
Expand DownExpand Up@@ -487,7 +495,7 @@ if (failures.length > 0) {
}

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}).`,
`Design-system contract passed (${files.length} production files; raw colors ${metrics.rawColorLiterals}; literal shadows ${metrics.literalShadowClasses}; legacy tap classes ${metrics.legacyTapClasses}; sub-floor interactive min-heights ${metrics.interactiveTapFloorDeclarations}; 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}; arbitrary tracking ${metrics.arbitraryTracking}.`,
Expand Down
23 changes: 21 additions & 2 deletions scripts/design-system-contract-baseline.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -3,6 +3,7 @@
"rawColorLiterals": 2,
"literalShadowClasses": 0,
"legacyTapClasses": 0,
"interactiveTapFloorDeclarations": 43,
"colourOnlyStatusIndicators": 4,
"statusColouredNumerals": 2,
"edgeOwnershipConflicts": 25,
Expand All@@ -11,7 +12,7 @@
"rawCssZIndices": 9,
"legacyPaletteUtilities": 0,
"darkColorOverrides": 0,
"legacyShadowAliases": 119,
"legacyShadowAliases": 118,
"arbitraryTracking": 0,
"rawPaddingLiterals": 63,
"rawRadiusLiterals": 24,
Expand All@@ -29,6 +30,25 @@
},
"literalShadowClasses": {},
"legacyTapClasses": {},
"interactiveTapFloorDeclarations": {
"src/components/DocumentTagCloud.tsx": 2,
"src/components/calculators/bedside-sheet.tsx": 3,
"src/components/calculators/calculator-ui.tsx": 3,
"src/components/calculators/clinical-console.tsx": 2,
"src/components/calculators/directory-grid.tsx": 1,
"src/components/calculators/search-page.tsx": 3,
"src/components/clinical-dashboard/DocumentManagerPanel.tsx": 6,
"src/components/clinical-dashboard/answer-content.tsx": 1,
"src/components/clinical-dashboard/document-admin.tsx": 5,
"src/components/clinical-dashboard/favourites-command-library-page.tsx": 1,
"src/components/clinical-dashboard/favourites-hub.tsx": 2,
"src/components/clinical-dashboard/search-results-header-band.tsx": 1,
"src/components/clinical-dashboard/settings-dialog.tsx": 3,
"src/components/forms/form-detail-page.tsx": 3,
"src/components/forms/forms-search-results-page.tsx": 2,
"src/components/patient-safety-plan.tsx": 4,
"src/components/ui/chip.tsx": 1
},
"colourOnlyStatusIndicators": {
"src/components/calculators/calculator-ui.tsx": 1,
"src/components/therapy-compass/ui.tsx": 1,
Expand DownExpand Up@@ -77,7 +97,6 @@
"src/components/calculators/search-detail.tsx": 8,
"src/components/calculators/search-page.tsx": 2,
"src/components/clinical-dashboard/ClinicalSidebar.tsx": 2,
"src/components/clinical-dashboard/account-setup-dialog.tsx": 1,
"src/components/clinical-dashboard/auth-panel.tsx": 1,
"src/components/clinical-dashboard/dashboard-nav.tsx": 2,
"src/components/clinical-dashboard/differentials-home.tsx": 2,
Expand Down
147 changes: 147 additions & 0 deletions scripts/design-system-contract-utils.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -195,6 +195,153 @@ export function findInteractiveTapLiteralsInSource(relativePath, sourceText) {
return findings;
}

/**
* Gate 2's remaining named case: an interactive control that declares its OWN
* minimum height below the 48px tap floor.
*
* Scoped to `min-h-*` deliberately, and NOT to `h-*`/`size-*`. A short `h-4` on
* an interactive element is frequently the *visible* box of a control whose hit
* area is owned by a tap-sized wrapper — `SelectionCheckbox` in
* `differentials-home.tsx` is exactly that, and `ui-smoke` asserts the label
* around it still meets the floor. Flagging those would pad the baseline with
* findings that are not defects, which GATES.md §5 calls out as the way a gate
* gets switched off. `min-h-*` carries no such ambiguity: it is the element's
* own declared floor, so a value under the token is a lowered tap target by
* construction.
*
* Unprefixed only. `min-h-12 sm:min-h-10` is the repo's correct pattern — 48px
* on phones, 40px from `sm` up — so a variant-prefixed short value is a
* deliberate desktop release, not a violation. An unprefixed `min-h-tap` or
* `min-h-12`+ on the same element rescues it.
*/
const TAP_FLOOR_INTERACTIVE_TAGS = new Set(["a", "button", "input", "select", "summary", "textarea"]);
const MAX_CLASS_ALTERNATIVES = 128;

function combineClassAlternatives(left, right) {
const combined = [];
for (const first of left) {
for (const second of right) {
combined.push(`${first} ${second}`.trim());
if (combined.length >= MAX_CLASS_ALTERNATIVES) return [...new Set(combined)];
}
}
return [...new Set(combined)];
}

function classExpressionAlternatives(node) {
if (ts.isStringLiteralLike(node) || ts.isNoSubstitutionTemplateLiteral(node)) return [node.text];
if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isNonNullExpression(node)) {
return classExpressionAlternatives(node.expression);
}
if (ts.isSatisfiesExpression?.(node)) return classExpressionAlternatives(node.expression);
if (ts.isConditionalExpression(node)) {
return [
...new Set([...classExpressionAlternatives(node.whenTrue), ...classExpressionAlternatives(node.whenFalse)]),
];
}
if (ts.isBinaryExpression(node)) {
if (node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) {
return [...new Set(["", ...classExpressionAlternatives(node.right)])];
}
if (
node.operatorToken.kind === ts.SyntaxKind.BarBarToken ||
node.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken
) {
return [...new Set([...classExpressionAlternatives(node.left), ...classExpressionAlternatives(node.right)])];
}
if (node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
return combineClassAlternatives(classExpressionAlternatives(node.left), classExpressionAlternatives(node.right));
}
return [""];
}
if (ts.isTemplateExpression(node)) {
let alternatives = [node.head.text];
for (const span of node.templateSpans) {
alternatives = combineClassAlternatives(alternatives, classExpressionAlternatives(span.expression));
alternatives = alternatives.map((value) => `${value}${span.literal.text}`);
}
return alternatives;
}
if (ts.isCallExpression(node) || ts.isArrayLiteralExpression(node)) {
const values = ts.isCallExpression(node) ? node.arguments : node.elements;
return values.reduce(
(alternatives, value) => combineClassAlternatives(alternatives, classExpressionAlternatives(value)),
[""],
);
}
if (ts.isObjectLiteralExpression(node)) {
return node.properties.reduce(
(alternatives, property) => {
if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property)) return alternatives;
const name = property.name;
const className =
name && (ts.isStringLiteralLike(name) || ts.isIdentifier(name) || ts.isNumericLiteral(name)) ? name.text : "";
return className ? combineClassAlternatives(alternatives, ["", className]) : alternatives;
},
[""],
);
}
return [""];
}

function jsxClassAlternatives(attribute) {
const initializer = attribute.initializer;
if (!initializer) return [];
if (ts.isStringLiteral(initializer)) return [initializer.text];
if (!ts.isJsxExpression(initializer) || !initializer.expression) return [];
return classExpressionAlternatives(initializer.expression);
}

function minHeightPixels(token) {
const normalized = token.replace(/^!/, "");
if (normalized === "min-h-tap") return 48;
if (normalized === "min-h-px") return 1;
const spacing = normalized.match(/^min-h-(\d+(?:\.\d+)?)$/);
if (spacing) return Number(spacing[1]) * 4;
const arbitrary = normalized.match(/^min-h-\[(-?\d+(?:\.\d+)?)(px|rem)\]$/);
if (!arbitrary) return null;
const value = Number(arbitrary[1]);
return arbitrary[2] === "rem" ? value * 16 : value;
}

function hasSubFloorEffectiveMinHeight(classText) {
const minHeightTokens = classText
.split(/\s+/)
.filter((token) => token && !token.includes(":"))
.map((token) => token.replace(/^!/, ""))
.filter((token) => token.startsWith("min-h-"));
if (minHeightTokens.length === 0) return false;
const pixels = minHeightPixels(minHeightTokens.at(-1));
return pixels !== null && pixels < 48;
}

export function findInteractiveTapFloorDeclarationsInSource(relativePath, sourceText) {
if (!relativePath.endsWith(".tsx")) return [];
if (!/\bmin-h-(?:[0-9]|1[01]|\[)/.test(sourceText)) return [];
const source = ts.createSourceFile(relativePath, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const findings = [];

function inspectOpeningElement(node) {
if (!TAP_FLOOR_INTERACTIVE_TAGS.has(node.tagName.getText(source))) return;
const classAttribute = node.attributes.properties.find(
(attribute) => ts.isJsxAttribute(attribute) && attribute.name.getText(source) === "className",
);
if (!classAttribute || !ts.isJsxAttribute(classAttribute)) return;
const alternatives = jsxClassAlternatives(classAttribute);
if (!alternatives.some(hasSubFloorEffectiveMinHeight)) return;
const line = source.getLineAndCharacterOfPosition(classAttribute.getStart(source)).line + 1;
findings.push(`${relativePath}:${line}`);
}

function visit(node) {
if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) inspectOpeningElement(node);
ts.forEachChild(node, visit);
}

visit(source);
return findings;
}

const BORDER_WIDTH_UTILITY = /^border(?:-[xytrblse])?(?:-(?:0|2|4|8|\[(?!color:)[^\]]+\]))?$/;
const RING_WIDTH_UTILITY = /^ring(?:-(?:0|1|2|4|8|\[(?!color:)[^\]]+\]))?$/;
// The status-colour family declared in `globals.css` (`--success`/`--warning`/
Expand Down
34 changes: 34 additions & 0 deletions tests/design-system-contract-utils.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,6 +12,7 @@ import {
findErrorStateCountPropsInSource,
findFailedStateResultCountsInSource,
findHardcodedMotionClassesInSource,
findInteractiveTapFloorDeclarationsInSource,
findInteractiveTapLiteralsInSource,
findJsxEdgeOwnershipConflictsInSource,
findLayoutTransitionClassesInSource,
Expand DownExpand Up@@ -81,6 +82,39 @@ describe("design-system contract helpers", () => {
).toEqual([]);
});

it("flags interactive controls declaring a sub-floor min-height, and only those (Gate 2)", () => {
const find = (source: string) => findInteractiveTapFloorDeclarationsInSource("src/example.tsx", source);

// The violation: an interactive element declaring its own floor under 48px.
expect(find('<button className="min-h-9 px-3">Reset</button>')).toEqual(["src/example.tsx:1"]);
expect(find('<button className={cn("inline-flex min-h-10", active && "px-3")}>Reset</button>')).toEqual([
"src/example.tsx:1",
]);
expect(find('<summary className="min-h-8">Details</summary>')).toEqual(["src/example.tsx:1"]);
expect(find('<summary className="min-h-[42px]">Details</summary>')).toEqual(["src/example.tsx:1"]);
expect(find('<button className="min-h-[2.5rem]">Reset</button>')).toEqual(["src/example.tsx:1"]);
expect(find('<button className={compact ? "min-h-10" : "min-h-12"}>Reset</button>')).toEqual(["src/example.tsx:1"]);
expect(find('<button className={cn("min-h-12", compact && "min-h-10")}>Reset</button>')).toEqual([
"src/example.tsx:1",
]);

// The repo's correct responsive pattern must NOT be flagged: 48px on
// phones, released to 40px from `sm` up.
expect(find('<button className="min-h-12 sm:min-h-10">Save</button>')).toEqual([]);
expect(find('<button className="min-h-tap sm:min-h-9">Save</button>')).toEqual([]);
expect(find('<button className="min-h-[48px]">Save</button>')).toEqual([]);
expect(find('<button className="min-h-[3rem]">Save</button>')).toEqual([]);
expect(find('<button className={compact ? "min-h-tap" : "min-h-12"}>Save</button>')).toEqual([]);

// A short height on a NON-interactive element is layout, not a tap target.
expect(find('<div className="min-h-9">Panel</div>')).toEqual([]);

// Scoped to `min-h-*`: a short `h-*`/`size-*` is routinely the visible box
// of a control whose hit area belongs to a tap-sized wrapper, so flagging
// it would pad the baseline with non-defects (GATES.md §5).
expect(find('<input type="checkbox" className="h-4 w-4" />')).toEqual([]);
});

it("finds whitespace, fallback, URL, string and template --text-soft consumers in TypeScript", () => {
const source = [
'const spacedClose = "text-[color:var(--text-soft )]";',
Expand Down
Loading