diff --git a/CLAUDE.md b/CLAUDE.md index 38f57782..a77907c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,19 +6,15 @@ SLASHED has **one version number**. It must be identical in every artifact. If you touch anything version-related, you are responsible for keeping all of these in sync: -| File | Field | How it's kept correct | -|------|-------|-----------------------| -| `package.json` | `.version` ← **source of truth** | you bump it (`npm version`) | -| `package-lock.json` | `.version` + `.packages[""].version` | `npm version`; checked by `check:version` (CI) | -| `docs/roadmap.md` | `Current version: **X.Y.Z**` line | `version-sync`; checked by `check:version` (CI) | -| `configurator/package.json` | `.version` | `version-sync`; checked by `check:version` (CI) | -| `configurator/package-lock.json` | `.version` + `.packages[""].version` | `version-sync`; checked by `check:version` (CI) | -| `dist/*.css` (unminified) | `/*! SLASHED vX.Y.Z */` comment header | **build-derived** — stamped from `package.json`/tag by `bundle.js`; `dist/*.css` is git-ignored, so it cannot drift. `release.yml` re-verifies the stamp before publishing | -| Configurator UI version pill | baked in via Vite `__SLASHED_VERSION__` at build time | **build-derived** — injected from root `package.json` at Vite build; cannot drift | - -The first five rows are the ones you ever sync; the last two are **build-derived** -(regenerated from `package.json` at build time and not committed as text you edit), -so `version-sync`/`check:version` intentionally don't touch them — never hand-edit them. +| File | Field | +|------|-------| +| `package.json` | `.version` ← **source of truth** | +| `package-lock.json` | `.version` + `.packages[""].version` | +| `docs/roadmap.md` | `Current version: **X.Y.Z**` line | +| `configurator/package.json` | `.version` | +| `configurator/package-lock.json` | `.version` + `.packages[""].version` | +| `dist/*.css` (unminified) | `/*! SLASHED vX.Y.Z */` comment header | +| Configurator UI version pill | baked in via Vite `__SLASHED_VERSION__` at build time | **Never edit version numbers by hand.** Use `npm version` then the sync script: diff --git a/analysis/gap-analysis.md b/analysis/gap-analysis.md index 5e18c5da..e11a134d 100644 --- a/analysis/gap-analysis.md +++ b/analysis/gap-analysis.md @@ -1,8 +1,3 @@ - - # SLASHED — Competitive Gap Analysis (v0.5.28) > **Question:** what is still missing from SLASHED that holds it back versus diff --git a/demos/full-api-demo-with-overrides.html b/demos/full-api-demo-with-overrides.html index ac4ccb47..4cca298f 100644 --- a/demos/full-api-demo-with-overrides.html +++ b/demos/full-api-demo-with-overrides.html @@ -122,7 +122,7 @@

SLASHED Full API Demo

-

v0.7.3 · optimal-components bundle from jsDelivr CDN · 324 classes · 729 tokens (238 configurable)

+

v0.7.0 · optimal-components bundle from jsDelivr CDN · 324 classes · 729 tokens (238 configurable)

ultimate-override.css is ACTIVE. Every value below is recomputed from perturbed knob tokens — toggle it off in the toolbar, or compare against the un-overridden page.
diff --git a/demos/full-api-demo.html b/demos/full-api-demo.html index 9c35ce5c..ec73973c 100644 --- a/demos/full-api-demo.html +++ b/demos/full-api-demo.html @@ -121,7 +121,7 @@

SLASHED Full API Demo

-

v0.7.3 · optimal-components bundle from jsDelivr CDN · 324 classes · 729 tokens (238 configurable)

+

v0.7.0 · optimal-components bundle from jsDelivr CDN · 324 classes · 729 tokens (238 configurable)

Baseline render with default tokens. Use the toolbar to switch theme, toggle the ultimate override live, or replay motion. The always-on override variant is full-api-demo-with-overrides.html.
diff --git a/demos/ultimate-override.css b/demos/ultimate-override.css index fabf3279..041aea18 100644 --- a/demos/ultimate-override.css +++ b/demos/ultimate-override.css @@ -1,6 +1,6 @@ /* ============================================================================ ULTIMATE OVERRIDE — generated by demos/generate.mjs (do not edit by hand) - SLASHED v0.7.3 + SLASHED v0.7.0 Perturbs every CONFIGURABLE (role: "knob") token to a valid, visibly-distinct value so full-api-demo.html can prove each one is wired end to end. diff --git a/optional/components.css b/optional/components.css index 7b94349f..d1c4a0cf 100644 --- a/optional/components.css +++ b/optional/components.css @@ -111,6 +111,7 @@ border-color: var(--sf-btn-color); } .sf-btn--secondary:hover:not(:disabled, .sf-is-disabled, .sf-is-loading) { + background: color-mix(in oklab, var(--sf-btn-color) 8%, transparent); border-color: var(--sf-btn-color); } @@ -119,19 +120,8 @@ color: var(--sf-btn-color); border-color: transparent; } - - /* The subtle hover tint uses color-mix() — a modern colour function. Gate it - with @supports so engines without color-mix() skip the tint (the border / - foreground hover cues still apply) instead of dropping the background - declaration mid-rule. Mirrors the framework-wide gating policy enforced by - tests/tier1-p2-coverage.test.js + tests/tier1-p7-oldengine.test.js. */ - @supports (background: color-mix(in oklab, red, red)) { - .sf-btn--secondary:hover:not(:disabled, .sf-is-disabled, .sf-is-loading) { - background: color-mix(in oklab, var(--sf-btn-color) 8%, transparent); - } - .sf-btn--ghost:hover:not(:disabled, .sf-is-disabled, .sf-is-loading) { - background: color-mix(in oklab, var(--sf-btn-color) 8%, transparent); - } + .sf-btn--ghost:hover:not(:disabled, .sf-is-disabled, .sf-is-loading) { + background: color-mix(in oklab, var(--sf-btn-color) 8%, transparent); } /* Outline: transparent to start, fills with the family colour on hover. */ diff --git a/package-lock.json b/package-lock.json index 3913ee4c..44581a3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@release-it/conventional-changelog": "^11.0.1", "culori": "^4.0.2", "fast-check": "^4.8.0", + "lightningcss": "^1.32.0", "release-it": "^20.2.1", "stylelint": "^17.14.0", "stylelint-config-standard": "^40.0.0" @@ -2120,8 +2121,8 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=8" } @@ -3154,8 +3155,8 @@ "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, "license": "MPL-2.0", - "optional": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -3187,6 +3188,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3207,6 +3209,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3227,6 +3230,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3247,6 +3251,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3267,6 +3272,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3287,6 +3293,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3307,6 +3314,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3327,6 +3335,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3347,6 +3356,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3367,6 +3377,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -3387,6 +3398,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MPL-2.0", "optional": true, "os": [ diff --git a/package.json b/package.json index 44b92648..6e47119b 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "@release-it/conventional-changelog": "^11.0.1", "culori": "^4.0.2", "fast-check": "^4.8.0", + "lightningcss": "^1.32.0", "release-it": "^20.2.1", "stylelint": "^17.14.0", "stylelint-config-standard": "^40.0.0" diff --git a/reports/full-api-audit/REPORT.md b/reports/full-api-audit/REPORT.md index 9875f8cd..d63d8504 100644 --- a/reports/full-api-audit/REPORT.md +++ b/reports/full-api-audit/REPORT.md @@ -1,14 +1,3 @@ - - # SLASHED Full-API Testing Operation — Report **Framework:** SLASHED v0.6.25 · **Date:** 2026-06-30 diff --git a/scripts/artifacts.json b/scripts/artifacts.json index 45c8c9c2..2169a057 100644 --- a/scripts/artifacts.json +++ b/scripts/artifacts.json @@ -13,7 +13,6 @@ "token-registry.json", "dist/css-custom-data.json", "configurator/src/data/api-index.generated.json", - "configurator/src/data/classes.generated.json", "configurator/src/data/bundles.generated.json", "configurator/src/data/token-registry.generated.json" ] @@ -24,19 +23,8 @@ "buildCmd": "npm run configurator:sync", "outputs": [ "configurator/src/data/api-index.generated.json", - "configurator/src/data/classes.generated.json", "configurator/src/data/bundles.generated.json", "configurator/src/data/token-registry.generated.json" ] - }, - { - "name": "full-api demos", - "srcPrefixes": ["docs/api-index.json", "package.json", "core/", "optional/"], - "buildCmd": "node demos/generate.mjs", - "outputs": [ - "demos/full-api-demo.html", - "demos/full-api-demo-with-overrides.html", - "demos/ultimate-override.css" - ] } ] diff --git a/scripts/check-artifacts.js b/scripts/check-artifacts.js index 09bbb3bb..2d1f0ebf 100644 --- a/scripts/check-artifacts.js +++ b/scripts/check-artifacts.js @@ -60,19 +60,6 @@ if (mode === 'fix') { run(cmd, artifact.cwd ? resolve(root, artifact.cwd) : root); for (const out of artifact.outputs) { - // A declared output that isn't tracked can never fail the diff check - // below (`git diff` ignores untracked/ignored paths), so it would be - // silently unguarded — exactly the drift class this gate exists to catch. - // Fail loudly instead. - if (!isTracked(out)) { - console.error( - `::error::${out} is a declared artifact of "${artifact.name}" but is not git-tracked ` + - `(untracked or .gitignore'd) — the freshness check cannot guard it. ` + - `Commit the file, or remove it from scripts/artifacts.json.`, - ); - failed = true; - continue; - } try { gitFile('diff', '--exit-code', '--', out); } catch { diff --git a/scripts/gen-class-reference.js b/scripts/gen-class-reference.js index 587f2c32..b34d8238 100644 --- a/scripts/gen-class-reference.js +++ b/scripts/gen-class-reference.js @@ -9,45 +9,37 @@ import fs from 'node:fs'; import path from 'node:path'; import { CLASS_FILES } from './registry-sources.js'; -import { stripComments, stripStrings, requireFile } from './lib/parse.js'; const ROOT = path.resolve(import.meta.dirname, '..'); -// Human-facing section title per class-source file. Keyed by the same paths -// as registry-sources.js CLASS_FILES — a file added there without a title here -// fails loudly below rather than emitting an "undefined" section. -const FILE_TITLES = { - 'core/layout.css': 'Layout primitives', - 'core/macros.css': 'Macro classes', - 'core/states.css': 'State classes', - 'core/accessibility.css': 'Accessibility', - 'core/motion.css': 'Motion / entrances', - 'core/print.css': 'Print utilities', - 'core/themes.css': 'Theme utilities', - 'optional/forms.css': 'Forms', - 'optional/components.css': 'Components', - 'optional/theme-example.css': 'Theme example', - 'optional/utilities.css': 'Utilities', +const FILE_META = { + 'core/layout.css': { title: 'Layout primitives', prefix: 'sf-' }, + 'core/macros.css': { title: 'Macro classes', prefix: 'sf-' }, + 'core/states.css': { title: 'State classes', prefix: 'sf-is-' }, + 'core/accessibility.css': { title: 'Accessibility', prefix: 'sf-' }, + 'core/motion.css': { title: 'Motion / entrances', prefix: 'sf-' }, + 'core/print.css': { title: 'Print utilities', prefix: '' }, + 'core/themes.css': { title: 'Theme utilities', prefix: 'sf-' }, + 'optional/forms.css': { title: 'Forms', prefix: 'sf-' }, + 'optional/components.css':{ title: 'Components', prefix: 'sf-' }, + 'optional/theme-example.css': { title: 'Theme example', prefix: '' }, + 'optional/utilities.css': { title: 'Utilities', prefix: 'sf-' }, }; -const SOURCES = CLASS_FILES.map(f => { - const title = FILE_TITLES[f]; - if (!title) { - throw new Error( - `[docs:classes] ${f} is in registry-sources.js CLASS_FILES but has no title ` + - `in FILE_TITLES (scripts/gen-class-reference.js). Add one.`, - ); - } - return { file: f, title: `${title} (\`${f}\`)` }; -}); +const SOURCES = CLASS_FILES.map(f => ({ + file: f, + title: `${FILE_META[f].title} (\`${f}\`)`, + prefix: FILE_META[f].prefix, +})); function extract(file) { - // Same parsing contract as scripts/audit.js (the authoritative counter): - // strip comments then string literals so content:"…" can't yield false - // `.sf-*` matches. Shared via scripts/lib/parse.js so the two never diverge. - const css = stripStrings(stripComments( - requireFile(file, ROOT, `[docs:classes] Missing canonical class source file: ${file}`), - )); + const abs = path.join(ROOT, file); + if (!fs.existsSync(abs)) { + throw new Error(`[docs:classes] Missing canonical class source file: ${abs}`); + } + const css = fs.readFileSync(abs, 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, '') // strip block comments + .replace(/"[^"]*"|'[^']*'/g, '""'); // strip string literals const names = new Set(); for (const m of css.matchAll(/\.(sf-[\w-]+)/g)) names.add(m[1]); return [...names].sort(); diff --git a/scripts/lib/api-index/extract.js b/scripts/lib/api-index/extract.js index f48d8cf7..0c2f3a96 100644 --- a/scripts/lib/api-index/extract.js +++ b/scripts/lib/api-index/extract.js @@ -85,24 +85,6 @@ export const FILE_META = { 'optional/utilities.css': { category: 'Utilities', area: 'utilities', kind: 'utility' }, }; -/** - * FILE_META lookup that fails loudly for a source file added to - * registry-sources.js (TOKEN_FILES/CLASS_FILES) but not described here — instead - * of the opaque `Cannot read properties of undefined (reading 'kind')` crash. - * @param {string} rel source path relative to repo root - * @returns {{category:string, area:string, kind:string}} - */ -function metaFor(rel) { - const meta = FILE_META[rel]; - if (!meta) { - throw new Error( - `[api-index] ${rel} is in registry-sources.js but has no FILE_META entry ` + - `(scripts/lib/api-index/extract.js). Add its { category, area, kind }.`, - ); - } - return meta; -} - // ── Source masking (length-preserving) ───────────────────────────────────────── // Replacing comment/string bodies with spaces (NOT removing them) keeps every // character index stable, so a token/class match found in the masked text can @@ -438,7 +420,7 @@ export function extractClassesFromFile(rel, root, bundlesFor) { const masked = maskStrings(maskComments(original)); const comments = collectComments(original); const layer = layerOf(masked); - const meta = metaFor(rel); + const meta = FILE_META[rel]; const rows = new Map(); // name -> entry /** @@ -506,7 +488,7 @@ export function buildTokenEntries(root, bundlesFor, tokenAnnotations = {}, exist for (const rel of TOKEN_FILES) { const rows = extractTokensFromFile(rel, root); - const meta = metaFor(rel); + const meta = FILE_META[rel]; for (const [name, data] of rows) { const existing = merged.get(name); if (!existing) { diff --git a/scripts/lib/parse.js b/scripts/lib/parse.js index ffd93074..ba429977 100644 --- a/scripts/lib/parse.js +++ b/scripts/lib/parse.js @@ -2,8 +2,8 @@ * Shared CSS/file-reading helpers for the scripts/*.js generators and checks. * * Before this module, `stripComments`/`maskComments`, `readValue`, and - * `readFile` were each reimplemented independently across 4+ scripts (the - * SL-007 consolidation; see scripts/README.md). `stripComments` and `maskComments` + * `readFile` were each reimplemented independently across 4+ scripts — see + * docs/technical-debt-audit.md SL-007. `stripComments` and `maskComments` * are NOT interchangeable and must stay separate exports: * - stripComments REMOVES comment bodies, shortening the string. Fine for * scripts that only count/extract values and don't need offsets to line @@ -22,13 +22,9 @@ export function stripComments(css) { return css.replace(/\/\*[\s\S]*?\*\//g, ''); } -/** - * Remove quoted string literals, shortening the string. Not offset-safe. - * Honours backslash escapes so a literal quote inside a string - * (e.g. `content: "\""`) doesn't end the match early. - */ +/** Remove quoted string literals, shortening the string. Not offset-safe. */ export function stripStrings(css) { - return css.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, '""'); + return css.replace(/"[^"]*"|'[^']*'/g, '""'); } /** @@ -48,7 +44,7 @@ export function maskComments(css) { * @returns {string} CSS with string bodies blanked */ export function maskStrings(css) { - return css.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, (m) => m.replace(/[^\n]/g, ' ')); + return css.replace(/"[^"]*"|'[^']*'/g, (m) => m.replace(/[^\n]/g, ' ')); } /** diff --git a/tests/configurator-data-contract.test.js b/tests/configurator-data-contract.test.js deleted file mode 100644 index 593971e9..00000000 --- a/tests/configurator-data-contract.test.js +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Contract test for the generated configurator data files. - * - * configurator/src/data/*.generated.json is emitted by - * configurator/scripts/sync-api.mjs and *vendored into SLASHED-Plugins*. A - * silent shape change there (a renamed/dropped column in `projectToken`, or an - * empty catalogue) breaks the plugin with no signal at the framework boundary. - * This pins the shape the downstream consumer relies on: required keys must be - * present on every row, and the catalogues must be non-empty. It runs in the - * root unit suite (node --test → CI), independent of the configurator's own - * Vitest suite. - * - * Run: node --test tests/configurator-data-contract.test.js - */ -import { test, describe } from 'node:test'; -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; - -const ROOT = path.resolve(import.meta.dirname, '..'); -const DATA = path.join(ROOT, 'configurator', 'src', 'data'); - -const readJson = (rel) => JSON.parse(fs.readFileSync(path.join(DATA, rel), 'utf8')); - -// The columns the configurator UI + vendored plugin read off each row. Adding a -// field is safe; renaming/removing one is a breaking change and must update the -// consumer — this list is the tripwire that forces that decision. -const TOKEN_KEYS = [ - 'name', 'tier', 'role', 'namespace', 'category', 'group', 'description', - 'note', 'value', 'aliasOf', 'registered', 'syntax', 'fallbackOnly', - 'optional', 'layer', 'bundles', -]; -const CLASS_KEYS = ['name', 'selector', 'kind', 'category', 'group', 'description', 'optional', 'layer']; -const BUNDLE_KEYS = ['id', 'output', 'min', 'cdn', 'fileCount', 'optionalModules']; - -function assertRowShape(rows, requiredKeys, label) { - assert.ok(Array.isArray(rows) && rows.length > 0, `${label}: expected a non-empty array`); - for (const row of rows) { - for (const key of requiredKeys) { - assert.ok( - Object.prototype.hasOwnProperty.call(row, key), - `${label}: row "${row.name ?? row.id ?? JSON.stringify(row)}" is missing required key "${key}"`, - ); - } - } -} - -describe('generated configurator data honours its consumer contract', () => { - test('api-index.generated.json: every token row has the required columns', () => { - assertRowShape(readJson('api-index.generated.json').tokens, TOKEN_KEYS, 'tokens'); - }); - - test('classes.generated.json: every class row has the required columns', () => { - assertRowShape(readJson('classes.generated.json').classes, CLASS_KEYS, 'classes'); - }); - - test('bundles.generated.json: every bundle row has the required columns', () => { - assertRowShape(readJson('bundles.generated.json').bundles, BUNDLE_KEYS, 'bundles'); - }); - - test('token-registry.generated.json is a verbatim mirror of the root registry', () => { - const source = JSON.parse(fs.readFileSync(path.join(ROOT, 'token-registry.json'), 'utf8')); - const mirror = readJson('token-registry.generated.json'); - assert.deepEqual(mirror, source, 'configurator registry mirror has drifted from token-registry.json'); - }); -}); diff --git a/tests/supports-helpers.js b/tests/supports-helpers.js deleted file mode 100644 index 7b30a35d..00000000 --- a/tests/supports-helpers.js +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Shared helpers for the @supports-gating property tests (P2 source-level and - * P7 build-level). Keeping one implementation here stops the two tests from - * drifting apart — historically P7 used a line-based scanner that miscounted - * braces inside an @supports *prelude* (the `@property` feature-query form) and - * only passed by accident. - */ - -/** - * Remove every @supports block (prelude + body) so only ungated declarations - * remain. Character-based and parenthesis-aware: the block body is opened by - * the first `{` seen at parenthesis depth 0, so prelude braces — as in - * `@supports (@property --x { syntax: ""; }) { … }` — never fool it. - * Comments are stripped up front so commented-out braces or `@supports` text - * cannot steer the scan. - * @param {string} css - * @returns {string} CSS with all @supports blocks removed - */ -export function stripSupports(css) { - const src = css.replace(/\/\*[\s\S]*?\*\//g, ''); - const n = src.length; - - // Advance past a quoted string that starts at `pos` (src[pos] is the opening - // quote), honouring backslash escapes. Returns the index just after the - // closing quote (or n if unterminated). Braces/parens inside strings — e.g. - // `content: "}"` or a `url("data:…(…)…")` — must never perturb the brace/paren - // counting, mirroring findMatchingBrace() in scripts/bundle.js. - const skipString = (pos) => { - const quote = src[pos]; - let k = pos + 1; - while (k < n) { - if (src[k] === '\\') { k += 2; continue; } - if (src[k] === quote) return k + 1; - k++; - } - return n; - }; - - let out = ''; - let i = 0; - while (i < n) { - const ch = src[i]; - // Copy string literals verbatim so their contents can neither trigger - // @supports detection nor be re-scanned as braces/parens later. - if (ch === '"' || ch === "'") { - const end = skipString(i); - out += src.slice(i, end); - i = end; - continue; - } - // Match `@supports` only at a token boundary so it is never matched inside - // a selector or value. - if (src.startsWith('@supports', i) && (i === 0 || /[\s{}();,]/.test(src[i - 1]))) { - // Skip the prelude to the body-opening `{` at parenthesis depth 0. - let j = i + '@supports'.length; - let paren = 0; - while (j < n) { - const c = src[j]; - if (c === '"' || c === "'") { j = skipString(j); continue; } - if (c === '(') paren++; - else if (c === ')') paren--; - else if (c === '{' && paren === 0) break; - j++; - } - if (j >= n) break; // malformed tail — nothing sensible left to keep - // Walk the body to its matching close brace (nested at-rules included) - // and drop the whole block. - let depth = 1; - j++; - while (j < n && depth > 0) { - const c = src[j]; - if (c === '"' || c === "'") { j = skipString(j); continue; } - if (c === '{') depth++; - else if (c === '}') depth--; - j++; - } - i = j; // resume after the dropped block - continue; - } - out += ch; - i++; - } - return out; -} - -// Modern colour expressions that MUST live behind an @supports gate: an engine -// without them drops the declaration, so an ungated one is a real regression. -export const MODERN_EXPRESSIONS = [ - { name: 'light-dark()', re: /light-dark\(/ }, - { name: 'oklch(from …)', re: /oklch\(from\b/ }, - { name: 'color-mix()', re: /color-mix\(/ }, -]; - -/** - * Every ungated occurrence of a modern colour expression in `css` — custom - * properties AND ordinary declarations (e.g. `background: color-mix(...)` on a - * component class). Strips @supports blocks, then string literals, so a match - * can only be a live, ungated declaration. - * @param {string} css - * @returns {string[]} human-readable "expr → line" descriptions (empty = clean) - */ -export function findUngatedModernExpressions(css) { - const text = stripSupports(css) - .replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, '""'); // neutralise strings - const hits = []; - for (const rawLine of text.split('\n')) { - const line = rawLine.trim(); - for (const { name, re } of MODERN_EXPRESSIONS) { - if (re.test(line)) hits.push(`${name} → ${line.slice(0, 100)}`); - } - } - return hits; -} diff --git a/tests/supports-helpers.test.js b/tests/supports-helpers.test.js deleted file mode 100644 index 9969a527..00000000 --- a/tests/supports-helpers.test.js +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Unit tests for the shared @supports scanner (tests/supports-helpers.js), - * which backs both P2 (source-level) and P7 (build-level) gating tests. The - * cases below pin the tricky invariants: braces in the @supports *prelude* - * (the @property feature-query form) and delimiter characters inside string - * literals must never perturb block matching. - * - * Run: node --test tests/supports-helpers.test.js - */ -import { test, describe } from 'node:test'; -import assert from 'node:assert/strict'; -import { stripSupports, findUngatedModernExpressions } from './supports-helpers.js'; - -describe('stripSupports', () => { - test('removes a plain @supports block', () => { - const css = 'a{color:red}@supports (display:grid){b{color:blue}}c{color:green}'; - assert.equal(stripSupports(css), 'a{color:red}c{color:green}'); - }); - - test('handles an @property feature-query prelude that contains braces', () => { - const css = - 'a{color:red}@supports (@property --x { syntax: ""; inherits: false; }) {' + - ' @keyframes k { 50% { --x: oklch(from var(--x) 0.9 c h); } } } d{color:green}'; - const out = stripSupports(css); - assert.ok(!out.includes('oklch(from'), 'gated keyframe must be removed'); - assert.ok(out.includes('a{color:red}') && out.includes('d{color:green}'), 'surrounding rules kept'); - }); - - test('braces inside string literals do not end the block early', () => { - // The `}` inside content:"}" must not be read as the block close, or the - // trailing gated declaration would leak out. - const css = '@supports (display:grid){ x{content:"}"} y{background:color-mix(in oklab,red,blue)} } z{color:green}'; - const out = stripSupports(css); - assert.ok(!out.includes('color-mix('), 'gated color-mix must stay removed despite the "}" in a string'); - assert.ok(out.includes('z{color:green}'), 'rule after the block is preserved'); - }); - - test('parens inside a prelude string do not mis-locate the body brace', () => { - const css = '@supports (background: url("data:image/svg+xml;utf8,(){}")) { p{background:light-dark(red,blue)} } q{color:green}'; - const out = stripSupports(css); - assert.ok(!out.includes('light-dark('), 'gated declaration removed'); - assert.ok(out.includes('q{color:green}'), 'trailing rule preserved'); - }); - - test('does not match @supports appearing inside a string value', () => { - const css = 'a{content:"@supports (x) {"}b{color:green}'; - assert.equal(stripSupports(css), css, 'string-embedded @supports text is left untouched'); - }); -}); - -describe('findUngatedModernExpressions', () => { - test('flags an ungated modern expression in an ordinary declaration', () => { - const hits = findUngatedModernExpressions('.x:hover{background:color-mix(in oklab,var(--c) 8%,transparent)}'); - assert.equal(hits.length, 1); - assert.match(hits[0], /color-mix\(\)/); - }); - - test('is clean when the expression is properly gated', () => { - const css = '@supports (background: color-mix(in oklab, red, red)) { .x:hover{background:color-mix(in oklab,red,blue)} }'; - assert.deepEqual(findUngatedModernExpressions(css), []); - }); -}); diff --git a/tests/tier1-p2-coverage.test.js b/tests/tier1-p2-coverage.test.js index fc9aca4c..7754227f 100644 --- a/tests/tier1-p2-coverage.test.js +++ b/tests/tier1-p2-coverage.test.js @@ -1,16 +1,6 @@ /** - * Property 2: every CSS source file @supports-gates its modern colour - * expressions — no bare light-dark(), oklch(from…) or color-mix() - * declaration sits outside an @supports block. - * - * This is the source-level complement to tier1-p7-oldengine.test.js (which - * proves the same invariant on the built, bundled+minified artifact). P2 gives - * the early, per-file signal; both share one robust scanner - * (tests/supports-helpers.js) so they can never drift apart. - * - * Coverage: ALL of core/*.css and optional/*.css — not just tokens.css. A - * modern expression used directly in a component/utility rule (e.g. - * `background: color-mix(...)`) is caught here, not only in colour tokens. + * Property 2: tokens.css uses @supports gating — no bare light-dark() or + * oklch(from…)/color-mix() declarations outside a @supports block. * * The P2/P7/P8/P10 numbering gaps (no P1, P3-P6, P9 test files) are * explained in tests/README.md — not dead history, don't renumber. @@ -21,28 +11,33 @@ import { test, describe } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; -import { findUngatedModernExpressions } from './supports-helpers.js'; const ROOT = path.resolve(import.meta.dirname, '..'); -const SOURCE_FILES = ['core', 'optional'].flatMap((dir) => - fs - .readdirSync(path.join(ROOT, dir)) - .filter((f) => f.endsWith('.css')) - .map((f) => `${dir}/${f}`), -); +describe('P2: tokens.css modern expressions are @supports-gated', () => { + test('tokens.css has no ungated light-dark() declarations', () => { + const tokensCss = fs.readFileSync(path.join(ROOT, 'core/tokens.css'), 'utf8'); + const stripped = tokensCss.replace(/\/\*[\s\S]*?\*\//g, ''); + let inSupports = 0; + for (const line of stripped.split('\n')) { + if (line.includes('@supports')) inSupports++; + if (line.trim() === '}') { if (inSupports > 0) inSupports--; } + if (line.includes('light-dark(') && inSupports === 0) { + assert.fail(`Ungated light-dark() found in tokens.css: ${line.trim()}`); + } + } + }); -describe('P2: source CSS modern expressions are @supports-gated', () => { - for (const rel of SOURCE_FILES) { - test(`${rel} has no ungated modern colour expression`, () => { - const css = fs.readFileSync(path.join(ROOT, rel), 'utf8'); - const hits = findUngatedModernExpressions(css); - assert.deepEqual( - hits, - [], - `Ungated modern colour expression(s) in ${rel} — wrap them in ` + - `@supports:\n ${hits.join('\n ')}`, - ); - }); - } + test('tokens.css has no ungated oklch(from…) declarations', () => { + const tokensCss = fs.readFileSync(path.join(ROOT, 'core/tokens.css'), 'utf8'); + const stripped = tokensCss.replace(/\/\*[\s\S]*?\*\//g, ''); + let inSupports = 0; + for (const line of stripped.split('\n')) { + if (line.includes('@supports')) inSupports++; + if (line.trim() === '}') { if (inSupports > 0) inSupports--; } + if ((line.includes('oklch(from') || line.includes('color-mix(')) && inSupports === 0) { + assert.fail(`Ungated oklch(from)/color-mix() found in tokens.css: ${line.trim()}`); + } + } + }); }); diff --git a/tests/tier1-p7-oldengine.test.js b/tests/tier1-p7-oldengine.test.js index 23380904..398681f7 100644 --- a/tests/tier1-p7-oldengine.test.js +++ b/tests/tier1-p7-oldengine.test.js @@ -16,11 +16,37 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import fc from 'fast-check'; -import { stripSupports, findUngatedModernExpressions } from './supports-helpers.js'; const ROOT = path.resolve(import.meta.dirname, '..'); const DIST = path.join(ROOT, 'dist/slashed.full.css'); +/** + * Simulate an engine with no @supports by stripping all @supports blocks. + * Returns only declarations that survive (ungated ones). + */ +function stripSupports(css) { + let result = ''; + let depth = 0; + let inSupports = false; + let supportsDepth = 0; + for (const line of css.split('\n')) { + const trimmed = line.trim(); + if (trimmed.startsWith('@supports')) { + inSupports = true; + supportsDepth = depth; + } + if (!inSupports) result += line + '\n'; + for (const ch of line) { + if (ch === '{') depth++; + else if (ch === '}') { + depth--; + if (inSupports && depth <= supportsDepth) inSupports = false; + } + } + } + return result; +} + function extractCustomDecls(css) { const stripped = css.replace(/\/\*[\s\S]*?\*\//g, ''); const decls = new Map(); @@ -73,21 +99,6 @@ describe('P7: @supports gating — no modern expressions in ungated bundle decla } }); - // Whole-bundle guard: the checks above only inspect `--sf-*` custom-property - // declarations, so a modern expression used directly in an ordinary rule - // (e.g. `background: color-mix(...)` on a component class) slipped through. - // This scans every declaration — custom props and plain properties alike. - test('no ungated modern colour expression anywhere in the bundle (all declarations)', () => { - const css = fs.readFileSync(DIST, 'utf8'); - const hits = findUngatedModernExpressions(css); - assert.deepEqual( - hits, - [], - `Ungated modern colour expression(s) found in dist/slashed.full.css — ` + - `wrap them in @supports:\n ${hits.join('\n ')}`, - ); - }); - // fast-check: random sample from the checked set — none contain modern expressions test('fast-check: sampled core tokens have no modern expressions when ungated (100 iterations)', () => { fc.assert(