From 9a884e3002414ac7b8766d1f6a3c7899ea5ff1fb Mon Sep 17 00:00:00 2001 From: Foscat Date: Sat, 8 Aug 2026 03:16:21 -0500 Subject: [PATCH 01/22] chore: align companion package versions --- README.md | 11 ++++++----- index.html | 11 ++++++----- package-lock.json | 8 ++++---- package.json | 4 ++-- tests/documentation.test.mjs | 8 ++++---- tests/public-contract.test.mjs | 1 + tests/ui-style-kit-compat.spec.ts | 2 +- wiki/Installation-and-Usage.md | 7 ++++--- wiki/Token-Reference.md | 9 +++++---- 9 files changed, 33 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 0cb79a7..7bb6f2f 100644 --- a/README.md +++ b/README.md @@ -185,23 +185,24 @@ Read the [accessibility guide](https://github.com/Foscat/Interactive-Surface-CSS ## Pair with UI Style Kit CSS -When UI Style Kit owns visual paint, import its opt-in bridge and the state-only core: +When UI Style Kit owns visual paint, import its visual layer, token-and-paint bridge, and the state-only core: ```js -import "ui-style-kit-css/with-bridge.css"; +import "ui-style-kit-css/visual.css"; +import "ui-style-kit-css/interactive-surface-theme.css"; import "interactive-surface-css/state-core.css"; ``` -The bridge maps active UI Style Kit theme and mode values into the `--interactive-surface-*` contract. Interactive Surface keeps ownership of focus, hover, pressed, selected, current, loading, disabled, and motion behavior. UI Style Kit remains optional: this package declares no runtime or peer dependency on it, and the documented import order keeps its paint layer before `state-core.css`. +The token-and-paint bridge maps active UI Style Kit theme and mode values into the `--interactive-surface-*` contract. Interactive Surface keeps ownership of focus, hover, pressed, selected, current, loading, disabled, and motion behavior. UI Style Kit remains optional: this package declares no runtime or peer dependency on it, and the documented import order keeps its paint layer before `state-core.css`. ## Use all three libraries Use the established order so paint, interaction, and structure retain clear ownership: ```js -import "ui-style-kit-css/with-bridge.css"; +import "ui-style-kit-css/visual.css"; +import "ui-style-kit-css/interactive-surface-theme.css"; import "interactive-surface-css/state-core.css"; -import "layout-style-css/bridge.css"; import "layout-style-css"; ``` diff --git a/index.html b/index.html index ca556a4..dc4954a 100644 --- a/index.html +++ b/index.html @@ -751,23 +751,24 @@

Edit token

## Pair with UI Style Kit CSS -When UI Style Kit owns visual paint, import its opt-in bridge and the state-only core: +When UI Style Kit owns visual paint, import its visual layer, token-and-paint bridge, and the state-only core: ```js -import "ui-style-kit-css/with-bridge.css"; +import "ui-style-kit-css/visual.css"; +import "ui-style-kit-css/interactive-surface-theme.css"; import "interactive-surface-css/state-core.css"; ``` -The bridge maps active UI Style Kit theme and mode values into the `--interactive-surface-*` contract. Interactive Surface keeps ownership of focus, hover, pressed, selected, current, loading, disabled, and motion behavior. UI Style Kit remains optional: this package declares no runtime or peer dependency on it, and the documented import order keeps its paint layer before `state-core.css`. +The token-and-paint bridge maps active UI Style Kit theme and mode values into the `--interactive-surface-*` contract. Interactive Surface keeps ownership of focus, hover, pressed, selected, current, loading, disabled, and motion behavior. UI Style Kit remains optional: this package declares no runtime or peer dependency on it, and the documented import order keeps its paint layer before `state-core.css`. ## Use all three libraries Use the established order so paint, interaction, and structure retain clear ownership: ```js -import "ui-style-kit-css/with-bridge.css"; +import "ui-style-kit-css/visual.css"; +import "ui-style-kit-css/interactive-surface-theme.css"; import "interactive-surface-css/state-core.css"; -import "layout-style-css/bridge.css"; import "layout-style-css"; ``` diff --git a/package-lock.json b/package-lock.json index c8641d8..3a5698c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "prettier": "3.9.5", "stylelint": "^17.14.0", "stylelint-config-standard": "^40.0.0", - "ui-style-kit-css": "2.0.1" + "ui-style-kit-css": "2.1.0" }, "engines": { "node": ">=20" @@ -1685,9 +1685,9 @@ } }, "node_modules/ui-style-kit-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ui-style-kit-css/-/ui-style-kit-css-2.0.1.tgz", - "integrity": "sha512-yn3L6MfKJpIPbmueiJzpWSv5U7ZIU95ABXK+ltJU34qP38CnMSrImyYKK140M6fAgvbe08kHO4+fftC4z3dlTQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ui-style-kit-css/-/ui-style-kit-css-2.1.0.tgz", + "integrity": "sha512-GSMpvsxzr0Hr2rGDRGRxYM8y1mLEwt/q49OgCxik5EGH8L0bobe28wc1OYJ3xvOn0H9OtM2gKFLlkJZZL2zajw==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index eb3e46d..8929aa2 100644 --- a/package.json +++ b/package.json @@ -110,10 +110,10 @@ "devDependencies": { "@playwright/test": "^1.57.0", "clean-css": "~5.3.3", + "prettier": "3.9.5", "stylelint": "^17.14.0", "stylelint-config-standard": "^40.0.0", - "prettier": "3.9.5", - "ui-style-kit-css": "2.0.1" + "ui-style-kit-css": "2.1.0" }, "publishConfig": { "access": "public" diff --git a/tests/documentation.test.mjs b/tests/documentation.test.mjs index 67e712e..5b280f4 100644 --- a/tests/documentation.test.mjs +++ b/tests/documentation.test.mjs @@ -216,9 +216,9 @@ test("README teaches the complete semantic and ecosystem contract", () => { assert.match(readme, /`:focus-visible`[^\n]*orthogonal/i); const allThreeOrder = [ - 'import "ui-style-kit-css/with-bridge.css";', + 'import "ui-style-kit-css/visual.css";', + 'import "ui-style-kit-css/interactive-surface-theme.css";', 'import "interactive-surface-css/state-core.css";', - 'import "layout-style-css/bridge.css";', 'import "layout-style-css";', ]; assertAppearsInOrder( @@ -368,9 +368,9 @@ test("wiki installation and quality guidance matches the release-candidate packa ); assert.match(wiki.installation, /use one[^\n]*use two[^\n]*use all three/i); assertAppearsInOrder(wiki.installation, [ - 'import "ui-style-kit-css/with-bridge.css";', + 'import "ui-style-kit-css/visual.css";', + 'import "ui-style-kit-css/interactive-surface-theme.css";', 'import "interactive-surface-css/state-core.css";', - 'import "layout-style-css/bridge.css";', 'import "layout-style-css";', ]); diff --git a/tests/public-contract.test.mjs b/tests/public-contract.test.mjs index 2ecd4af..df5cff7 100644 --- a/tests/public-contract.test.mjs +++ b/tests/public-contract.test.mjs @@ -144,4 +144,5 @@ test("UI Style Kit remains an optional development-only compatibility fixture", assert.equal(manifest.dependencies?.["ui-style-kit-css"], undefined); assert.equal(manifest.optionalDependencies?.["ui-style-kit-css"], undefined); assert.equal(manifest.peerDependencies?.["ui-style-kit-css"], undefined); + assert.equal(manifest.devDependencies?.["ui-style-kit-css"], "2.1.0"); }); diff --git a/tests/ui-style-kit-compat.spec.ts b/tests/ui-style-kit-compat.spec.ts index 5678d57..a73b03c 100644 --- a/tests/ui-style-kit-compat.spec.ts +++ b/tests/ui-style-kit-compat.spec.ts @@ -222,7 +222,7 @@ async function expectStateLayerOpacity( .toBe(expectedOpacity); } -test.describe("ui-style-kit-css 2.0.1 compatibility", () => { +test.describe("ui-style-kit-css 2.1.0 legacy bridge compatibility", () => { for (const order of importOrders) { for (const system of systems) { for (const mode of modes) { diff --git a/wiki/Installation-and-Usage.md b/wiki/Installation-and-Usage.md index c61bd5a..b85210a 100644 --- a/wiki/Installation-and-Usage.md +++ b/wiki/Installation-and-Usage.md @@ -118,7 +118,8 @@ import "interactive-surface-css/standalone-preset.css"; ### With UI Style Kit CSS ```js -import "ui-style-kit-css/with-bridge.css"; +import "ui-style-kit-css/visual.css"; +import "ui-style-kit-css/interactive-surface-theme.css"; import "interactive-surface-css/state-core.css"; ``` @@ -127,9 +128,9 @@ UI Style Kit owns paint and theme modes; Interactive Surface owns interaction st ### With all three libraries ```js -import "ui-style-kit-css/with-bridge.css"; +import "ui-style-kit-css/visual.css"; +import "ui-style-kit-css/interactive-surface-theme.css"; import "interactive-surface-css/state-core.css"; -import "layout-style-css/bridge.css"; import "layout-style-css"; ``` diff --git a/wiki/Token-Reference.md b/wiki/Token-Reference.md index 1403978..fb6affe 100644 --- a/wiki/Token-Reference.md +++ b/wiki/Token-Reference.md @@ -170,13 +170,14 @@ These non-namespaced families are compatibility-only. Prefer the namespaced prop Use functional color notation such as `rgb(0 95 115)` or `hsl(190deg 100% 23%)`. Avoid reducing focus-ring visibility or disabled distinction below practical usability. -## UI Style Kit bridge +## UI Style Kit theme bridge -`ui-style-kit-css/with-bridge.css` maps its active theme and mode values into this public contract. Pair it with the state-only entry: +`ui-style-kit-css/interactive-surface-theme.css` maps active theme and mode values into this public contract. Pair UI paint, the token-and-paint bridge, and the state-only entry: ```js -import "ui-style-kit-css/with-bridge.css"; +import "ui-style-kit-css/visual.css"; +import "ui-style-kit-css/interactive-surface-theme.css"; import "interactive-surface-css/state-core.css"; ``` -UI Style Kit owns the mapped paint values. Interactive Surface owns state behavior and remains usable without the bridge. +UI Style Kit owns the mapped paint values. Interactive Surface owns state behavior and remains usable without the bridge. The stateful `ui-style-kit-css/with-bridge.css` path remains a deprecated migration-only compatibility export; new integrations should use the token-and-paint bridge above. From a845aaff5520534e46198a3fcd26939671a17ffe Mon Sep 17 00:00:00 2001 From: Foscat Date: Sat, 8 Aug 2026 03:33:49 -0500 Subject: [PATCH 02/22] test: align interactive surface ecosystem coverage --- tests/canonical-ui-theme.spec.ts | 71 ++++++++++ tests/fixtures/packed-ecosystem.ts | 209 +++++++++++++++++++++++++++++ tests/ui-style-kit-compat.spec.ts | 2 +- 3 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 tests/canonical-ui-theme.spec.ts create mode 100644 tests/fixtures/packed-ecosystem.ts diff --git a/tests/canonical-ui-theme.spec.ts b/tests/canonical-ui-theme.spec.ts new file mode 100644 index 0000000..e5713fb --- /dev/null +++ b/tests/canonical-ui-theme.spec.ts @@ -0,0 +1,71 @@ +import { expect, test } from "@playwright/test"; + +import { createPackedEcosystemFixture } from "./fixtures/packed-ecosystem"; + +test.describe("canonical UI Style Kit theme integration", () => { + test("packed Interactive Surface standalone entry renders the published preset", async ({ + page, + }) => { + const fixture = createPackedEcosystemFixture(); + + try { + await page.setContent(` + + + `); + + const backgroundColor = await page + .locator(".interactive-surface") + .evaluate( + (element) => window.getComputedStyle(element).backgroundColor, + ); + + expect(backgroundColor).not.toBe("rgba(0, 0, 0, 0)"); + expect( + fixture.resolvePublicExport( + "interactive-surface-css/standalone-preset.css", + ), + ).toContain("node_modules"); + } finally { + fixture.cleanup(); + } + }); + + test("packed canonical theme entry paints the surface while state core owns focus", async ({ + page, + }) => { + const fixture = createPackedEcosystemFixture({ includeUiStyleKit: true }); + + try { + expect(() => + fixture.resolvePublicExport("ui-style-kit-css/visual.css"), + ).not.toThrow(); + + await page.setContent(` + + + + + + + `); + + await page.keyboard.press("Tab"); + const styles = await page.locator("#primary").evaluate((element) => { + const computed = window.getComputedStyle(element); + + return { + backgroundColor: computed.backgroundColor, + borderWidth: computed.borderWidth, + outlineStyle: computed.outlineStyle, + }; + }); + + expect(styles.backgroundColor).not.toBe("rgba(0, 0, 0, 0)"); + expect(styles.borderWidth).toBe("1px"); + expect(styles.outlineStyle).toBe("solid"); + } finally { + fixture.cleanup(); + } + }); +}); diff --git a/tests/fixtures/packed-ecosystem.ts b/tests/fixtures/packed-ecosystem.ts new file mode 100644 index 0000000..2b8dd52 --- /dev/null +++ b/tests/fixtures/packed-ecosystem.ts @@ -0,0 +1,209 @@ +import { spawnSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; + +type PackageArtifact = { + name: string; + tarballPath: string; + version: string; +}; + +type PackedEcosystemOptions = { + includeUiStyleKit?: boolean; +}; + +const repositoryRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "..", +); +const uiStyleKitRoot = + process.env.UI_STYLE_KIT_CSS_SOURCE ?? + path.resolve(repositoryRoot, "..", "ui-style-kit-css"); + +function runNpm(args: string[], cwd: string, label: string) { + const executableDirectory = path.dirname(process.execPath); + const npmCliCandidates = [ + process.env.npm_execpath, + path.join(executableDirectory, "node_modules", "npm", "bin", "npm-cli.js"), + path.resolve( + executableDirectory, + "..", + "lib", + "node_modules", + "npm", + "bin", + "npm-cli.js", + ), + ].filter((candidate): candidate is string => Boolean(candidate)); + const npmCli = npmCliCandidates.find((candidate) => existsSync(candidate)); + + if (!npmCli) { + throw new Error( + "Unable to locate npm's JavaScript CLI for the packed fixture.", + ); + } + + const result = spawnSync(process.execPath, [npmCli, ...args], { + cwd, + encoding: "utf8", + maxBuffer: 10 * 1024 * 1024, + }); + + if (result.error || result.status !== 0 || result.signal) { + throw new Error( + `${label} failed.\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + { cause: result.error }, + ); + } + + return result.stdout; +} + +function packPackage( + packageRoot: string, + packDirectory: string, + cacheDirectory: string, +) { + const output = runNpm( + [ + "pack", + "--json", + "--ignore-scripts", + "--pack-destination", + packDirectory, + "--cache", + cacheDirectory, + ], + packageRoot, + `npm pack ${packageRoot}`, + ); + const [packedPackage] = JSON.parse(output) as Array<{ + filename: string; + name: string; + version: string; + }>; + + if (!packedPackage) { + throw new Error(`npm pack ${packageRoot} did not produce an artifact.`); + } + + const tarballPath = path.resolve(packDirectory, packedPackage.filename); + + if (!tarballPath.startsWith(`${path.resolve(packDirectory)}${path.sep}`)) { + throw new Error( + `npm pack ${packageRoot} wrote outside its fixture directory.`, + ); + } + + return { + name: packedPackage.name, + tarballPath, + version: packedPackage.version, + } satisfies PackageArtifact; +} + +export function createPackedEcosystemFixture( + options: PackedEcosystemOptions = {}, +) { + const fixtureDirectory = mkdtempSync( + path.join(tmpdir(), "interactive-surface-ecosystem-"), + ); + const packDirectory = path.join(fixtureDirectory, "packages"); + const consumerDirectory = path.join(fixtureDirectory, "consumer"); + const cacheDirectory = path.join(fixtureDirectory, ".npm-cache"); + + try { + mkdirSync(packDirectory, { recursive: true }); + mkdirSync(consumerDirectory, { recursive: true }); + const interactiveArtifact = packPackage( + repositoryRoot, + packDirectory, + cacheDirectory, + ); + const artifacts = [interactiveArtifact]; + + if (options.includeUiStyleKit) { + if (!existsSync(path.join(uiStyleKitRoot, "package.json"))) { + throw new Error( + `UI Style Kit source checkout is required at ${uiStyleKitRoot}.`, + ); + } + + const uiStyleKitArtifact = packPackage( + uiStyleKitRoot, + packDirectory, + cacheDirectory, + ); + + if ( + uiStyleKitArtifact.name !== "ui-style-kit-css" || + uiStyleKitArtifact.version !== "2.1.0" + ) { + throw new Error( + `Expected ui-style-kit-css@2.1.0, received ${uiStyleKitArtifact.name}@${uiStyleKitArtifact.version}.`, + ); + } + + artifacts.push(uiStyleKitArtifact); + } + + writeFileSync( + path.join(consumerDirectory, "package.json"), + `${JSON.stringify( + { + name: "interactive-surface-packed-consumer", + private: true, + type: "module", + }, + null, + 2, + )}\n`, + "utf8", + ); + runNpm( + [ + "install", + ...artifacts.map((artifact) => artifact.tarballPath), + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--no-package-lock", + "--cache", + cacheDirectory, + ], + consumerDirectory, + "npm install packed ecosystem", + ); + + const consumerRequire = createRequire( + path.join(consumerDirectory, "package.json"), + ); + + return { + artifacts, + cleanup() { + rmSync(fixtureDirectory, { force: true, recursive: true }); + }, + readCss(publicSpecifier: string) { + return readFileSync(this.resolvePublicExport(publicSpecifier), "utf8"); + }, + resolvePublicExport(publicSpecifier: string) { + return consumerRequire.resolve(publicSpecifier); + }, + }; + } catch (error) { + rmSync(fixtureDirectory, { force: true, recursive: true }); + throw error; + } +} diff --git a/tests/ui-style-kit-compat.spec.ts b/tests/ui-style-kit-compat.spec.ts index a73b03c..497daf0 100644 --- a/tests/ui-style-kit-compat.spec.ts +++ b/tests/ui-style-kit-compat.spec.ts @@ -222,7 +222,7 @@ async function expectStateLayerOpacity( .toBe(expectedOpacity); } -test.describe("ui-style-kit-css 2.1.0 legacy bridge compatibility", () => { +test.describe("deprecated compatibility: ui-style-kit-css 2.1.0 stateful bridges", () => { for (const order of importOrders) { for (const system of systems) { for (const mode of modes) { From 928ba808170ecf6f71a1135f85c3f9ba192cfae1 Mon Sep 17 00:00:00 2001 From: Foscat Date: Sat, 8 Aug 2026 03:45:09 -0500 Subject: [PATCH 03/22] test: harden packed ecosystem assertions --- tests/canonical-ui-theme.spec.ts | 89 ++++++++++++++++++------------ tests/fixtures/packed-ecosystem.ts | 29 +++++++++- 2 files changed, 82 insertions(+), 36 deletions(-) diff --git a/tests/canonical-ui-theme.spec.ts b/tests/canonical-ui-theme.spec.ts index e5713fb..92f40a5 100644 --- a/tests/canonical-ui-theme.spec.ts +++ b/tests/canonical-ui-theme.spec.ts @@ -1,7 +1,34 @@ -import { expect, test } from "@playwright/test"; +import { expect, test, type Page } from "@playwright/test"; import { createPackedEcosystemFixture } from "./fixtures/packed-ecosystem"; +async function surfaceSnapshot(page: Page, stylesheets: string[]) { + await page.setContent(` + ${stylesheets.map((stylesheet) => ``).join("\n")} + + + + + `); + await page.keyboard.press("Tab"); + + return page.locator("#surface").evaluate((element) => { + const computed = window.getComputedStyle(element); + const icon = window.getComputedStyle( + document.querySelector("#icon") as HTMLElement, + ); + + return { + borderRadius: computed.borderRadius, + focusOutlineStyle: computed.outlineStyle, + focusOutlineWidth: computed.outlineWidth, + themeRadius: computed.getPropertyValue("--interactive-surface-radius"), + iconMinHeight: icon.minHeight, + iconMinWidth: icon.minWidth, + }; + }); +} + test.describe("canonical UI Style Kit theme integration", () => { test("packed Interactive Surface standalone entry renders the published preset", async ({ page, @@ -9,18 +36,17 @@ test.describe("canonical UI Style Kit theme integration", () => { const fixture = createPackedEcosystemFixture(); try { - await page.setContent(` - - - `); + const standalone = fixture.readCss( + "interactive-surface-css/standalone-preset.css", + ); + const full = await surfaceSnapshot(page, [standalone]); + const nativeBaseline = await surfaceSnapshot(page, [""]); - const backgroundColor = await page - .locator(".interactive-surface") - .evaluate( - (element) => window.getComputedStyle(element).backgroundColor, - ); - - expect(backgroundColor).not.toBe("rgba(0, 0, 0, 0)"); + expect(full.borderRadius).toBe("12px"); + expect(full.iconMinHeight).toBe("44px"); + expect(full.iconMinWidth).toBe("44px"); + expect(nativeBaseline.borderRadius).not.toBe(full.borderRadius); + expect(nativeBaseline.iconMinHeight).not.toBe(full.iconMinHeight); expect( fixture.resolvePublicExport( "interactive-surface-css/standalone-preset.css", @@ -41,29 +67,24 @@ test.describe("canonical UI Style Kit theme integration", () => { fixture.resolvePublicExport("ui-style-kit-css/visual.css"), ).not.toThrow(); - await page.setContent(` - - - - - - - `); - - await page.keyboard.press("Tab"); - const styles = await page.locator("#primary").evaluate((element) => { - const computed = window.getComputedStyle(element); - - return { - backgroundColor: computed.backgroundColor, - borderWidth: computed.borderWidth, - outlineStyle: computed.outlineStyle, - }; - }); + const visual = fixture.readCss("ui-style-kit-css/visual.css"); + const theme = fixture.readCss( + "ui-style-kit-css/interactive-surface-theme.css", + ); + const stateCore = fixture.readCss( + "interactive-surface-css/state-core.css", + ); + const full = await surfaceSnapshot(page, [visual, theme, stateCore]); + const withoutTheme = await surfaceSnapshot(page, [visual, stateCore]); + const withoutStateCore = await surfaceSnapshot(page, [visual, theme]); - expect(styles.backgroundColor).not.toBe("rgba(0, 0, 0, 0)"); - expect(styles.borderWidth).toBe("1px"); - expect(styles.outlineStyle).toBe("solid"); + expect(full.themeRadius).toBe(".85rem"); + expect(withoutTheme.themeRadius).toBe(""); + expect(full.focusOutlineStyle).toBe("solid"); + expect(full.focusOutlineWidth).toBe("2px"); + expect(withoutStateCore.focusOutlineWidth).not.toBe( + full.focusOutlineWidth, + ); } finally { fixture.cleanup(); } diff --git a/tests/fixtures/packed-ecosystem.ts b/tests/fixtures/packed-ecosystem.ts index 2b8dd52..2899019 100644 --- a/tests/fixtures/packed-ecosystem.ts +++ b/tests/fixtures/packed-ecosystem.ts @@ -33,6 +33,7 @@ const uiStyleKitRoot = function runNpm(args: string[], cwd: string, label: string) { const executableDirectory = path.dirname(process.execPath); + // Use Node's npm CLI directly so fixture paths with spaces work on Windows and Unix. const npmCliCandidates = [ process.env.npm_execpath, path.join(executableDirectory, "node_modules", "npm", "bin", "npm-cli.js"), @@ -100,6 +101,7 @@ function packPackage( const tarballPath = path.resolve(packDirectory, packedPackage.filename); + // Keep consumer installation confined to this fixture even if npm returns a malformed filename. if (!tarballPath.startsWith(`${path.resolve(packDirectory)}${path.sep}`)) { throw new Error( `npm pack ${packageRoot} wrote outside its fixture directory.`, @@ -113,6 +115,19 @@ function packPackage( } satisfies PackageArtifact; } +function removeFixtureDirectory(fixtureDirectory: string) { + try { + rmSync(fixtureDirectory, { + force: true, + maxRetries: 3, + recursive: true, + retryDelay: 100, + }); + } catch (error) { + return error; + } +} + export function createPackedEcosystemFixture( options: PackedEcosystemOptions = {}, ) { @@ -193,7 +208,9 @@ export function createPackedEcosystemFixture( return { artifacts, cleanup() { - rmSync(fixtureDirectory, { force: true, recursive: true }); + const cleanupError = removeFixtureDirectory(fixtureDirectory); + + if (cleanupError) throw cleanupError; }, readCss(publicSpecifier: string) { return readFileSync(this.resolvePublicExport(publicSpecifier), "utf8"); @@ -203,7 +220,15 @@ export function createPackedEcosystemFixture( }, }; } catch (error) { - rmSync(fixtureDirectory, { force: true, recursive: true }); + const cleanupError = removeFixtureDirectory(fixtureDirectory); + + if (cleanupError) { + throw new AggregateError( + [error, cleanupError], + "Packed ecosystem setup failed and its fixture could not be removed.", + ); + } + throw error; } } From 566eece74bd679d8938f58c6200c8b156da3014e Mon Sep 17 00:00:00 2001 From: Foscat Date: Sat, 8 Aug 2026 13:03:58 -0500 Subject: [PATCH 04/22] test: strengthen packed integration coverage --- package.json | 2 +- tests/canonical-ui-theme.spec.ts | 96 +++++++++++++++--------------- tests/fixture-cleanup.test.mjs | 43 +++++++++++++ tests/fixtures/fixture-cleanup.mjs | 29 +++++++++ 4 files changed, 122 insertions(+), 48 deletions(-) create mode 100644 tests/fixture-cleanup.test.mjs create mode 100644 tests/fixtures/fixture-cleanup.mjs diff --git a/package.json b/package.json index 8929aa2..60965f0 100644 --- a/package.json +++ b/package.json @@ -91,7 +91,7 @@ "minify": "node ./scripts/build.mjs minify", "build": "node ./scripts/build.mjs", "test": "playwright test --config=./playwright.config.mjs", - "test:contracts": "node --test tests/public-contract.test.mjs tests/build.test.mjs tests/documentation.test.mjs", + "test:contracts": "node --test tests/public-contract.test.mjs tests/build.test.mjs tests/documentation.test.mjs tests/fixture-cleanup.test.mjs", "test:package": "node --test tests/package-contract.test.mjs", "test:chromium": "playwright test --config=./playwright.config.mjs --project=chromium", "test:install": "playwright install --with-deps chromium firefox webkit", diff --git a/tests/canonical-ui-theme.spec.ts b/tests/canonical-ui-theme.spec.ts index 92f40a5..693a305 100644 --- a/tests/canonical-ui-theme.spec.ts +++ b/tests/canonical-ui-theme.spec.ts @@ -1,6 +1,7 @@ import { expect, test, type Page } from "@playwright/test"; import { createPackedEcosystemFixture } from "./fixtures/packed-ecosystem"; +import { withFixtureCleanup } from "./fixtures/fixture-cleanup.mjs"; async function surfaceSnapshot(page: Page, stylesheets: string[]) { await page.setContent(` @@ -19,6 +20,7 @@ async function surfaceSnapshot(page: Page, stylesheets: string[]) { ); return { + backgroundColor: computed.backgroundColor, borderRadius: computed.borderRadius, focusOutlineStyle: computed.outlineStyle, focusOutlineWidth: computed.outlineWidth, @@ -33,60 +35,60 @@ test.describe("canonical UI Style Kit theme integration", () => { test("packed Interactive Surface standalone entry renders the published preset", async ({ page, }) => { - const fixture = createPackedEcosystemFixture(); - - try { - const standalone = fixture.readCss( - "interactive-surface-css/standalone-preset.css", - ); - const full = await surfaceSnapshot(page, [standalone]); - const nativeBaseline = await surfaceSnapshot(page, [""]); - - expect(full.borderRadius).toBe("12px"); - expect(full.iconMinHeight).toBe("44px"); - expect(full.iconMinWidth).toBe("44px"); - expect(nativeBaseline.borderRadius).not.toBe(full.borderRadius); - expect(nativeBaseline.iconMinHeight).not.toBe(full.iconMinHeight); - expect( - fixture.resolvePublicExport( + await withFixtureCleanup( + createPackedEcosystemFixture(), + async (fixture) => { + const standalone = fixture.readCss( "interactive-surface-css/standalone-preset.css", - ), - ).toContain("node_modules"); - } finally { - fixture.cleanup(); - } + ); + const full = await surfaceSnapshot(page, [standalone]); + const nativeBaseline = await surfaceSnapshot(page, [""]); + + expect(full.borderRadius).toBe("12px"); + expect(full.iconMinHeight).toBe("44px"); + expect(full.iconMinWidth).toBe("44px"); + expect(nativeBaseline.borderRadius).not.toBe(full.borderRadius); + expect(nativeBaseline.iconMinHeight).not.toBe(full.iconMinHeight); + expect( + fixture.resolvePublicExport( + "interactive-surface-css/standalone-preset.css", + ), + ).toContain("node_modules"); + }, + ); }); test("packed canonical theme entry paints the surface while state core owns focus", async ({ page, }) => { - const fixture = createPackedEcosystemFixture({ includeUiStyleKit: true }); - - try { - expect(() => - fixture.resolvePublicExport("ui-style-kit-css/visual.css"), - ).not.toThrow(); + await withFixtureCleanup( + createPackedEcosystemFixture({ includeUiStyleKit: true }), + async (fixture) => { + expect(() => + fixture.resolvePublicExport("ui-style-kit-css/visual.css"), + ).not.toThrow(); - const visual = fixture.readCss("ui-style-kit-css/visual.css"); - const theme = fixture.readCss( - "ui-style-kit-css/interactive-surface-theme.css", - ); - const stateCore = fixture.readCss( - "interactive-surface-css/state-core.css", - ); - const full = await surfaceSnapshot(page, [visual, theme, stateCore]); - const withoutTheme = await surfaceSnapshot(page, [visual, stateCore]); - const withoutStateCore = await surfaceSnapshot(page, [visual, theme]); + const visual = fixture.readCss("ui-style-kit-css/visual.css"); + const theme = fixture.readCss( + "ui-style-kit-css/interactive-surface-theme.css", + ); + const stateCore = fixture.readCss( + "interactive-surface-css/state-core.css", + ); + const full = await surfaceSnapshot(page, [visual, theme, stateCore]); + const withoutVisual = await surfaceSnapshot(page, [theme, stateCore]); + const withoutTheme = await surfaceSnapshot(page, [visual, stateCore]); + const withoutStateCore = await surfaceSnapshot(page, [visual, theme]); - expect(full.themeRadius).toBe(".85rem"); - expect(withoutTheme.themeRadius).toBe(""); - expect(full.focusOutlineStyle).toBe("solid"); - expect(full.focusOutlineWidth).toBe("2px"); - expect(withoutStateCore.focusOutlineWidth).not.toBe( - full.focusOutlineWidth, - ); - } finally { - fixture.cleanup(); - } + expect(full.backgroundColor).not.toBe(withoutVisual.backgroundColor); + expect(full.themeRadius).toBe(".85rem"); + expect(withoutTheme.themeRadius).toBe(""); + expect(full.focusOutlineStyle).toBe("solid"); + expect(full.focusOutlineWidth).toBe("2px"); + expect(withoutStateCore.focusOutlineWidth).not.toBe( + full.focusOutlineWidth, + ); + }, + ); }); }); diff --git a/tests/fixture-cleanup.test.mjs b/tests/fixture-cleanup.test.mjs new file mode 100644 index 0000000..abab837 --- /dev/null +++ b/tests/fixture-cleanup.test.mjs @@ -0,0 +1,43 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { withFixtureCleanup } from "./fixtures/fixture-cleanup.mjs"; + +test("preserves assertion and cleanup failures in one AggregateError", async () => { + const assertionFailure = new Error("assertion failed"); + const cleanupFailure = new Error("cleanup failed"); + + await assert.rejects( + withFixtureCleanup( + { + cleanup() { + throw cleanupFailure; + }, + }, + async () => { + throw assertionFailure; + }, + ), + (error) => { + assert.ok(error instanceof AggregateError); + assert.deepEqual(error.errors, [assertionFailure, cleanupFailure]); + return true; + }, + ); +}); + +test("reports a cleanup failure when the test body succeeds", async () => { + const cleanupFailure = new Error("cleanup failed"); + + await assert.rejects( + withFixtureCleanup( + { + cleanup() { + throw cleanupFailure; + }, + }, + async () => "result", + ), + cleanupFailure, + ); +}); diff --git a/tests/fixtures/fixture-cleanup.mjs b/tests/fixtures/fixture-cleanup.mjs new file mode 100644 index 0000000..b9eeb9f --- /dev/null +++ b/tests/fixtures/fixture-cleanup.mjs @@ -0,0 +1,29 @@ +export async function withFixtureCleanup(fixture, run) { + let bodyResult; + let bodyError; + + try { + bodyResult = await run(fixture); + } catch (error) { + bodyError = error; + } + + let cleanupError; + try { + fixture.cleanup(); + } catch (error) { + cleanupError = error; + } + + // Preserve the primary assertion failure while retaining teardown diagnostics. + if (bodyError && cleanupError) { + throw new AggregateError( + [bodyError, cleanupError], + "The test body and packed fixture cleanup both failed.", + ); + } + if (bodyError) throw bodyError; + if (cleanupError) throw cleanupError; + + return bodyResult; +} From e9c47c6233ade3c374eee168124763a15c40e13b Mon Sep 17 00:00:00 2001 From: Foscat Date: Sat, 8 Aug 2026 14:17:59 -0500 Subject: [PATCH 05/22] feat: publish interactive surface manifest --- manifest.json | 36 +++++++++++ package.json | 4 +- tests/manifest-contract.test.mjs | 105 +++++++++++++++++++++++++++++++ tests/package-contract.test.mjs | 2 + wiki/API-Reference.md | 2 + 5 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 manifest.json create mode 100644 tests/manifest-contract.test.mjs diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..fbd03b6 --- /dev/null +++ b/manifest.json @@ -0,0 +1,36 @@ +{ + "schemaVersion": 1, + "schemaPolicy": { + "compatibility": "additive-within-major", + "breakingChange": "increment-schemaVersion-before-removing-or-renaming-fields" + }, + "name": "interactive-surface-css", + "version": "1.5.0", + "entrypoints": { + "stateCore": "./state-core.css", + "standalonePreset": "./standalone-preset.css", + "compatibility": "./interactive-surface.css" + }, + "selectors": { + "stable": [".interactive-surface", ".size-sm", ".size-lg", ".icon-only"], + "deprecated": [], + "plannedRemoval": [] + }, + "states": { + "sizes": ["sm", "md", "lg"], + "variants": ["primary", "secondary", "accent", "subtle", "warning", "danger"], + "levels": ["1", "2", "3"], + "precedence": ["disabled", "busy-loading", "active", "persistent", "hover", "base"], + "ariaHooks": ["aria-pressed=true", "aria-pressed=mixed", "aria-current!=false", "aria-selected=true", "aria-busy=true", "aria-disabled=true"] + }, + "tokens": { + "public": [ + "--interactive-surface-bg", "--interactive-surface-fg", "--interactive-surface-border-color", "--interactive-surface-border-width", "--interactive-surface-radius", "--interactive-surface-focus-ring-color", "--interactive-surface-focus-ring-width", "--interactive-surface-focus-ring-offset", "--interactive-surface-state-layer-color", "--interactive-surface-state-layer-hover-opacity", "--interactive-surface-state-layer-focus-opacity", "--interactive-surface-state-layer-active-opacity", "--interactive-surface-transition-property", "--interactive-surface-transition-duration", "--interactive-surface-transition-easing", "--interactive-surface-transition-delay", "--interactive-surface-motion-default", "--interactive-surface-motion-press", "--interactive-surface-ease-standard", "--interactive-surface-ease-press", "--interactive-surface-lift-base", "--interactive-surface-lift-hover", "--interactive-surface-lift-active", "--interactive-surface-shadow-base", "--interactive-surface-shadow-hover", "--interactive-surface-shadow-active", "--interactive-surface-disabled-opacity", "--interactive-surface-tap-highlight-color", "--interactive-surface-light-icon-color", "--interactive-surface-dark-icon-color", "--interactive-surface-accessibility-icon-color" + ], + "fallbacks": ["--interactive-surface-motion-*", "--interactive-surface-ease-*", "--motion-*", "--ease-*"] + }, + "companions": { + "ui-style-kit-css": ">=2.1.0 <3.0.0", + "layout-style-css": ">=3.0.0 <4.0.0" + } +} diff --git a/package.json b/package.json index 60965f0..5dec36d 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "./interactive-surface.css": "./interactive-surface.css", "./state-core.css": "./state-core.css", "./standalone-preset.css": "./standalone-preset.css", + "./manifest.json": "./manifest.json", "./index.html": "./index.html", "./index.cjs": "./index.cjs", "./package.json": "./package.json" @@ -55,6 +56,7 @@ "files": [ "index.js", "index.cjs", + "manifest.json", "interactive-surface.css", "state-core.css", "standalone-preset.css", @@ -91,7 +93,7 @@ "minify": "node ./scripts/build.mjs minify", "build": "node ./scripts/build.mjs", "test": "playwright test --config=./playwright.config.mjs", - "test:contracts": "node --test tests/public-contract.test.mjs tests/build.test.mjs tests/documentation.test.mjs tests/fixture-cleanup.test.mjs", + "test:contracts": "node --test tests/manifest-contract.test.mjs tests/public-contract.test.mjs tests/build.test.mjs tests/documentation.test.mjs tests/fixture-cleanup.test.mjs", "test:package": "node --test tests/package-contract.test.mjs", "test:chromium": "playwright test --config=./playwright.config.mjs --project=chromium", "test:install": "playwright install --with-deps chromium firefox webkit", diff --git a/tests/manifest-contract.test.mjs b/tests/manifest-contract.test.mjs new file mode 100644 index 0000000..94e6511 --- /dev/null +++ b/tests/manifest-contract.test.mjs @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const root = fileURLToPath(new URL("..", import.meta.url)); +const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); +const manifest = JSON.parse(readFileSync(join(root, "manifest.json"), "utf8")); + +const entrypoints = { + stateCore: "./state-core.css", + standalonePreset: "./standalone-preset.css", + compatibility: "./interactive-surface.css" +}; +const variants = ["primary", "secondary", "accent", "subtle", "warning", "danger"]; +const publicTokens = [ + "--interactive-surface-bg", + "--interactive-surface-fg", + "--interactive-surface-focus-ring-color", + "--interactive-surface-transition-property", + "--interactive-surface-transition-duration", + "--interactive-surface-transition-easing", + "--interactive-surface-transition-delay" +]; + +test("ecosystem manifest publishes the interactive surface API and package export", () => { + assert.equal(manifest.schemaVersion, 1); + assert.equal(manifest.name, packageJson.name); + assert.equal(manifest.version, packageJson.version); + assert.equal(manifest.schemaPolicy.compatibility, "additive-within-major"); + assert.equal( + manifest.schemaPolicy.breakingChange, + "increment-schemaVersion-before-removing-or-renaming-fields" + ); + assert.deepEqual(manifest.entrypoints, entrypoints); + + for (const entrypoint of Object.values(entrypoints)) { + assert.equal( + packageJson.exports[entrypoint], + entrypoint, + `${entrypoint} must resolve through package exports` + ); + } + assert.equal(packageJson.exports["./manifest.json"], "./manifest.json"); + assert(packageJson.files.includes("manifest.json")); +}); + +test("ecosystem manifest describes real interactive state and token contracts", () => { + assert.deepEqual(manifest.selectors.stable, [ + ".interactive-surface", + ".size-sm", + ".size-lg", + ".icon-only" + ]); + assert.deepEqual(manifest.selectors.deprecated, []); + assert.deepEqual(manifest.selectors.plannedRemoval, []); + assert.deepEqual(manifest.states.sizes, ["sm", "md", "lg"]); + assert.deepEqual(manifest.states.variants, variants); + assert.deepEqual(manifest.states.levels, ["1", "2", "3"]); + assert.deepEqual(manifest.states.precedence, [ + "disabled", + "busy-loading", + "active", + "persistent", + "hover", + "base" + ]); + assert.deepEqual(manifest.states.ariaHooks, [ + "aria-pressed=true", + "aria-pressed=mixed", + "aria-current!=false", + "aria-selected=true", + "aria-busy=true", + "aria-disabled=true" + ]); + // The core tuple is required for state-only consumers; standalone tokens may extend it. + for (const token of publicTokens) { + assert(manifest.tokens.public.includes(token), `${token} must remain public`); + } + assert.deepEqual(manifest.tokens.fallbacks, [ + "--interactive-surface-motion-*", + "--interactive-surface-ease-*", + "--motion-*", + "--ease-*" + ]); + assert.deepEqual(manifest.companions, { + "ui-style-kit-css": ">=2.1.0 <3.0.0", + "layout-style-css": ">=3.0.0 <4.0.0" + }); + + const css = Object.values(entrypoints) + .map((entrypoint) => readFileSync(join(root, entrypoint), "utf8")) + .join("\n"); + for (const selector of manifest.selectors.stable) { + assert(css.includes(selector), `${selector} must remain in public CSS`); + } + for (const variant of manifest.states.variants) { + assert(css.includes(`variant-${variant}`), `${variant} must remain in public CSS`); + assert(css.includes(`data-surface-variant=\"${variant}\"`), `${variant} attribute hook must remain public`); + } + for (const token of manifest.tokens.public) { + assert(css.includes(token), `${token} must remain in public CSS`); + } +}); diff --git a/tests/package-contract.test.mjs b/tests/package-contract.test.mjs index 6357c46..ad77da4 100644 --- a/tests/package-contract.test.mjs +++ b/tests/package-contract.test.mjs @@ -42,6 +42,7 @@ const expectedPackedFiles = [ "index.html", "index.js", "interactive-surface.css", + "manifest.json", "package.json", "standalone-preset.css", "state-core.css", @@ -60,6 +61,7 @@ const expectedExports = { "./interactive-surface.css": "./interactive-surface.css", "./state-core.css": "./state-core.css", "./standalone-preset.css": "./standalone-preset.css", + "./manifest.json": "./manifest.json", "./index.html": "./index.html", "./index.cjs": "./index.cjs", "./package.json": "./package.json", diff --git a/wiki/API-Reference.md b/wiki/API-Reference.md index 0ac4479..0107aa2 100644 --- a/wiki/API-Reference.md +++ b/wiki/API-Reference.md @@ -32,6 +32,7 @@ The following table is contract-tested against `package.json`; it includes every | `exports["./interactive-surface.css"]` | `./interactive-surface.css` | | `exports["./state-core.css"]` | `./state-core.css` | | `exports["./standalone-preset.css"]` | `./standalone-preset.css` | +| `exports["./manifest.json"]` | `./manifest.json` | | `exports["./index.html"]` | `./index.html` | | `exports["./index.cjs"]` | `./index.cjs` | | `exports["./package.json"]` | `./package.json` | @@ -175,6 +176,7 @@ An icon-only control still needs an accessible name: - `interactive-surface-css/interactive-surface.css` - `interactive-surface-css/state-core.css` - `interactive-surface-css/standalone-preset.css` +- `interactive-surface-css/manifest.json` - `interactive-surface-css/index.html` - `interactive-surface-css/index.cjs` - `interactive-surface-css/package.json` From 6974032bee1a9b212f781dbfdcca661766f12e68 Mon Sep 17 00:00:00 2001 From: Foscat Date: Sat, 8 Aug 2026 14:33:29 -0500 Subject: [PATCH 06/22] fix: document interactive state hooks --- manifest.json | 11 +++++++++++ tests/manifest-contract.test.mjs | 30 ++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/manifest.json b/manifest.json index fbd03b6..e817336 100644 --- a/manifest.json +++ b/manifest.json @@ -13,6 +13,17 @@ }, "selectors": { "stable": [".interactive-surface", ".size-sm", ".size-lg", ".icon-only"], + "stateClasses": [".is-active", ".is-loading", ".is-disabled"], + "dataHooks": [ + { + "name": "data-surface-variant", + "selectors": ["[data-surface-variant='primary']", "[data-surface-variant='secondary']", "[data-surface-variant='accent']", "[data-surface-variant='subtle']", "[data-surface-variant='warning']", "[data-surface-variant='danger']"] + }, + { + "name": "data-surface-level", + "selectors": ["[data-surface-level='1']", "[data-surface-level='2']", "[data-surface-level='3']"] + } + ], "deprecated": [], "plannedRemoval": [] }, diff --git a/tests/manifest-contract.test.mjs b/tests/manifest-contract.test.mjs index 94e6511..1561ce5 100644 --- a/tests/manifest-contract.test.mjs +++ b/tests/manifest-contract.test.mjs @@ -14,6 +14,8 @@ const entrypoints = { compatibility: "./interactive-surface.css" }; const variants = ["primary", "secondary", "accent", "subtle", "warning", "danger"]; +const attributeSelector = (name, value) => + `[${name}='${value}']`; const publicTokens = [ "--interactive-surface-bg", "--interactive-surface-fg", @@ -53,6 +55,25 @@ test("ecosystem manifest describes real interactive state and token contracts", ".size-lg", ".icon-only" ]); + assert.deepEqual(manifest.selectors.stateClasses, [ + ".is-active", + ".is-loading", + ".is-disabled" + ]); + assert.deepEqual(manifest.selectors.dataHooks, [ + { + name: "data-surface-variant", + selectors: variants.map((variant) => attributeSelector("data-surface-variant", variant)) + }, + { + name: "data-surface-level", + selectors: [ + attributeSelector("data-surface-level", "1"), + attributeSelector("data-surface-level", "2"), + attributeSelector("data-surface-level", "3") + ] + } + ]); assert.deepEqual(manifest.selectors.deprecated, []); assert.deepEqual(manifest.selectors.plannedRemoval, []); assert.deepEqual(manifest.states.sizes, ["sm", "md", "lg"]); @@ -95,6 +116,15 @@ test("ecosystem manifest describes real interactive state and token contracts", for (const selector of manifest.selectors.stable) { assert(css.includes(selector), `${selector} must remain in public CSS`); } + for (const selector of manifest.selectors.stateClasses) { + assert(css.includes(selector), `${selector} must remain in public CSS`); + } + for (const hook of manifest.selectors.dataHooks) { + for (const selector of hook.selectors) { + const sourceSelector = selector.replaceAll("'", String.fromCharCode(34)); + assert(css.includes(sourceSelector), `${selector} must remain a public ${hook.name} hook`); + } + } for (const variant of manifest.states.variants) { assert(css.includes(`variant-${variant}`), `${variant} must remain in public CSS`); assert(css.includes(`data-surface-variant=\"${variant}\"`), `${variant} attribute hook must remain public`); From ccb3ac4c11e97517d5a329410c87348c24ad1537 Mon Sep 17 00:00:00 2001 From: Foscat Date: Sat, 8 Aug 2026 16:57:39 -0500 Subject: [PATCH 07/22] fix: complete interactive manifest inventory --- manifest.json | 153 ++++++++++++++++++- tests/manifest-contract.test.mjs | 247 ++++++++++++++++++++++++++----- 2 files changed, 352 insertions(+), 48 deletions(-) diff --git a/manifest.json b/manifest.json index e817336..397ba94 100644 --- a/manifest.json +++ b/manifest.json @@ -12,16 +12,49 @@ "compatibility": "./interactive-surface.css" }, "selectors": { - "stable": [".interactive-surface", ".size-sm", ".size-lg", ".icon-only"], + "stable": [ + ".interactive-surface", + ".size-sm", + ".size-lg", + ".icon-only", + ".variant-primary", + ".variant-secondary", + ".variant-accent", + ".variant-subtle", + ".variant-warning", + ".variant-danger", + ".light-icon", + ".dark-icon", + ".accessibility-icon" + ], "stateClasses": [".is-active", ".is-loading", ".is-disabled"], "dataHooks": [ { "name": "data-surface-variant", - "selectors": ["[data-surface-variant='primary']", "[data-surface-variant='secondary']", "[data-surface-variant='accent']", "[data-surface-variant='subtle']", "[data-surface-variant='warning']", "[data-surface-variant='danger']"] + "selectors": [ + "[data-surface-variant='primary']", + "[data-surface-variant='secondary']", + "[data-surface-variant='accent']", + "[data-surface-variant='subtle']", + "[data-surface-variant='warning']", + "[data-surface-variant='danger']" + ] }, { "name": "data-surface-level", - "selectors": ["[data-surface-level='1']", "[data-surface-level='2']", "[data-surface-level='3']"] + "selectors": [ + "[data-surface-level='1']", + "[data-surface-level='2']", + "[data-surface-level='3']" + ] + }, + { + "name": "data-icon-role", + "selectors": [ + "[data-icon-role='light']", + "[data-icon-role='dark']", + "[data-icon-role='accessibility']" + ] } ], "deprecated": [], @@ -29,16 +62,120 @@ }, "states": { "sizes": ["sm", "md", "lg"], - "variants": ["primary", "secondary", "accent", "subtle", "warning", "danger"], + "variants": [ + "primary", + "secondary", + "accent", + "subtle", + "warning", + "danger" + ], "levels": ["1", "2", "3"], - "precedence": ["disabled", "busy-loading", "active", "persistent", "hover", "base"], - "ariaHooks": ["aria-pressed=true", "aria-pressed=mixed", "aria-current!=false", "aria-selected=true", "aria-busy=true", "aria-disabled=true"] + "precedence": [ + "disabled", + "busy-loading", + "active", + "persistent", + "hover", + "base" + ], + "ariaHooks": [ + "aria-pressed=true", + "aria-pressed=mixed", + "aria-current!=false", + "aria-selected=true", + "aria-busy=true", + "aria-disabled=true" + ] }, "tokens": { "public": [ - "--interactive-surface-bg", "--interactive-surface-fg", "--interactive-surface-border-color", "--interactive-surface-border-width", "--interactive-surface-radius", "--interactive-surface-focus-ring-color", "--interactive-surface-focus-ring-width", "--interactive-surface-focus-ring-offset", "--interactive-surface-state-layer-color", "--interactive-surface-state-layer-hover-opacity", "--interactive-surface-state-layer-focus-opacity", "--interactive-surface-state-layer-active-opacity", "--interactive-surface-transition-property", "--interactive-surface-transition-duration", "--interactive-surface-transition-easing", "--interactive-surface-transition-delay", "--interactive-surface-motion-default", "--interactive-surface-motion-press", "--interactive-surface-ease-standard", "--interactive-surface-ease-press", "--interactive-surface-lift-base", "--interactive-surface-lift-hover", "--interactive-surface-lift-active", "--interactive-surface-shadow-base", "--interactive-surface-shadow-hover", "--interactive-surface-shadow-active", "--interactive-surface-disabled-opacity", "--interactive-surface-tap-highlight-color", "--interactive-surface-light-icon-color", "--interactive-surface-dark-icon-color", "--interactive-surface-accessibility-icon-color" + "--interactive-surface-accessibility-icon-color", + "--interactive-surface-accessibility-icon-color-dark", + "--interactive-surface-bg", + "--interactive-surface-border-color", + "--interactive-surface-border-width", + "--interactive-surface-darken-active", + "--interactive-surface-darken-hover", + "--interactive-surface-dark-icon-color", + "--interactive-surface-dark-icon-color-dark", + "--interactive-surface-disabled-opacity", + "--interactive-surface-ease-press", + "--interactive-surface-ease-standard", + "--interactive-surface-fg", + "--interactive-surface-focus-ring-color", + "--interactive-surface-focus-ring-offset", + "--interactive-surface-focus-ring-width", + "--interactive-surface-level-1-active-opacity", + "--interactive-surface-level-1-bg", + "--interactive-surface-level-1-border-color", + "--interactive-surface-level-1-focus-opacity", + "--interactive-surface-level-1-hover-opacity", + "--interactive-surface-level-1-shadow", + "--interactive-surface-level-2-active-opacity", + "--interactive-surface-level-2-bg", + "--interactive-surface-level-2-border-color", + "--interactive-surface-level-2-focus-opacity", + "--interactive-surface-level-2-hover-opacity", + "--interactive-surface-level-2-shadow", + "--interactive-surface-level-3-active-opacity", + "--interactive-surface-level-3-bg", + "--interactive-surface-level-3-border-color", + "--interactive-surface-level-3-focus-opacity", + "--interactive-surface-level-3-hover-opacity", + "--interactive-surface-level-3-shadow", + "--interactive-surface-level-bg", + "--interactive-surface-level-border-color", + "--interactive-surface-level-shadow", + "--interactive-surface-lift-active", + "--interactive-surface-lift-base", + "--interactive-surface-lift-hover", + "--interactive-surface-light-icon-color", + "--interactive-surface-light-icon-color-dark", + "--interactive-surface-motion-default", + "--interactive-surface-motion-press", + "--interactive-surface-radius", + "--interactive-surface-shadow-active", + "--interactive-surface-shadow-base", + "--interactive-surface-shadow-hover", + "--interactive-surface-state-layer-active-opacity", + "--interactive-surface-state-layer-color", + "--interactive-surface-state-layer-focus-opacity", + "--interactive-surface-state-layer-hover-opacity", + "--interactive-surface-state-layer-opacity", + "--interactive-surface-state-layer-opacity-active", + "--interactive-surface-state-layer-opacity-focus", + "--interactive-surface-state-layer-opacity-hover", + "--interactive-surface-tap-highlight-color", + "--interactive-surface-transition-property", + "--interactive-surface-transition-duration", + "--interactive-surface-transition-easing", + "--interactive-surface-transition-delay", + "--interactive-surface-variant-accent-bg", + "--interactive-surface-variant-accent-border-color", + "--interactive-surface-variant-accent-fg", + "--interactive-surface-variant-danger-bg", + "--interactive-surface-variant-danger-border-color", + "--interactive-surface-variant-danger-fg", + "--interactive-surface-variant-primary-bg", + "--interactive-surface-variant-primary-border-color", + "--interactive-surface-variant-primary-fg", + "--interactive-surface-variant-secondary-bg", + "--interactive-surface-variant-secondary-border-color", + "--interactive-surface-variant-secondary-fg", + "--interactive-surface-variant-subtle-bg", + "--interactive-surface-variant-subtle-border-color", + "--interactive-surface-variant-subtle-fg", + "--interactive-surface-variant-warning-bg", + "--interactive-surface-variant-warning-border-color", + "--interactive-surface-variant-warning-fg" ], - "fallbacks": ["--interactive-surface-motion-*", "--interactive-surface-ease-*", "--motion-*", "--ease-*"] + "fallbacks": [ + "--interactive-surface-motion-*", + "--interactive-surface-ease-*", + "--motion-*", + "--ease-*" + ] }, "companions": { "ui-style-kit-css": ">=2.1.0 <3.0.0", diff --git a/tests/manifest-contract.test.mjs b/tests/manifest-contract.test.mjs index 1561ce5..fe6b40c 100644 --- a/tests/manifest-contract.test.mjs +++ b/tests/manifest-contract.test.mjs @@ -5,25 +5,165 @@ import { fileURLToPath } from "node:url"; import test from "node:test"; const root = fileURLToPath(new URL("..", import.meta.url)); -const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); +const packageJson = JSON.parse( + readFileSync(join(root, "package.json"), "utf8"), +); const manifest = JSON.parse(readFileSync(join(root, "manifest.json"), "utf8")); +const tokenReference = readFileSync( + join(root, "wiki", "Token-Reference.md"), + "utf8", +); +const apiReference = readFileSync( + join(root, "wiki", "API-Reference.md"), + "utf8", +); const entrypoints = { stateCore: "./state-core.css", standalonePreset: "./standalone-preset.css", - compatibility: "./interactive-surface.css" + compatibility: "./interactive-surface.css", }; -const variants = ["primary", "secondary", "accent", "subtle", "warning", "danger"]; -const attributeSelector = (name, value) => - `[${name}='${value}']`; +const variants = [ + "primary", + "secondary", + "accent", + "subtle", + "warning", + "danger", +]; +const attributeSelector = (name, value) => `[${name}='${value}']`; +const stableSelectors = [ + ".interactive-surface", + ".size-sm", + ".size-lg", + ".icon-only", + ".variant-primary", + ".variant-secondary", + ".variant-accent", + ".variant-subtle", + ".variant-warning", + ".variant-danger", + ".light-icon", + ".dark-icon", + ".accessibility-icon", +]; +const dataHooks = [ + { + name: "data-surface-variant", + selectors: variants.map((variant) => + attributeSelector("data-surface-variant", variant), + ), + }, + { + name: "data-surface-level", + selectors: [ + attributeSelector("data-surface-level", "1"), + attributeSelector("data-surface-level", "2"), + attributeSelector("data-surface-level", "3"), + ], + }, + { + name: "data-icon-role", + selectors: [ + attributeSelector("data-icon-role", "light"), + attributeSelector("data-icon-role", "dark"), + attributeSelector("data-icon-role", "accessibility"), + ], + }, +]; +const documentedDataHooks = [ + 'data-surface-variant="primary"', + 'data-surface-variant="secondary"', + 'data-surface-variant="accent"', + 'data-surface-variant="subtle"', + 'data-surface-variant="warning"', + 'data-surface-variant="danger"', + 'data-surface-level="1|2|3"', + 'data-icon-role="light"', + 'data-icon-role="dark"', + 'data-icon-role="accessibility"', +]; +// A literal inventory prevents the public CSS, documentation, and ecosystem manifest from drifting independently. const publicTokens = [ + "--interactive-surface-accessibility-icon-color", + "--interactive-surface-accessibility-icon-color-dark", "--interactive-surface-bg", + "--interactive-surface-border-color", + "--interactive-surface-border-width", + "--interactive-surface-darken-active", + "--interactive-surface-darken-hover", + "--interactive-surface-dark-icon-color", + "--interactive-surface-dark-icon-color-dark", + "--interactive-surface-disabled-opacity", + "--interactive-surface-ease-press", + "--interactive-surface-ease-standard", "--interactive-surface-fg", "--interactive-surface-focus-ring-color", + "--interactive-surface-focus-ring-offset", + "--interactive-surface-focus-ring-width", + "--interactive-surface-level-1-active-opacity", + "--interactive-surface-level-1-bg", + "--interactive-surface-level-1-border-color", + "--interactive-surface-level-1-focus-opacity", + "--interactive-surface-level-1-hover-opacity", + "--interactive-surface-level-1-shadow", + "--interactive-surface-level-2-active-opacity", + "--interactive-surface-level-2-bg", + "--interactive-surface-level-2-border-color", + "--interactive-surface-level-2-focus-opacity", + "--interactive-surface-level-2-hover-opacity", + "--interactive-surface-level-2-shadow", + "--interactive-surface-level-3-active-opacity", + "--interactive-surface-level-3-bg", + "--interactive-surface-level-3-border-color", + "--interactive-surface-level-3-focus-opacity", + "--interactive-surface-level-3-hover-opacity", + "--interactive-surface-level-3-shadow", + "--interactive-surface-level-bg", + "--interactive-surface-level-border-color", + "--interactive-surface-level-shadow", + "--interactive-surface-lift-active", + "--interactive-surface-lift-base", + "--interactive-surface-lift-hover", + "--interactive-surface-light-icon-color", + "--interactive-surface-light-icon-color-dark", + "--interactive-surface-motion-default", + "--interactive-surface-motion-press", + "--interactive-surface-radius", + "--interactive-surface-shadow-active", + "--interactive-surface-shadow-base", + "--interactive-surface-shadow-hover", + "--interactive-surface-state-layer-active-opacity", + "--interactive-surface-state-layer-color", + "--interactive-surface-state-layer-focus-opacity", + "--interactive-surface-state-layer-hover-opacity", + "--interactive-surface-state-layer-opacity", + "--interactive-surface-state-layer-opacity-active", + "--interactive-surface-state-layer-opacity-focus", + "--interactive-surface-state-layer-opacity-hover", + "--interactive-surface-tap-highlight-color", "--interactive-surface-transition-property", "--interactive-surface-transition-duration", "--interactive-surface-transition-easing", - "--interactive-surface-transition-delay" + "--interactive-surface-transition-delay", + "--interactive-surface-variant-accent-bg", + "--interactive-surface-variant-accent-border-color", + "--interactive-surface-variant-accent-fg", + "--interactive-surface-variant-danger-bg", + "--interactive-surface-variant-danger-border-color", + "--interactive-surface-variant-danger-fg", + "--interactive-surface-variant-primary-bg", + "--interactive-surface-variant-primary-border-color", + "--interactive-surface-variant-primary-fg", + "--interactive-surface-variant-secondary-bg", + "--interactive-surface-variant-secondary-border-color", + "--interactive-surface-variant-secondary-fg", + "--interactive-surface-variant-subtle-bg", + "--interactive-surface-variant-subtle-border-color", + "--interactive-surface-variant-subtle-fg", + "--interactive-surface-variant-warning-bg", + "--interactive-surface-variant-warning-border-color", + "--interactive-surface-variant-warning-fg", ]; test("ecosystem manifest publishes the interactive surface API and package export", () => { @@ -33,7 +173,7 @@ test("ecosystem manifest publishes the interactive surface API and package expor assert.equal(manifest.schemaPolicy.compatibility, "additive-within-major"); assert.equal( manifest.schemaPolicy.breakingChange, - "increment-schemaVersion-before-removing-or-renaming-fields" + "increment-schemaVersion-before-removing-or-renaming-fields", ); assert.deepEqual(manifest.entrypoints, entrypoints); @@ -41,7 +181,7 @@ test("ecosystem manifest publishes the interactive surface API and package expor assert.equal( packageJson.exports[entrypoint], entrypoint, - `${entrypoint} must resolve through package exports` + `${entrypoint} must resolve through package exports`, ); } assert.equal(packageJson.exports["./manifest.json"], "./manifest.json"); @@ -49,31 +189,13 @@ test("ecosystem manifest publishes the interactive surface API and package expor }); test("ecosystem manifest describes real interactive state and token contracts", () => { - assert.deepEqual(manifest.selectors.stable, [ - ".interactive-surface", - ".size-sm", - ".size-lg", - ".icon-only" - ]); + assert.deepEqual(manifest.selectors.stable, stableSelectors); assert.deepEqual(manifest.selectors.stateClasses, [ ".is-active", ".is-loading", - ".is-disabled" - ]); - assert.deepEqual(manifest.selectors.dataHooks, [ - { - name: "data-surface-variant", - selectors: variants.map((variant) => attributeSelector("data-surface-variant", variant)) - }, - { - name: "data-surface-level", - selectors: [ - attributeSelector("data-surface-level", "1"), - attributeSelector("data-surface-level", "2"), - attributeSelector("data-surface-level", "3") - ] - } + ".is-disabled", ]); + assert.deepEqual(manifest.selectors.dataHooks, dataHooks); assert.deepEqual(manifest.selectors.deprecated, []); assert.deepEqual(manifest.selectors.plannedRemoval, []); assert.deepEqual(manifest.states.sizes, ["sm", "md", "lg"]); @@ -85,7 +207,7 @@ test("ecosystem manifest describes real interactive state and token contracts", "active", "persistent", "hover", - "base" + "base", ]); assert.deepEqual(manifest.states.ariaHooks, [ "aria-pressed=true", @@ -93,21 +215,18 @@ test("ecosystem manifest describes real interactive state and token contracts", "aria-current!=false", "aria-selected=true", "aria-busy=true", - "aria-disabled=true" + "aria-disabled=true", ]); - // The core tuple is required for state-only consumers; standalone tokens may extend it. - for (const token of publicTokens) { - assert(manifest.tokens.public.includes(token), `${token} must remain public`); - } + assert.deepEqual(manifest.tokens.public, publicTokens); assert.deepEqual(manifest.tokens.fallbacks, [ "--interactive-surface-motion-*", "--interactive-surface-ease-*", "--motion-*", - "--ease-*" + "--ease-*", ]); assert.deepEqual(manifest.companions, { "ui-style-kit-css": ">=2.1.0 <3.0.0", - "layout-style-css": ">=3.0.0 <4.0.0" + "layout-style-css": ">=3.0.0 <4.0.0", }); const css = Object.values(entrypoints) @@ -122,14 +241,62 @@ test("ecosystem manifest describes real interactive state and token contracts", for (const hook of manifest.selectors.dataHooks) { for (const selector of hook.selectors) { const sourceSelector = selector.replaceAll("'", String.fromCharCode(34)); - assert(css.includes(sourceSelector), `${selector} must remain a public ${hook.name} hook`); + assert( + css.includes(sourceSelector), + `${selector} must remain a public ${hook.name} hook`, + ); } } for (const variant of manifest.states.variants) { - assert(css.includes(`variant-${variant}`), `${variant} must remain in public CSS`); - assert(css.includes(`data-surface-variant=\"${variant}\"`), `${variant} attribute hook must remain public`); + assert( + css.includes(`variant-${variant}`), + `${variant} must remain in public CSS`, + ); + assert( + css.includes(`data-surface-variant=\"${variant}\"`), + `${variant} attribute hook must remain public`, + ); } for (const token of manifest.tokens.public) { assert(css.includes(token), `${token} must remain in public CSS`); } }); + +test("manifest inventories the documented and implemented public contract bidirectionally", () => { + const authoredCss = ["styles/state-core.css", "styles/standalone-preset.css"] + .map((file) => readFileSync(join(root, file), "utf8")) + .join("\n"); + const implementedTokens = [ + ...new Set(authoredCss.match(/--interactive-surface-[a-z0-9-]+/g) ?? []), + ].sort(); + + assert.deepEqual([...manifest.tokens.public].sort(), implementedTokens); + for (const token of manifest.tokens.public) { + assert( + tokenReference.includes(`\`${token}\``), + `${token} must remain documented`, + ); + } + for (const selector of stableSelectors) { + assert( + authoredCss.includes(selector), + `${selector} must remain implemented`, + ); + assert( + apiReference.includes(`\`${selector}\``), + `${selector} must remain documented`, + ); + } + for (const hook of dataHooks) { + for (const selector of hook.selectors) { + const sourceSelector = selector.replaceAll("'", String.fromCharCode(34)); + assert( + authoredCss.includes(sourceSelector), + `${selector} must remain implemented`, + ); + } + } + for (const hook of documentedDataHooks) { + assert(apiReference.includes(hook), `${hook} must remain documented`); + } +}); From bc611ca42f8afff5be7aac8523a4caa39ad068a1 Mon Sep 17 00:00:00 2001 From: Foscat Date: Sat, 8 Aug 2026 18:14:37 -0500 Subject: [PATCH 08/22] test: enforce css ownership boundaries --- ownership-allowlist.json | 18 ++ package-lock.json | 1 + package.json | 6 +- scripts/check-css-ownership.mjs | 327 ++++++++++++++++++++++++++++ tests/ownership-boundaries.test.mjs | 100 +++++++++ tests/package-contract.test.mjs | 1 + 6 files changed, 451 insertions(+), 2 deletions(-) create mode 100644 ownership-allowlist.json create mode 100644 scripts/check-css-ownership.mjs create mode 100644 tests/ownership-boundaries.test.mjs diff --git a/ownership-allowlist.json b/ownership-allowlist.json new file mode 100644 index 0000000..84f063d --- /dev/null +++ b/ownership-allowlist.json @@ -0,0 +1,18 @@ +{ + "state-core": [ + { + "selector": ".interactive-surface", + "property": "--_is-focus-ring-color", + "reason": "Preserves a visible WCAG-oriented focus fallback when no companion theme supplies the public focus token.", + "owner": "interactive-surface-css", + "reviewDate": "2026-08-08" + }, + { + "selector": ".interactive-surface", + "property": "--_is-tap-highlight", + "reason": "Preserves visible touch feedback as an accessibility fallback when no companion theme supplies a tap color.", + "owner": "interactive-surface-css", + "reviewDate": "2026-08-08" + } + ] +} diff --git a/package-lock.json b/package-lock.json index 3a5698c..770d3f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "devDependencies": { "@playwright/test": "^1.57.0", "clean-css": "~5.3.3", + "css-tree": "3.2.1", "prettier": "3.9.5", "stylelint": "^17.14.0", "stylelint-config-standard": "^40.0.0", diff --git a/package.json b/package.json index 5dec36d..7d76a7a 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ }, "scripts": { "check:no-hex-colors": "node ./scripts/check-no-hex-colors.mjs", + "check:ownership": "node ./scripts/check-css-ownership.mjs", "check:generated": "node ./scripts/build.mjs check", "check:public": "node ./scripts/build.mjs check-public", "lint:css": "stylelint \"styles/**/*.css\" \"*.css\" \"demo/**/*.css\"", @@ -93,7 +94,7 @@ "minify": "node ./scripts/build.mjs minify", "build": "node ./scripts/build.mjs", "test": "playwright test --config=./playwright.config.mjs", - "test:contracts": "node --test tests/manifest-contract.test.mjs tests/public-contract.test.mjs tests/build.test.mjs tests/documentation.test.mjs tests/fixture-cleanup.test.mjs", + "test:contracts": "node --test tests/manifest-contract.test.mjs tests/public-contract.test.mjs tests/build.test.mjs tests/documentation.test.mjs tests/fixture-cleanup.test.mjs tests/ownership-boundaries.test.mjs", "test:package": "node --test tests/package-contract.test.mjs", "test:chromium": "playwright test --config=./playwright.config.mjs --project=chromium", "test:install": "playwright install --with-deps chromium firefox webkit", @@ -102,7 +103,7 @@ "format": "prettier --write .", "format:check": "prettier --check .", "validate:node20": "npm run validate:ci", - "validate:publish": "npm run check:no-hex-colors && npm run lint:css && npm run check:public && npm run build && npm run check:generated && npm run test:contracts && npm run test:package && npm run pack:dry", + "validate:publish": "npm run check:no-hex-colors && npm run lint:css && npm run check:public && npm run build && npm run check:generated && npm run check:ownership && npm run test:contracts && npm run test:package && npm run pack:dry", "validate:ci": "npm run validate:publish && npm run audit", "validate:browsers": "npm run validate:ci && npm run test:install:chromium && npm run test:chromium", "validate:full": "npm run validate:ci && npm run test:install && npm test", @@ -112,6 +113,7 @@ "devDependencies": { "@playwright/test": "^1.57.0", "clean-css": "~5.3.3", + "css-tree": "3.2.1", "prettier": "3.9.5", "stylelint": "^17.14.0", "stylelint-config-standard": "^40.0.0", diff --git a/scripts/check-css-ownership.mjs b/scripts/check-css-ownership.mjs new file mode 100644 index 0000000..f231072 --- /dev/null +++ b/scripts/check-css-ownership.mjs @@ -0,0 +1,327 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { generate, parse, walk } from "css-tree"; + +const packageRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); +const allowlistFields = [ + "owner", + "property", + "reason", + "reviewDate", + "selector", +]; +const pageTopologyProperties = new Set([ + "grid-area", + "grid-template", + "grid-template-areas", + "grid-template-columns", + "grid-template-rows", + "order", +]); +const pageSelectorPattern = + /(?:^|[-_.])(?:container|layout|main|page|shell|wrapper)(?:$|[-_ .:#[])/; +const majorPageProperties = new Set([ + "display", + "inline-size", + "margin", + "max-inline-size", + "max-width", + "min-inline-size", + "min-width", + "padding", + "position", + "width", +]); +const paintProperties = new Set([ + "background", + "background-color", + "background-image", + "border", + "border-color", + "border-bottom-color", + "border-left-color", + "border-right-color", + "border-top-color", + "box-shadow", + "color", + "fill", + "outline-color", + "stroke", + "text-shadow", +]); +const chromaticNames = new Set([ + "aqua", + "blue", + "cyan", + "fuchsia", + "gold", + "green", + "lime", + "magenta", + "maroon", + "navy", + "olive", + "orange", + "pink", + "purple", + "red", + "teal", + "yellow", +]); + +function entryKey({ selector, property }) { + return `${selector}\u0000${property}`; +} + +function isIsoDate(value) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + + const parsed = new Date(`${value}T00:00:00Z`); + return ( + !Number.isNaN(parsed.valueOf()) && parsed.toISOString().startsWith(value) + ); +} + +export function validateAllowlist({ entries, now = new Date() }) { + if (!Array.isArray(entries)) + throw new Error("state-core allowlist must be an array."); + + // Strict metadata keeps accessibility fallbacks reviewable without granting broad paint ownership. + const seen = new Set(); + for (const entry of entries) { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + throw new Error("state-core allowlist entries must be objects."); + } + + const fields = Object.keys(entry).sort(); + if (fields.join("\u0000") !== allowlistFields.join("\u0000")) { + throw new Error( + `state-core allowlist entries must contain exactly ${allowlistFields.join(", ")}.`, + ); + } + if (entry.selector.includes("*") || entry.property.includes("*")) { + throw new Error( + "state-core allowlist entries must not contain wildcards.", + ); + } + if (!entry.selector.trim() || !entry.property.trim()) { + throw new Error( + "state-core selector and property must be exact non-empty values.", + ); + } + if (entry.owner !== "interactive-surface-css") { + throw new Error( + "state-core allowlist owner must be interactive-surface-css.", + ); + } + if (entry.reason.trim().length < 24) { + throw new Error( + "state-core allowlist entries require a professional reason.", + ); + } + if (!isIsoDate(entry.reviewDate)) { + throw new Error("state-core allowlist reviewDate must be an ISO date."); + } + + const reviewTime = new Date(`${entry.reviewDate}T00:00:00Z`).valueOf(); + const ageDays = (now.valueOf() - reviewTime) / 86_400_000; + if (ageDays < 0 || ageDays > 366) { + throw new Error( + `state-core allowlist entry has a stale reviewDate: ${entry.reviewDate}.`, + ); + } + + const key = entryKey(entry); + if (seen.has(key)) { + throw new Error( + `state-core allowlist has a duplicate selector and property: ${entry.selector} ${entry.property}.`, + ); + } + seen.add(key); + } +} + +function hexIsChromatic(value) { + const expanded = + value.length <= 4 + ? value + .slice(0, 3) + .split("") + .map((digit) => `${digit}${digit}`) + : [value.slice(0, 2), value.slice(2, 4), value.slice(4, 6)]; + const [red, green, blue] = expanded.map((channel) => + Number.parseInt(channel, 16), + ); + return red !== green || green !== blue; +} + +function functionChannels(node) { + const channels = []; + node.children.forEach((child) => { + if (child.type === "Number" || child.type === "Percentage") { + channels.push(Number.parseFloat(child.value)); + } + }); + return channels; +} + +function containsChromaticLiteral(value) { + let chromatic = false; + + /* CSS variables stay theme-neutral unless their fallback embeds chromatic paint. */ + walk(value, { + enter(node) { + if (node.type === "Hash" && hexIsChromatic(node.value)) chromatic = true; + if ( + node.type === "Identifier" && + chromaticNames.has(node.name.toLowerCase()) + ) { + chromatic = true; + } + if (node.type !== "Function") return; + + const channels = functionChannels(node); + if ( + ["rgb", "rgba"].includes(node.name.toLowerCase()) && + channels.length >= 3 + ) { + if (channels[0] !== channels[1] || channels[1] !== channels[2]) + chromatic = true; + } + if ( + ["hsl", "hsla"].includes(node.name.toLowerCase()) && + channels.length >= 2 + ) { + if (channels[1] !== 0) chromatic = true; + } + }, + }); + + return chromatic; +} + +function violationRule({ selector, declaration }) { + if (pageTopologyProperties.has(declaration.property)) + return "interactive-page-topology"; + if ( + pageSelectorPattern.test(selector) && + majorPageProperties.has(declaration.property) + ) { + return "interactive-page-topology"; + } + if (declaration.property === "font-family") + return "interactive-branded-paint"; + + const paintToken = + declaration.property.startsWith("--") && + /(?:bg|color|fg|highlight|paint|shadow)$/.test(declaration.property); + if ( + (paintProperties.has(declaration.property) || paintToken) && + containsChromaticLiteral(declaration.value) + ) { + return "interactive-branded-paint"; + } + + return null; +} + +export function auditOwnership({ css, allowlist, now = new Date() }) { + validateAllowlist({ entries: allowlist, now }); + + const ast = parse(css, { + filename: "state-core", + parseCustomProperty: true, + positions: true, + }); + const allowlistByKey = new Map( + allowlist.map((entry) => [entryKey(entry), entry]), + ); + const matchedKeys = new Set(); + const violations = []; + let declarationCount = 0; + + walk(ast, { + visit: "Rule", + enter(rule) { + const selector = generate(rule.prelude); + rule.block.children.forEach((node) => { + if (node.type !== "Declaration") return; + declarationCount += 1; + + const ruleName = violationRule({ selector, declaration: node }); + if (!ruleName) return; + + const key = entryKey({ selector, property: node.property }); + if (allowlistByKey.has(key)) { + matchedKeys.add(key); + return; + } + + violations.push({ + target: "state-core", + selector, + property: node.property, + line: node.loc.start.line, + rule: ruleName, + }); + }); + }, + }); + + for (const entry of allowlist) { + if (!matchedKeys.has(entryKey(entry))) { + throw new Error( + `state-core allowlist entry does not match a forbidden declaration: ${entry.selector} ${entry.property}.`, + ); + } + } + + return { + declarationCount, + matchedAllowlistCount: matchedKeys.size, + violations, + }; +} + +function run() { + const startedAt = performance.now(); + const allowlist = JSON.parse( + fs.readFileSync(path.join(packageRoot, "ownership-allowlist.json"), "utf8"), + ); + const result = auditOwnership({ + css: fs.readFileSync(path.join(packageRoot, "state-core.css"), "utf8"), + allowlist: allowlist["state-core"], + }); + + if (result.violations.length > 0) { + const details = result.violations + .map( + ({ selector, property, line, rule }) => + `state-core.css:${line} ${selector} ${property} (${rule})`, + ) + .join("\n"); + throw new Error(`CSS ownership violations:\n${details}`); + } + + const duration = Math.round(performance.now() - startedAt); + console.log( + `CSS ownership passed for ${result.declarationCount} declarations with ${result.matchedAllowlistCount} reviewed exceptions in ${duration}ms.`, + ); +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + try { + run(); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/tests/ownership-boundaries.test.mjs b/tests/ownership-boundaries.test.mjs new file mode 100644 index 0000000..ab73fc4 --- /dev/null +++ b/tests/ownership-boundaries.test.mjs @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import test from "node:test"; + +import { + auditOwnership, + validateAllowlist, +} from "../scripts/check-css-ownership.mjs"; + +const reviewedAt = new Date("2026-08-08T12:00:00Z"); + +function exception(overrides = {}) { + return { + selector: ".interactive-surface", + property: "--_is-focus-ring-color", + reason: + "Preserves a visible accessible focus fallback when no companion theme provides one.", + owner: "interactive-surface-css", + reviewDate: "2026-08-08", + ...overrides, + }; +} + +test("state core rejects branded paint literals outside an exact reviewed fallback", () => { + const css = ` + .interactive-surface { --_is-focus-ring-color: rgb(11 99 246); } + .interactive-surface { background: #ff00aa; } + `; + const result = auditOwnership({ + css, + allowlist: [exception()], + now: reviewedAt, + }); + + assert.deepEqual(result.violations, [ + { + target: "state-core", + selector: ".interactive-surface", + property: "background", + line: 3, + rule: "interactive-branded-paint", + }, + ]); + assert.equal(result.matchedAllowlistCount, 1); +}); + +test("state core rejects page topology but permits internal state positioning", () => { + const css = ` + .interactive-surface::before { position: absolute; inset: 0; } + .page-shell { grid-template-columns: 1fr 2fr; } + `; + const result = auditOwnership({ css, allowlist: [], now: reviewedAt }); + + assert.deepEqual(result.violations, [ + { + target: "state-core", + selector: ".page-shell", + property: "grid-template-columns", + line: 3, + rule: "interactive-page-topology", + }, + ]); +}); + +test("allowlist rejects invalid metadata and declarations that no longer need exceptions", () => { + assert.throws( + () => + validateAllowlist({ + entries: [exception({ owner: "ui-style-kit-css" })], + now: reviewedAt, + }), + /owner must be interactive-surface-css/, + ); + assert.throws( + () => + auditOwnership({ + css: ".interactive-surface { color: var(--interactive-surface-fg); }", + allowlist: [exception()], + now: reviewedAt, + }), + /does not match a forbidden declaration/, + ); +}); + +test("reviewed built state core satisfies its ownership contract", () => { + const allowlist = JSON.parse( + fs.readFileSync( + new URL("../ownership-allowlist.json", import.meta.url), + "utf8", + ), + ); + const result = auditOwnership({ + css: fs.readFileSync(new URL("../state-core.css", import.meta.url), "utf8"), + allowlist: allowlist["state-core"], + now: reviewedAt, + }); + + assert.deepEqual(result.violations, []); + assert.equal(result.matchedAllowlistCount, 2); +}); diff --git a/tests/package-contract.test.mjs b/tests/package-contract.test.mjs index ad77da4..aeffdad 100644 --- a/tests/package-contract.test.mjs +++ b/tests/package-contract.test.mjs @@ -78,6 +78,7 @@ const expectedScripts = { "npm run check:public", "npm run build", "npm run check:generated", + "npm run check:ownership", "npm run test:contracts", "npm run test:package", "npm run pack:dry", From 34dcf1f5c1a59153514addb778132e0cec80c724 Mon Sep 17 00:00:00 2001 From: Foscat Date: Sat, 8 Aug 2026 18:37:44 -0500 Subject: [PATCH 09/22] fix: close css ownership bypasses --- scripts/check-css-ownership.mjs | 342 ++++++++++++++++++++++------ tests/ownership-boundaries.test.mjs | 141 +++++++++++- 2 files changed, 396 insertions(+), 87 deletions(-) diff --git a/scripts/check-css-ownership.mjs b/scripts/check-css-ownership.mjs index f231072..edb80b8 100644 --- a/scripts/check-css-ownership.mjs +++ b/scripts/check-css-ownership.mjs @@ -2,7 +2,13 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { generate, parse, walk } from "css-tree"; +import { + generate, + lexer, + parse, + property as describeProperty, + walk, +} from "css-tree"; const packageRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -16,64 +22,135 @@ const allowlistFields = [ "selector", ]; const pageTopologyProperties = new Set([ + "grid", "grid-area", + "grid-auto-columns", + "grid-auto-flow", + "grid-auto-rows", + "grid-column", + "grid-column-end", + "grid-column-start", + "grid-row", + "grid-row-end", + "grid-row-start", "grid-template", "grid-template-areas", "grid-template-columns", "grid-template-rows", "order", ]); -const pageSelectorPattern = - /(?:^|[-_.])(?:container|layout|main|page|shell|wrapper)(?:$|[-_ .:#[])/; const majorPageProperties = new Set([ + "block-size", + "bottom", + "clear", "display", + "float", + "height", "inline-size", + "inset", + "inset-block", + "inset-block-end", + "inset-block-start", + "inset-inline", + "inset-inline-end", + "inset-inline-start", + "left", "margin", + "margin-block", + "margin-block-end", + "margin-block-start", + "margin-inline", + "margin-inline-end", + "margin-inline-start", + "max-block-size", + "max-height", "max-inline-size", "max-width", + "min-block-size", + "min-height", "min-inline-size", "min-width", "padding", + "place-self", "position", + "right", + "top", "width", ]); -const paintProperties = new Set([ - "background", - "background-color", - "background-image", - "border", - "border-color", - "border-bottom-color", - "border-left-color", - "border-right-color", - "border-top-color", - "box-shadow", - "color", - "fill", - "outline-color", - "stroke", - "text-shadow", +const nativeStatePseudos = new Set([ + "active", + "checked", + "disabled", + "enabled", + "focus", + "focus-visible", + "focus-within", + "hover", + "indeterminate", + "invalid", + "open", + "optional", + "placeholder-shown", + "read-only", + "read-write", + "required", + "target", + "user-invalid", + "valid", ]); -const chromaticNames = new Set([ - "aqua", - "blue", - "cyan", - "fuchsia", - "gold", - "green", - "lime", - "magenta", - "maroon", - "navy", - "olive", - "orange", - "pink", - "purple", - "red", - "teal", - "yellow", +const commonStateClasses = new Set([ + "is-active", + "is-busy", + "is-checked", + "is-disabled", + "is-loading", + "is-open", + "is-pressed", + "is-selected", +]); +const stateAttributes = new Set([ + "aria-busy", + "aria-checked", + "aria-current", + "aria-disabled", + "aria-expanded", + "aria-invalid", + "aria-pressed", + "aria-selected", + "data-active", + "data-checked", + "data-disabled", + "data-loading", + "data-pressed", + "data-selected", + "data-state", +]); +const neutralColorNames = new Set([ + "black", + "currentcolor", + "darkgray", + "darkgrey", + "dimgray", + "dimgrey", + "gainsboro", + "gray", + "grey", + "lightgray", + "lightgrey", + "silver", + "transparent", + "white", + "whitesmoke", ]); +function propertyContract(propertyName) { + const described = describeProperty(propertyName); + return { + custom: described.custom, + name: described.custom ? propertyName : described.basename, + }; +} + function entryKey({ selector, property }) { return `${selector}\u0000${property}`; } @@ -104,6 +181,9 @@ export function validateAllowlist({ entries, now = new Date() }) { `state-core allowlist entries must contain exactly ${allowlistFields.join(", ")}.`, ); } + if (allowlistFields.some((field) => typeof entry[field] !== "string")) { + throw new Error("state-core allowlist entries must use string fields."); + } if (entry.selector.includes("*") || entry.property.includes("*")) { throw new Error( "state-core allowlist entries must not contain wildcards.", @@ -170,67 +250,169 @@ function functionChannels(node) { return channels; } +function colorNodeIsChromatic(node) { + const text = generate(node); + if (!lexer.matchType("color", text).matched) return false; + + const lower = text.toLowerCase(); + if (neutralColorNames.has(lower)) return false; + if (node.type === "Identifier") { + // System colors are accessibility-dependent rather than package branding. + return !/^(?:accentcolor|accentcolortext|activetext|buttonborder|buttonface|buttontext|canvas|canvastext|field|fieldtext|graytext|highlight|highlighttext|linktext|mark|marktext|selecteditem|selecteditemtext|visitedtext)$/.test( + lower, + ); + } + if (node.type === "Hash") return hexIsChromatic(node.value); + if (node.type !== "Function") return true; + + const channels = functionChannels(node); + const functionName = node.name.toLowerCase(); + if (["rgb", "rgba"].includes(functionName) && channels.length >= 3) { + return channels[0] !== channels[1] || channels[1] !== channels[2]; + } + if (["hsl", "hsla"].includes(functionName) && channels.length >= 2) { + return channels[1] !== 0; + } + if (["lab", "oklab"].includes(functionName) && channels.length >= 3) { + return channels[1] !== 0 || channels[2] !== 0; + } + if (["lch", "oklch"].includes(functionName) && channels.length >= 2) { + return channels[1] !== 0; + } + + return true; +} + function containsChromaticLiteral(value) { let chromatic = false; - /* CSS variables stay theme-neutral unless their fallback embeds chromatic paint. */ + /* Parser grammar covers named, legacy, and modern color functions inside variable fallbacks. */ walk(value, { enter(node) { - if (node.type === "Hash" && hexIsChromatic(node.value)) chromatic = true; - if ( - node.type === "Identifier" && - chromaticNames.has(node.name.toLowerCase()) - ) { - chromatic = true; + if (["Function", "Hash", "Identifier"].includes(node.type) && + colorNodeIsChromatic(node)) chromatic = true; + }, + }); + + return chromatic; +} + +function containsImage(value) { + let image = false; + walk(value, { + enter(node) { + if (node.type === "Url") image = true; + if (node.type === "Function" && + /(?:gradient|image|paint|cross-fade|element|url)/.test(node.name.toLowerCase())) { + image = true; + } + }, + }); + return image; +} + +function selectorOwnsPageTopology(rule) { + const structuralNames = new Set([ + "container", + "content", + "grid", + "layout", + "main", + "page", + "section", + "shell", + "split", + "stack", + "wrapper", + ]); + let pageRoot = false; + + walk(rule.prelude, { + enter(node) { + if (node.type === "TypeSelector" && + ["body", "html", "main", "section"].includes(node.name.toLowerCase())) { + pageRoot = true; + } + if (node.type === "IdSelector" && + ["app", "layout", "main", "page", "root", "shell"].includes(node.name.toLowerCase())) { + pageRoot = true; } - if (node.type !== "Function") return; - - const channels = functionChannels(node); - if ( - ["rgb", "rgba"].includes(node.name.toLowerCase()) && - channels.length >= 3 - ) { - if (channels[0] !== channels[1] || channels[1] !== channels[2]) - chromatic = true; + if (node.type === "ClassSelector" && + node.name.split(/[-_]/).some((segment) => structuralNames.has(segment))) { + pageRoot = true; } - if ( - ["hsl", "hsla"].includes(node.name.toLowerCase()) && - channels.length >= 2 - ) { - if (channels[1] !== 0) chromatic = true; + if (node.type === "AttributeSelector") { + const name = node.name.name.toLowerCase(); + const value = node.value?.name?.toLowerCase() ?? node.value?.value?.toLowerCase(); + if (["data-layout", "data-page", "data-shell"].includes(name) || + (name === "role" && value === "main")) pageRoot = true; } }, }); - return chromatic; + return pageRoot; } -function violationRule({ selector, declaration }) { - if (pageTopologyProperties.has(declaration.property)) +function selectorHasState(rule, manifest) { + const manifestClasses = manifest.selectors?.stateClasses ?? []; + const stateClasses = new Set([ + ...commonStateClasses, + ...manifestClasses.map((selector) => selector.replace(/^\./, "").toLowerCase()), + ]); + let stateful = false; + + walk(rule.prelude, { + enter(node) { + if (node.type === "PseudoClassSelector" && nativeStatePseudos.has(node.name.toLowerCase())) { + stateful = true; + } + if (node.type === "ClassSelector" && stateClasses.has(node.name.toLowerCase())) { + stateful = true; + } + if (node.type === "AttributeSelector" && stateAttributes.has(node.name.name.toLowerCase())) { + stateful = true; + } + }, + }); + return stateful; +} + +function violationRule({ rule, property, value, manifest }) { + if (pageTopologyProperties.has(property.name)) return "interactive-page-topology"; if ( - pageSelectorPattern.test(selector) && - majorPageProperties.has(declaration.property) + selectorOwnsPageTopology(rule) && + majorPageProperties.has(property.name) ) { return "interactive-page-topology"; } - if (declaration.property === "font-family") + if (property.name === "font-family") return "interactive-branded-paint"; - const paintToken = - declaration.property.startsWith("--") && - /(?:bg|color|fg|highlight|paint|shadow)$/.test(declaration.property); - if ( - (paintProperties.has(declaration.property) || paintToken) && - containsChromaticLiteral(declaration.value) - ) { + const hasChromaticPaint = containsChromaticLiteral(value); + const hasImage = containsImage(value); + if (property.custom && (hasChromaticPaint || hasImage)) { + return "interactive-branded-paint"; + } + if (/^(?:background|mask)(?:-|$)/.test(property.name) && (hasChromaticPaint || hasImage)) { + return "interactive-branded-paint"; + } + if (/^(?:border|outline|text-decoration)(?:-|$)/.test(property.name) && hasChromaticPaint) { + return "interactive-branded-paint"; + } + if (["color", "fill", "stroke", "box-shadow", "text-shadow"].includes(property.name) && + hasChromaticPaint) return "interactive-branded-paint"; + if (["filter", "backdrop-filter"].includes(property.name) && generate(value).trim() !== "none") { return "interactive-branded-paint"; } + // State mechanics remain package-owned; this shared AST contract prevents selector drift. + selectorHasState(rule, manifest); + return null; } -export function auditOwnership({ css, allowlist, now = new Date() }) { +export function auditOwnership({ css, manifest = {}, allowlist, now = new Date() }) { validateAllowlist({ entries: allowlist, now }); const ast = parse(css, { @@ -253,10 +435,16 @@ export function auditOwnership({ css, allowlist, now = new Date() }) { if (node.type !== "Declaration") return; declarationCount += 1; - const ruleName = violationRule({ selector, declaration: node }); + const property = propertyContract(node.property); + const ruleName = violationRule({ + rule, + property, + value: node.value, + manifest, + }); if (!ruleName) return; - const key = entryKey({ selector, property: node.property }); + const key = entryKey({ selector, property: property.name }); if (allowlistByKey.has(key)) { matchedKeys.add(key); return; @@ -265,7 +453,7 @@ export function auditOwnership({ css, allowlist, now = new Date() }) { violations.push({ target: "state-core", selector, - property: node.property, + property: property.name, line: node.loc.start.line, rule: ruleName, }); @@ -290,12 +478,16 @@ export function auditOwnership({ css, allowlist, now = new Date() }) { function run() { const startedAt = performance.now(); + const manifest = JSON.parse( + fs.readFileSync(path.join(packageRoot, "manifest.json"), "utf8"), + ); const allowlist = JSON.parse( fs.readFileSync(path.join(packageRoot, "ownership-allowlist.json"), "utf8"), ); const result = auditOwnership({ css: fs.readFileSync(path.join(packageRoot, "state-core.css"), "utf8"), allowlist: allowlist["state-core"], + manifest, }); if (result.violations.length > 0) { diff --git a/tests/ownership-boundaries.test.mjs b/tests/ownership-boundaries.test.mjs index ab73fc4..b59498e 100644 --- a/tests/ownership-boundaries.test.mjs +++ b/tests/ownership-boundaries.test.mjs @@ -25,6 +25,12 @@ test("state core rejects branded paint literals outside an exact reviewed fallba const css = ` .interactive-surface { --_is-focus-ring-color: rgb(11 99 246); } .interactive-surface { background: #ff00aa; } + .interactive-surface { --Arbitrary-Paint: red; } + .interactive-surface { --Modern-Paint: oklch(62% .22 24); } + .interactive-surface { background-image: url("brand-texture.svg"); } + .interactive-surface { border-top: 1px solid red; } + .interactive-surface { filter: drop-shadow(0 2px 4px red); } + .interactive-surface { --Neutral-State-Layer: rgb(0 0 0 / .12); } `; const result = auditOwnership({ css, @@ -40,6 +46,41 @@ test("state core rejects branded paint literals outside an exact reviewed fallba line: 3, rule: "interactive-branded-paint", }, + { + target: "state-core", + selector: ".interactive-surface", + property: "--Arbitrary-Paint", + line: 4, + rule: "interactive-branded-paint", + }, + { + target: "state-core", + selector: ".interactive-surface", + property: "--Modern-Paint", + line: 5, + rule: "interactive-branded-paint", + }, + { + target: "state-core", + selector: ".interactive-surface", + property: "background-image", + line: 6, + rule: "interactive-branded-paint", + }, + { + target: "state-core", + selector: ".interactive-surface", + property: "border-top", + line: 7, + rule: "interactive-branded-paint", + }, + { + target: "state-core", + selector: ".interactive-surface", + property: "filter", + line: 8, + rule: "interactive-branded-paint", + }, ]); assert.equal(result.matchedAllowlistCount, 1); }); @@ -47,30 +88,106 @@ test("state core rejects branded paint literals outside an exact reviewed fallba test("state core rejects page topology but permits internal state positioning", () => { const css = ` .interactive-surface::before { position: absolute; inset: 0; } - .page-shell { grid-template-columns: 1fr 2fr; } + .state-grid { grid-column: 1 / 3; } + .page-shell { max-width: 72rem; } + #app { width: 100%; } `; const result = auditOwnership({ css, allowlist: [], now: reviewedAt }); assert.deepEqual(result.violations, [ { target: "state-core", - selector: ".page-shell", - property: "grid-template-columns", + selector: ".state-grid", + property: "grid-column", line: 3, rule: "interactive-page-topology", }, + { + target: "state-core", + selector: ".page-shell", + property: "max-width", + line: 4, + rule: "interactive-page-topology", + }, + { + target: "state-core", + selector: "#app", + property: "width", + line: 5, + rule: "interactive-page-topology", + }, ]); }); -test("allowlist rejects invalid metadata and declarations that no longer need exceptions", () => { - assert.throws( - () => - validateAllowlist({ - entries: [exception({ owner: "ui-style-kit-css" })], - now: reviewedAt, - }), - /owner must be interactive-surface-css/, - ); +test("allowlist rejects every malformed, stale, broad, duplicate, and unmatched mutation", () => { + const missingReason = exception(); + delete missingReason.reason; + const cases = [ + { + name: "stale", + entries: [exception({ reviewDate: "2025-01-01" })], + message: /stale reviewDate/, + }, + { + name: "future", + entries: [exception({ reviewDate: "2026-08-09" })], + message: /stale reviewDate/, + }, + { + name: "invalid date", + entries: [exception({ reviewDate: "2026-02-30" })], + message: /ISO date/, + }, + { + name: "duplicate", + entries: [exception(), exception()], + message: /duplicate selector and property/, + }, + { + name: "selector wildcard", + entries: [exception({ selector: ".interactive-*" })], + message: /must not contain wildcards/, + }, + { + name: "property wildcard", + entries: [exception({ property: "--_is-*" })], + message: /must not contain wildcards/, + }, + { + name: "unexplained", + entries: [exception({ reason: "Needed." })], + message: /professional reason/, + }, + { + name: "wrong owner", + entries: [exception({ owner: "ui-style-kit-css" })], + message: /owner must be interactive-surface-css/, + }, + { + name: "missing field", + entries: [missingReason], + message: /contain exactly/, + }, + { + name: "extra field", + entries: [exception({ ticket: "IS-42" })], + message: /contain exactly/, + }, + { + name: "non-string field", + entries: [exception({ reason: null })], + message: /string fields/, + }, + ]; + + for (const fixture of cases) { + assert.throws( + () => validateAllowlist({ entries: fixture.entries, now: reviewedAt }), + fixture.message, + fixture.name, + ); + } + assert.throws( () => auditOwnership({ From cb2942573fcfce564f858b8766f21e8abafa9998 Mon Sep 17 00:00:00 2001 From: Foscat Date: Sat, 8 Aug 2026 19:05:49 -0500 Subject: [PATCH 10/22] fix: complete css ownership policy --- scripts/check-css-ownership.mjs | 93 ++++++++++++++++++++++++++--- tests/ownership-boundaries.test.mjs | 36 +++++++++++ 2 files changed, 120 insertions(+), 9 deletions(-) diff --git a/scripts/check-css-ownership.mjs b/scripts/check-css-ownership.mjs index edb80b8..134d845 100644 --- a/scripts/check-css-ownership.mjs +++ b/scripts/check-css-ownership.mjs @@ -91,12 +91,14 @@ const nativeStatePseudos = new Set([ "open", "optional", "placeholder-shown", + "popover-open", "read-only", "read-write", "required", "target", "user-invalid", "valid", + "visited", ]); const commonStateClasses = new Set([ "is-active", @@ -109,6 +111,7 @@ const commonStateClasses = new Set([ "is-selected", ]); const stateAttributes = new Set([ + "disabled", "aria-busy", "aria-checked", "aria-current", @@ -142,6 +145,8 @@ const neutralColorNames = new Set([ "white", "whitesmoke", ]); +const systemColorPattern = + /^(?:accentcolor|accentcolortext|activetext|buttonborder|buttonface|buttontext|canvas|canvastext|field|fieldtext|graytext|highlight|highlighttext|linktext|mark|marktext|selecteditem|selecteditemtext|visitedtext)$/; function propertyContract(propertyName) { const described = describeProperty(propertyName); @@ -258,9 +263,7 @@ function colorNodeIsChromatic(node) { if (neutralColorNames.has(lower)) return false; if (node.type === "Identifier") { // System colors are accessibility-dependent rather than package branding. - return !/^(?:accentcolor|accentcolortext|activetext|buttonborder|buttonface|buttontext|canvas|canvastext|field|fieldtext|graytext|highlight|highlighttext|linktext|mark|marktext|selecteditem|selecteditemtext|visitedtext)$/.test( - lower, - ); + return !systemColorPattern.test(lower); } if (node.type === "Hash") return hexIsChromatic(node.value); if (node.type !== "Function") return true; @@ -297,6 +300,43 @@ function containsChromaticLiteral(value) { return chromatic; } +function colorNodeIsDirectLiteral(node) { + if ( + node.type === "Function" && + ["env", "var"].includes(node.name.toLowerCase()) + ) { + return false; + } + + const text = generate(node); + if (!lexer.matchType("color", text).matched) return false; + const lower = text.toLowerCase(); + return lower !== "currentcolor" && !systemColorPattern.test(lower); +} + +function containsDirectLiteralPaint(value) { + let literal = false; + + // Token-driven declarations remain neutral; literal fallbacks belong in reviewed custom properties. + walk(value, { + enter(node) { + if ( + node.type === "Function" && + ["env", "var"].includes(node.name.toLowerCase()) + ) { + return walk.skip; + } + if ( + ["Function", "Hash", "Identifier"].includes(node.type) && + colorNodeIsDirectLiteral(node) + ) { + literal = true; + } + }, + }); + return literal; +} + function containsImage(value) { let image = false; walk(value, { @@ -353,11 +393,35 @@ function selectorOwnsPageTopology(rule) { return pageRoot; } +function manifestStateClasses(manifest) { + const stateSuffixes = new Set([...commonStateClasses].map((name) => name.replace(/^is-/, ""))); + const manifestClasses = new Set([ + ...(manifest.selectors?.stateClasses ?? []), + ...(manifest.classApi?.stateClasses ?? []), + ].map((selector) => selector.replace(/^\./, "").toLowerCase())); + + for (const preset of manifest.presets ?? []) { + const suffixes = [ + ...(manifest.classApi?.universalVisualSuffixes ?? []), + ...(manifest.classApi?.presetExtras?.[preset.id] ?? []), + ]; + for (const suffix of suffixes) { + if ( + stateSuffixes.has(suffix) || + [...stateSuffixes].some((state) => suffix.endsWith(`-${state}`)) + ) { + manifestClasses.add(`${preset.prefix}-${suffix}`.toLowerCase()); + } + } + } + + return manifestClasses; +} + function selectorHasState(rule, manifest) { - const manifestClasses = manifest.selectors?.stateClasses ?? []; const stateClasses = new Set([ ...commonStateClasses, - ...manifestClasses.map((selector) => selector.replace(/^\./, "").toLowerCase()), + ...manifestStateClasses(manifest), ]); let stateful = false; @@ -390,18 +454,29 @@ function violationRule({ rule, property, value, manifest }) { return "interactive-branded-paint"; const hasChromaticPaint = containsChromaticLiteral(value); + const hasDirectLiteralPaint = containsDirectLiteralPaint(value); const hasImage = containsImage(value); if (property.custom && (hasChromaticPaint || hasImage)) { return "interactive-branded-paint"; } - if (/^(?:background|mask)(?:-|$)/.test(property.name) && (hasChromaticPaint || hasImage)) { + if ( + /^(?:background|mask)(?:-|$)/.test(property.name) && + (hasDirectLiteralPaint || hasImage) + ) { + return "interactive-branded-paint"; + } + if ( + /^(?:border|outline|text-decoration)(?:-|$)/.test(property.name) && + (hasDirectLiteralPaint || hasImage) + ) { return "interactive-branded-paint"; } - if (/^(?:border|outline|text-decoration)(?:-|$)/.test(property.name) && hasChromaticPaint) { + if ( + ["color", "fill", "stroke", "box-shadow", "text-shadow"].includes(property.name) && + hasDirectLiteralPaint + ) { return "interactive-branded-paint"; } - if (["color", "fill", "stroke", "box-shadow", "text-shadow"].includes(property.name) && - hasChromaticPaint) return "interactive-branded-paint"; if (["filter", "backdrop-filter"].includes(property.name) && generate(value).trim() !== "none") { return "interactive-branded-paint"; } diff --git a/tests/ownership-boundaries.test.mjs b/tests/ownership-boundaries.test.mjs index b59498e..f0d4c69 100644 --- a/tests/ownership-boundaries.test.mjs +++ b/tests/ownership-boundaries.test.mjs @@ -31,9 +31,24 @@ test("state core rejects branded paint literals outside an exact reviewed fallba .interactive-surface { border-top: 1px solid red; } .interactive-surface { filter: drop-shadow(0 2px 4px red); } .interactive-surface { --Neutral-State-Layer: rgb(0 0 0 / .12); } + :is(a:visited, button:popover-open, button[disabled], .saas-disabled) { + background: var(--interactive-surface-bg); + box-shadow: var(--interactive-surface-shadow-hover); + transform: translateY(var(--interactive-surface-lift-hover)); + } + .x { border: 1px solid #777; } + .x { background: #fff; } + .x { box-shadow: 0 2px 4px #0008; } `; const result = auditOwnership({ css, + manifest: { + presets: [{ id: "minimal-saas", prefix: "saas" }], + classApi: { + universalVisualSuffixes: ["disabled"], + presetExtras: { "minimal-saas": [] }, + }, + }, allowlist: [exception()], now: reviewedAt, }); @@ -81,6 +96,27 @@ test("state core rejects branded paint literals outside an exact reviewed fallba line: 8, rule: "interactive-branded-paint", }, + { + target: "state-core", + selector: ".x", + property: "border", + line: 15, + rule: "interactive-branded-paint", + }, + { + target: "state-core", + selector: ".x", + property: "background", + line: 16, + rule: "interactive-branded-paint", + }, + { + target: "state-core", + selector: ".x", + property: "box-shadow", + line: 17, + rule: "interactive-branded-paint", + }, ]); assert.equal(result.matchedAllowlistCount, 1); }); From acebdd9f572b9bfff19e4d76d24544bc2d212ac7 Mon Sep 17 00:00:00 2001 From: Foscat Date: Sat, 8 Aug 2026 19:21:51 -0500 Subject: [PATCH 11/22] fix: seal css ownership fallbacks --- scripts/check-css-ownership.mjs | 16 +++++----- tests/ownership-boundaries.test.mjs | 49 ++++++++++++++++++++++++++--- 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/scripts/check-css-ownership.mjs b/scripts/check-css-ownership.mjs index 134d845..1febb4d 100644 --- a/scripts/check-css-ownership.mjs +++ b/scripts/check-css-ownership.mjs @@ -79,6 +79,7 @@ const majorPageProperties = new Set([ ]); const nativeStatePseudos = new Set([ "active", + "any-link", "checked", "disabled", "enabled", @@ -110,8 +111,15 @@ const commonStateClasses = new Set([ "is-pressed", "is-selected", ]); +// Reflected native attributes are state selectors even when no pseudo-class is used. const stateAttributes = new Set([ + "checked", "disabled", + "hidden", + "open", + "readonly", + "required", + "selected", "aria-busy", "aria-checked", "aria-current", @@ -317,15 +325,9 @@ function colorNodeIsDirectLiteral(node) { function containsDirectLiteralPaint(value) { let literal = false; - // Token-driven declarations remain neutral; literal fallbacks belong in reviewed custom properties. + // A token reference is neutral, but a literal fallback still paints when the token is absent. walk(value, { enter(node) { - if ( - node.type === "Function" && - ["env", "var"].includes(node.name.toLowerCase()) - ) { - return walk.skip; - } if ( ["Function", "Hash", "Identifier"].includes(node.type) && colorNodeIsDirectLiteral(node) diff --git a/tests/ownership-boundaries.test.mjs b/tests/ownership-boundaries.test.mjs index f0d4c69..6a9ed22 100644 --- a/tests/ownership-boundaries.test.mjs +++ b/tests/ownership-boundaries.test.mjs @@ -23,7 +23,7 @@ function exception(overrides = {}) { test("state core rejects branded paint literals outside an exact reviewed fallback", () => { const css = ` - .interactive-surface { --_is-focus-ring-color: rgb(11 99 246); } + .interactive-surface { --_is-focus-ring-color: var(--focus-ring, rgb(11 99 246)); } .interactive-surface { background: #ff00aa; } .interactive-surface { --Arbitrary-Paint: red; } .interactive-surface { --Modern-Paint: oklch(62% .22 24); } @@ -31,11 +31,22 @@ test("state core rejects branded paint literals outside an exact reviewed fallba .interactive-surface { border-top: 1px solid red; } .interactive-surface { filter: drop-shadow(0 2px 4px red); } .interactive-surface { --Neutral-State-Layer: rgb(0 0 0 / .12); } - :is(a:visited, button:popover-open, button[disabled], .saas-disabled) { + .interactive-surface { --Neutral-Fallback: var(--neutral-layer, #fff); } + :is(a:any-link, details[open], input[checked], input[required], option[selected], textarea[readonly], [hidden]) { background: var(--interactive-surface-bg); box-shadow: var(--interactive-surface-shadow-hover); transform: translateY(var(--interactive-surface-lift-hover)); } + .token-only { + background: var(--surface-bg); + box-shadow: var(--surface-shadow); + border: var(--surface-border); + color: env(surface-color); + } + .fallback-paint { background: var(--bg, #fff); } + .fallback-paint { box-shadow: var(--shadow, 0 2px 4px #0008); } + .fallback-paint { border: var(--border-color, red); } + .fallback-paint { color: env(surface-color, red); } .x { border: 1px solid #777; } .x { background: #fff; } .x { box-shadow: 0 2px 4px #0008; } @@ -96,25 +107,53 @@ test("state core rejects branded paint literals outside an exact reviewed fallba line: 8, rule: "interactive-branded-paint", }, + { + target: "state-core", + selector: ".fallback-paint", + property: "background", + line: 22, + rule: "interactive-branded-paint", + }, + { + target: "state-core", + selector: ".fallback-paint", + property: "box-shadow", + line: 23, + rule: "interactive-branded-paint", + }, + { + target: "state-core", + selector: ".fallback-paint", + property: "border", + line: 24, + rule: "interactive-branded-paint", + }, + { + target: "state-core", + selector: ".fallback-paint", + property: "color", + line: 25, + rule: "interactive-branded-paint", + }, { target: "state-core", selector: ".x", property: "border", - line: 15, + line: 26, rule: "interactive-branded-paint", }, { target: "state-core", selector: ".x", property: "background", - line: 16, + line: 27, rule: "interactive-branded-paint", }, { target: "state-core", selector: ".x", property: "box-shadow", - line: 17, + line: 28, rule: "interactive-branded-paint", }, ]); From af922af64f6671d19f71d1e39c841e067d0e9d42 Mon Sep 17 00:00:00 2001 From: Foscat Date: Sat, 8 Aug 2026 19:37:15 -0500 Subject: [PATCH 12/22] fix: align css ownership states --- scripts/check-css-ownership.mjs | 15 +++++++++++++++ tests/ownership-boundaries.test.mjs | 28 +++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/scripts/check-css-ownership.mjs b/scripts/check-css-ownership.mjs index 1febb4d..081820a 100644 --- a/scripts/check-css-ownership.mjs +++ b/scripts/check-css-ownership.mjs @@ -125,6 +125,7 @@ const stateAttributes = new Set([ "aria-current", "aria-disabled", "aria-expanded", + "aria-hidden", "aria-invalid", "aria-pressed", "aria-selected", @@ -443,6 +444,20 @@ function selectorHasState(rule, manifest) { return stateful; } +export function matchesStateSelector(selector, manifest = {}) { + const ast = parse(`${selector} {}`, { filename: "state-selector" }); + let stateful = false; + + // The exported probe keeps the shared selector vocabulary directly testable in state-owning builds. + walk(ast, { + visit: "Rule", + enter(rule) { + if (selectorHasState(rule, manifest)) stateful = true; + }, + }); + return stateful; +} + function violationRule({ rule, property, value, manifest }) { if (pageTopologyProperties.has(property.name)) return "interactive-page-topology"; diff --git a/tests/ownership-boundaries.test.mjs b/tests/ownership-boundaries.test.mjs index 6a9ed22..f9f80ab 100644 --- a/tests/ownership-boundaries.test.mjs +++ b/tests/ownership-boundaries.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { auditOwnership, + matchesStateSelector, validateAllowlist, } from "../scripts/check-css-ownership.mjs"; @@ -32,7 +33,7 @@ test("state core rejects branded paint literals outside an exact reviewed fallba .interactive-surface { filter: drop-shadow(0 2px 4px red); } .interactive-surface { --Neutral-State-Layer: rgb(0 0 0 / .12); } .interactive-surface { --Neutral-Fallback: var(--neutral-layer, #fff); } - :is(a:any-link, details[open], input[checked], input[required], option[selected], textarea[readonly], [hidden]) { + :is(a:any-link, details[open], input[checked], input[required], option[selected], textarea[readonly], [hidden], [aria-hidden="true"]) { background: var(--interactive-surface-bg); box-shadow: var(--interactive-surface-shadow-hover); transform: translateY(var(--interactive-surface-lift-hover)); @@ -160,6 +161,31 @@ test("state core rejects branded paint literals outside an exact reviewed fallba assert.equal(result.matchedAllowlistCount, 1); }); +test("state core permits token value supply across shared reflected ARIA states", () => { + const ariaStates = [ + "busy", + "checked", + "current", + "disabled", + "expanded", + "hidden", + "invalid", + "pressed", + "selected", + ]; + + for (const state of ariaStates) { + const selector = `:is(.interactive-surface,[aria-${state}="true"])`; + assert.equal(matchesStateSelector(selector), true, selector); + const result = auditOwnership({ + css: `${selector} { --State-Opacity: .8; background: var(--surface-bg); transform: scale(.98); }`, + allowlist: [], + now: reviewedAt, + }); + assert.deepEqual(result.violations, [], selector); + } +}); + test("state core rejects page topology but permits internal state positioning", () => { const css = ` .interactive-surface::before { position: absolute; inset: 0; } From 6238fc8dca5f1f45b9a0f74419c361954b6c5c4c Mon Sep 17 00:00:00 2001 From: Foscat Date: Sat, 8 Aug 2026 19:45:44 -0500 Subject: [PATCH 13/22] fix: align shared state probe --- scripts/check-css-ownership.mjs | 53 +++++++++++++++++-- tests/ownership-boundaries.test.mjs | 80 +++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 4 deletions(-) diff --git a/scripts/check-css-ownership.mjs b/scripts/check-css-ownership.mjs index 081820a..26b3981 100644 --- a/scripts/check-css-ownership.mjs +++ b/scripts/check-css-ownership.mjs @@ -137,6 +137,40 @@ const stateAttributes = new Set([ "data-selected", "data-state", ]); +const sharedStateClassVocabulary = new Set([ + "active", + "any-link", + "busy", + "busy-loading", + "checked", + "current", + "disabled", + "enabled", + "expanded", + "focus", + "focus-visible", + "focus-within", + "hidden", + "hover", + "indeterminate", + "invalid", + "loading", + "open", + "optional", + "persistent", + "placeholder-shown", + "popover-open", + "pressed", + "read-only", + "read-write", + "readonly", + "required", + "selected", + "target", + "user-invalid", + "valid", + "visited", +]); const neutralColorNames = new Set([ "black", "currentcolor", @@ -422,9 +456,15 @@ function manifestStateClasses(manifest) { } function selectorHasState(rule, manifest) { - const stateClasses = new Set([ + const manifestClasses = manifestStateClasses(manifest); + const exactStateClasses = new Set([ ...commonStateClasses, - ...manifestStateClasses(manifest), + ...sharedStateClassVocabulary, + ...manifestClasses, + ]); + const stateVocabulary = new Set([ + ...sharedStateClassVocabulary, + ...manifestClasses, ]); let stateful = false; @@ -433,8 +473,13 @@ function selectorHasState(rule, manifest) { if (node.type === "PseudoClassSelector" && nativeStatePseudos.has(node.name.toLowerCase())) { stateful = true; } - if (node.type === "ClassSelector" && stateClasses.has(node.name.toLowerCase())) { - stateful = true; + if (node.type === "ClassSelector") { + const className = node.name.toLowerCase(); + const hasBoundarySuffix = [...stateVocabulary].some( + (state) => + className.endsWith(`-${state}`) || className.endsWith(`_${state}`), + ); + if (exactStateClasses.has(className) || hasBoundarySuffix) stateful = true; } if (node.type === "AttributeSelector" && stateAttributes.has(node.name.name.toLowerCase())) { stateful = true; diff --git a/tests/ownership-boundaries.test.mjs b/tests/ownership-boundaries.test.mjs index f9f80ab..ebb4e68 100644 --- a/tests/ownership-boundaries.test.mjs +++ b/tests/ownership-boundaries.test.mjs @@ -186,6 +186,86 @@ test("state core permits token value supply across shared reflected ARIA states" } }); +test("shared state probe recognizes exact and boundary-delimited common class vocabulary", () => { + const stateVocabulary = [ + "active", + "any-link", + "busy", + "busy-loading", + "checked", + "current", + "disabled", + "enabled", + "expanded", + "focus", + "focus-visible", + "focus-within", + "hidden", + "hover", + "indeterminate", + "invalid", + "loading", + "open", + "optional", + "persistent", + "placeholder-shown", + "popover-open", + "pressed", + "read-only", + "read-write", + "readonly", + "required", + "selected", + "target", + "user-invalid", + "valid", + "visited", + ]; + const selectors = stateVocabulary.flatMap((state) => [ + `.${state}`, + `.navigation-${state}`, + `.navigation_${state}`, + ]); + + assert.deepEqual( + selectors.map((selector) => [selector, matchesStateSelector(selector)]), + selectors.map((selector) => [selector, true]), + ); +}); + +test("shared state probe recognizes manifest classes at exact and boundary-delimited forms", () => { + const manifest = { selectors: { stateClasses: [".custom-state"] } }; + const selectors = [ + ".custom-state", + ".navigation-custom-state", + ".navigation_custom-state", + ]; + + assert.deepEqual( + selectors.map((selector) => [selector, matchesStateSelector(selector, manifest)]), + selectors.map((selector) => [selector, true]), + ); +}); + +test("shared state probe preserves boundary controls and state-core mechanics ownership", () => { + for (const selector of [ + ".card-static", + ".proactive", + ".undisabled", + ".selectedness", + ]) { + assert.equal(matchesStateSelector(selector), false, selector); + } + + const result = auditOwnership({ + css: ".active { transform: scale(.98); } .navigation_active { animation: pulse 1s; } .custom-state { transition: opacity 100ms; }", + manifest: { selectors: { stateClasses: [".custom-state"] } }, + allowlist: [], + now: reviewedAt, + }); + assert.deepEqual(result.violations, []); +}); + test("state core rejects page topology but permits internal state positioning", () => { const css = ` .interactive-surface::before { position: absolute; inset: 0; } From 4a20497de64429d3cdaf28a4ee0ec47b1489560e Mon Sep 17 00:00:00 2001 From: Foscat Date: Sat, 8 Aug 2026 20:23:24 -0500 Subject: [PATCH 14/22] ci: verify ecosystem contract before release --- .github/workflows/ci.yaml | 60 +++++++++ .github/workflows/npm-publish.yml | 39 ++++++ ecosystem-release-fixture.json | 4 + package.json | 6 +- scripts/release-fixture-contract.mjs | 192 +++++++++++++++++++++++++++ tests/package-contract.test.mjs | 5 +- tests/release-preflight.test.mjs | 79 +++++++++++ wiki/Publishing-and-Releases.md | 10 +- 8 files changed, 391 insertions(+), 4 deletions(-) create mode 100644 ecosystem-release-fixture.json create mode 100644 scripts/release-fixture-contract.mjs create mode 100644 tests/release-preflight.test.mjs diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f65d541..421f78d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -44,3 +44,63 @@ jobs: - name: Validate package run: npm run ${{ matrix.validation-script }} + + release-preflight: + name: PR-safe ecosystem release preflight + if: github.event_name == 'pull_request' + needs: quality + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + steps: + - name: Checkout Interactive Surface candidate + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + + - name: Use Node.js 22 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 + with: + node-version: 22 + cache: npm + + - name: Install candidate dependencies + run: npm ci + + - name: Resolve immutable UI release fixture + id: ui_fixture + run: node scripts/release-fixture-contract.mjs --write-github-outputs + + - name: Checkout reviewed UI release fixture + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + repository: ${{ steps.ui_fixture.outputs.ui_repository }} + ref: ${{ steps.ui_fixture.outputs.ui_revision }} + path: _ecosystem/ui-style-kit-css + + - name: Install UI fixture dependencies + working-directory: _ecosystem/ui-style-kit-css + run: npm ci + + - name: Resolve immutable Layout companion + id: ecosystem_sources + working-directory: _ecosystem/ui-style-kit-css + run: node scripts/write-ecosystem-workflow-outputs.mjs + + - name: Checkout reviewed Layout companion + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + repository: ${{ steps.ecosystem_sources.outputs.layout_repository }} + ref: ${{ steps.ecosystem_sources.outputs.layout_revision }} + path: _ecosystem/Layout-Style-CSS + + - name: Install Layout companion dependencies + working-directory: _ecosystem/Layout-Style-CSS + run: npm ci + + - name: Install clean-consumer Chromium + working-directory: _ecosystem/ui-style-kit-css + run: npx playwright install --with-deps chromium + + # Candidate override is an npm-pack tarball; the other two sources stay at reviewed immutable commits. + - name: Run read-only release preflight + run: npm run release:preflight -- --fixture-root $GITHUB_WORKSPACE/_ecosystem/ui-style-kit-css --layout-repo $GITHUB_WORKSPACE/_ecosystem/Layout-Style-CSS --layout-docs-repo $GITHUB_WORKSPACE/_ecosystem/Layout-Style-CSS --interactive-docs-repo $GITHUB_WORKSPACE diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 223601d..081f5ea 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -60,6 +60,45 @@ jobs: - name: Validate publish package run: npm run validate:publish + - name: Resolve immutable UI release fixture + id: ui_fixture + run: node scripts/release-fixture-contract.mjs --write-github-outputs + + - name: Checkout reviewed UI release fixture + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + repository: ${{ steps.ui_fixture.outputs.ui_repository }} + ref: ${{ steps.ui_fixture.outputs.ui_revision }} + path: _ecosystem/ui-style-kit-css + + - name: Install UI fixture dependencies + working-directory: _ecosystem/ui-style-kit-css + run: npm ci + + - name: Resolve immutable Layout companion + id: ecosystem_sources + working-directory: _ecosystem/ui-style-kit-css + run: node scripts/write-ecosystem-workflow-outputs.mjs + + - name: Checkout reviewed Layout companion + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + repository: ${{ steps.ecosystem_sources.outputs.layout_repository }} + ref: ${{ steps.ecosystem_sources.outputs.layout_revision }} + path: _ecosystem/Layout-Style-CSS + + - name: Install Layout companion dependencies + working-directory: _ecosystem/Layout-Style-CSS + run: npm ci + + - name: Install clean-consumer Chromium + working-directory: _ecosystem/ui-style-kit-css + run: npx playwright install --with-deps chromium + + # This gate performs only registry reads, candidate packing, and clean-consumer validation. + - name: Run ecosystem release preflight + run: npm run release:preflight -- --fixture-root $GITHUB_WORKSPACE/_ecosystem/ui-style-kit-css --layout-repo $GITHUB_WORKSPACE/_ecosystem/Layout-Style-CSS --layout-docs-repo $GITHUB_WORKSPACE/_ecosystem/Layout-Style-CSS --interactive-docs-repo $GITHUB_WORKSPACE + - name: Publish to npm run: npm publish --provenance --access public --ignore-scripts env: diff --git a/ecosystem-release-fixture.json b/ecosystem-release-fixture.json new file mode 100644 index 0000000..01a8d21 --- /dev/null +++ b/ecosystem-release-fixture.json @@ -0,0 +1,4 @@ +{ + "repository": "Foscat/ui-style-kit-css", + "revision": "72286fc27e4c3664ab05598a34c4dcf7e8267821" +} diff --git a/package.json b/package.json index 7d76a7a..f1557fd 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,7 @@ "minify": "node ./scripts/build.mjs minify", "build": "node ./scripts/build.mjs", "test": "playwright test --config=./playwright.config.mjs", - "test:contracts": "node --test tests/manifest-contract.test.mjs tests/public-contract.test.mjs tests/build.test.mjs tests/documentation.test.mjs tests/fixture-cleanup.test.mjs tests/ownership-boundaries.test.mjs", + "test:contracts": "node --test tests/manifest-contract.test.mjs tests/public-contract.test.mjs tests/build.test.mjs tests/documentation.test.mjs tests/fixture-cleanup.test.mjs tests/ownership-boundaries.test.mjs tests/release-preflight.test.mjs", "test:package": "node --test tests/package-contract.test.mjs", "test:chromium": "playwright test --config=./playwright.config.mjs --project=chromium", "test:install": "playwright install --with-deps chromium firefox webkit", @@ -108,7 +108,9 @@ "validate:browsers": "npm run validate:ci && npm run test:install:chromium && npm run test:chromium", "validate:full": "npm run validate:ci && npm run test:install && npm test", "validate": "npm run validate:ci", - "prepublishOnly": "npm run validate:publish" + "release:preflight": "npm run build && node ./scripts/release-fixture-contract.mjs", + "release:verify": "npm run validate:publish && npm run release:preflight", + "prepublishOnly": "npm run release:verify" }, "devDependencies": { "@playwright/test": "^1.57.0", diff --git a/scripts/release-fixture-contract.mjs b/scripts/release-fixture-contract.mjs new file mode 100644 index 0000000..feac0e2 --- /dev/null +++ b/scripts/release-fixture-contract.mjs @@ -0,0 +1,192 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); + +export function readFixtureDescriptor(repositoryRoot) { + const descriptor = JSON.parse( + fs.readFileSync( + path.join(repositoryRoot, "ecosystem-release-fixture.json"), + "utf8", + ), + ); + assert.match( + descriptor.repository, + /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/, + "Fixture repository must be owner/name.", + ); + assert.match( + descriptor.revision, + /^[0-9a-f]{40}$/, + "Fixture revision must be an immutable 40-character commit SHA.", + ); + return descriptor; +} + +export function writeGithubOutputs(descriptor, outputPath) { + assert.ok( + outputPath, + "GITHUB_OUTPUT is required for workflow source resolution.", + ); + fs.appendFileSync( + outputPath, + `ui_repository=${descriptor.repository}\nui_revision=${descriptor.revision}\n`, + ); +} + +export function validateWorkflowSources(workflows) { + const pullRequestWorkflows = workflows.filter(({ source }) => + /^\s*pull_request\s*:/m.test(source), + ); + assert.ok( + pullRequestWorkflows.some(({ source }) => + /\bnpm\s+run\s+release:preflight\b/.test(source), + ), + "A pull-request workflow must execute npm run release:preflight.", + ); + for (const workflow of pullRequestWorkflows) { + if (/^(?!\s*(?:name:|#)).*\bnpm\s+publish\b/m.test(workflow.source)) { + throw new Error( + `pull-request workflow ${workflow.name} enables npm publish`, + ); + } + if (/^(?!\s*(?:name:|#)).*\bgit\s+(?:push|tag)\b/m.test(workflow.source)) { + throw new Error( + `pull-request workflow ${workflow.name} enables a git mutation`, + ); + } + } + + const publishWorkflow = workflows.find( + ({ name }) => name === "npm-publish.yml", + ); + assert.ok(publishWorkflow, "npm-publish.yml must exist."); + const preflightIndex = publishWorkflow.source.search( + /\bnpm\s+run\s+release:preflight\b/, + ); + const publishIndex = publishWorkflow.source.search( + /^(?!\s*(?:name:|#)).*\bnpm\s+publish\b/m, + ); + assert.ok( + publishIndex >= 0, + "npm-publish.yml must retain the package publish step.", + ); + assert.ok( + preflightIndex >= 0 && preflightIndex < publishIndex, + "npm-publish.yml must run preflight before npm publish.", + ); +} + +export function validateRepositoryWorkflows(repositoryRoot) { + const workflowRoot = path.join(repositoryRoot, ".github", "workflows"); + const workflows = fs + .readdirSync(workflowRoot) + .filter((name) => /\.ya?ml$/i.test(name)) + .map((name) => ({ + name, + source: fs.readFileSync(path.join(workflowRoot, name), "utf8"), + })); + validateWorkflowSources(workflows); +} + +async function runCli(args) { + const descriptor = readFixtureDescriptor(rootDir); + if (args.includes("--write-github-outputs")) { + writeGithubOutputs(descriptor, process.env.GITHUB_OUTPUT); + return; + } + + const { fixtureRoot, forwardedArgs } = parseFixtureRoot(args); + const resolvedFixtureRoot = path.resolve( + fixtureRoot ?? + process.env.CSS_ECOSYSTEM_FIXTURE_ROOT ?? + path.join(rootDir, "..", "ui-style-kit-css"), + ); + const preflightModule = path.join( + resolvedFixtureRoot, + "scripts", + "release-preflight.mjs", + ); + assert.ok( + fs.existsSync(preflightModule), + `Reviewed UI release fixture is missing ${preflightModule}.`, + ); + assertReviewedRevision(resolvedFixtureRoot, descriptor.revision); + + const packageName = JSON.parse( + fs.readFileSync(path.join(rootDir, "package.json"), "utf8"), + ).name; + const siblingLayout = path.resolve(rootDir, "..", "Layout-Style-CSS"); + const commandArgs = [ + preflightModule, + "--fixture-root", + resolvedFixtureRoot, + "--candidate-root", + rootDir, + "--candidate-package", + packageName, + "--layout-repo", + siblingLayout, + "--layout-docs-repo", + siblingLayout, + "--interactive-docs-repo", + rootDir, + ...forwardedArgs, + ]; + run(process.execPath, commandArgs, { cwd: rootDir }); +} + +function parseFixtureRoot(args) { + const forwardedArgs = []; + let fixtureRoot; + for (let index = 0; index < args.length; index += 1) { + if (args[index] === "--fixture-root") { + fixtureRoot = args[(index += 1)]; + assert.ok(fixtureRoot, "--fixture-root requires a value."); + } else { + forwardedArgs.push(args[index]); + } + } + return { fixtureRoot, forwardedArgs }; +} + +function assertReviewedRevision(fixtureRoot, revision) { + const result = spawnSync( + "git", + ["-C", fixtureRoot, "merge-base", "--is-ancestor", revision, "HEAD"], + { + encoding: "utf8", + }, + ); + assert.equal( + result.status, + 0, + `UI fixture checkout must contain reviewed revision ${revision}; got ${result.stderr || result.stdout || "unknown git error"}.`, + ); +} + +function run(command, args, { cwd }) { + const result = spawnSync(command, args, { + cwd, + encoding: "utf8", + stdio: "inherit", + }); + if (result.status !== 0) { + throw new Error( + `Command failed (${result.status}): ${command} ${args.join(" ")}`, + ); + } +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + await runCli(process.argv.slice(2)); +} diff --git a/tests/package-contract.test.mjs b/tests/package-contract.test.mjs index aeffdad..403b954 100644 --- a/tests/package-contract.test.mjs +++ b/tests/package-contract.test.mjs @@ -88,7 +88,10 @@ const expectedScripts = { "validate:browsers": "npm run validate:ci && npm run test:install:chromium && npm run test:chromium", "validate:full": "npm run validate:ci && npm run test:install && npm test", - prepublishOnly: "npm run validate:publish", + "release:preflight": + "npm run build && node ./scripts/release-fixture-contract.mjs", + "release:verify": "npm run validate:publish && npm run release:preflight", + prepublishOnly: "npm run release:verify", }; function locateNpmCli() { diff --git a/tests/release-preflight.test.mjs b/tests/release-preflight.test.mjs new file mode 100644 index 0000000..acbcb2d --- /dev/null +++ b/tests/release-preflight.test.mjs @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); +let releaseContract; +try { + releaseContract = await import("../scripts/release-fixture-contract.mjs"); +} catch { + // RED remains an assertion failure until the companion fixture contract exists. +} + +test("pins an immutable reviewed UI release fixture and writes exact checkout outputs", () => { + assert.ok( + releaseContract, + "scripts/release-fixture-contract.mjs must implement the fixture contract", + ); + + const descriptor = releaseContract.readFixtureDescriptor(rootDir); + assert.deepEqual(descriptor, { + repository: "Foscat/ui-style-kit-css", + revision: "72286fc27e4c3664ab05598a34c4dcf7e8267821", + }); + + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), "interactive-release-fixture-"), + ); + const outputPath = path.join(tempRoot, "github-output.txt"); + try { + releaseContract.writeGithubOutputs(descriptor, outputPath); + assert.equal( + fs.readFileSync(outputPath, "utf8"), + "ui_repository=Foscat/ui-style-kit-css\nui_revision=72286fc27e4c3664ab05598a34c4dcf7e8267821\n", + ); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +test("pull requests execute read-only preflight and npm publish stays downstream", () => { + assert.ok( + releaseContract, + "scripts/release-fixture-contract.mjs must implement the fixture contract", + ); + + assert.doesNotThrow(() => + releaseContract.validateRepositoryWorkflows(rootDir), + ); +}); + +test("workflow policy rejects a pull-request publish command", () => { + assert.ok( + releaseContract, + "scripts/release-fixture-contract.mjs must implement the fixture contract", + ); + + assert.throws( + () => + releaseContract.validateWorkflowSources([ + { + name: "ci.yaml", + source: + "on:\n pull_request:\njobs:\n verify:\n steps:\n - run: npm run release:preflight\n - run: npm publish\n", + }, + { + name: "npm-publish.yml", + source: + "on:\n release:\njobs:\n publish:\n steps:\n - run: npm run release:preflight\n - run: npm publish\n", + }, + ]), + /pull-request workflow ci\.yaml enables npm publish/, + ); +}); diff --git a/wiki/Publishing-and-Releases.md b/wiki/Publishing-and-Releases.md index 060fad9..32d961c 100644 --- a/wiki/Publishing-and-Releases.md +++ b/wiki/Publishing-and-Releases.md @@ -24,6 +24,14 @@ See the [npm publish workflow](https://github.com/Foscat/Interactive-Surface-CSS npm run validate ``` + Then run the read-only ecosystem release preflight: + + ```bash + npm run release:preflight + ``` + + The preflight uses the immutable UI fixture in `ecosystem-release-fixture.json`, overrides Interactive Surface with the candidate tarball, queries npm for every exact documented minimum/current version, resolves all packed exports, validates current documentation, and runs the reviewed clean-install matrices. Pull requests execute this same gate without enabling publish, tag, release, or deployment mutations. + 5. Run the supported browser matrix: ```bash @@ -40,7 +48,7 @@ See the [npm publish workflow](https://github.com/Foscat/Interactive-Surface-CSS - `validate:browsers`: deterministic checks plus Chromium. - `validate:full`: deterministic checks plus Chromium, Firefox, and WebKit. -The publish guard avoids downloading browser binaries. Browser verification must already be complete before the irreversible release step. +The deterministic publish guard avoids downloading browser binaries. The separate ecosystem release preflight installs Chromium in CI and must pass before the irreversible release step. ## Release identity From 937c0234bcc33a936d2cd1d657bc9d7c7b21c98c Mon Sep 17 00:00:00 2001 From: Foscat Date: Sat, 8 Aug 2026 21:12:40 -0500 Subject: [PATCH 15/22] fix: make release preflight executable --- scripts/release-fixture-contract.mjs | 48 +++++++++++++++++---- tests/release-preflight.test.mjs | 63 +++++++++++++++++++++------- wiki/Publishing-and-Releases.md | 12 ++++++ 3 files changed, 98 insertions(+), 25 deletions(-) diff --git a/scripts/release-fixture-contract.mjs b/scripts/release-fixture-contract.mjs index feac0e2..711b7d5 100644 --- a/scripts/release-fixture-contract.mjs +++ b/scripts/release-fixture-contract.mjs @@ -41,6 +41,34 @@ export function writeGithubOutputs(descriptor, outputPath) { } export function validateWorkflowSources(workflows) { + const mutationPatterns = [ + { + label: "npm publish", + pattern: /^(?!\s*(?:name:|#)).*\bnpm\s+publish\b/m, + }, + { + label: "npm version", + pattern: /^(?!\s*(?:name:|#)).*\bnpm\s+version(?:\s|$)/m, + }, + { + label: "git tag", + pattern: /^(?!\s*(?:name:|#)).*\bgit\s+tag(?:\s|$)/m, + }, + { + label: "git push", + pattern: /^(?!\s*(?:name:|#)).*\bgit\s+push(?:\s|$)/m, + }, + { + label: "GitHub release", + pattern: + /(?:^\s*(?:-\s*)?uses:\s*(?:softprops\/action-gh-release|ncipollo\/release-action|actions\/create-release)@|^(?!\s*(?:name:|#)).*\bgh\s+release\b)/m, + }, + { + label: "deployment", + pattern: + /(?:^\s*(?:-\s*)?uses:\s*(?:actions\/(?:deploy-pages|upload-pages-artifact)|peaceiris\/actions-gh-pages|cloudflare\/wrangler-action|azure\/webapps-deploy)@|^(?!\s*(?:name:|#)).*\b(?:wrangler\s+(?:deploy|publish)|netlify\s+deploy|firebase\s+deploy|vercel(?:\s+deploy)?)\b)/m, + }, + ]; const pullRequestWorkflows = workflows.filter(({ source }) => /^\s*pull_request\s*:/m.test(source), ); @@ -51,15 +79,12 @@ export function validateWorkflowSources(workflows) { "A pull-request workflow must execute npm run release:preflight.", ); for (const workflow of pullRequestWorkflows) { - if (/^(?!\s*(?:name:|#)).*\bnpm\s+publish\b/m.test(workflow.source)) { - throw new Error( - `pull-request workflow ${workflow.name} enables npm publish`, - ); - } - if (/^(?!\s*(?:name:|#)).*\bgit\s+(?:push|tag)\b/m.test(workflow.source)) { - throw new Error( - `pull-request workflow ${workflow.name} enables a git mutation`, - ); + for (const mutation of mutationPatterns) { + if (mutation.pattern.test(workflow.source)) { + throw new Error( + `pull-request workflow ${workflow.name} enables forbidden mutation: ${mutation.label}`, + ); + } } } @@ -81,6 +106,11 @@ export function validateWorkflowSources(workflows) { preflightIndex >= 0 && preflightIndex < publishIndex, "npm-publish.yml must run preflight before npm publish.", ); + assert.match( + publishWorkflow.source, + /^(?!\s*(?:name:|#)).*\bnpm\s+publish\b[^\r\n]*--ignore-scripts(?:\s|$)/m, + "npm-publish.yml must suppress lifecycle re-entry after explicit preflight.", + ); } export function validateRepositoryWorkflows(repositoryRoot) { diff --git a/tests/release-preflight.test.mjs b/tests/release-preflight.test.mjs index acbcb2d..a49b689 100644 --- a/tests/release-preflight.test.mjs +++ b/tests/release-preflight.test.mjs @@ -54,26 +54,57 @@ test("pull requests execute read-only preflight and npm publish stays downstream ); }); -test("workflow policy rejects a pull-request publish command", () => { +test("workflow policy rejects every release or deployment mutation from pull requests", () => { assert.ok( releaseContract, "scripts/release-fixture-contract.mjs must implement the fixture contract", ); - assert.throws( - () => - releaseContract.validateWorkflowSources([ - { - name: "ci.yaml", - source: - "on:\n pull_request:\njobs:\n verify:\n steps:\n - run: npm run release:preflight\n - run: npm publish\n", - }, - { - name: "npm-publish.yml", - source: - "on:\n release:\njobs:\n publish:\n steps:\n - run: npm run release:preflight\n - run: npm publish\n", - }, - ]), - /pull-request workflow ci\.yaml enables npm publish/, + const forbiddenMutations = [ + ["npm publish", " - run: npm publish"], + ["npm version", " - run: npm version patch"], + ["git tag", " - run: git tag v1.5.1"], + ["git push", " - run: git push origin HEAD"], + ["GitHub release", " - uses: softprops/action-gh-release@v2"], + ["GitHub release", " - run: gh release create v1.5.1"], + ["deployment", " - uses: actions/deploy-pages@v4"], + ["deployment", " - run: npx wrangler deploy"], + ]; + const safeRelease = + "on:\n release:\njobs:\n publish:\n steps:\n - run: npm run release:preflight\n - run: npm publish --provenance --access public --ignore-scripts\n"; + + for (const [label, mutation] of forbiddenMutations) { + assert.throws( + () => + releaseContract.validateWorkflowSources([ + { + name: "ci.yaml", + source: + "on:\n pull_request:\njobs:\n verify:\n steps:\n - run: npm run release:preflight\n" + + `${mutation}\n`, + }, + { name: "npm-publish.yml", source: safeRelease }, + ]), + new RegExp( + `pull-request workflow ci\\.yaml enables forbidden mutation: ${label}`, + ), + ); + } +}); + +test("publishing guide records the immutable bootstrap and merge sequence", () => { + const guide = fs.readFileSync( + path.join(rootDir, "wiki", "Publishing-and-Releases.md"), + "utf8", ); + + for (const phrase of [ + "72286fc27e4c3664ab05598a34c4dcf7e8267821", + "Push a stable UI bootstrap ref", + "merge commits", + "Update and verify the final UI companion pins", + "Do not squash, rebase, or delete the only remote refs", + ]) { + assert.match(guide, new RegExp(phrase, "i")); + } }); diff --git a/wiki/Publishing-and-Releases.md b/wiki/Publishing-and-Releases.md index 32d961c..2c10dc9 100644 --- a/wiki/Publishing-and-Releases.md +++ b/wiki/Publishing-and-Releases.md @@ -50,6 +50,18 @@ See the [npm publish workflow](https://github.com/Foscat/Interactive-Surface-CSS The deterministic publish guard avoids downloading browser binaries. The separate ecosystem release preflight installs Chromium in CI and must pass before the irreversible release step. +## Coordinated bootstrap sequence + +The immutable cross-repository pins require this exact remote sequence: + +1. Push a stable UI bootstrap ref containing `72286fc27e4c3664ab05598a34c4dcf7e8267821`. +2. Push and merge Interactive Surface CSS and Layout Style CSS with merge commits so their reviewed commit SHAs remain reachable. +3. Update and verify the final UI companion pins against those merged companion commits. +4. Push the final UI branch, rerun its ecosystem preflight, and merge UI with a merge commit. +5. Do not squash, rebase, or delete the only remote refs until every pinned commit is reachable through merged ancestry. + +The bootstrap SHA is deliberately stable: companion workflows use it to load the reviewed preflight implementation before the final UI commit can reference the companion heads. + ## Release identity The tag, GitHub Release, package version, lockfile version, and changelog heading must agree. For this candidate, the expected tag is `v1.5.0`. From c9977fccd816bb26dbaf01b35c179938c6d6c5a5 Mon Sep 17 00:00:00 2001 From: Foscat Date: Sat, 8 Aug 2026 22:23:18 -0500 Subject: [PATCH 16/22] fix: enforce gate c ownership boundaries --- ecosystem-release-fixture.json | 2 +- scripts/check-css-ownership.mjs | 214 ++++++++++++++++++++++------ tests/ownership-boundaries.test.mjs | 80 ++++++++++- tests/release-preflight.test.mjs | 4 +- 4 files changed, 251 insertions(+), 49 deletions(-) diff --git a/ecosystem-release-fixture.json b/ecosystem-release-fixture.json index 01a8d21..2c497ee 100644 --- a/ecosystem-release-fixture.json +++ b/ecosystem-release-fixture.json @@ -1,4 +1,4 @@ { "repository": "Foscat/ui-style-kit-css", - "revision": "72286fc27e4c3664ab05598a34c4dcf7e8267821" + "revision": "0080528295e485a340959c602f35b47ff5b8fea3" } diff --git a/scripts/check-css-ownership.mjs b/scripts/check-css-ownership.mjs index 26b3981..b809c1d 100644 --- a/scripts/check-css-ownership.mjs +++ b/scripts/check-css-ownership.mjs @@ -39,6 +39,15 @@ const pageTopologyProperties = new Set([ "grid-template-rows", "order", ]); +const flexTopologyProperties = new Set([ + "column-gap", + "flex", + "flex-direction", + "flex-flow", + "flex-wrap", + "gap", + "row-gap", +]); const majorPageProperties = new Set([ "block-size", "bottom", @@ -335,8 +344,11 @@ function containsChromaticLiteral(value) { /* Parser grammar covers named, legacy, and modern color functions inside variable fallbacks. */ walk(value, { enter(node) { - if (["Function", "Hash", "Identifier"].includes(node.type) && - colorNodeIsChromatic(node)) chromatic = true; + if ( + ["Function", "Hash", "Identifier"].includes(node.type) && + colorNodeIsChromatic(node) + ) + chromatic = true; }, }); @@ -374,13 +386,31 @@ function containsDirectLiteralPaint(value) { return literal; } +function isColorPaintProperty(property) { + return ( + property === "color" || + property === "color-scheme" || + property.endsWith("-color") || + ["fill", "stroke"].includes(property) + ); +} + +function colorSchemeHasLiteral(value) { + // A bare token reference remains theme-owned; literals and token fallbacks still choose paint. + return !/^(?:env|var)\([^,()]+\)$/.test(generate(value).trim()); +} + function containsImage(value) { let image = false; walk(value, { enter(node) { if (node.type === "Url") image = true; - if (node.type === "Function" && - /(?:gradient|image|paint|cross-fade|element|url)/.test(node.name.toLowerCase())) { + if ( + node.type === "Function" && + /(?:gradient|image|paint|cross-fade|element|url)/.test( + node.name.toLowerCase(), + ) + ) { image = true; } }, @@ -388,7 +418,89 @@ function containsImage(value) { return image; } -function selectorOwnsPageTopology(rule) { +function manifestComponentClasses(manifest) { + const componentClasses = new Set( + (manifest.selectors?.stable ?? []) + .filter((selector) => /^\.[a-zA-Z0-9_-]+$/.test(selector)) + .map((selector) => selector.slice(1)), + ); + const universalSuffixes = manifest.classApi?.universalVisualSuffixes ?? []; + + for (const preset of manifest.presets ?? []) { + for (const suffix of universalSuffixes) { + componentClasses.add(`${preset.prefix}-${suffix}`); + } + for (const suffix of manifest.classApi?.presetExtras?.[preset.id] ?? []) { + componentClasses.add(`${preset.prefix}-${suffix}`); + } + } + return componentClasses; +} + +function rightmostCompound(selector) { + const nodes = [...selector.children]; + let subjectStart = 0; + nodes.forEach((node, index) => { + if (node.type === "Combinator") subjectStart = index + 1; + }); + return nodes.slice(subjectStart); +} + +function selectorListHasPageSubject(selectorList, context) { + for (const selector of selectorList.children) { + if ( + rightmostCompound(selector).some((node) => + subjectNodeOwnsPageTopology(node, context), + ) + ) { + return true; + } + } + return false; +} + +function subjectNodeOwnsPageTopology(node, context) { + if (node.type === "TypeSelector") { + return ["body", "html", "main", "section"].includes( + node.name.toLowerCase(), + ); + } + if (node.type === "IdSelector") { + return ["app", "layout", "main", "page", "root", "shell"].includes( + node.name.toLowerCase(), + ); + } + if (node.type === "ClassSelector") { + if (context.componentClasses.has(node.name)) return false; + return node.name + .split(/[-_]/) + .some((segment) => context.structuralNames.has(segment)); + } + if (node.type === "AttributeSelector") { + const name = node.name.name.toLowerCase(); + const value = + node.value?.name?.toLowerCase() ?? node.value?.value?.toLowerCase(); + return ( + ["data-layout", "data-page", "data-shell"].includes(name) || + (name === "role" && value === "main") + ); + } + if ( + node.type === "PseudoClassSelector" && + ["is", "where"].includes(node.name.toLowerCase()) + ) { + for (const child of node.children ?? []) { + if ( + child.type === "SelectorList" && + selectorListHasPageSubject(child, context) + ) + return true; + } + } + return false; +} + +function selectorOwnsPageTopology(rule, manifest) { const structuralNames = new Set([ "container", "content", @@ -402,40 +514,22 @@ function selectorOwnsPageTopology(rule) { "stack", "wrapper", ]); - let pageRoot = false; - - walk(rule.prelude, { - enter(node) { - if (node.type === "TypeSelector" && - ["body", "html", "main", "section"].includes(node.name.toLowerCase())) { - pageRoot = true; - } - if (node.type === "IdSelector" && - ["app", "layout", "main", "page", "root", "shell"].includes(node.name.toLowerCase())) { - pageRoot = true; - } - if (node.type === "ClassSelector" && - node.name.split(/[-_]/).some((segment) => structuralNames.has(segment))) { - pageRoot = true; - } - if (node.type === "AttributeSelector") { - const name = node.name.name.toLowerCase(); - const value = node.value?.name?.toLowerCase() ?? node.value?.value?.toLowerCase(); - if (["data-layout", "data-page", "data-shell"].includes(name) || - (name === "role" && value === "main")) pageRoot = true; - } - }, + return selectorListHasPageSubject(rule.prelude, { + componentClasses: manifestComponentClasses(manifest), + structuralNames, }); - - return pageRoot; } function manifestStateClasses(manifest) { - const stateSuffixes = new Set([...commonStateClasses].map((name) => name.replace(/^is-/, ""))); - const manifestClasses = new Set([ - ...(manifest.selectors?.stateClasses ?? []), - ...(manifest.classApi?.stateClasses ?? []), - ].map((selector) => selector.replace(/^\./, "").toLowerCase())); + const stateSuffixes = new Set( + [...commonStateClasses].map((name) => name.replace(/^is-/, "")), + ); + const manifestClasses = new Set( + [ + ...(manifest.selectors?.stateClasses ?? []), + ...(manifest.classApi?.stateClasses ?? []), + ].map((selector) => selector.replace(/^\./, "").toLowerCase()), + ); for (const preset of manifest.presets ?? []) { const suffixes = [ @@ -470,7 +564,10 @@ function selectorHasState(rule, manifest) { walk(rule.prelude, { enter(node) { - if (node.type === "PseudoClassSelector" && nativeStatePseudos.has(node.name.toLowerCase())) { + if ( + node.type === "PseudoClassSelector" && + nativeStatePseudos.has(node.name.toLowerCase()) + ) { stateful = true; } if (node.type === "ClassSelector") { @@ -479,9 +576,13 @@ function selectorHasState(rule, manifest) { (state) => className.endsWith(`-${state}`) || className.endsWith(`_${state}`), ); - if (exactStateClasses.has(className) || hasBoundarySuffix) stateful = true; + if (exactStateClasses.has(className) || hasBoundarySuffix) + stateful = true; } - if (node.type === "AttributeSelector" && stateAttributes.has(node.name.name.toLowerCase())) { + if ( + node.type === "AttributeSelector" && + stateAttributes.has(node.name.name.toLowerCase()) + ) { stateful = true; } }, @@ -503,17 +604,23 @@ export function matchesStateSelector(selector, manifest = {}) { return stateful; } +function isFlexTopologyProperty(property) { + return ( + flexTopologyProperties.has(property) || /^(?:align|justify)-/.test(property) + ); +} + function violationRule({ rule, property, value, manifest }) { if (pageTopologyProperties.has(property.name)) return "interactive-page-topology"; if ( - selectorOwnsPageTopology(rule) && - majorPageProperties.has(property.name) + selectorOwnsPageTopology(rule, manifest) && + (majorPageProperties.has(property.name) || + isFlexTopologyProperty(property.name)) ) { return "interactive-page-topology"; } - if (property.name === "font-family") - return "interactive-branded-paint"; + if (property.name === "font-family") return "interactive-branded-paint"; const hasChromaticPaint = containsChromaticLiteral(value); const hasDirectLiteralPaint = containsDirectLiteralPaint(value); @@ -534,12 +641,24 @@ function violationRule({ rule, property, value, manifest }) { return "interactive-branded-paint"; } if ( - ["color", "fill", "stroke", "box-shadow", "text-shadow"].includes(property.name) && + !property.custom && + isColorPaintProperty(property.name) && + (property.name === "color-scheme" + ? colorSchemeHasLiteral(value) + : hasDirectLiteralPaint) + ) { + return "interactive-branded-paint"; + } + if ( + ["box-shadow", "text-shadow"].includes(property.name) && hasDirectLiteralPaint ) { return "interactive-branded-paint"; } - if (["filter", "backdrop-filter"].includes(property.name) && generate(value).trim() !== "none") { + if ( + ["filter", "backdrop-filter"].includes(property.name) && + generate(value).trim() !== "none" + ) { return "interactive-branded-paint"; } @@ -549,7 +668,12 @@ function violationRule({ rule, property, value, manifest }) { return null; } -export function auditOwnership({ css, manifest = {}, allowlist, now = new Date() }) { +export function auditOwnership({ + css, + manifest = {}, + allowlist, + now = new Date(), +}) { validateAllowlist({ entries: allowlist, now }); const ast = parse(css, { diff --git a/tests/ownership-boundaries.test.mjs b/tests/ownership-boundaries.test.mjs index ebb4e68..1b7c9ab 100644 --- a/tests/ownership-boundaries.test.mjs +++ b/tests/ownership-boundaries.test.mjs @@ -161,6 +161,43 @@ test("state core rejects branded paint literals outside an exact reviewed fallba assert.equal(result.matchedAllowlistCount, 1); }); +test("state core rejects literal paint across every color property family", () => { + const literals = [ + ["accent-color", "#ff0066"], + ["caret-color", "red"], + ["column-rule-color", "blue"], + ["color", "red"], + ["color-scheme", "dark"], + ["fill", "red"], + ["stroke", "blue"], + ["flood-color", "red"], + ["lighting-color", "blue"], + ["stop-color", "red"], + ["scrollbar-color", "red blue"], + ]; + + for (const [property, value] of literals) { + const result = auditOwnership({ + css: `.literal-paint { ${property}: ${value}; }`, + allowlist: [], + now: reviewedAt, + }); + assert.equal(result.violations.length, 1, property); + assert.equal( + result.violations[0].rule, + "interactive-branded-paint", + property, + ); + } + + const tokenResult = auditOwnership({ + css: ".token-paint { accent-color: var(--accent); color-scheme: var(--scheme); fill: var(--fill); scrollbar-color: var(--thumb) var(--track); }", + allowlist: [], + now: reviewedAt, + }); + assert.deepEqual(tokenResult.violations, []); +}); + test("state core permits token value supply across shared reflected ARIA states", () => { const ariaStates = [ "busy", @@ -242,7 +279,10 @@ test("shared state probe recognizes manifest classes at exact and boundary-delim ]; assert.deepEqual( - selectors.map((selector) => [selector, matchesStateSelector(selector, manifest)]), + selectors.map((selector) => [ + selector, + matchesStateSelector(selector, manifest), + ]), selectors.map((selector) => [selector, true]), ); }); @@ -300,6 +340,44 @@ test("state core rejects page topology but permits internal state positioning", ]); }); +test("state core rejects structural flex topology while preserving component subjects", () => { + const cases = [ + ["html", "flex", "1"], + ["body", "flex-flow", "row wrap"], + ["#app", "flex-direction", "column"], + ["#root", "flex-wrap", "wrap"], + [".page-shell", "gap", "2rem"], + [".page", "row-gap", "1rem"], + ["main", "column-gap", "3rem"], + ["[data-layout]", "align-content", "start"], + ["[data-page]", "align-items", "center"], + ["[data-shell]", "align-self", "stretch"], + ["[role=main]", "justify-content", "space-between"], + [".main", "justify-items", "center"], + ["section", "justify-self", "stretch"], + ]; + + for (const [selector, property, value] of cases) { + const result = auditOwnership({ + css: `${selector} { ${property}: ${value}; }`, + allowlist: [], + now: reviewedAt, + }); + assert.equal(result.violations.length, 1, `${selector} ${property}`); + assert.equal(result.violations[0].rule, "interactive-page-topology"); + } + + const componentResult = auditOwnership({ + css: ".page .interactive-surface { flex-flow: column wrap; gap: 1rem; } .page-shell-component { flex-direction: column; align-items: center; }", + manifest: { + selectors: { stable: [".interactive-surface", ".page-shell-component"] }, + }, + allowlist: [], + now: reviewedAt, + }); + assert.deepEqual(componentResult.violations, []); +}); + test("allowlist rejects every malformed, stale, broad, duplicate, and unmatched mutation", () => { const missingReason = exception(); delete missingReason.reason; diff --git a/tests/release-preflight.test.mjs b/tests/release-preflight.test.mjs index a49b689..ad0daa7 100644 --- a/tests/release-preflight.test.mjs +++ b/tests/release-preflight.test.mjs @@ -25,7 +25,7 @@ test("pins an immutable reviewed UI release fixture and writes exact checkout ou const descriptor = releaseContract.readFixtureDescriptor(rootDir); assert.deepEqual(descriptor, { repository: "Foscat/ui-style-kit-css", - revision: "72286fc27e4c3664ab05598a34c4dcf7e8267821", + revision: "0080528295e485a340959c602f35b47ff5b8fea3", }); const tempRoot = fs.mkdtempSync( @@ -36,7 +36,7 @@ test("pins an immutable reviewed UI release fixture and writes exact checkout ou releaseContract.writeGithubOutputs(descriptor, outputPath); assert.equal( fs.readFileSync(outputPath, "utf8"), - "ui_repository=Foscat/ui-style-kit-css\nui_revision=72286fc27e4c3664ab05598a34c4dcf7e8267821\n", + "ui_repository=Foscat/ui-style-kit-css\nui_revision=0080528295e485a340959c602f35b47ff5b8fea3\n", ); } finally { fs.rmSync(tempRoot, { recursive: true, force: true }); From 0058a34ac75139ed5c5f9f62728d767718a91281 Mon Sep 17 00:00:00 2001 From: Foscat Date: Sat, 8 Aug 2026 22:50:10 -0500 Subject: [PATCH 17/22] docs: align publishing bootstrap fixture --- tests/release-preflight.test.mjs | 10 +++++++++- wiki/Publishing-and-Releases.md | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/release-preflight.test.mjs b/tests/release-preflight.test.mjs index ad0daa7..6505863 100644 --- a/tests/release-preflight.test.mjs +++ b/tests/release-preflight.test.mjs @@ -97,9 +97,17 @@ test("publishing guide records the immutable bootstrap and merge sequence", () = path.join(rootDir, "wiki", "Publishing-and-Releases.md"), "utf8", ); + const uiFixture = JSON.parse( + fs.readFileSync( + path.join(rootDir, "ecosystem-release-fixture.json"), + "utf8", + ), + ); + + // The operator guide must follow the same immutable revision used by release automation. + assert.match(guide, new RegExp(uiFixture.revision, "i")); for (const phrase of [ - "72286fc27e4c3664ab05598a34c4dcf7e8267821", "Push a stable UI bootstrap ref", "merge commits", "Update and verify the final UI companion pins", diff --git a/wiki/Publishing-and-Releases.md b/wiki/Publishing-and-Releases.md index 2c10dc9..8caa7f5 100644 --- a/wiki/Publishing-and-Releases.md +++ b/wiki/Publishing-and-Releases.md @@ -54,7 +54,7 @@ The deterministic publish guard avoids downloading browser binaries. The separat The immutable cross-repository pins require this exact remote sequence: -1. Push a stable UI bootstrap ref containing `72286fc27e4c3664ab05598a34c4dcf7e8267821`. +1. Push a stable UI bootstrap ref containing `0080528295e485a340959c602f35b47ff5b8fea3`. 2. Push and merge Interactive Surface CSS and Layout Style CSS with merge commits so their reviewed commit SHAs remain reachable. 3. Update and verify the final UI companion pins against those merged companion commits. 4. Push the final UI branch, rerun its ecosystem preflight, and merge UI with a merge commit. From 53995c79a2b4bceedce2f8a2ad236cf3e0d13ec2 Mon Sep 17 00:00:00 2001 From: Foscat Date: Sun, 9 Aug 2026 02:28:55 -0500 Subject: [PATCH 18/22] refactor: consume semantic interaction fallbacks --- CHANGELOG.md | 5 + README.md | 11 ++ index.html | 11 ++ interactive-surface.css | 51 +++++-- manifest.json | 68 +++++++++ package.json | 2 +- standalone-preset.css | 51 +++++-- state-core.css | 16 +- styles/standalone-preset.css | 35 +++-- styles/state-core.css | 16 +- tests/package-contract.test.mjs | 24 ++- tests/semantic-fallbacks.spec.ts | 244 ++++++++++++++++++++++++++++++ tests/semantic-fallbacks.test.mjs | 124 +++++++++++++++ wiki/Installation-and-Usage.md | 9 ++ wiki/Token-Reference.md | 22 +++ 15 files changed, 630 insertions(+), 59 deletions(-) create mode 100644 tests/semantic-fallbacks.spec.ts create mode 100644 tests/semantic-fallbacks.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 894678c..2efd709 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ All notable changes to this package are documented in this file. ## Unreleased +### Changed + +- Added optional shared semantic fallbacks for standalone paint, control geometry, focus, and default motion while preserving package-specific precedence and all legacy behavior when the shared tokens are absent. +- Documented the third-party theme integration path, fallback order, and canonical UI Style Kit bridge boundary across the README and wiki. + ## 1.5.0 - 2026-07-20 ### Added diff --git a/README.md b/README.md index 7bb6f2f..8b8ece6 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,15 @@ For selector details and responsibilities, see the [complete API reference](http The transition defaults are `translate, box-shadow, outline-color`, the established default motion duration, the standard easing curve, and zero delay. Existing `--interactive-surface-motion-*`, `--interactive-surface-ease-*`, `--motion-*`, and `--ease-*` fallbacks remain supported. All public custom properties use the `--interactive-surface-*` namespace. See the [token ownership tables and full reference](https://github.com/Foscat/Interactive-Surface-CSS/wiki/Token-Reference). +A third-party design system can theme the standalone entry point with the optional shared semantic tokens `--ui-color-surface`, `--ui-color-text`, `--ui-color-muted`, `--ui-color-primary`, `--ui-color-on-primary`, `--ui-color-border`, `--ui-radius-control`, `--ui-shadow-control`, `--ui-focus-color`, `--ui-motion-duration`, and `--ui-motion-easing`. The fallback order is package-specific `--interactive-surface-*` values, then shared semantic values, then existing legacy values and literals. When the shared tokens are absent, standalone output is unchanged. + +```js +import "third-party-theme/tokens.css"; +import "interactive-surface-css/standalone-preset.css"; +``` + +Use `state-core.css` instead when the third-party theme already paints and sizes the component directly. The full token-to-property mapping is in the [token reference](https://github.com/Foscat/Interactive-Surface-CSS/wiki/Token-Reference). + ## Accessibility responsibilities The CSS package provides visible keyboard focus, persistent-state treatment, disabled precedence, reduced-motion behavior, higher-contrast behavior, forced-colors affordances, hover gating for capable pointers, and standalone icon target sizing. The `:focus-visible` ring remains orthogonal across every focusable non-disabled interaction state. @@ -195,6 +204,8 @@ import "interactive-surface-css/state-core.css"; The token-and-paint bridge maps active UI Style Kit theme and mode values into the `--interactive-surface-*` contract. Interactive Surface keeps ownership of focus, hover, pressed, selected, current, loading, disabled, and motion behavior. UI Style Kit remains optional: this package declares no runtime or peer dependency on it, and the documented import order keeps its paint layer before `state-core.css`. +For a generic semantic integration without specialized UI Style Kit variant and level mappings, `ui-style-kit-css/visual.css` may instead precede `interactive-surface-css/standalone-preset.css`. The shared tokens cover the portable control baseline; the canonical token-and-paint bridge above remains the preferred UI Style Kit composition. + ## Use all three libraries Use the established order so paint, interaction, and structure retain clear ownership: diff --git a/index.html b/index.html index dc4954a..a62beb1 100644 --- a/index.html +++ b/index.html @@ -735,6 +735,15 @@

Edit token

The transition defaults are `translate, box-shadow, outline-color`, the established default motion duration, the standard easing curve, and zero delay. Existing `--interactive-surface-motion-*`, `--interactive-surface-ease-*`, `--motion-*`, and `--ease-*` fallbacks remain supported. All public custom properties use the `--interactive-surface-*` namespace. See the [token ownership tables and full reference](https://github.com/Foscat/Interactive-Surface-CSS/wiki/Token-Reference). +A third-party design system can theme the standalone entry point with the optional shared semantic tokens `--ui-color-surface`, `--ui-color-text`, `--ui-color-muted`, `--ui-color-primary`, `--ui-color-on-primary`, `--ui-color-border`, `--ui-radius-control`, `--ui-shadow-control`, `--ui-focus-color`, `--ui-motion-duration`, and `--ui-motion-easing`. The fallback order is package-specific `--interactive-surface-*` values, then shared semantic values, then existing legacy values and literals. When the shared tokens are absent, standalone output is unchanged. + +```js +import "third-party-theme/tokens.css"; +import "interactive-surface-css/standalone-preset.css"; +``` + +Use `state-core.css` instead when the third-party theme already paints and sizes the component directly. The full token-to-property mapping is in the [token reference](https://github.com/Foscat/Interactive-Surface-CSS/wiki/Token-Reference). + ## Accessibility responsibilities The CSS package provides visible keyboard focus, persistent-state treatment, disabled precedence, reduced-motion behavior, higher-contrast behavior, forced-colors affordances, hover gating for capable pointers, and standalone icon target sizing. The `:focus-visible` ring remains orthogonal across every focusable non-disabled interaction state. @@ -761,6 +770,8 @@

Edit token

The token-and-paint bridge maps active UI Style Kit theme and mode values into the `--interactive-surface-*` contract. Interactive Surface keeps ownership of focus, hover, pressed, selected, current, loading, disabled, and motion behavior. UI Style Kit remains optional: this package declares no runtime or peer dependency on it, and the documented import order keeps its paint layer before `state-core.css`. +For a generic semantic integration without specialized UI Style Kit variant and level mappings, `ui-style-kit-css/visual.css` may instead precede `interactive-surface-css/standalone-preset.css`. The shared tokens cover the portable control baseline; the canonical token-and-paint bridge above remains the preferred UI Style Kit composition. + ## Use all three libraries Use the established order so paint, interaction, and structure retain clear ownership: diff --git a/interactive-surface.css b/interactive-surface.css index 745f52e..de10504 100644 --- a/interactive-surface.css +++ b/interactive-surface.css @@ -10,11 +10,14 @@ */ :is(.interactive-surface) { - /* Complete bundles retain the established lift and shadow fallbacks without imposing them on the core. */ + /* Shared semantic values remain optional and sit below package-specific overrides. */ --_is-preset-lift-base: var(--lift-base, 0px); --_is-preset-lift-hover: var(--lift-hover, -4px); --_is-preset-lift-active: var(--lift-active, -2px); - --_is-preset-shadow-base: var(--shadow-base, 0 0 0 rgb(0 0 0 / 0)); + --_is-preset-shadow-base: var( + --ui-shadow-control, + var(--shadow-base, 0 0 0 rgb(0 0 0 / 0)) + ); --_is-preset-shadow-hover: var( --shadow-hover, 0 10px 28px rgb(0 0 0 / 0.35), @@ -27,20 +30,29 @@ ); --_is-base-bg: var( --interactive-surface-bg, - var(--surface-bg, var(--bg-surface, rgb(248 250 252))) + var( + --ui-color-surface, + var(--surface-bg, var(--bg-surface, rgb(248 250 252))) + ) ); --_is-bg: var(--_is-base-bg); --_is-fg: var( --interactive-surface-fg, - var(--surface-fg, var(--text-primary, rgb(17 24 39))) + var(--ui-color-text, var(--surface-fg, var(--text-primary, rgb(17 24 39)))) ); --_is-base-border-color: var( --interactive-surface-border-color, - var(--surface-border, var(--border-color, rgba(15, 23, 42, 0.2))) + var( + --ui-color-border, + var(--surface-border, var(--border-color, rgba(15, 23, 42, 0.2))) + ) ); --_is-border-color: var(--_is-base-border-color); --_is-border-width: var(--interactive-surface-border-width, 1px); - --_is-radius: var(--interactive-surface-radius, 0.75rem); + --_is-radius: var( + --interactive-surface-radius, + var(--ui-radius-control, 0.75rem) + ); box-sizing: border-box; border: var(--_is-border-width) solid var(--_is-border-color); @@ -347,15 +359,18 @@ .interactive-surface[data-surface-variant="primary"] { --interactive-surface-bg: var( --interactive-surface-variant-primary-bg, - var(--variant-primary-bg, rgb(15 79 127)) + var(--ui-color-primary, var(--variant-primary-bg, rgb(15 79 127))) ); --interactive-surface-fg: var( --interactive-surface-variant-primary-fg, - var(--variant-primary-fg, rgb(244 251 255)) + var(--ui-color-on-primary, var(--variant-primary-fg, rgb(244 251 255))) ); --interactive-surface-border-color: var( --interactive-surface-variant-primary-border-color, - var(--variant-primary-border, var(--interactive-surface-bg)) + var( + --ui-color-primary, + var(--variant-primary-border, var(--interactive-surface-bg)) + ) ); --interactive-surface-darken-hover: 0.95; --interactive-surface-darken-active: 0.97; @@ -405,7 +420,7 @@ ); --interactive-surface-fg: var( --interactive-surface-variant-subtle-fg, - var(--variant-subtle-fg, rgb(26 52 75)) + var(--ui-color-muted, var(--variant-subtle-fg, rgb(26 52 75))) ); --interactive-surface-border-color: var( --interactive-surface-variant-subtle-border-color, @@ -553,7 +568,7 @@ ); --_is-motion-default: var( --interactive-surface-motion-default, - var(--motion-default, 140ms) + var(--ui-motion-duration, var(--motion-default, 140ms)) ); --_is-motion-press: var( --interactive-surface-motion-press, @@ -561,7 +576,7 @@ ); --_is-ease-standard: var( --interactive-surface-ease-standard, - var(--ease-standard, cubic-bezier(0.2, 0, 0.2, 1)) + var(--ui-motion-easing, var(--ease-standard, cubic-bezier(0.2, 0, 0.2, 1))) ); --_is-ease-press: var( --interactive-surface-ease-press, @@ -576,19 +591,25 @@ ); --_is-transition-duration: var( --interactive-surface-transition-duration, - var(--interactive-surface-motion-default, var(--motion-default, 140ms)) + var( + --interactive-surface-motion-default, + var(--ui-motion-duration, var(--motion-default, 140ms)) + ) ); --_is-transition-easing: var( --interactive-surface-transition-easing, var( --interactive-surface-ease-standard, - var(--ease-standard, cubic-bezier(0.2, 0, 0.2, 1)) + var( + --ui-motion-easing, + var(--ease-standard, cubic-bezier(0.2, 0, 0.2, 1)) + ) ) ); --_is-transition-delay: var(--interactive-surface-transition-delay, 0s); --_is-focus-ring-color: var( --interactive-surface-focus-ring-color, - var(--focus-ring, rgb(11 99 246)) + var(--ui-focus-color, var(--focus-ring, rgb(11 99 246))) ); --_is-focus-ring-width: var(--interactive-surface-focus-ring-width, 2px); --_is-focus-ring-offset: var(--interactive-surface-focus-ring-offset, 2px); diff --git a/manifest.json b/manifest.json index 397ba94..5c58d3c 100644 --- a/manifest.json +++ b/manifest.json @@ -175,6 +175,74 @@ "--interactive-surface-ease-*", "--motion-*", "--ease-*" + ], + "sharedSemanticFallbacks": [ + { + "name": "--ui-color-surface", + "type": "", + "precedence": "after-package-specific-before-legacy", + "standaloneExpectation": "optional-with-existing-fallbacks" + }, + { + "name": "--ui-color-text", + "type": "", + "precedence": "after-package-specific-before-legacy", + "standaloneExpectation": "optional-with-existing-fallbacks" + }, + { + "name": "--ui-color-muted", + "type": "", + "precedence": "after-package-specific-before-legacy", + "standaloneExpectation": "optional-with-existing-fallbacks" + }, + { + "name": "--ui-color-primary", + "type": "", + "precedence": "after-package-specific-before-legacy", + "standaloneExpectation": "optional-with-existing-fallbacks" + }, + { + "name": "--ui-color-on-primary", + "type": "", + "precedence": "after-package-specific-before-legacy", + "standaloneExpectation": "optional-with-existing-fallbacks" + }, + { + "name": "--ui-color-border", + "type": "", + "precedence": "after-package-specific-before-legacy", + "standaloneExpectation": "optional-with-existing-fallbacks" + }, + { + "name": "--ui-radius-control", + "type": "", + "precedence": "after-package-specific-before-legacy", + "standaloneExpectation": "optional-with-existing-fallbacks" + }, + { + "name": "--ui-shadow-control", + "type": "", + "precedence": "after-package-specific-before-legacy", + "standaloneExpectation": "optional-with-existing-fallbacks" + }, + { + "name": "--ui-focus-color", + "type": "", + "precedence": "after-package-specific-before-legacy", + "standaloneExpectation": "optional-with-existing-fallbacks" + }, + { + "name": "--ui-motion-duration", + "type": "