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/CHANGELOG.md b/CHANGELOG.md
index 894678c..54dc42b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,32 @@ All notable changes to this package are documented in this file.
## Unreleased
+## 1.6.0 - 2026-08-09
+
+### Added
+
+- Added optional shared semantic fallbacks for standalone paint, control geometry, focus, and default motion while retaining package-specific precedence and all legacy behavior when shared tokens are absent.
+
+### Changed
+
+- Documented the third-party theme integration path, fallback order, and canonical UI Style Kit bridge boundary across the README and wiki without changing ownership or compatibility ranges.
+
+### Fixed
+
+- Preserved legacy standalone behavior when shared semantic tokens are unavailable by retaining the existing fallback chains.
+
+### Accessibility
+
+- Kept the established focus and motion semantics while allowing optional shared focus-color and default-motion values.
+
+### Documentation
+
+- Updated release-facing package, CDN, README, and wiki guidance for the unpublished `1.6.0` candidate.
+
+### Testing
+
+- Added focused contracts for shared semantic fallback precedence and manifest inventory while retaining generated, package, and public-export coverage.
+
## 1.5.0 - 2026-07-20
### Added
diff --git a/README.md b/README.md
index 0cb79a7..86b89a9 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
Framework-agnostic CSS for reliable hover, focus, press, selected, current, loading, disabled, and motion affordances on interactive controls. Use it alone or as the interaction layer beside your existing layout and theme system.
-Version 1.5.0 is a release candidate in this repository until its npm release is published. Existing 1.x imports, selectors, data hooks, ARIA hooks, and tokens remain supported.
+Version 1.6.0 is a release candidate in this repository until its npm release is published. Existing 1.x imports, selectors, data hooks, ARIA hooks, and tokens remain supported.
The package targets Node.js 20+ for npm installs and local validation. CI proves the minimum Node 20 lane and the preferred Node 22 lane before release.
@@ -56,11 +56,11 @@ For a no-build page, pin the release:
```html
```
-The equivalent unpkg URL is `https://unpkg.com/interactive-surface-css@1.5.0/standalone-preset.css`. To follow future releases deliberately, use `https://cdn.jsdelivr.net/npm/interactive-surface-css@latest/standalone-preset.css` — unpinned opt-in.
+The equivalent unpkg URL is `https://unpkg.com/interactive-surface-css@1.6.0/standalone-preset.css`. To follow future releases deliberately, use `https://cdn.jsdelivr.net/npm/interactive-surface-css@latest/standalone-preset.css` — unpinned opt-in.
## Semantic recipes
@@ -127,7 +127,7 @@ Native `disabled` is preferred because the browser suppresses focus and activati
| `import "interactive-surface-css/interactive-surface.css";` | Complete standalone compatibility bundle | Existing direct-CSS 1.x consumers |
| `import "interactive-surface-css";` | JavaScript entry that imports the complete compatibility bundle | Existing bundlers configured for CSS imports |
-`standalone-preset.css` and `interactive-surface.css` are generated from the same authored modules and are behaviorally equivalent in 1.5.0. The compatibility paths remain stable; no 1.x migration is required.
+`standalone-preset.css` and `interactive-surface.css` are generated from the same authored modules and are behaviorally equivalent in 1.6.0. The compatibility paths remain stable; no 1.x migration is required.
The package `main` and `module` fields preserve the CommonJS and ESM entries; both load `interactive-surface.css`. The `style`, `unpkg`, and `jsdelivr` fields also resolve to that complete compatibility bundle.
@@ -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.
@@ -185,23 +194,26 @@ 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`.
+
+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:
```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/ecosystem-release-fixture.json b/ecosystem-release-fixture.json
new file mode 100644
index 0000000..ffe5858
--- /dev/null
+++ b/ecosystem-release-fixture.json
@@ -0,0 +1,4 @@
+{
+ "repository": "Foscat/ui-style-kit-css",
+ "revision": "3869ca49c11d8cc085affa25115e8e80546f7a3c"
+}
diff --git a/index.html b/index.html
index ca556a4..b6776b3 100644
--- a/index.html
+++ b/index.html
@@ -571,7 +571,7 @@
Edit token
Framework-agnostic CSS for reliable hover, focus, press, selected, current, loading, disabled, and motion affordances on interactive controls. Use it alone or as the interaction layer beside your existing layout and theme system.
-Version 1.5.0 is a release candidate in this repository until its npm release is published. Existing 1.x imports, selectors, data hooks, ARIA hooks, and tokens remain supported.
+Version 1.6.0 is a release candidate in this repository until its npm release is published. Existing 1.x imports, selectors, data hooks, ARIA hooks, and tokens remain supported.
The package targets Node.js 20+ for npm installs and local validation. CI proves the minimum Node 20 lane and the preferred Node 22 lane before release.
@@ -622,11 +622,11 @@ Edit token
```html
```
-The equivalent unpkg URL is `https://unpkg.com/interactive-surface-css@1.5.0/standalone-preset.css`. To follow future releases deliberately, use `https://cdn.jsdelivr.net/npm/interactive-surface-css@latest/standalone-preset.css` — unpinned opt-in.
+The equivalent unpkg URL is `https://unpkg.com/interactive-surface-css@1.6.0/standalone-preset.css`. To follow future releases deliberately, use `https://cdn.jsdelivr.net/npm/interactive-surface-css@latest/standalone-preset.css` — unpinned opt-in.
## Semantic recipes
@@ -693,7 +693,7 @@ Edit token
| `import "interactive-surface-css/interactive-surface.css";` | Complete standalone compatibility bundle | Existing direct-CSS 1.x consumers |
| `import "interactive-surface-css";` | JavaScript entry that imports the complete compatibility bundle | Existing bundlers configured for CSS imports |
-`standalone-preset.css` and `interactive-surface.css` are generated from the same authored modules and are behaviorally equivalent in 1.5.0. The compatibility paths remain stable; no 1.x migration is required.
+`standalone-preset.css` and `interactive-surface.css` are generated from the same authored modules and are behaviorally equivalent in 1.6.0. The compatibility paths remain stable; no 1.x migration is required.
The package `main` and `module` fields preserve the CommonJS and ESM entries; both load `interactive-surface.css`. The `style`, `unpkg`, and `jsdelivr` fields also resolve to that complete compatibility bundle.
@@ -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.
@@ -751,23 +760,26 @@ 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`.
+
+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:
```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/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
new file mode 100644
index 0000000..5a59ae0
--- /dev/null
+++ b/manifest.json
@@ -0,0 +1,252 @@
+{
+ "schemaVersion": 1,
+ "schemaPolicy": {
+ "compatibility": "additive-within-major",
+ "breakingChange": "increment-schemaVersion-before-removing-or-renaming-fields"
+ },
+ "name": "interactive-surface-css",
+ "version": "1.6.0",
+ "entrypoints": {
+ "stateCore": "./state-core.css",
+ "standalonePreset": "./standalone-preset.css",
+ "compatibility": "./interactive-surface.css"
+ },
+ "selectors": {
+ "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']"
+ ]
+ },
+ {
+ "name": "data-surface-level",
+ "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": [],
+ "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-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-*"
+ ],
+ "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": "",
+ "precedence": "after-package-specific-before-legacy",
+ "standaloneExpectation": "optional-with-existing-fallbacks"
+ },
+ {
+ "name": "--ui-motion-easing",
+ "type": "",
+ "precedence": "after-package-specific-before-legacy",
+ "standaloneExpectation": "optional-with-existing-fallbacks"
+ }
+ ]
+ },
+ "companions": {
+ "ui-style-kit-css": ">=2.1.0 <3.0.0",
+ "layout-style-css": ">=3.0.0 <4.0.0"
+ }
+}
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 c8641d8..8df269b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,20 +1,21 @@
{
"name": "interactive-surface-css",
- "version": "1.5.0",
+ "version": "1.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "interactive-surface-css",
- "version": "1.5.0",
+ "version": "1.6.0",
"license": "MIT",
"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",
- "ui-style-kit-css": "2.0.1"
+ "ui-style-kit-css": "2.1.0"
},
"engines": {
"node": ">=20"
@@ -596,9 +597,9 @@
}
},
"node_modules/fast-uri": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
- "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
+ "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
"dev": true,
"funding": [
{
@@ -940,9 +941,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
- "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
+ "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"dev": true,
"funding": [
{
@@ -1073,9 +1074,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
- "version": "3.3.12",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
- "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+ "version": "3.3.17",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
+ "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",
"dev": true,
"funding": [
{
@@ -1186,9 +1187,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.15",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
- "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
+ "version": "8.5.23",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
+ "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
"dev": true,
"funding": [
{
@@ -1206,7 +1207,7 @@
],
"license": "MIT",
"dependencies": {
- "nanoid": "^3.3.12",
+ "nanoid": "^3.3.16",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -1685,9 +1686,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..fcef428 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "interactive-surface-css",
- "version": "1.5.0",
+ "version": "1.6.0",
"description": "A framework-agnostic, accessible CSS interaction primitive for buttons, cards, and icon controls with token-driven theming.",
"keywords": [
"css",
@@ -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",
@@ -83,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\"",
@@ -91,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/public-contract.test.mjs tests/build.test.mjs tests/documentation.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 tests/semantic-fallbacks.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",
@@ -100,20 +103,29 @@
"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",
"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",
"clean-css": "~5.3.3",
+ "css-tree": "3.2.1",
+ "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"
+ },
+ "overrides": {
+ "fast-uri": "3.1.5",
+ "js-yaml": "4.3.1",
+ "nanoid": "3.3.17",
+ "postcss": "8.5.23"
},
"publishConfig": {
"access": "public"
diff --git a/scripts/check-css-ownership.mjs b/scripts/check-css-ownership.mjs
new file mode 100644
index 0000000..b809c1d
--- /dev/null
+++ b/scripts/check-css-ownership.mjs
@@ -0,0 +1,780 @@
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import {
+ generate,
+ lexer,
+ parse,
+ property as describeProperty,
+ 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",
+ "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 flexTopologyProperties = new Set([
+ "column-gap",
+ "flex",
+ "flex-direction",
+ "flex-flow",
+ "flex-wrap",
+ "gap",
+ "row-gap",
+]);
+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 nativeStatePseudos = new Set([
+ "active",
+ "any-link",
+ "checked",
+ "disabled",
+ "enabled",
+ "focus",
+ "focus-visible",
+ "focus-within",
+ "hover",
+ "indeterminate",
+ "invalid",
+ "open",
+ "optional",
+ "placeholder-shown",
+ "popover-open",
+ "read-only",
+ "read-write",
+ "required",
+ "target",
+ "user-invalid",
+ "valid",
+ "visited",
+]);
+const commonStateClasses = new Set([
+ "is-active",
+ "is-busy",
+ "is-checked",
+ "is-disabled",
+ "is-loading",
+ "is-open",
+ "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",
+ "aria-disabled",
+ "aria-expanded",
+ "aria-hidden",
+ "aria-invalid",
+ "aria-pressed",
+ "aria-selected",
+ "data-active",
+ "data-checked",
+ "data-disabled",
+ "data-loading",
+ "data-pressed",
+ "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",
+ "darkgray",
+ "darkgrey",
+ "dimgray",
+ "dimgrey",
+ "gainsboro",
+ "gray",
+ "grey",
+ "lightgray",
+ "lightgrey",
+ "silver",
+ "transparent",
+ "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);
+ return {
+ custom: described.custom,
+ name: described.custom ? propertyName : described.basename,
+ };
+}
+
+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 (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.",
+ );
+ }
+ 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 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 !systemColorPattern.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;
+
+ /* 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;
+ },
+ });
+
+ 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;
+
+ // A token reference is neutral, but a literal fallback still paints when the token is absent.
+ walk(value, {
+ enter(node) {
+ if (
+ ["Function", "Hash", "Identifier"].includes(node.type) &&
+ colorNodeIsDirectLiteral(node)
+ ) {
+ literal = true;
+ }
+ },
+ });
+ 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(),
+ )
+ ) {
+ image = true;
+ }
+ },
+ });
+ return image;
+}
+
+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",
+ "grid",
+ "layout",
+ "main",
+ "page",
+ "section",
+ "shell",
+ "split",
+ "stack",
+ "wrapper",
+ ]);
+ return selectorListHasPageSubject(rule.prelude, {
+ componentClasses: manifestComponentClasses(manifest),
+ structuralNames,
+ });
+}
+
+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 = manifestStateClasses(manifest);
+ const exactStateClasses = new Set([
+ ...commonStateClasses,
+ ...sharedStateClassVocabulary,
+ ...manifestClasses,
+ ]);
+ const stateVocabulary = new Set([
+ ...sharedStateClassVocabulary,
+ ...manifestClasses,
+ ]);
+ let stateful = false;
+
+ walk(rule.prelude, {
+ enter(node) {
+ if (
+ node.type === "PseudoClassSelector" &&
+ nativeStatePseudos.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;
+ }
+ },
+ });
+ 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 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, manifest) &&
+ (majorPageProperties.has(property.name) ||
+ isFlexTopologyProperty(property.name))
+ ) {
+ return "interactive-page-topology";
+ }
+ if (property.name === "font-family") 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) &&
+ (hasDirectLiteralPaint || hasImage)
+ ) {
+ return "interactive-branded-paint";
+ }
+ if (
+ /^(?:border|outline|text-decoration)(?:-|$)/.test(property.name) &&
+ (hasDirectLiteralPaint || hasImage)
+ ) {
+ return "interactive-branded-paint";
+ }
+ if (
+ !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"
+ ) {
+ 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,
+ manifest = {},
+ 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 property = propertyContract(node.property);
+ const ruleName = violationRule({
+ rule,
+ property,
+ value: node.value,
+ manifest,
+ });
+ if (!ruleName) return;
+
+ const key = entryKey({ selector, property: property.name });
+ if (allowlistByKey.has(key)) {
+ matchedKeys.add(key);
+ return;
+ }
+
+ violations.push({
+ target: "state-core",
+ selector,
+ property: property.name,
+ 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 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) {
+ 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/scripts/release-fixture-contract.mjs b/scripts/release-fixture-contract.mjs
new file mode 100644
index 0000000..711b7d5
--- /dev/null
+++ b/scripts/release-fixture-contract.mjs
@@ -0,0 +1,222 @@
+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 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),
+ );
+ 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) {
+ for (const mutation of mutationPatterns) {
+ if (mutation.pattern.test(workflow.source)) {
+ throw new Error(
+ `pull-request workflow ${workflow.name} enables forbidden mutation: ${mutation.label}`,
+ );
+ }
+ }
+ }
+
+ 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.",
+ );
+ 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) {
+ 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/standalone-preset.css b/standalone-preset.css
index b5846a3..f410c3d 100644
--- a/standalone-preset.css
+++ b/standalone-preset.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/state-core.css b/state-core.css
index 1660498..6f24672 100644
--- a/state-core.css
+++ b/state-core.css
@@ -75,7 +75,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,
@@ -83,7 +83,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,
@@ -98,19 +98,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/styles/standalone-preset.css b/styles/standalone-preset.css
index 2bbb6a4..a644fe2 100644
--- a/styles/standalone-preset.css
+++ b/styles/standalone-preset.css
@@ -4,11 +4,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),
@@ -21,20 +24,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);
@@ -341,15 +353,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;
@@ -399,7 +414,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,
diff --git a/styles/state-core.css b/styles/state-core.css
index 3298352..a5e0a40 100644
--- a/styles/state-core.css
+++ b/styles/state-core.css
@@ -69,7 +69,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,
@@ -77,7 +77,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,
@@ -92,19 +92,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/tests/canonical-ui-theme.spec.ts b/tests/canonical-ui-theme.spec.ts
new file mode 100644
index 0000000..693a305
--- /dev/null
+++ b/tests/canonical-ui-theme.spec.ts
@@ -0,0 +1,94 @@
+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(`
+ ${stylesheets.map((stylesheet) => ``).join("\n")}
+
+ Primary
+ S
+
+ `);
+ 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 {
+ backgroundColor: computed.backgroundColor,
+ 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,
+ }) => {
+ await withFixtureCleanup(
+ createPackedEcosystemFixture(),
+ async (fixture) => {
+ 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(
+ "interactive-surface-css/standalone-preset.css",
+ ),
+ ).toContain("node_modules");
+ },
+ );
+ });
+
+ test("packed canonical theme entry paints the surface while state core owns focus", async ({
+ page,
+ }) => {
+ 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 withoutVisual = await surfaceSnapshot(page, [theme, stateCore]);
+ const withoutTheme = await surfaceSnapshot(page, [visual, stateCore]);
+ const withoutStateCore = await surfaceSnapshot(page, [visual, theme]);
+
+ 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/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/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;
+}
diff --git a/tests/fixtures/packed-ecosystem.ts b/tests/fixtures/packed-ecosystem.ts
new file mode 100644
index 0000000..2899019
--- /dev/null
+++ b/tests/fixtures/packed-ecosystem.ts
@@ -0,0 +1,234 @@
+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);
+ // 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"),
+ 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);
+
+ // 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.`,
+ );
+ }
+
+ return {
+ name: packedPackage.name,
+ tarballPath,
+ version: packedPackage.version,
+ } 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 = {},
+) {
+ 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() {
+ const cleanupError = removeFixtureDirectory(fixtureDirectory);
+
+ if (cleanupError) throw cleanupError;
+ },
+ readCss(publicSpecifier: string) {
+ return readFileSync(this.resolvePublicExport(publicSpecifier), "utf8");
+ },
+ resolvePublicExport(publicSpecifier: string) {
+ return consumerRequire.resolve(publicSpecifier);
+ },
+ };
+ } catch (error) {
+ const cleanupError = removeFixtureDirectory(fixtureDirectory);
+
+ if (cleanupError) {
+ throw new AggregateError(
+ [error, cleanupError],
+ "Packed ecosystem setup failed and its fixture could not be removed.",
+ );
+ }
+
+ throw error;
+ }
+}
diff --git a/tests/manifest-contract.test.mjs b/tests/manifest-contract.test.mjs
new file mode 100644
index 0000000..fe6b40c
--- /dev/null
+++ b/tests/manifest-contract.test.mjs
@@ -0,0 +1,302 @@
+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 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",
+};
+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-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", () => {
+ 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, stableSelectors);
+ assert.deepEqual(manifest.selectors.stateClasses, [
+ ".is-active",
+ ".is-loading",
+ ".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"]);
+ 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",
+ ]);
+ assert.deepEqual(manifest.tokens.public, publicTokens);
+ 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 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`,
+ );
+ }
+ 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`);
+ }
+});
diff --git a/tests/ownership-boundaries.test.mjs b/tests/ownership-boundaries.test.mjs
new file mode 100644
index 0000000..1b7c9ab
--- /dev/null
+++ b/tests/ownership-boundaries.test.mjs
@@ -0,0 +1,476 @@
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import test from "node:test";
+
+import {
+ auditOwnership,
+ matchesStateSelector,
+ 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: var(--focus-ring, 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); }
+ .interactive-surface { --Neutral-Fallback: var(--neutral-layer, #fff); }
+ :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));
+ }
+ .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; }
+ `;
+ const result = auditOwnership({
+ css,
+ manifest: {
+ presets: [{ id: "minimal-saas", prefix: "saas" }],
+ classApi: {
+ universalVisualSuffixes: ["disabled"],
+ presetExtras: { "minimal-saas": [] },
+ },
+ },
+ allowlist: [exception()],
+ now: reviewedAt,
+ });
+
+ assert.deepEqual(result.violations, [
+ {
+ target: "state-core",
+ selector: ".interactive-surface",
+ property: "background",
+ 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",
+ },
+ {
+ 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: 26,
+ rule: "interactive-branded-paint",
+ },
+ {
+ target: "state-core",
+ selector: ".x",
+ property: "background",
+ line: 27,
+ rule: "interactive-branded-paint",
+ },
+ {
+ target: "state-core",
+ selector: ".x",
+ property: "box-shadow",
+ line: 28,
+ rule: "interactive-branded-paint",
+ },
+ ]);
+ 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",
+ "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("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; }
+ .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: ".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("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;
+ 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({
+ 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 6357c46..79db7a0 100644
--- a/tests/package-contract.test.mjs
+++ b/tests/package-contract.test.mjs
@@ -8,7 +8,7 @@ import { spawnSync } from "node:child_process";
import test from "node:test";
const EXPECTED_NAME = "interactive-surface-css";
-const EXPECTED_VERSION = "1.5.0";
+const EXPECTED_VERSION = "1.6.0";
const CHECKOUT_V4_SHA = "34e114876b0b11c390a56381ad16ebd13914f8d5";
const CHECKOUT_V5_SHA = "93cb6efe18208431cddfb8368fd83d5badbf9bfd";
const SETUP_NODE_V5_SHA = "a0853c24544627f65ddf259abe73b1d18a591444";
@@ -18,6 +18,17 @@ const repositoryRoot = path.resolve(
);
const manifestPath = path.join(repositoryRoot, "package.json");
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
+const packageLock = JSON.parse(
+ await readFile(path.join(repositoryRoot, "package-lock.json"), "utf8"),
+);
+
+// Exact overrides keep the release audit deterministic without promoting transitive tooling to direct dependencies.
+const expectedSecurityOverrides = {
+ "fast-uri": "3.1.5",
+ "js-yaml": "4.3.1",
+ nanoid: "3.3.17",
+ postcss: "8.5.23",
+};
const expectedPackedFiles = [
"CHANGELOG.md",
@@ -42,6 +53,7 @@ const expectedPackedFiles = [
"index.html",
"index.js",
"interactive-surface.css",
+ "manifest.json",
"package.json",
"standalone-preset.css",
"state-core.css",
@@ -60,6 +72,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",
@@ -76,6 +89,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",
@@ -85,7 +99,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() {
@@ -242,9 +259,14 @@ function collectReferencedAssetPaths(assetFile, assetSource) {
.sort();
}
-test("the release manifest and validation graph are pinned to 1.5.0", () => {
+test("the release manifest and validation graph are pinned to 1.6.0", () => {
assert.equal(manifest.name, EXPECTED_NAME);
assert.equal(manifest.version, EXPECTED_VERSION);
+ assert.equal(
+ manifest.homepage,
+ "https://foscat.github.io/Interactive-Surface-CSS/",
+ "The npm homepage must open the live GitHub Pages demo",
+ );
assert.deepEqual(
[...manifest.files].sort(),
[...expectedManifestFiles].sort(),
@@ -268,23 +290,48 @@ test("the release manifest and validation graph are pinned to 1.5.0", () => {
}
});
-test("the changelog records the complete 1.5.0 release immediately after Unreleased", async () => {
+test("release security overrides resolve audited transitive tooling", () => {
+ assert.deepEqual(manifest.overrides, expectedSecurityOverrides);
+ assert.deepEqual(manifest.dependencies ?? {}, {});
+ assert.deepEqual(packageLock.packages[""].dependencies ?? {}, {});
+
+ for (const [packageName, expectedVersion] of Object.entries(
+ expectedSecurityOverrides,
+ )) {
+ assert.equal(
+ packageLock.packages[`node_modules/${packageName}`]?.version,
+ expectedVersion,
+ `Expected ${packageName}@${expectedVersion} in the release lockfile`,
+ );
+ }
+});
+
+test("the changelog keeps the complete 1.6.0 release after the Unreleased section", async () => {
const changelog = await readFile(
path.join(repositoryRoot, "CHANGELOG.md"),
"utf8",
);
- const releaseHeading = `## ${EXPECTED_VERSION} - 2026-07-20`;
+ const releaseHeading = `## ${EXPECTED_VERSION} - 2026-08-09`;
const releaseMatches =
changelog.match(
new RegExp(`^${releaseHeading.replaceAll(".", "\\.")}$`, "gm"),
) ?? [];
- assert.match(
- changelog,
- new RegExp(
- `^## Unreleased\\s+${releaseHeading.replaceAll(".", "\\.")}$`,
- "m",
- ),
+ const unreleasedStart = changelog.indexOf("## Unreleased");
+ const releaseStart = changelog.indexOf(releaseHeading);
+
+ assert.ok(
+ unreleasedStart !== -1,
+ "Changelog is missing its Unreleased section",
+ );
+ assert.ok(
+ releaseStart > unreleasedStart,
+ `${releaseHeading} must follow the Unreleased section`,
+ );
+ assert.doesNotMatch(
+ changelog.slice(unreleasedStart + "## Unreleased".length, releaseStart),
+ /\n## /,
+ "No released version may appear between Unreleased and the current release",
);
assert.equal(
releaseMatches.length,
@@ -292,7 +339,6 @@ test("the changelog records the complete 1.5.0 release immediately after Unrelea
`Expected exactly one ${releaseHeading} heading`,
);
- const releaseStart = changelog.indexOf(releaseHeading);
const nextRelease = changelog.indexOf(
"\n## ",
releaseStart + releaseHeading.length,
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/release-preflight.test.mjs b/tests/release-preflight.test.mjs
new file mode 100644
index 0000000..b605b95
--- /dev/null
+++ b/tests/release-preflight.test.mjs
@@ -0,0 +1,120 @@
+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 the immutable U-I bootstrap 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: "3869ca49c11d8cc085affa25115e8e80546f7a3c",
+ });
+
+ 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=3869ca49c11d8cc085affa25115e8e80546f7a3c\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 every release or deployment mutation from pull requests", () => {
+ assert.ok(
+ releaseContract,
+ "scripts/release-fixture-contract.mjs must implement the fixture contract",
+ );
+
+ 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",
+ );
+ 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 [
+ "U-I bootstrap",
+ "Interactive Surface CSS 1.6.0 candidate",
+ "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/tests/semantic-fallbacks.spec.ts b/tests/semantic-fallbacks.spec.ts
new file mode 100644
index 0000000..0181ac0
--- /dev/null
+++ b/tests/semantic-fallbacks.spec.ts
@@ -0,0 +1,244 @@
+import fs from "node:fs";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { expect, test, type Page } from "@playwright/test";
+
+import { withFixtureCleanup } from "./fixtures/fixture-cleanup.mjs";
+import { createPackedEcosystemFixture } from "./fixtures/packed-ecosystem";
+
+const packageRoot = path.resolve(
+ path.dirname(fileURLToPath(import.meta.url)),
+ "..",
+);
+const standalone = fs.readFileSync(
+ path.join(packageRoot, "standalone-preset.css"),
+ "utf8",
+);
+const stateCore = fs.readFileSync(
+ path.join(packageRoot, "state-core.css"),
+ "utf8",
+);
+const semanticTheme = `
+ :root {
+ --ui-color-surface: rgb(12 34 56);
+ --ui-color-text: rgb(222 223 224);
+ --ui-color-muted: rgb(101 102 103);
+ --ui-color-primary: rgb(90 60 210);
+ --ui-color-on-primary: rgb(250 250 251);
+ --ui-color-border: rgb(44 55 66);
+ --ui-radius-control: 17px;
+ --ui-shadow-control: 0 5px 11px rgb(1 2 3 / 0.4);
+ --ui-focus-color: rgb(255 0 128);
+ --ui-motion-duration: 275ms;
+ --ui-motion-easing: linear;
+ }
+`;
+
+async function render(page: Page, styles: string[]) {
+ await page.setContent(`
+ ${styles.map((css) => ``).join("\n")}
+ Base
+ Primary
+ Subtle
+ Namespaced
+ Namespaced primary
+ `);
+}
+
+async function snapshot(page: Page, selector: string) {
+ return page.locator(selector).evaluate((element) => {
+ const computed = window.getComputedStyle(element);
+
+ return {
+ backgroundColor: computed.backgroundColor,
+ borderColor: computed.borderColor,
+ borderRadius: computed.borderRadius,
+ boxShadow: computed.boxShadow,
+ color: computed.color,
+ focusRing: computed.getPropertyValue("--_is-focus-ring-color").trim(),
+ outlineColor: computed.outlineColor,
+ transitionDuration: computed.transitionDuration,
+ transitionTimingFunction: computed.transitionTimingFunction,
+ };
+ });
+}
+
+test("semantic tokens are optional and preserve the complete standalone baseline when absent", async ({
+ page,
+}) => {
+ await render(page, [standalone]);
+
+ expect(await snapshot(page, "#base")).toMatchObject({
+ backgroundColor: "rgb(248, 250, 252)",
+ borderColor: "rgba(15, 23, 42, 0.2)",
+ borderRadius: "12px",
+ color: "rgb(17, 24, 39)",
+ transitionDuration: "0.14s",
+ transitionTimingFunction: "cubic-bezier(0.2, 0, 0.2, 1)",
+ });
+ expect(await snapshot(page, "#primary")).toMatchObject({
+ backgroundColor: "rgb(15, 79, 127)",
+ borderColor: "rgb(15, 79, 127)",
+ color: "rgb(244, 251, 255)",
+ });
+});
+
+test("third-party semantic tokens supply complete standalone defaults", async ({
+ page,
+}) => {
+ await render(page, [semanticTheme, standalone]);
+ await page.keyboard.press("Tab");
+
+ expect(await snapshot(page, "#base")).toMatchObject({
+ backgroundColor: "rgb(12, 34, 56)",
+ borderColor: "rgb(44, 55, 66)",
+ borderRadius: "17px",
+ boxShadow: "rgba(1, 2, 3, 0.4) 0px 5px 11px 0px",
+ color: "rgb(222, 223, 224)",
+ focusRing: "rgb(255 0 128)",
+ transitionDuration: "0.275s",
+ transitionTimingFunction: "linear",
+ });
+ expect(await snapshot(page, "#primary")).toMatchObject({
+ backgroundColor: "rgb(90, 60, 210)",
+ borderColor: "rgb(90, 60, 210)",
+ color: "rgb(250, 250, 251)",
+ });
+ expect((await snapshot(page, "#subtle")).color).toBe("rgb(101, 102, 103)");
+});
+
+test("package-specific values remain above shared semantic values", async ({
+ page,
+}) => {
+ await render(page, [semanticTheme, standalone]);
+ for (let index = 0; index < 4; index += 1) {
+ await page.keyboard.press("Tab");
+ }
+
+ expect(await snapshot(page, "#namespaced")).toMatchObject({
+ backgroundColor: "rgb(1, 11, 21)",
+ borderColor: "rgb(31, 41, 51)",
+ borderRadius: "19px",
+ boxShadow: "rgba(2, 3, 4, 0.5) 0px 7px 13px 0px",
+ color: "rgb(231, 232, 233)",
+ focusRing: "rgb(0 255 170)",
+ transitionDuration: "0.325s",
+ transitionTimingFunction: "ease-in",
+ });
+ expect(await snapshot(page, "#namespaced-primary")).toMatchObject({
+ backgroundColor: "rgb(71, 72, 73)",
+ borderColor: "rgb(81, 82, 83)",
+ color: "rgb(241, 242, 243)",
+ });
+});
+
+test("state core consumes semantic mechanics without taking consumer paint or geometry", async ({
+ page,
+}) => {
+ await page.setContent(`
+
+
+
+ Consumer
+ `);
+ await page.keyboard.press("Tab");
+
+ expect(await snapshot(page, "#consumer")).toMatchObject({
+ backgroundColor: "rgb(201, 202, 203)",
+ borderColor: "rgb(11, 22, 33)",
+ borderRadius: "23px",
+ color: "rgb(41, 42, 43)",
+ focusRing: "rgb(255 0 128)",
+ transitionDuration: "0.275s",
+ transitionTimingFunction: "linear",
+ });
+});
+
+test("reduced motion and forced colors override shared semantic presentation", async ({
+ page,
+}) => {
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await render(page, [semanticTheme, standalone]);
+ expect((await snapshot(page, "#base")).transitionDuration).toBe("0s");
+
+ await page.emulateMedia({
+ forcedColors: "active",
+ reducedMotion: "no-preference",
+ });
+ await render(page, [semanticTheme, standalone]);
+ await page.locator("#base").focus();
+ const forced = await snapshot(page, "#base");
+ expect(forced.boxShadow).toBe("none");
+ expect(forced.focusRing).toBe("rgb(255 0 128)");
+ expect(forced.outlineColor).not.toBe("rgb(255, 0, 128)");
+});
+
+test("packed UI producer themes standalone Interactive Surface through shared semantics", async ({
+ page,
+}) => {
+ await withFixtureCleanup(
+ createPackedEcosystemFixture({ includeUiStyleKit: true }),
+ async (fixture) => {
+ const visual = fixture.readCss("ui-style-kit-css/visual.css");
+ const interactive = fixture.readCss(
+ "interactive-surface-css/standalone-preset.css",
+ );
+ await page.setContent(`
+
+
+
+ Packed
+
+ `);
+
+ const values = await page.locator("#packed").evaluate((element) => {
+ const computed = window.getComputedStyle(element);
+ return {
+ backgroundColor: computed.backgroundColor,
+ borderRadius: computed.borderRadius,
+ color: computed.color,
+ semanticPrimary: computed
+ .getPropertyValue("--ui-color-primary")
+ .trim(),
+ semanticRadius: computed
+ .getPropertyValue("--ui-radius-control")
+ .trim(),
+ };
+ });
+
+ expect(values.semanticPrimary).not.toBe("");
+ expect(values.semanticRadius).not.toBe("");
+ expect(values.backgroundColor).not.toBe("rgb(15, 79, 127)");
+ expect(values.borderRadius).toBe("16px");
+ expect(values.color).not.toBe("rgb(244, 251, 255)");
+ },
+ );
+});
diff --git a/tests/semantic-fallbacks.test.mjs b/tests/semantic-fallbacks.test.mjs
new file mode 100644
index 0000000..e7f594f
--- /dev/null
+++ b/tests/semantic-fallbacks.test.mjs
@@ -0,0 +1,124 @@
+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 read = (...parts) => readFileSync(join(root, ...parts), "utf8");
+const standalone = read("styles", "standalone-preset.css");
+const stateCore = read("styles", "state-core.css");
+const manifest = JSON.parse(read("manifest.json"));
+const readme = read("README.md");
+const installation = read("wiki", "Installation-and-Usage.md");
+const tokenReference = read("wiki", "Token-Reference.md");
+const changelog = read("CHANGELOG.md");
+
+const semanticTokens = [
+ "--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",
+ "--ui-motion-easing",
+];
+
+function compact(value) {
+ return value.replaceAll(/\s+/g, "");
+}
+
+test("standalone paint and geometry use semantic tokens between package and legacy fallbacks", () => {
+ const source = compact(standalone);
+
+ [
+ "var(--interactive-surface-bg,var(--ui-color-surface,var(--surface-bg,var(--bg-surface,rgb(248250252)))))",
+ "var(--interactive-surface-fg,var(--ui-color-text,var(--surface-fg,var(--text-primary,rgb(172439)))))",
+ "var(--interactive-surface-border-color,var(--ui-color-border,var(--surface-border,var(--border-color,rgba(15,23,42,0.2)))))",
+ "var(--interactive-surface-radius,var(--ui-radius-control,0.75rem))",
+ "var(--ui-shadow-control,var(--shadow-base,000rgb(000/0)))",
+ ].forEach((chain) =>
+ assert.ok(
+ source.includes(chain),
+ `Missing semantic fallback chain: ${chain}`,
+ ),
+ );
+});
+
+test("primary and subtle variants preserve package-specific precedence", () => {
+ const source = compact(standalone);
+
+ [
+ "var(--interactive-surface-variant-primary-bg,var(--ui-color-primary,var(--variant-primary-bg,rgb(1579127))))",
+ "var(--interactive-surface-variant-primary-fg,var(--ui-color-on-primary,var(--variant-primary-fg,rgb(244251255))))",
+ "var(--interactive-surface-variant-primary-border-color,var(--ui-color-primary,var(--variant-primary-border,var(--interactive-surface-bg))))",
+ "var(--interactive-surface-variant-subtle-fg,var(--ui-color-muted,var(--variant-subtle-fg,rgb(265275))))",
+ ].forEach((chain) =>
+ assert.ok(
+ source.includes(chain),
+ `Missing variant fallback chain: ${chain}`,
+ ),
+ );
+});
+
+test("state mechanics consume only shared focus and default-motion semantics", () => {
+ const source = compact(stateCore);
+
+ [
+ "var(--interactive-surface-motion-default,var(--ui-motion-duration,var(--motion-default,140ms)))",
+ "var(--interactive-surface-ease-standard,var(--ui-motion-easing,var(--ease-standard,cubic-bezier(0.2,0,0.2,1))))",
+ "var(--interactive-surface-focus-ring-color,var(--ui-focus-color,var(--focus-ring,rgb(1199246))))",
+ ].forEach((chain) =>
+ assert.ok(
+ source.includes(chain),
+ `Missing mechanics fallback chain: ${chain}`,
+ ),
+ );
+
+ assert.doesNotMatch(
+ stateCore,
+ /--ui-(?:color-(?:surface|text|muted|primary|on-primary|border)|radius-control|shadow-control)/,
+ "State core must not consume shared paint or component-geometry tokens.",
+ );
+});
+
+test("manifest inventories every optional shared semantic fallback", () => {
+ assert.deepEqual(
+ manifest.tokens.sharedSemanticFallbacks.map(({ name }) => name),
+ semanticTokens,
+ );
+
+ for (const token of manifest.tokens.sharedSemanticFallbacks) {
+ assert.equal(token.precedence, "after-package-specific-before-legacy");
+ assert.equal(
+ token.standaloneExpectation,
+ "optional-with-existing-fallbacks",
+ );
+ }
+});
+
+test("release-facing docs explain third-party semantic theming and precedence", () => {
+ for (const name of semanticTokens) {
+ assert.ok(
+ tokenReference.includes(name),
+ `Token reference is missing ${name}`,
+ );
+ }
+
+ [readme, installation, tokenReference].forEach((document) => {
+ assert.match(document, /third-party (?:theme|design system)/i);
+ assert.match(
+ document,
+ /package-specific[\s\S]*shared semantic[\s\S]*legacy/i,
+ );
+ });
+
+ assert.ok(
+ readme.includes('import "interactive-surface-css/standalone-preset.css";'),
+ );
+ assert.match(changelog, /shared semantic fallback/i);
+});
diff --git a/tests/ui-style-kit-compat.spec.ts b/tests/ui-style-kit-compat.spec.ts
index 5678d57..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.0.1 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) {
diff --git a/wiki/API-Reference.md b/wiki/API-Reference.md
index 0ac4479..dd64c11 100644
--- a/wiki/API-Reference.md
+++ b/wiki/API-Reference.md
@@ -1,6 +1,6 @@
# API Reference
-Interactive Surface CSS 1.5.0 is a CSS state primitive. It exports stylesheets and compatibility JavaScript entries, but it does not ship state-management or component-runtime behavior.
+Interactive Surface CSS 1.6.0 is a CSS state primitive. It exports stylesheets and compatibility JavaScript entries, but it does not ship state-management or component-runtime behavior.
## Entry points
@@ -11,7 +11,7 @@ Interactive Surface CSS 1.5.0 is a CSS state primitive. It exports stylesheets a
| `interactive-surface-css/interactive-surface.css` | Preserved complete 1.x compatibility bundle |
| `interactive-surface-css` | Preserved JavaScript entry that imports the compatibility bundle |
-The preset and compatibility stylesheet are generated from the same authored modules and are behaviorally equivalent in 1.5.0.
+The preset and compatibility stylesheet are generated from the same authored modules and are behaviorally equivalent in 1.6.0.
Package metadata keeps established resolution intact: `main` points to the CommonJS entry, `module` points to the ESM entry, and both load `interactive-surface.css`. The `style`, `unpkg`, and `jsdelivr` fields point directly to that complete compatibility bundle.
@@ -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`
diff --git a/wiki/FAQ.md b/wiki/FAQ.md
index df2f58d..a0e3a45 100644
--- a/wiki/FAQ.md
+++ b/wiki/FAQ.md
@@ -42,9 +42,9 @@ Yes. Interactive Surface uses the individual `translate` longhand for lift and p
In the standalone preset and compatibility bundle it supplies centered icon presentation and a 44 × 44px minimum target. A core-only consumer owns that geometry. Every icon-only control still needs an accessible name.
-## Is 1.5.0 published?
+## Is 1.6.0 published?
-The repository describes 1.5.0 as a release candidate until npm publication is explicitly approved and completed. Pinned CDN URLs are distribution checks, not proof of publication.
+The repository describes 1.6.0 as a release candidate until npm publication is explicitly approved and completed. Pinned CDN URLs are distribution checks, not proof of publication.
## What browsers are tested?
diff --git a/wiki/Getting-Started.md b/wiki/Getting-Started.md
index 3ee340d..95edb61 100644
--- a/wiki/Getting-Started.md
+++ b/wiki/Getting-Started.md
@@ -1,6 +1,6 @@
# Getting Started
-This is the shortest reliable path to the Interactive Surface CSS 1.5.0 release candidate.
+This is the shortest reliable path to the Interactive Surface CSS 1.6.0 release candidate.
## Install
@@ -61,11 +61,11 @@ Pin the release candidate version:
```html
```
-The unpkg equivalent is `https://unpkg.com/interactive-surface-css@1.5.0/standalone-preset.css`. Use `https://cdn.jsdelivr.net/npm/interactive-surface-css@latest/standalone-preset.css` — unpinned opt-in — only when automatically following future releases is intentional.
+The unpkg equivalent is `https://unpkg.com/interactive-surface-css@1.6.0/standalone-preset.css`. Use `https://cdn.jsdelivr.net/npm/interactive-surface-css@latest/standalone-preset.css` — unpinned opt-in — only when automatically following future releases is intentional.
## Already have a design system?
diff --git a/wiki/Home.md b/wiki/Home.md
index 1a413e1..3f499e3 100644
--- a/wiki/Home.md
+++ b/wiki/Home.md
@@ -2,7 +2,7 @@
Interactive Surface CSS is a framework-agnostic interaction-state layer for buttons, links, toggles, tabs, cards, icon controls, and similar interactive hosts. It provides consistent focus, hover, press, current, selected, loading, disabled, reduced-motion, and forced-colors treatment without requiring a component runtime.
-Version 1.5.0 is a release candidate until it is published. It preserves every established 1.x import and selector while adding a public transition tuple and deterministic state precedence to the focused `state-core.css` and complete `standalone-preset.css` entry points.
+Version 1.6.0 is a release candidate until it is published. It preserves every established 1.x import and selector while adding optional shared semantic fallbacks to the focused `state-core.css` and complete `standalone-preset.css` entry points.
## Choose the layer you need
@@ -14,7 +14,7 @@ Version 1.5.0 is a release candidate until it is published. It preserves every e
Use one library, use two compatible libraries, or use all three. None of the packages is a prerequisite for another.
-## 1.5.0 entry points
+## 1.6.0 entry points
- `interactive-surface-css/standalone-preset.css`: complete state behavior plus neutral standalone paint and geometry.
- `interactive-surface-css/state-core.css`: state behavior and the public interaction token contract without theme paint or consumer-facing layout.
diff --git a/wiki/Installation-and-Usage.md b/wiki/Installation-and-Usage.md
index c61bd5a..c7f93af 100644
--- a/wiki/Installation-and-Usage.md
+++ b/wiki/Installation-and-Usage.md
@@ -1,6 +1,6 @@
# Installation and Usage
-These instructions target the Interactive Surface CSS 1.5.0 release candidate. Existing 1.x imports remain supported.
+These instructions target the Interactive Surface CSS 1.6.0 release candidate. Existing 1.x imports remain supported.
The npm package targets Node.js 20+ for installation and local validation. CI also proves the preferred Node.js 22 release lane.
@@ -51,11 +51,11 @@ Pin the release in reproducible pages:
```html
```
@@ -118,18 +118,28 @@ 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";
```
UI Style Kit owns paint and theme modes; Interactive Surface owns interaction states.
+### With a third-party semantic theme
+
+```js
+import "third-party-theme/tokens.css";
+import "interactive-surface-css/standalone-preset.css";
+```
+
+The third-party design system may provide the optional `--ui-*` semantic control tokens documented in the token reference. Resolution remains package-specific first, shared semantic second, and legacy fallback or literal last. Use `state-core.css` instead when that theme already paints and sizes the component itself.
+
### 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/Publishing-and-Releases.md b/wiki/Publishing-and-Releases.md
index 060fad9..218ddb5 100644
--- a/wiki/Publishing-and-Releases.md
+++ b/wiki/Publishing-and-Releases.md
@@ -1,6 +1,6 @@
# Publishing and Releases
-Interactive Surface CSS 1.5.0 is a release candidate until the package is published. Preparing this branch does not authorize an npm publish, Git tag, or GitHub Release.
+Interactive Surface CSS 1.6.0 is a release candidate until the package is published. Preparing this branch does not authorize an npm publish, Git tag, or GitHub Release.
## Release ownership
@@ -13,10 +13,10 @@ The repository's intended path is:
See the [npm publish workflow](https://github.com/Foscat/Interactive-Surface-CSS/blob/main/.github/workflows/npm-publish.yml) for the executable release rules.
-## 1.5.0 release-candidate checklist
+## 1.6.0 release-candidate checklist
-1. Confirm `package.json` and `package-lock.json` identify `1.5.0`.
-2. Confirm the 1.5.0 changelog entry describes the transition tuple, state precedence, compatibility, accessibility, documentation, and testing.
+1. Confirm `package.json` and `package-lock.json` identify `1.6.0`.
+2. Confirm the 1.6.0 changelog entry describes shared semantic fallback precedence, compatibility, accessibility, documentation, and testing.
3. Build public stylesheets and verify generated parity.
4. Run deterministic validation:
@@ -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,23 +48,35 @@ 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.
+
+## Coordinated U-I bootstrap sequence
+
+The U-I bootstrap gives the Interactive Surface CSS 1.6.0 candidate an immutable UI compatibility fixture after Layout Style CSS 3.0.1 has been published. The cross-repository pins require this exact remote sequence:
+
+1. Push a stable UI bootstrap ref containing `3869ca49c11d8cc085affa25115e8e80546f7a3c`.
+2. Use merge commits: push and merge Interactive Surface CSS so its reviewed candidate commit SHA remains reachable.
+3. Update and verify the final UI companion pins against the merged Interactive Surface commit and the published Layout Style commit.
+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 U-I bootstrap SHA is deliberately stable: the Interactive Surface candidate workflow uses it to load the reviewed preflight implementation before the final UI commit can reference the merged Interactive Surface head.
## 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`.
+The tag, GitHub Release, package version, lockfile version, and changelog heading must agree. For this candidate, the expected tag is `v1.6.0`.
## Distribution verification
After an approved publish, verify:
- `https://registry.npmjs.org/interactive-surface-css`
-- `https://cdn.jsdelivr.net/npm/interactive-surface-css@1.5.0/interactive-surface.css`
-- `https://cdn.jsdelivr.net/npm/interactive-surface-css@1.5.0/state-core.css`
-- `https://cdn.jsdelivr.net/npm/interactive-surface-css@1.5.0/standalone-preset.css`
-- `https://unpkg.com/interactive-surface-css@1.5.0/interactive-surface.css`
-- `https://unpkg.com/interactive-surface-css@1.5.0/state-core.css`
-- `https://unpkg.com/interactive-surface-css@1.5.0/standalone-preset.css`
+- `https://cdn.jsdelivr.net/npm/interactive-surface-css@1.6.0/interactive-surface.css`
+- `https://cdn.jsdelivr.net/npm/interactive-surface-css@1.6.0/state-core.css`
+- `https://cdn.jsdelivr.net/npm/interactive-surface-css@1.6.0/standalone-preset.css`
+- `https://unpkg.com/interactive-surface-css@1.6.0/interactive-surface.css`
+- `https://unpkg.com/interactive-surface-css@1.6.0/state-core.css`
+- `https://unpkg.com/interactive-surface-css@1.6.0/standalone-preset.css`
Do not treat a local pack or a successful workflow validation as proof that these live URLs are available.
@@ -75,4 +95,4 @@ npm publish --access public
- Minor: backward-compatible capabilities or entry points.
- Major: intentional breaking changes.
-The 1.5.0 minor release adds a public transition tuple and deterministic interaction-state precedence while preserving the focused entry points and complete 1.x contract.
+The 1.6.0 minor release adds optional shared semantic fallbacks while preserving the focused entry points, complete 1.x contract, and compatibility ranges.
diff --git a/wiki/Roadmap.md b/wiki/Roadmap.md
index 9fc59a3..a570a71 100644
--- a/wiki/Roadmap.md
+++ b/wiki/Roadmap.md
@@ -1,8 +1,8 @@
# Roadmap
-Interactive Surface CSS 1.5.0 is a release candidate. The work in this branch focuses the package on a durable interaction-state contract without expanding into layout or theme ownership.
+Interactive Surface CSS 1.6.0 is a release candidate. The work in this branch adds optional shared semantic fallbacks without expanding into layout or theme ownership.
-## 1.5.0 release candidate
+## 1.6.0 release candidate
- Preserve every existing 1.x import, selector, hook, token fallback, and complete or companion stylesheet entry point.
- Expose public transition property, duration, easing, and delay tokens from `state-core.css`.
@@ -16,7 +16,7 @@ Interactive Surface CSS 1.5.0 is a release candidate. The work in this branch fo
Publication, tagging, and a GitHub Release remain separate approval-gated steps.
-## After 1.5.0
+## After 1.6.0
Potential follow-up work must be driven by demonstrated consumer need:
diff --git a/wiki/Testing-and-Quality.md b/wiki/Testing-and-Quality.md
index 4615a34..a6bfabf 100644
--- a/wiki/Testing-and-Quality.md
+++ b/wiki/Testing-and-Quality.md
@@ -1,6 +1,6 @@
# Testing and Quality
-The 1.5.0 release candidate uses layered gates so contributors can choose fast deterministic checks or the complete cross-browser suite without confusing the two.
+The 1.6.0 release candidate uses layered gates so contributors can choose fast deterministic checks or the complete cross-browser suite without confusing the two.
## Validation tiers
@@ -36,7 +36,7 @@ The Playwright configuration uses a stable two-worker limit. Focused release pro
| `npm run pack:dry` | Inspect the npm tarball allowlist without publishing |
| `npm audit` | Check the dependency tree against the npm advisory database |
-The exact script graph is finalized as part of the 1.5.0 release candidate before publication. Browser downloads intentionally remain outside `prepublishOnly`.
+The exact script graph is finalized as part of the 1.6.0 release candidate before publication. Browser downloads intentionally remain outside `prepublishOnly`.
## Contract coverage
diff --git a/wiki/Token-Reference.md b/wiki/Token-Reference.md
index 1403978..bfe651c 100644
--- a/wiki/Token-Reference.md
+++ b/wiki/Token-Reference.md
@@ -116,6 +116,26 @@ Generic bridge-facing level tokens:
These values apply to the matching `data-icon-role` hooks and preserved icon-role classes inside `.icon-only`.
+## Shared semantic fallbacks
+
+A third-party theme or design system may provide these optional, package-neutral values. Interactive Surface resolves package-specific values first, shared semantic values second, and existing legacy values or literals last.
+
+| Shared token | Interactive Surface use |
+| ----------------------- | ------------------------------------------- |
+| `--ui-color-surface` | Standalone base surface background |
+| `--ui-color-text` | Standalone base foreground |
+| `--ui-color-muted` | Subtle-variant foreground |
+| `--ui-color-primary` | Primary-variant background and border |
+| `--ui-color-on-primary` | Primary-variant foreground |
+| `--ui-color-border` | Standalone base border |
+| `--ui-radius-control` | Standalone control radius |
+| `--ui-shadow-control` | Standalone base shadow |
+| `--ui-focus-color` | Focus-ring color |
+| `--ui-motion-duration` | Default interaction and transition duration |
+| `--ui-motion-easing` | Standard interaction and transition easing |
+
+These fallbacks are additive. Existing output is unchanged when they are absent, and a package-specific `--interactive-surface-*` override always wins. `state-core.css` consumes only the focus and default-motion semantic values; paint and component geometry remain consumer-owned in the state-only entry point.
+
## Existing fallback tokens
### Legacy interaction fallbacks
@@ -170,13 +190,16 @@ 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.
+
+Third-party design systems that publish only the shared semantic contract can load their token stylesheet before `interactive-surface-css/standalone-preset.css`. UI Style Kit integrations should keep the canonical bridge imports when they need specialized variant, level, icon, and state-opacity mappings.