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
13 changes: 9 additions & 4 deletions docs/design-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,15 @@ contract and the code — not reinvention. If a change genuinely needs a new dir
`src/app/globals.css` (`:root` + `.dark`). No raw Tailwind palette classes (`red-50`,
`slate-200`, `bg-white`) and no hex values in components. **If you typed a hex or a Tailwind
colour name in a component, you broke dark mode** — those values have no `.dark` override.
Sanctioned raw-colour exceptions are explicit and narrow: brand artwork, diagnostic-only
visualizations, generated OpenGraph artwork, emergency error fallbacks, the scoped
fixed-white Therapy patient sheet, and the scoped factsheet print sheet. `scripts/check-design-system-contract.mjs` owns the
path allowlist; adding a category requires documenting why semantic app tokens are wrong.
Sanctioned raw-colour exceptions are explicit and narrow: the token definitions in
`globals.css` itself, brand artwork, diagnostic-only visualizations, generated OpenGraph
artwork, emergency error fallbacks, the two pre-paint theme-colour values in
`src/lib/theme.ts` (read by the pre-hydration script before any CSS exists), the scoped
fixed-white Therapy patient sheet, and the scoped factsheet print sheet.
`RAW_COLOR_EXEMPTIONS` in `scripts/design-system-contract-utils.mjs` owns the path allowlist
and each entry's `scope`. Prefer a bounded scope over `whole-file` so unrelated colours in
the same file stay counted, and make a missing boundary fail closed; adding a category
requires documenting why semantic app tokens are wrong.
- **Semantic vs categorical vs brand.** Three token families, never interchangeable:
- Semantic triads (`--info/-soft/-border`, `--success-*`, `--warning-*`, `--danger-*`) mean
something happened or matters clinically. Green is success-only; red is safety/danger-only.
Expand Down
99 changes: 96 additions & 3 deletions scripts/design-system-contract-utils.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,12 @@ export const RAW_COLOR_EXEMPTIONS = [
// Pre-paint / meta theme-color values: consumed as raw colours by the inline
// pre-hydration theme script and the browser theme-color meta tag, before any
// CSS (and therefore any token) is available, so they cannot be tokenised.
// Scoped to the APP_THEME_COLORS declaration rather than the whole file: only
// those two literals are un-tokenisable, so any other raw colour added to
// theme.ts later must stay visible to the ratcheting contract.
category: "pre-paint theme color",
pattern: /^src\/lib\/theme\.ts$/,
scope: "whole-file",
scope: "app-theme-colors",
},
{
category: "printable Therapy paper",
Expand Down Expand Up @@ -116,8 +119,33 @@ function maskRanges(source, ranges) {
}

function balancedBlockRange(source, marker) {
const start = source.indexOf(marker);
if (start < 0) return null;
// Find a valid occurrence of the marker, skipping false matches that are:
// 1. Followed by an identifier-continuation character (to avoid suffixed declarations)
// 2. Inside a line comment, block comment, or string literal
let candidateStart = 0;
while (true) {
candidateStart = source.indexOf(marker, candidateStart);
if (candidateStart < 0) return null;

// Check if the character after the marker is an identifier-continuation character
const charAfterMarker = source[candidateStart + marker.length];
const isIdentifierContinuation = charAfterMarker && /[A-Za-z0-9_$]/.test(charAfterMarker);
if (isIdentifierContinuation) {
candidateStart += 1;
continue;
}

// Check if this occurrence is inside a comment or string
if (isInsideCommentOrString(source, candidateStart)) {
candidateStart += 1;
continue;
}

// Valid match found
break;
}

const start = candidateStart;
const openingBrace = source.indexOf("{", start);
if (openingBrace < 0) return null;

Expand Down Expand Up @@ -157,6 +185,60 @@ function balancedBlockRange(source, marker) {
return null;
}

function isInsideCommentOrString(source, position) {
// Scan from the beginning to determine if position is inside a comment or string
let inLineComment = false;
let inBlockComment = false;
let inString = null;
let escaped = false;

for (let index = 0; index < position; index += 1) {
const character = source[index];

if (inLineComment) {
if (character === "\n") inLineComment = false;
continue;
}

if (inBlockComment) {
if (character === "*" && source[index + 1] === "/") {
inBlockComment = false;
index += 1;
}
continue;
}

if (inString) {
if (escaped) {
escaped = false;
} else if (character === "\\") {
escaped = true;
} else if (character === inString) {
inString = null;
}
continue;
}

if (character === "/" && source[index + 1] === "/") {
inLineComment = true;
index += 1;
continue;
}

if (character === "/" && source[index + 1] === "*") {
inBlockComment = true;
index += 1;
continue;
}

if (character === '"' || character === "'" || character === "`") {
inString = character;
}
}

return inLineComment || inBlockComment || inString !== null;
}

function namedFunctionRange(relativePath, source, functionName) {
const parsed = ts.createSourceFile(relativePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const declaration = parsed.statements.find(
Expand All @@ -179,6 +261,17 @@ export function rawColorContractSource(relativePath, source, reportFailure = ()
return maskRanges(source, ranges);
}

if (exemption.scope === "app-theme-colors") {
// Anchored on the declaration keyword, not a bare identifier, so a later
// *reference* to APP_THEME_COLORS can never be mistaken for the boundary.
const range = balancedBlockRange(source, "export const APP_THEME_COLORS");
Comment thread
BigSimmo marked this conversation as resolved.
Comment thread
BigSimmo marked this conversation as resolved.
if (!range) {
reportFailure("pre-paint theme-color boundary is missing");
return source;
}
return maskRanges(source, [range]);
}
Comment thread
BigSimmo marked this conversation as resolved.

if (exemption.scope === "factsheet-print-sheet") {
const range = namedFunctionRange(relativePath, source, "FactsheetPrintSheet");
if (!range) {
Expand Down
68 changes: 68 additions & 0 deletions tests/design-system-contract-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,74 @@ describe("design-system contract helpers", () => {
expect(reportFailure).not.toHaveBeenCalled();
});

it("masks only the pre-paint theme-color constant, not the rest of theme.ts", () => {
const reportFailure = vi.fn();
const source = [
"export const APP_THEME_COLORS = {",
' light: "#ffffff",',
' dark: "#060708",',
"} as const satisfies Record<ResolvedTheme, string>;",
"",
"// A later, unrelated raw colour in this file must stay countable — the",
"// whole-file exemption this replaced would have hidden it.",
'export const UNRELATED_ACCENT = "#0f766e";',
'export const SCRIPT = `var c=d?"${APP_THEME_COLORS.dark}":"${APP_THEME_COLORS.light}";`;',
].join("\n");

const scoped = rawColorContractSource("src/lib/theme.ts", source, reportFailure);

expect(scoped).not.toContain("#ffffff");
expect(scoped).not.toContain("#060708");
expect(scoped).toContain("#0f766e");
// The interpolating bootstrap script holds no literals of its own and must
// survive masking intact.
expect(scoped).toContain("APP_THEME_COLORS.dark");
expect(reportFailure).not.toHaveBeenCalled();
});

it("anchors the theme-color boundary on the declaration, not a passing mention", () => {
const reportFailure = vi.fn();
// A doc comment naming the constant sits ABOVE an unrelated raw colour. A
// bare "APP_THEME_COLORS" marker would anchor on that mention and mask
// everything from the comment through the block, silently swallowing the
// unrelated colour with no failure reported. Masking runs before comments
// are stripped, so comment-stripping does not rescue it.
const source = [
"// Pre-paint values live in APP_THEME_COLORS below.",
'export const UNRELATED_ACCENT = "#0f766e";',
"export const APP_THEME_COLORS = {",
' light: "#ffffff",',
' dark: "#060708",',
"} as const;",
].join("\n");

const scoped = rawColorContractSource("src/lib/theme.ts", source, reportFailure);

expect(scoped).toContain("#0f766e");
expect(scoped).not.toContain("#ffffff");
expect(scoped).not.toContain("#060708");
expect(reportFailure).not.toHaveBeenCalled();
});

it("fails closed when the pre-paint theme-color boundary disappears", () => {
const reportFailure = vi.fn();
// The constant was renamed/removed but the exemption still matches the path.
const source = 'export const THEME_COLORS = { light: "#ffffff" };';

// Unmasked, so both literals are counted and the ratcheted baseline goes red
// rather than silently exempting the file.
expect(rawColorContractSource("src/lib/theme.ts", source, reportFailure)).toBe(source);
expect(reportFailure).toHaveBeenCalledWith("pre-paint theme-color boundary is missing");
});

it("does not mistake a renamed/suffixed declaration for the real APP_THEME_COLORS boundary", () => {
const reportFailure = vi.fn();
const source = 'export const APP_THEME_COLORS_V2 = { light: "#ffffff" };';

expect(rawColorContractSource("src/lib/theme.ts", source, reportFailure)).toBe(source);
expect(reportFailure).toHaveBeenCalledWith("pre-paint theme-color boundary is missing");
});

it("fails closed when a fixed-paper boundary disappears", () => {
const reportFailure = vi.fn();
const source = ".tc-app { color: #123456; }";
Expand Down
Loading