From d1c6180e0f84129e75094bbb4b61aa0d2e862e15 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 15:23:16 +0000 Subject: [PATCH 1/4] feat(ci): add check:forbidden-strings gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two framework promises were honour-system only, with no gate able to catch a regression against either: 1. "Every visual value is a named token; hardcoded numbers are treated as bugs" — a literal #3b5bdb in core/macros.css passes every existing check. 2. "Standalone, no external requests" — an absolute URL reaching a dist bundle would make every consumer page phone home, silently. The existing check:* gates all answer "is X in sync with Y?"; neither of the above is a sync question, so nothing covered them. Adds a rule-driven scanner over the source tree and the built bundles: - hardcoded-color: hex / rgb() / hsl() literals in core/ + optional/ CSS, excluding the four token source files whose job is to declare raw values. Comments are masked with scripts/lib/parse.js's offset-preserving maskComments, so issue references (#496, #497) are not false positives and reported line numbers still point at the original source. - external-url: any absolute URL in dist/*.css. - debug-statement: console.log/debug and debugger in configurator/src. scripts/ is out of scope — those are CLIs whose console.log is output. Deliberate exceptions live in ALLOW, keyed by rule id -> file -> the exact matched text (not merely the path), so excepting `#fff` in core/base.css does not blanket-approve a future literal in that same file. Each entry carries a reason, matching the docs/ref-allowlist.json contract. Runs in the artifacts-freshness job after check-artifacts.js, which rebuilds dist/ — so the bundle rules have something to scan. 13 negative tests assert each rule bites, that comment masking neither hides a real violation nor mis-reports its line, and that an unbuilt tree does not crash the gate. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01STwfcKmspjEbUYrKDVZgqt --- .github/workflows/ci.yml | 5 + CLAUDE.md | 1 + package.json | 1 + scripts/check-forbidden-strings.js | 265 ++++++++++++++++++++++++++ tests/check-forbidden-strings.test.js | 176 +++++++++++++++++ 5 files changed, 448 insertions(+) create mode 100644 scripts/check-forbidden-strings.js create mode 100644 tests/check-forbidden-strings.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b571819e..dcbfe9d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,6 +109,11 @@ jobs: - run: node scripts/check-hook-tokens.js - run: node scripts/check-mirrors.js - run: node scripts/check-bundle-defs.js + # Strings that must never ship: hardcoded colour literals outside the + # token source files, and external URLs in a built bundle. Runs after + # check-artifacts.js, which rebuilds dist/ — so the bundle rules have + # something to scan. + - run: npm run check:forbidden-strings dependency-audit: name: Dependency vulnerability audit diff --git a/CLAUDE.md b/CLAUDE.md index 53ab14a8..1e7360ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,6 +61,7 @@ requires a rebuild+redeploy, not just a file edit. | `npm run check:layer-order` | Verify `docs/architecture.md`'s `@layer` block and specificity ladder match `core/layers.css` (CI gate) | | `npm run check:macros` | Verify `.sf-*` macro classes match `docs/macros.md` (CI gate) | | `npm run check:registry` | Verify `token-registry.json` is in sync with source (CI gate) | +| `npm run check:forbidden-strings` | Verify no hardcoded colour literal sits outside the token source files and no shipped bundle contains an external URL (CI gate) | | `npm run audit:check` | Verify `docs/registry.json` matches source without writing (CI gate) | | `npm run lint:css` | Lint all CSS source with stylelint (CI gate) | | `npm run lint:css:fix` | Lint CSS source and auto-fix violations | diff --git a/package.json b/package.json index f99a4af0..7a8698ef 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "check:hook-tokens": "node scripts/check-hook-tokens.js", "check:mirrors": "node scripts/check-mirrors.js", "check:bundle-defs": "node scripts/check-bundle-defs.js", + "check:forbidden-strings": "node scripts/check-forbidden-strings.js --check", "configurator:sync": "node configurator/scripts/sync-api.mjs", "docs:tokens": "node scripts/gen-token-reference.js", "docs:index": "node scripts/gen-token-index.js", diff --git a/scripts/check-forbidden-strings.js b/scripts/check-forbidden-strings.js new file mode 100644 index 00000000..477faaed --- /dev/null +++ b/scripts/check-forbidden-strings.js @@ -0,0 +1,265 @@ +#!/usr/bin/env node +/** + * CI gate: strings that must never ship. + * + * The other check:* gates all answer "is X still in sync with Y?" — they prove + * generated artifacts match their sources (check-artifacts), that docs only + * name live API (check-doc-refs, check-llm-guide), that ids are permanent + * (check-token-registry). None of them can answer the different question this + * gate exists for: "did something get into the source or the shipped bundle + * that has no business being there at all?" + * + * Two framework promises are currently honour-system only: + * + * 1. "Every visual value is a named token; hardcoded numbers are treated as + * bugs" (README, CLAUDE.md). Nothing stops a literal `#3b5bdb` landing in + * core/macros.css — it would pass every existing gate. + * 2. "Standalone — no runtime dependencies", "no external requests". Nothing + * stops a `@import url(https://fonts.googleapis.com/…)` reaching a dist + * bundle, which would silently make every consumer's page phone home. + * + * Both are exactly the class of regression that is cheap to prevent and + * expensive to notice later. This gate turns each promise into a mechanical + * check over the source tree AND the built bundles. + * + * Deliberate exceptions are recorded per rule in ALLOW, each with a reason — + * same contract as docs/ref-allowlist.json. An unexplained exception is not + * possible: the allow entry IS the explanation, and it names the exact matched + * text, not just the file, so a second unrelated violation in an + * already-excepted file still fails. + * + * Run: + * node scripts/check-forbidden-strings.js # report, exit 0 + * node scripts/check-forbidden-strings.js --check # report, exit 1 on any hit + * npm run check:forbidden-strings # the --check form (CI) + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { maskComments } from './lib/parse.js'; + +// SLASHED_ROOT lets the negative tests run this gate against a fixture tree. +const slashedRoot = process.env.SLASHED_ROOT?.trim(); +const ROOT = slashedRoot + ? path.resolve(slashedRoot) + : path.resolve(import.meta.dirname, '..'); + +const checkMode = process.argv.includes('--check'); + +/** + * Files whose entire job is to declare raw values — the bottom of the token + * stack, where a literal colour is the definition, not a shortcut. + */ +const TOKEN_SOURCE_FILES = [ + 'core/tokens.css', + 'core/tokens.layout.css', + 'core/tokens.macros.css', + 'optional/tokens.components.css', +]; + +/** + * @typedef {object} Rule + * @property {string} id stable slug, used in output and in ALLOW + * @property {string} label one-line description of what was found + * @property {string} why what breaks if this ships (printed on failure) + * @property {RegExp} pattern global regex; every match is a candidate hit + * @property {string[]} targets path prefixes to walk, relative to ROOT + * @property {string[]} extensions file extensions to read + * @property {string[]} [exclude] exact relative paths to skip entirely + * @property {boolean} [maskComments] blank comment bodies before matching + * (offset-preserving, so line numbers still + * point at the original source) + */ + +/** @type {Rule[]} */ +const RULES = [ + { + id: 'hardcoded-color', + label: 'hardcoded colour literal outside the token source files', + why: + 'The framework promises every visual value is a named token. A literal colour ' + + 'here cannot be rebranded, cannot participate in the light-dark() / oklch() ' + + 'derivation chain, and will not follow a consumer\'s theme.', + // Hex literals plus the legacy colour functions. oklch()/color-mix() are + // absent by design: those are the derivation syntax the token layer is + // built on, not a hardcoded value. + pattern: /#[0-9a-fA-F]{3,8}\b|\b(?:rgba?|hsla?)\(/g, + targets: ['core/', 'optional/'], + extensions: ['.css'], + exclude: TOKEN_SOURCE_FILES, + maskComments: true, + }, + { + id: 'external-url', + label: 'external URL in a shipped bundle', + why: + 'A shipped bundle must never cause a network request. Any absolute URL here ' + + 'means every consumer page silently fetches from a third party — breaking the ' + + '"standalone, no external requests" guarantee and leaking visitor IPs.', + pattern: /https?:\/\/[^\s"')]+/g, + targets: ['dist/'], + extensions: ['.css'], + // Deliberately NOT comment-masked: a URL in a bundle banner is still a URL + // shipped to consumers, and should be a recorded decision. + }, + { + id: 'debug-statement', + label: 'debug statement left in configurator source', + why: + 'console.log/debug and debugger statements are development leftovers. They ship ' + + 'to the deployed configurator, clutter the console, and can leak internal state. ' + + 'Diagnostics that are meant to survive belong in console.warn/error.', + pattern: /\bconsole\s*\.\s*(?:log|debug)\s*\(|\bdebugger\b/g, + targets: ['configurator/src/'], + extensions: ['.ts', '.svelte', '.js'], + // scripts/ is deliberately out of scope: those are CLIs whose console.log + // IS their output. + }, +]; + +/** + * Deliberate, reasoned exceptions. + * + * Shape: ALLOW[ruleId][relativePath][exactMatchedText] = reason. + * + * Keyed by the matched TEXT, not just the file, so excepting `#fff` in a file + * does not blanket-approve a future `#3b5bdb` in that same file. + */ +const ALLOW = { + 'hardcoded-color': { + 'core/base.css': { + '#fff': + 'var() fallback of last resort — renders readable if the token layer ' + + 'failed to load entirely. Not a themeable value by definition.', + }, + 'optional/components.css': { + '#000': + 'linear-gradient(#000 0 0) mask stops — a mask reads only the alpha ' + + 'channel, so the colour is structurally inert and tokenising it would ' + + 'imply a themeable value that does not exist.', + }, + }, + 'external-url': { + 'dist/slashed.full.css': { + 'http://www.w3.org/2000/svg': + 'XML namespace identifier inside an inline SVG data: URI. Namespaces are ' + + 'never dereferenced — no network request is made.', + }, + }, +}; + +// The same inline-SVG namespace appears in every bundle variant; mirror the one +// documented exception across them rather than repeating the reason four times. +for (const variant of [ + 'dist/slashed.optimal.css', + 'dist/slashed.full.flat.css', + 'dist/slashed.optimal.flat.css', + 'dist/slashed.full.min.css', + 'dist/slashed.optimal.min.css', + 'dist/slashed.full.flat.min.css', + 'dist/slashed.optimal.flat.min.css', +]) { + ALLOW['external-url'][variant] = ALLOW['external-url']['dist/slashed.full.css']; +} + +/** + * Collect every file under a rule's targets matching its extensions. + * A target may be a directory prefix ("core/") or an exact file. + * @param {Rule} rule + * @returns {string[]} repo-relative paths, sorted + */ +function collectFiles(rule) { + const out = new Set(); + const exclude = new Set(rule.exclude ?? []); + + const visit = (abs, rel) => { + let stat; + try { + stat = fs.statSync(abs); + } catch { + return; // target absent (e.g. dist/ before a build) — nothing to scan + } + if (stat.isDirectory()) { + for (const entry of fs.readdirSync(abs)) { + if (entry === 'node_modules' || entry.startsWith('.')) continue; + visit(path.join(abs, entry), rel ? `${rel}/${entry}` : entry); + } + return; + } + if (!rule.extensions.some((ext) => rel.endsWith(ext))) return; + if (exclude.has(rel)) return; + out.add(rel); + }; + + for (const target of rule.targets) { + const clean = target.replace(/\/$/, ''); + visit(path.join(ROOT, clean), clean); + } + return [...out].sort(); +} + +/** 1-based line number of a character offset. */ +function lineAt(text, index) { + let line = 1; + for (let i = 0; i < index && i < text.length; i++) { + if (text[i] === '\n') line++; + } + return line; +} + +/** + * Run one rule over the tree. + * @param {Rule} rule + * @returns {{ rule: Rule, hits: Array<{file:string,line:number,text:string}>, allowed: number, scanned: number }} + */ +function runRule(rule) { + const hits = []; + let allowed = 0; + const files = collectFiles(rule); + + for (const rel of files) { + const raw = fs.readFileSync(path.join(ROOT, rel), 'utf8'); + // maskComments blanks comment bodies with same-length whitespace, so match + // offsets still line up with the original file's line numbering. + const haystack = rule.maskComments ? maskComments(raw) : raw; + const allowForFile = ALLOW[rule.id]?.[rel] ?? {}; + + rule.pattern.lastIndex = 0; + for (const match of haystack.matchAll(rule.pattern)) { + const text = match[0]; + if (Object.prototype.hasOwnProperty.call(allowForFile, text)) { + allowed++; + continue; + } + hits.push({ file: rel, line: lineAt(haystack, match.index ?? 0), text }); + } + } + + return { rule, hits, allowed, scanned: files.length }; +} + +const results = RULES.map(runRule); +const failing = results.filter((r) => r.hits.length > 0); + +if (failing.length) { + console.error('check:forbidden-strings FAILED:\n'); + for (const { rule, hits } of failing) { + console.error(` [${rule.id}] ${rule.label}`); + console.error(` ${rule.why}\n`); + for (const hit of hits) { + console.error(` ${hit.file}:${hit.line} ${hit.text}`); + } + console.error(''); + } + console.error( + 'If a match is deliberate, add it to ALLOW in scripts/check-forbidden-strings.js\n' + + 'keyed by rule id → file → the exact matched text, with a reason explaining why\n' + + 'it is safe. An exception without a reason is not an exception.', + ); + if (checkMode) process.exit(1); +} else { + const summary = results + .map((r) => `${r.rule.id}: ${r.scanned} file(s)${r.allowed ? `, ${r.allowed} allowed` : ''}`) + .join('; '); + console.log(`check:forbidden-strings OK — ${summary}.`); +} diff --git a/tests/check-forbidden-strings.test.js b/tests/check-forbidden-strings.test.js new file mode 100644 index 00000000..7efd4fd5 --- /dev/null +++ b/tests/check-forbidden-strings.test.js @@ -0,0 +1,176 @@ +/** + * Negative tests for scripts/check-forbidden-strings.js. + * + * A gate that has never been observed to fail is indistinguishable from a gate + * that cannot fail. Each test below plants exactly one violation in a fixture + * tree (SLASHED_ROOT), and asserts the gate reports it and — under --check — + * exits non-zero. The passing-fixture test guards the opposite failure mode: a + * rule so broad it fires on legitimate source. + */ +import { test, describe, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const ROOT = path.resolve(import.meta.dirname, '..'); +const GATE = path.join(ROOT, 'scripts', 'check-forbidden-strings.js'); + +const tmpDirs = []; + +/** + * Build a minimal fixture tree. Every file is optional; omitted ones are + * written with clean, rule-satisfying content so a test can plant a single + * violation without the others interfering. + * @param {{ [relPath: string]: string }} files + */ +function buildFixture(files = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'slashed-forbidden-')); + tmpDirs.push(dir); + + const defaults = { + // Excluded from hardcoded-color by design — proves the exclusion works. + 'core/tokens.css': ':root { --sf-color-primary: #3b5bdb; }\n', + 'core/base.css': 'body { color: var(--sf-color-text); }\n', + 'optional/forms.css': 'input { border: 1px solid var(--sf-color-border); }\n', + 'dist/slashed.optimal.css': '/*! SLASHED v0.0.0 */\n:root { --sf-x: 1; }\n', + 'configurator/src/lib/thing.ts': 'export const x = 1;\n', + }; + + for (const [rel, content] of Object.entries({ ...defaults, ...files })) { + const abs = path.join(dir, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, content); + } + return dir; +} + +function runGate(dir, args = []) { + return spawnSync(process.execPath, [GATE, ...args], { + encoding: 'utf8', + env: { ...process.env, SLASHED_ROOT: dir }, + }); +} + +after(() => { + for (const dir of tmpDirs) fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe('check-forbidden-strings', () => { + test('passes on a clean fixture', () => { + const r = runGate(buildFixture(), ['--check']); + assert.equal(r.status, 0, `expected pass:\n${r.stderr}`); + assert.match(r.stdout, /check:forbidden-strings OK/); + }); + + test('does not flag colour literals inside the token source files', () => { + // core/tokens.css in the default fixture is full of hex; it must stay silent. + const r = runGate(buildFixture(), ['--check']); + assert.equal(r.status, 0, `token source files must be exempt:\n${r.stderr}`); + }); + + test('flags a hardcoded hex colour in a non-token CSS file', () => { + const dir = buildFixture({ + 'core/macros.css': '.sf-prose { color: #3b5bdb; }\n', + }); + const r = runGate(dir, ['--check']); + assert.equal(r.status, 1, 'expected a non-zero exit'); + assert.match(r.stderr, /hardcoded-color/); + assert.match(r.stderr, /core\/macros\.css:1/); + assert.match(r.stderr, /#3b5bdb/); + }); + + test('flags a hardcoded rgb() colour', () => { + const dir = buildFixture({ + 'optional/utilities.css': '.sf-x { background: rgba(0, 0, 0, 0.5); }\n', + }); + const r = runGate(dir, ['--check']); + assert.equal(r.status, 1); + assert.match(r.stderr, /optional\/utilities\.css:1/); + }); + + test('ignores a colour literal that appears only inside a comment', () => { + const dir = buildFixture({ + 'core/macros.css': '/* was #3b5bdb before tokenising, and issue #496 */\n.sf-x { color: var(--sf-color-primary); }\n', + }); + const r = runGate(dir, ['--check']); + assert.equal(r.status, 0, `comments must be masked:\n${r.stderr}`); + }); + + test('reports the original line number despite comment masking', () => { + const dir = buildFixture({ + 'core/macros.css': '/* banner\n spanning\n lines */\n.sf-x { color: #abc; }\n', + }); + const r = runGate(dir, ['--check']); + assert.equal(r.status, 1); + // The violation is on line 4 of the original file; a non-offset-preserving + // comment strip would report line 1 or 2 here. + assert.match(r.stderr, /core\/macros\.css:4/); + }); + + test('flags an external URL in a shipped bundle', () => { + const dir = buildFixture({ + 'dist/slashed.optimal.css': "@import url(https://fonts.googleapis.com/css2?family=Inter);\n", + }); + const r = runGate(dir, ['--check']); + assert.equal(r.status, 1); + assert.match(r.stderr, /external-url/); + assert.match(r.stderr, /fonts\.googleapis\.com/); + }); + + test('allows the inline-SVG XML namespace in a bundle', () => { + const dir = buildFixture({ + 'dist/slashed.optimal.css': + ".sf-x { background: url('data:image/svg+xml,'); }\n", + }); + const r = runGate(dir, ['--check']); + assert.equal(r.status, 0, `namespace URI must be allowed:\n${r.stderr}`); + }); + + test('flags a console.log left in configurator source', () => { + const dir = buildFixture({ + 'configurator/src/lib/thing.ts': 'export function f() { console.log("hi"); }\n', + }); + const r = runGate(dir, ['--check']); + assert.equal(r.status, 1); + assert.match(r.stderr, /debug-statement/); + }); + + test('does not flag console.warn in configurator source', () => { + const dir = buildFixture({ + 'configurator/src/lib/thing.ts': 'export function f() { console.warn("real diagnostic"); }\n', + }); + const r = runGate(dir, ['--check']); + assert.equal(r.status, 0, `console.warn is legitimate:\n${r.stderr}`); + }); + + test('without --check, reports the violation but exits 0', () => { + const dir = buildFixture({ + 'core/macros.css': '.sf-x { color: #3b5bdb; }\n', + }); + const r = runGate(dir); + assert.equal(r.status, 0, 'report-only mode must not fail the shell'); + assert.match(r.stderr, /hardcoded-color/); + }); + + test('an allowed match does not blanket-approve other matches in the same file', () => { + // core/base.css has a real ALLOW entry for `#fff`. A different literal in + // that same file must still fail — this is the key property of keying the + // allowlist by matched text rather than by path. + const dir = buildFixture({ + 'core/base.css': 'body { color: var(--sf-color-bg, #fff); border-color: #3b5bdb; }\n', + }); + const r = runGate(dir, ['--check']); + assert.equal(r.status, 1, 'a second, unexcepted literal must still fail'); + assert.match(r.stderr, /#3b5bdb/); + assert.doesNotMatch(r.stderr, /#fff/); + }); + + test('tolerates a missing target directory', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'slashed-forbidden-empty-')); + tmpDirs.push(dir); + const r = runGate(dir, ['--check']); + assert.equal(r.status, 0, `an unbuilt tree must not crash the gate:\n${r.stderr}`); + }); +}); From e46134802dd3d683da666aa65e8a2e39a2e364c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 15:26:58 +0000 Subject: [PATCH 2/4] fix(components): complete the text--subtle rename in the .sf-card surface block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0.7.25 rename of --sf-color-text--secondary to --sf-color-text--subtle was applied in core/tokens.css but missed one mirror: the .sf-card block in optional/components.css, which re-derives the resolved colour tokens for a card sitting on an active surface, still declared the pre-rename name. The two declarations are character-for-character the same light-dark()/oklch formula, and that block's own comment (SL-001) states it mirrors tokens.css's "Resolved color tokens" — so this was a missed spot, not a deliberate divergence. Effect: nothing reads var(--sf-color-text--secondary), so the card was setting a name no one consumes while consumers reading the live --sf-color-text--subtle inside a card on a coloured surface silently got the unadjusted :root value instead of the card-tuned one. token-registry.json already had the old name flagged removed; only the stale declaration kept it textually alive. No catalogue or doc churn: a full rebuild regenerates every artifact byte for byte, because the declaration sat inside a @container style() block the API index never catalogued. Found by the new check:token-renames gate, which refuses to migrate users away from a token that is still live. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01STwfcKmspjEbUYrKDVZgqt --- optional/components.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/optional/components.css b/optional/components.css index 715167b9..555eeffc 100644 --- a/optional/components.css +++ b/optional/components.css @@ -444,7 +444,7 @@ oklch(from var(--sf-color-neutral-source-light) clamp(0.05, calc(l - 0.4 - var(--sf-contrast-bias)), 0.35) c h), oklch(from var(--sf-color-neutral) clamp(0.70, calc(l + 0.25 + var(--sf-contrast-bias)), 1) c h)); --sf-color-text: var(--sf-color-heading); - --sf-color-text--secondary: light-dark( + --sf-color-text--subtle: light-dark( oklch(from var(--sf-color-neutral-source-light) clamp(0.15, calc(l - 0.25 - var(--sf-contrast-bias)), 0.45) c h), oklch(from var(--sf-color-neutral) clamp(0.55, calc(l + 0.1 + var(--sf-contrast-bias)), 0.90) c h)); --sf-color-border: light-dark( From f63dcd0c3dc46276345dabcc95be598d3b179181 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 15:37:22 +0000 Subject: [PATCH 3/4] feat(tooling): portable theme file with automatic token migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The configurator could persist an override set two ways, and neither is a file you can put in a repository: localStorage (trapped in one browser profile) and the share link (a compressed base64url blob — ideal for a short URL, opaque in a diff, and keyed by numeric token id). Adds a third form: *.slashed-theme.json, a name-keyed, sorted, pretty-printed snapshot. Because it is keyed by name and byte-stable, `git diff` shows exactly which token changed value — so a rebrand becomes reviewable in a pull request, and a theme can live next to the CSS it themes. Name-keying is what makes it readable and also what makes it vulnerable to a rename, so the format ships with a migration path: docs/token-renames.json — a curated mirror of docs/migration.md, seeded with the 45 renames and 31 removals that release actually documents. This cannot be generated: token-registry.json keeps ids permanent, but check-token-registry.js deliberately permits renames as in-place name updates on the same id, so after a rename the old name is simply gone with nothing to look it up by. Harmless for the id-keyed codec, fatal for a name-keyed file. A rename and a delete+add pair are also indistinguishable to a generator. scripts/check-token-renames.js — CI gate holding the map to three invariants: every rename target is live, no old name is still live, renames and removals are disjoint. Requiring targets to be live forbids chains by construction. It found a real bug on its first run (fixed in the preceding commit). scripts/migrate-theme.js — CLI. Report-only by default and exits non-zero when migration is needed, so it doubles as a CI check over committed theme files; --write is the explicit opt-in to mutate. Never discards an override it does not recognise: unknown names are reported and kept, because absence of knowledge is not evidence the user was wrong. Configurator gets export + import in the Setup panel, applying the same migration on load and listing every adjustment made. Its implementation is a deliberate mirror rather than a shared import: the @framework-css alias is remapped by the WordPress plugin to a vendored dist/ with no scripts/, so reaching across the package boundary at runtime would break that build. The mirror is held honest by configurator/tests/themeFile.test.ts, which runs both implementations over the same fixtures under Vitest (which, unlike the shipped bundle, can import both) and asserts identical results. 32 new tests: format validation, migration semantics (idempotence, collision handling, unknown-token preservation), round-trip byte stability, CLI exit codes, and 10 negative tests per gate invariant. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01STwfcKmspjEbUYrKDVZgqt --- .github/workflows/ci.yml | 4 + CLAUDE.md | 36 +++ configurator/scripts/sync-api.mjs | 46 ++++ .../src/components/DomainPanel.svelte | 2 +- .../src/components/panels/ExportPanel.svelte | 107 +++++++- .../src/data/token-renames.generated.json | 86 +++++++ configurator/src/lib/themeFile.ts | 199 +++++++++++++++ configurator/tests/themeFile.test.ts | 133 ++++++++++ docs/getting-started.md | 38 +++ docs/token-renames.json | 109 ++++++++ package.json | 2 + scripts/artifacts.json | 8 +- scripts/check-token-renames.js | 127 ++++++++++ scripts/lib/theme-file.js | 215 ++++++++++++++++ scripts/migrate-theme.js | 132 ++++++++++ tests/check-token-renames.test.js | 141 +++++++++++ tests/theme-file.test.js | 238 ++++++++++++++++++ 17 files changed, 1617 insertions(+), 6 deletions(-) create mode 100644 configurator/src/data/token-renames.generated.json create mode 100644 configurator/src/lib/themeFile.ts create mode 100644 configurator/tests/themeFile.test.ts create mode 100644 docs/token-renames.json create mode 100644 scripts/check-token-renames.js create mode 100644 scripts/lib/theme-file.js create mode 100644 scripts/migrate-theme.js create mode 100644 tests/check-token-renames.test.js create mode 100644 tests/theme-file.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dcbfe9d5..90066707 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,6 +114,10 @@ jobs: # check-artifacts.js, which rebuilds dist/ — so the bundle rules have # something to scan. - run: npm run check:forbidden-strings + # docs/token-renames.json must stay truthful: every rename target live, + # no old name still live. A stale map migrates theme files onto dead + # tokens, which is worse than having no map at all. + - run: npm run check:token-renames dependency-audit: name: Dependency vulnerability audit diff --git a/CLAUDE.md b/CLAUDE.md index 1e7360ae..712f2a21 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,6 +62,8 @@ requires a rebuild+redeploy, not just a file edit. | `npm run check:macros` | Verify `.sf-*` macro classes match `docs/macros.md` (CI gate) | | `npm run check:registry` | Verify `token-registry.json` is in sync with source (CI gate) | | `npm run check:forbidden-strings` | Verify no hardcoded colour literal sits outside the token source files and no shipped bundle contains an external URL (CI gate) | +| `npm run check:token-renames` | Verify `docs/token-renames.json` is truthful — every rename target is a live token, no old name still is (CI gate) | +| `npm run migrate:theme -- [--write]` | Migrate a `*.slashed-theme.json` theme file onto the current token API | | `npm run audit:check` | Verify `docs/registry.json` matches source without writing (CI gate) | | `npm run lint:css` | Lint all CSS source with stylelint (CI gate) | | `npm run lint:css:fix` | Lint CSS source and auto-fix violations | @@ -141,6 +143,40 @@ instance token, an example of a component the framework does not ship) — recor it in `docs/ref-allowlist.json` with a reason. `docs/migration.md` (historical) and `docs/roadmap.md` (forward-looking) are whole-doc exclusions. +## Token renames — MANDATORY + +`docs/token-renames.json` is the machine-readable mirror of `docs/migration.md`. +It is what lets a **theme file** (`*.slashed-theme.json` — the portable, +name-keyed override snapshot, see `scripts/lib/theme-file.js`) survive a rename: +`npm run migrate:theme -- --write` rewrites old names, drops removed ones +with the reason, and never discards an override it does not recognise. + +**Any PR that renames or removes a `--sf-*` token must add the corresponding +entry**, in the same PR as the CSS change: + +- **Renamed** → add to `renames` as `"--sf-old": "--sf-new"`. Record the + *fully resolved* destination, never an intermediate name: rename targets must + be live, so a chain (`a → b → c`) fails the gate by construction. +- **Removed with no replacement** → add to `removals` with a reason saying what + to use instead. A removal without a reason is rejected. + +This cannot be generated. `token-registry.json` keeps ids permanent, but +`check-token-registry.js` deliberately permits renames as *in-place name +updates on the same id* — so after a rename the old name is simply gone, with +nothing to look it up by. That is harmless for the share-link codec (it stores +ids) and fatal for a name-keyed theme file. A rename and a delete+add pair are +also indistinguishable to a generator, so the map is curated by hand. + +```bash +npm run check:token-renames # must pass — CI fails if it doesn't +``` + +The gate holds the map to three invariants: every rename target is live, no old +name is still live, and renames and removals are disjoint. Note that "live" +includes tokens merely *declared* in `core/`/`optional/` CSS — so a leftover +declaration of a supposedly-renamed token will fail this gate, which is how it +catches a half-finished rename. + ## Tests ```bash diff --git a/configurator/scripts/sync-api.mjs b/configurator/scripts/sync-api.mjs index c5cf95f9..60529052 100644 --- a/configurator/scripts/sync-api.mjs +++ b/configurator/scripts/sync-api.mjs @@ -43,12 +43,14 @@ const SOURCE = const ANNOTATIONS_FILE = path.join(FRAMEWORK_ROOT, 'docs', 'token-annotations.json'); const BUNDLE_CONFIG_FILE = path.join(FRAMEWORK_ROOT, 'bundle.config.json'); const REGISTRY_FILE = path.join(FRAMEWORK_ROOT, 'token-registry.json'); +const RENAMES_FILE = path.join(FRAMEWORK_ROOT, 'docs', 'token-renames.json'); const OUT_DIR = path.join(CONFIGURATOR_ROOT, 'src', 'data'); const OUT = path.join(OUT_DIR, 'api-index.generated.json'); const CLASSES_OUT = path.join(OUT_DIR, 'classes.generated.json'); const BUNDLES_OUT = path.join(OUT_DIR, 'bundles.generated.json'); const REGISTRY_OUT = path.join(OUT_DIR, 'token-registry.generated.json'); +const RENAMES_OUT = path.join(OUT_DIR, 'token-renames.generated.json'); // jsDelivr serves the published dist branch (see .github/workflows/publish-dist.yml) // at the repo root, so a bundle's minified file is /slashed..min.css. @@ -314,6 +316,10 @@ function main() { // verbatim so the configurator imports it the same way model.js imports the // api-index — and so the runtime can never drift from the committed registry. syncRegistry(); + + // Rename/removal map for theme-file import (src/lib/themeFile.ts), so an + // override set authored against an older SLASHED can be migrated on load. + syncRenames(); } /** @@ -348,4 +354,44 @@ function syncRegistry() { ); } +/** + * Copy docs/token-renames.json → src/data/token-renames.generated.json, so the + * configurator's theme-file import can migrate an old override set without + * reaching outside its own package at runtime (the @framework-css alias is + * remapped by the WP plugin, so cross-boundary runtime imports are not safe + * here — a generated data file is). + * + * The map's truthfulness is guaranteed upstream by scripts/check-token-renames.js. + */ +function syncRenames() { + if (!fs.existsSync(RENAMES_FILE)) { + console.error( + `[configurator:sync] token-renames.json not found at ${RENAMES_FILE}\n` + + `It is a hand-maintained mirror of docs/migration.md — it should be committed.` + ); + process.exit(1); + } + let map; + try { + map = JSON.parse(fs.readFileSync(RENAMES_FILE, 'utf8')); + } catch (err) { + console.error(`[configurator:sync] ${RENAMES_FILE} is not valid JSON (${err.message}).`); + process.exit(1); + } + const out = { + _sync: { + generatedBy: 'configurator/scripts/sync-api.mjs', + source: 'docs/token-renames.json', + }, + renames: map.renames ?? {}, + removals: map.removals ?? {}, + }; + fs.writeFileSync(RENAMES_OUT, JSON.stringify(out, null, 2) + '\n', 'utf8'); + console.log( + `[configurator:sync] ${path.relative(FRAMEWORK_ROOT, RENAMES_OUT)} ← ` + + `docs/token-renames.json (${Object.keys(out.renames).length} renames, ` + + `${Object.keys(out.removals).length} removals)` + ); +} + main(); diff --git a/configurator/src/components/DomainPanel.svelte b/configurator/src/components/DomainPanel.svelte index 89c8b55e..fb7270cb 100644 --- a/configurator/src/components/DomainPanel.svelte +++ b/configurator/src/components/DomainPanel.svelte @@ -64,7 +64,7 @@ {:else if domain === "wcag"} {:else if domain === "setup"} - + {:else if domain === "cheatsheet"} {/if} diff --git a/configurator/src/components/panels/ExportPanel.svelte b/configurator/src/components/panels/ExportPanel.svelte index 1d014fbd..1851c829 100644 --- a/configurator/src/components/panels/ExportPanel.svelte +++ b/configurator/src/components/panels/ExportPanel.svelte @@ -1,16 +1,61 @@