From b48b8b9080e4b1d4e344b6749ab1969a2863b3d1 Mon Sep 17 00:00:00 2001 From: Foscat Date: Fri, 4 Sep 2026 14:26:09 -0500 Subject: [PATCH] feat: prepare semantic feedback motion 1.7.0 --- CHANGELOG.md | 26 +++ README.md | 33 +++- demo/demo.js | 57 ++++++ index.html | 71 ++++++- interactive-surface.css | 186 +++++++++++++++++ manifest.json | 18 +- package-lock.json | 16 +- package.json | 6 +- scripts/release-fixture-contract.mjs | 285 +++++++++++++++++++++++---- standalone-preset.css | 186 +++++++++++++++++ state-core.css | 183 +++++++++++++++++ styles/standalone-preset.css | 3 + styles/state-core.css | 183 +++++++++++++++++ tests/documentation.test.mjs | 28 ++- tests/example.spec.ts | 40 ++++ tests/feedback-motion.spec.ts | 219 ++++++++++++++++++++ tests/fixtures/packed-ecosystem.ts | 70 ++++++- tests/manifest-contract.test.mjs | 19 ++ tests/package-contract.test.mjs | 12 +- tests/public-contract.test.mjs | 43 ++++ tests/release-preflight.test.mjs | 44 ++++- tests/state-core.spec.ts | 141 ++++++++++++- wiki/API-Reference.md | 22 ++- wiki/Accessibility.md | 8 +- wiki/FAQ.md | 8 +- wiki/Getting-Started.md | 21 +- wiki/Home.md | 6 +- wiki/Installation-and-Usage.md | 82 +++++++- wiki/Publishing-and-Releases.md | 41 ++-- wiki/Roadmap.md | 21 +- wiki/Testing-and-Quality.md | 8 +- wiki/Token-Reference.md | 14 ++ 32 files changed, 1975 insertions(+), 125 deletions(-) create mode 100644 tests/feedback-motion.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 54dc42b..72199db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,32 @@ All notable changes to this package are documented in this file. ## Unreleased +## 1.7.0 - 2026-09-03 + +### Added + +- Added CSS-only `data-surface-feedback="error|success|attention"` outcomes with restrained one-shot motion, state-layer feedback, standalone color defaults, and seven public customization tokens. + +### Changed + +- Extended interaction precedence to disabled, busy/loading, feedback, transient active, persistent, hover, and base while keeping `:focus-visible` orthogonal. + +### Fixed + +- Prevented feedback motion from replacing consumer `transform`, `scale`, or `rotate` and prevented feedback from leaking through busy, loading, or disabled states. + +### Accessibility + +- Added static reduced-motion feedback, higher-contrast and forced-colors outlines, focus-priority coverage, and explicit application responsibility for textual status announcements. + +### Documentation + +- Documented outcome triggering, deterministic replay and cleanup, token customization, accessibility responsibilities, and the separate tooltip-motion roadmap. + +### Testing + +- Added static contracts, generated-bundle checks, demo workflows, combined-state precedence proofs, and Chromium, Firefox, and WebKit feedback coverage. + ## 1.6.0 - 2026-08-09 ### Added diff --git a/README.md b/README.md index 86b89a9..cf983b2 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ [![npm version](https://img.shields.io/npm/v/interactive-surface-css.svg)](https://www.npmjs.com/package/interactive-surface-css) [![license](https://img.shields.io/npm/l/interactive-surface-css.svg)](https://github.com/Foscat/Interactive-Surface-CSS/blob/main/LICENSE) -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. +Framework-agnostic CSS for reliable hover, focus, press, selected, current, loading, disabled, outcome feedback, and motion affordances on interactive controls. Use it alone or as the interaction layer beside your existing layout and theme system. -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. +Version 1.7.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.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. +The equivalent unpkg URL is `https://unpkg.com/interactive-surface-css@1.7.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 @@ -90,6 +90,15 @@ Prefer native elements, then reflect persistent application state through ARIA: Saving… + + + @@ -127,7 +136,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.6.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.7.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. @@ -140,13 +149,14 @@ The package `main` and `module` fields preserve the CommonJS and ESM entries; bo | Transient | pointer `:hover`, keyboard `:focus-visible`, `:active` | | Persistent | `.is-active`, `aria-pressed="true"`, `aria-pressed="mixed"`, any non-false `aria-current`, `aria-selected="true"` | | Loading | `aria-busy="true"`, `.is-loading` | +| Outcome feedback | `data-surface-feedback="error"`, `data-surface-feedback="success"`, `data-surface-feedback="attention"` | | Disabled | native `:disabled`, `aria-disabled="true"`, `.is-disabled` | | Variant | `.variant-primary`, `.variant-secondary`, `.variant-accent`, `.variant-subtle`, `.variant-warning`, `.variant-danger`, or matching `data-surface-variant` values | | Level | `data-surface-level="1"`, `"2"`, or `"3"` | | Icon | `.icon-only`; child `data-icon-role="light"`, `"dark"`, `"accessibility"`, or the legacy role classes | | Native subcontrol | `input[type="file"].interactive-surface::file-selector-button` inherits surface paint and hover/active feedback | -Disabled > busy/loading > transient `:active` > pressed/selected/current > `:hover` > base. The `:focus-visible` ring is orthogonal to that precedence, so keyboard focus remains visible without replacing the active interaction state. The state layer preserves static meaning under reduced motion and uses system-color affordances in forced-colors mode. Interaction lift uses the individual `translate` property, so consumer-owned `transform`, `scale`, and `rotate` declarations can coexist. +Disabled > busy/loading > feedback > transient `:active` > pressed/selected/current > `:hover` > base. The `:focus-visible` ring is orthogonal to that precedence, so keyboard focus remains visible without replacing the active interaction state. The state layer preserves static meaning under reduced motion and uses system-color affordances in forced-colors mode. Interaction lift uses the individual `translate` property, so consumer-owned `transform`, `scale`, and `rotate` declarations can coexist. For selector details and responsibilities, see the [complete API reference](https://github.com/Foscat/Interactive-Surface-CSS/wiki/API-Reference). @@ -169,6 +179,16 @@ 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). +Outcome feedback can be customized per control or per feedback selector: + +- `--interactive-surface-feedback-duration` +- `--interactive-surface-feedback-easing` +- `--interactive-surface-feedback-distance` +- `--interactive-surface-feedback-layer-opacity` +- `--interactive-surface-feedback-error-color` +- `--interactive-surface-feedback-success-color` +- `--interactive-surface-feedback-attention-color` + 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 @@ -186,6 +206,7 @@ Applications still own semantics and behavior: - Use ` + data-surface-feedback="error" + + +
+

Success feedback

+ + data-surface-feedback="success" +
+ +
+

Attention feedback

+ + data-surface-feedback="attention" +
+

Toggle

@@ -569,9 +605,9 @@

Edit token

[![npm version](https://img.shields.io/npm/v/interactive-surface-css.svg)](https://www.npmjs.com/package/interactive-surface-css) [![license](https://img.shields.io/npm/l/interactive-surface-css.svg)](https://github.com/Foscat/Interactive-Surface-CSS/blob/main/LICENSE) -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. +Framework-agnostic CSS for reliable hover, focus, press, selected, current, loading, disabled, outcome feedback, and motion affordances on interactive controls. Use it alone or as the interaction layer beside your existing layout and theme system. -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. +Version 1.7.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 +658,11 @@

Edit token

```html ``` -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. +The equivalent unpkg URL is `https://unpkg.com/interactive-surface-css@1.7.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 @@ -656,6 +692,15 @@

Edit token

Saving… + + + @@ -693,7 +738,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.6.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.7.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. @@ -706,13 +751,14 @@

Edit token

| Transient | pointer `:hover`, keyboard `:focus-visible`, `:active` | | Persistent | `.is-active`, `aria-pressed="true"`, `aria-pressed="mixed"`, any non-false `aria-current`, `aria-selected="true"` | | Loading | `aria-busy="true"`, `.is-loading` | +| Outcome feedback | `data-surface-feedback="error"`, `data-surface-feedback="success"`, `data-surface-feedback="attention"` | | Disabled | native `:disabled`, `aria-disabled="true"`, `.is-disabled` | | Variant | `.variant-primary`, `.variant-secondary`, `.variant-accent`, `.variant-subtle`, `.variant-warning`, `.variant-danger`, or matching `data-surface-variant` values | | Level | `data-surface-level="1"`, `"2"`, or `"3"` | | Icon | `.icon-only`; child `data-icon-role="light"`, `"dark"`, `"accessibility"`, or the legacy role classes | | Native subcontrol | `input[type="file"].interactive-surface::file-selector-button` inherits surface paint and hover/active feedback | -Disabled > busy/loading > transient `:active` > pressed/selected/current > `:hover` > base. The `:focus-visible` ring is orthogonal to that precedence, so keyboard focus remains visible without replacing the active interaction state. The state layer preserves static meaning under reduced motion and uses system-color affordances in forced-colors mode. Interaction lift uses the individual `translate` property, so consumer-owned `transform`, `scale`, and `rotate` declarations can coexist. +Disabled > busy/loading > feedback > transient `:active` > pressed/selected/current > `:hover` > base. The `:focus-visible` ring is orthogonal to that precedence, so keyboard focus remains visible without replacing the active interaction state. The state layer preserves static meaning under reduced motion and uses system-color affordances in forced-colors mode. Interaction lift uses the individual `translate` property, so consumer-owned `transform`, `scale`, and `rotate` declarations can coexist. For selector details and responsibilities, see the [complete API reference](https://github.com/Foscat/Interactive-Surface-CSS/wiki/API-Reference). @@ -735,6 +781,16 @@

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). +Outcome feedback can be customized per control or per feedback selector: + +- `--interactive-surface-feedback-duration` +- `--interactive-surface-feedback-easing` +- `--interactive-surface-feedback-distance` +- `--interactive-surface-feedback-layer-opacity` +- `--interactive-surface-feedback-error-color` +- `--interactive-surface-feedback-success-color` +- `--interactive-surface-feedback-attention-color` + 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 @@ -752,6 +808,7 @@

Edit token

- Use `
`, + ) + .join(""); + + return ` + + + + + + + + ${buttons} + + + + `; +} + +test.describe("semantic feedback motion", () => { + test.beforeEach(async ({ page }) => { + await page.setContent(feedbackFixtureHtml()); + }); + + test("maps each outcome to its exact host, layer, and standalone color", async ({ + page, + }) => { + for (const feedbackCase of feedbackCases) { + const styles = await page + .locator(`#${feedbackCase.value}`) + .evaluate((element) => { + const host = window.getComputedStyle(element); + const layer = window.getComputedStyle(element, "::before"); + return { + hostAnimation: host.animationName, + hostDuration: host.animationDuration, + hostEasing: host.animationTimingFunction, + hostDelay: host.animationDelay, + hostFillMode: host.animationFillMode, + hostIterations: host.animationIterationCount, + layerAnimation: layer.animationName, + layerColor: layer.backgroundColor, + }; + }); + + expect(styles, feedbackCase.value).toEqual({ + hostAnimation: feedbackCase.hostAnimation, + hostDuration: "0.24s", + hostEasing: "cubic-bezier(0.2, 0, 0.2, 1)", + hostDelay: "0s", + hostFillMode: "none", + hostIterations: "1", + layerAnimation: feedbackCase.layerAnimation, + layerColor: feedbackCase.color, + }); + } + }); + + test("resolves scoped custom tokens and ignores unknown outcomes", async ({ + page, + }) => { + const custom = await page.locator("#custom").evaluate((element) => { + const host = window.getComputedStyle(element); + const layer = window.getComputedStyle(element, "::before"); + return { + duration: host.animationDuration, + easing: host.animationTimingFunction, + layerColor: layer.backgroundColor, + distance: host + .getPropertyValue("--interactive-surface-feedback-distance") + .trim(), + layerOpacityToken: host + .getPropertyValue("--interactive-surface-feedback-layer-opacity") + .trim(), + }; + }); + expect(custom).toEqual({ + duration: "0.48s", + easing: "linear", + layerColor: "rgb(10, 20, 30)", + distance: "5px", + layerOpacityToken: "0.24", + }); + await expect(page.locator("#unknown")).toHaveCSS("animation-name", "none"); + }); + + test("exposes the exact host keyframe offsets and returns every profile to rest", async ({ + page, + }) => { + const expectedOffsets = { + error: [0, 0.2, 0.4, 0.6, 0.8, 1], + success: [0, 0.45, 1], + attention: [0, 0.25, 0.45, 0.7, 1], + } as const; + + for (const feedbackCase of feedbackCases) { + const frames = await page + .locator(`#${feedbackCase.value}`) + .evaluate(async (element, value) => { + const target = element as HTMLElement; + const nextFrame = () => + new Promise((resolve) => + requestAnimationFrame(() => resolve()), + ); + target.style.setProperty( + "--interactive-surface-feedback-duration", + "10s", + ); + target.removeAttribute("data-surface-feedback"); + await nextFrame(); + target.setAttribute("data-surface-feedback", value); + await nextFrame(); + const animation = target + .getAnimations() + .find((candidate) => + candidate instanceof CSSAnimation + ? candidate.animationName === + `interactive-surface-feedback-${value}` + : false, + ); + if (!animation) + throw new Error(`Missing host animation for ${value}`); + const keyframes = + animation.effect instanceof KeyframeEffect + ? animation.effect.getKeyframes() + : []; + return keyframes.map(({ offset, translate }) => ({ + offset: + typeof offset === "number" ? Number(offset.toFixed(4)) : offset, + translate, + })); + }, feedbackCase.value); + + expect( + frames.map(({ offset }) => offset), + feedbackCase.value, + ).toEqual(expectedOffsets[feedbackCase.value]); + expect(frames[0]?.translate, feedbackCase.value).toBe( + frames.at(-1)?.translate, + ); + } + }); + + test("removing and reapplying the same outcome creates a new animation", async ({ + page, + }) => { + const replayed = await page.locator("#error").evaluate(async (element) => { + const target = element as HTMLElement; + const nextFrame = () => + new Promise((resolve) => requestAnimationFrame(() => resolve())); + target.style.setProperty( + "--interactive-surface-feedback-duration", + "10s", + ); + target.removeAttribute("data-surface-feedback"); + await nextFrame(); + target.setAttribute("data-surface-feedback", "error"); + await nextFrame(); + const first = target.getAnimations()[0]; + + target.removeAttribute("data-surface-feedback"); + await nextFrame(); + target.setAttribute("data-surface-feedback", "error"); + await nextFrame(); + const second = target.getAnimations()[0]; + + return Boolean(first && second && first !== second); + }); + + expect(replayed).toBe(true); + }); +}); diff --git a/tests/fixtures/packed-ecosystem.ts b/tests/fixtures/packed-ecosystem.ts index 2899019..f51944d 100644 --- a/tests/fixtures/packed-ecosystem.ts +++ b/tests/fixtures/packed-ecosystem.ts @@ -27,9 +27,72 @@ const repositoryRoot = path.resolve( "..", "..", ); -const uiStyleKitRoot = - process.env.UI_STYLE_KIT_CSS_SOURCE ?? - path.resolve(repositoryRoot, "..", "ui-style-kit-css"); + +/** + * Reads the immutable UI Style Kit fixture revision used for packed ecosystem tests. + * + * @returns The release fixture descriptor checked into the repository. + */ +function readUiStyleKitFixtureDescriptor() { + return JSON.parse( + readFileSync( + path.join(repositoryRoot, "ecosystem-release-fixture.json"), + "utf8", + ), + ) as { repository: string; revision: string }; +} + +/** + * Runs a git command and reports stdout and stderr when fixture setup fails. + * + * @param args - Arguments passed directly to the git executable. + * @param label - Operation label included in setup failure messages. + */ +function runGit(args: string[], label: string) { + const result = spawnSync("git", args, { + 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 }, + ); + } +} + +/** + * Materializes the reviewed UI Style Kit fixture without changing local sibling work. + * + * @param fixtureDirectory - Temporary packed ecosystem root that owns cleanup. + * @returns The package root that should be packed into the consumer fixture. + */ +function prepareUiStyleKitRoot(fixtureDirectory: string) { + if (process.env.UI_STYLE_KIT_CSS_SOURCE) { + return path.resolve(process.env.UI_STYLE_KIT_CSS_SOURCE); + } + + const descriptor = readUiStyleKitFixtureDescriptor(); + const sourceRoot = path.resolve(repositoryRoot, "..", "ui-style-kit-css"); + if (!existsSync(path.join(sourceRoot, "package.json"))) { + throw new Error( + `UI Style Kit source checkout is required at ${sourceRoot}.`, + ); + } + + const reviewedSourceRoot = path.join(fixtureDirectory, "ui-style-kit-css"); + runGit( + ["clone", "--no-checkout", sourceRoot, reviewedSourceRoot], + "git clone UI Style Kit fixture", + ); + runGit( + ["-C", reviewedSourceRoot, "checkout", "--force", descriptor.revision], + "git checkout UI Style Kit fixture revision", + ); + + return reviewedSourceRoot; +} function runNpm(args: string[], cwd: string, label: string) { const executableDirectory = path.dirname(process.execPath); @@ -149,6 +212,7 @@ export function createPackedEcosystemFixture( const artifacts = [interactiveArtifact]; if (options.includeUiStyleKit) { + const uiStyleKitRoot = prepareUiStyleKitRoot(fixtureDirectory); if (!existsSync(path.join(uiStyleKitRoot, "package.json"))) { throw new Error( `UI Style Kit source checkout is required at ${uiStyleKitRoot}.`, diff --git a/tests/manifest-contract.test.mjs b/tests/manifest-contract.test.mjs index fe6b40c..fb8f7f8 100644 --- a/tests/manifest-contract.test.mjs +++ b/tests/manifest-contract.test.mjs @@ -62,6 +62,14 @@ const dataHooks = [ attributeSelector("data-surface-level", "3"), ], }, + { + name: "data-surface-feedback", + selectors: [ + attributeSelector("data-surface-feedback", "error"), + attributeSelector("data-surface-feedback", "success"), + attributeSelector("data-surface-feedback", "attention"), + ], + }, { name: "data-icon-role", selectors: [ @@ -79,6 +87,9 @@ const documentedDataHooks = [ 'data-surface-variant="warning"', 'data-surface-variant="danger"', 'data-surface-level="1|2|3"', + 'data-surface-feedback="error"', + 'data-surface-feedback="success"', + 'data-surface-feedback="attention"', 'data-icon-role="light"', 'data-icon-role="dark"', 'data-icon-role="accessibility"', @@ -97,6 +108,13 @@ const publicTokens = [ "--interactive-surface-disabled-opacity", "--interactive-surface-ease-press", "--interactive-surface-ease-standard", + "--interactive-surface-feedback-attention-color", + "--interactive-surface-feedback-distance", + "--interactive-surface-feedback-duration", + "--interactive-surface-feedback-easing", + "--interactive-surface-feedback-error-color", + "--interactive-surface-feedback-layer-opacity", + "--interactive-surface-feedback-success-color", "--interactive-surface-fg", "--interactive-surface-focus-ring-color", "--interactive-surface-focus-ring-offset", @@ -204,6 +222,7 @@ test("ecosystem manifest describes real interactive state and token contracts", assert.deepEqual(manifest.states.precedence, [ "disabled", "busy-loading", + "feedback", "active", "persistent", "hover", diff --git a/tests/package-contract.test.mjs b/tests/package-contract.test.mjs index 79db7a0..6cf4cca 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.6.0"; +const EXPECTED_VERSION = "1.7.0"; const CHECKOUT_V4_SHA = "34e114876b0b11c390a56381ad16ebd13914f8d5"; const CHECKOUT_V5_SHA = "93cb6efe18208431cddfb8368fd83d5badbf9bfd"; const SETUP_NODE_V5_SHA = "a0853c24544627f65ddf259abe73b1d18a591444"; @@ -24,9 +24,9 @@ const packageLock = JSON.parse( // Exact overrides keep the release audit deterministic without promoting transitive tooling to direct dependencies. const expectedSecurityOverrides = { - "fast-uri": "3.1.5", + "fast-uri": "3.1.6", "js-yaml": "4.3.1", - nanoid: "3.3.17", + nanoid: "3.3.18", postcss: "8.5.23", }; @@ -259,7 +259,7 @@ function collectReferencedAssetPaths(assetFile, assetSource) { .sort(); } -test("the release manifest and validation graph are pinned to 1.6.0", () => { +test("the release manifest and validation graph are pinned to 1.7.0", () => { assert.equal(manifest.name, EXPECTED_NAME); assert.equal(manifest.version, EXPECTED_VERSION); assert.equal( @@ -306,12 +306,12 @@ test("release security overrides resolve audited transitive tooling", () => { } }); -test("the changelog keeps the complete 1.6.0 release after the Unreleased section", async () => { +test("the changelog keeps the complete 1.7.0 release after the Unreleased section", async () => { const changelog = await readFile( path.join(repositoryRoot, "CHANGELOG.md"), "utf8", ); - const releaseHeading = `## ${EXPECTED_VERSION} - 2026-08-09`; + const releaseHeading = `## ${EXPECTED_VERSION} - 2026-09-03`; const releaseMatches = changelog.match( new RegExp(`^${releaseHeading.replaceAll(".", "\\.")}$`, "gm"), diff --git a/tests/public-contract.test.mjs b/tests/public-contract.test.mjs index df5cff7..13d134c 100644 --- a/tests/public-contract.test.mjs +++ b/tests/public-contract.test.mjs @@ -146,3 +146,46 @@ test("UI Style Kit remains an optional development-only compatibility fixture", assert.equal(manifest.peerDependencies?.["ui-style-kit-css"], undefined); assert.equal(manifest.devDependencies?.["ui-style-kit-css"], "2.1.0"); }); + +test("semantic feedback exposes exact hooks, tokens, and namespaced keyframes", () => { + const feedbackValues = ["error", "success", "attention"]; + const feedbackTokens = [ + "--interactive-surface-feedback-attention-color", + "--interactive-surface-feedback-distance", + "--interactive-surface-feedback-duration", + "--interactive-surface-feedback-easing", + "--interactive-surface-feedback-error-color", + "--interactive-surface-feedback-layer-opacity", + "--interactive-surface-feedback-success-color", + ]; + const feedbackKeyframes = [ + "interactive-surface-feedback-error", + "interactive-surface-feedback-success", + "interactive-surface-feedback-attention", + "interactive-surface-feedback-layer", + "interactive-surface-feedback-layer-attention", + ]; + + for (const value of feedbackValues) { + assert.ok( + stateCoreSource.includes(`data-surface-feedback="${value}"`), + `Missing feedback hook: ${value}`, + ); + } + for (const token of feedbackTokens) { + assert.ok( + stateCoreSource.includes(token), + `Missing feedback token: ${token}`, + ); + } + for (const keyframe of feedbackKeyframes) { + assert.ok( + stateCoreSource.includes(`@keyframes ${keyframe}`), + `Missing feedback keyframe: ${keyframe}`, + ); + } + assert.deepEqual(manifest.dependencies ?? {}, {}); + assert.equal(manifest.peerDependencies?.["ui-style-kit-css"], undefined); + assert.doesNotMatch(stateCoreSource, /\.motion-(?:shake|pop|bounce)/); + assert.doesNotMatch(stateCoreSource, /aria-invalid/); +}); diff --git a/tests/release-preflight.test.mjs b/tests/release-preflight.test.mjs index b605b95..4ee6247 100644 --- a/tests/release-preflight.test.mjs +++ b/tests/release-preflight.test.mjs @@ -54,6 +54,48 @@ test("pull requests execute read-only preflight and npm publish stays downstream ); }); +test("candidate version overlay repairs stale current fixture drift only", () => { + assert.ok( + releaseContract, + "scripts/release-fixture-contract.mjs must implement the fixture contract", + ); + + const fixtureContract = { + supportedCombinations: { + minimum: { + "interactive-surface-css": "1.5.0", + "layout-style-css": "3.0.0", + "ui-style-kit-css": "2.1.0", + }, + current: { + "interactive-surface-css": "1.6.0", + "layout-style-css": "3.0.1", + "ui-style-kit-css": "2.1.0", + }, + }, + }; + + const overlay = releaseContract.withCandidateCurrentVersion( + fixtureContract, + "interactive-surface-css", + "1.7.0", + ); + + assert.equal( + fixtureContract.supportedCombinations.current["interactive-surface-css"], + "1.6.0", + "the reviewed fixture contract must not be mutated", + ); + assert.deepEqual(overlay.supportedCombinations, { + minimum: fixtureContract.supportedCombinations.minimum, + current: { + "interactive-surface-css": "1.7.0", + "layout-style-css": "3.0.1", + "ui-style-kit-css": "2.1.0", + }, + }); +}); + test("workflow policy rejects every release or deployment mutation from pull requests", () => { assert.ok( releaseContract, @@ -109,7 +151,7 @@ test("publishing guide records the immutable bootstrap and merge sequence", () = for (const phrase of [ "U-I bootstrap", - "Interactive Surface CSS 1.6.0 candidate", + "Interactive Surface CSS 1.7.0 candidate", "Push a stable UI bootstrap ref", "merge commits", "Update and verify the final UI companion pins", diff --git a/tests/state-core.spec.ts b/tests/state-core.spec.ts index 0c2cb5d..a150767 100644 --- a/tests/state-core.spec.ts +++ b/tests/state-core.spec.ts @@ -55,9 +55,13 @@ function stateFixtureHtml() { #pressed-true, #current-step, #busy, - #loading { + #loading, + #feedback-focus, + #feedback-busy, + #feedback-loading { --interactive-surface-motion-default: 0ms; --interactive-surface-motion-press: 0ms; + --interactive-surface-feedback-duration: 0ms; } .consumer-transform { @@ -70,6 +74,7 @@ function stateFixtureHtml() { --interactive-surface-lift-base: 0px; --interactive-surface-lift-hover: -7px; --interactive-surface-lift-active: -3px; + --interactive-surface-feedback-duration: 0ms; } #precedence { @@ -79,6 +84,7 @@ function stateFixtureHtml() { --interactive-surface-shadow-base: 0 1px 0 rgb(10 20 30); --interactive-surface-shadow-hover: 0 2px 0 rgb(40 50 60); --interactive-surface-shadow-active: 0 3px 0 rgb(70 80 90); + --interactive-surface-feedback-duration: 0ms; --interactive-surface-motion-default: 0ms; --interactive-surface-motion-press: 0ms; } @@ -114,11 +120,15 @@ function stateFixtureHtml() { + + +
{ } }); - test("state precedence is disabled, busy, transient active, persistent, hover, then base", async ({ + test("state precedence is disabled, busy, feedback, transient active, persistent, hover, then base", async ({ page, }) => { const target = page.locator("#precedence"); @@ -379,9 +391,26 @@ test.describe("state core semantics and precedence", () => { translateY: -3, }); + await target.evaluate((element) => + element.setAttribute("data-surface-feedback", "error"), + ); + expect(await interactionSnapshot(page, "#precedence")).toEqual({ + boxShadow: "rgb(10, 20, 30) 0px 1px 0px 0px", + layerOpacity: 0.18, + translateY: 0, + }); + await expect(target).toHaveCSS( + "animation-name", + "interactive-surface-feedback-error", + ); + await page.mouse.down(); try { - expect(await interactionSnapshot(page, "#precedence")).toEqual(base); + expect(await interactionSnapshot(page, "#precedence")).toEqual({ + boxShadow: "rgb(10, 20, 30) 0px 1px 0px 0px", + layerOpacity: 0.18, + translateY: 0, + }); await target.evaluate((element) => element.setAttribute("aria-busy", "true"), @@ -405,6 +434,32 @@ test.describe("state core semantics and precedence", () => { } }); + test("busy and loading defer feedback while disabled states suppress it", async ({ + page, + }) => { + for (const selector of ["#feedback-busy", "#feedback-loading"]) { + const target = page.locator(selector); + await expect(target).toHaveCSS("animation-name", "none"); + const layerName = await target.evaluate( + (element) => window.getComputedStyle(element, "::before").animationName, + ); + expect(layerName, selector).toBe("none"); + } + + const busy = page.locator("#feedback-busy"); + await busy.evaluate((element) => + element.setAttribute("aria-busy", "false"), + ); + await expect(busy).toHaveCSS( + "animation-name", + "interactive-surface-feedback-error", + ); + + for (const selector of disabledSelectors) { + await expect(page.locator(selector)).toHaveCSS("animation-name", "none"); + } + }); + test("busy and class loading retain precedence during a transient press", async ({ page, }) => { @@ -427,6 +482,7 @@ test.describe("state core semantics and precedence", () => { const cases = [ { selector: "#focus-target", expectedOpacity: 0 }, { selector: "#pressed-true", expectedOpacity: 0.32 }, + { selector: "#feedback-focus", expectedOpacity: 0.18 }, { selector: "#busy", expectedOpacity: 0.32 }, { selector: "#loading", expectedOpacity: 0.32 }, ]; @@ -491,6 +547,25 @@ test.describe("state core user preferences", () => { transitionDuration: "0s", }); + const feedback = await page + .locator("#feedback-focus") + .evaluate((element) => { + const host = window.getComputedStyle(element); + const layer = window.getComputedStyle(element, "::before"); + return { + animationName: host.animationName, + layerAnimationName: layer.animationName, + layerOpacity: Number.parseFloat(layer.opacity), + translate: host.getPropertyValue("translate"), + }; + }); + expect(feedback).toEqual({ + animationName: "none", + layerAnimationName: "none", + layerOpacity: 0.18, + translate: "none", + }); + const focusTarget = page.locator("#focus-target"); await focusTarget.focus(); const focusOutline = await focusTarget.evaluate( @@ -507,6 +582,7 @@ test.describe("state core user preferences", () => { const buttonText = await systemColor(page, "ButtonText"); const grayText = await systemColor(page, "GrayText"); + const highlight = await systemColor(page, "Highlight"); const selected = await page.locator("#selected").evaluate((element) => { const host = window.getComputedStyle(element); @@ -524,6 +600,36 @@ test.describe("state core user preferences", () => { expect(selected.outlineWidth).toBeGreaterThanOrEqual(2); expect(selected.outlineColor).toBe(buttonText); + const feedback = await page + .locator("#feedback-focus") + .evaluate((element) => { + const styles = window.getComputedStyle(element); + const layer = window.getComputedStyle(element, "::before"); + return { + layerDisplay: layer.display, + outlineColor: styles.outlineColor, + outlineStyle: styles.outlineStyle, + outlineWidth: Number.parseFloat(styles.outlineWidth), + }; + }); + expect(feedback).toEqual({ + layerDisplay: "none", + outlineColor: buttonText, + outlineStyle: "solid", + outlineWidth: 2, + }); + + const feedbackFocus = page.locator("#feedback-focus"); + await feedbackFocus.focus(); + const feedbackFocusOutline = await feedbackFocus.evaluate((element) => { + const styles = window.getComputedStyle(element); + return { color: styles.outlineColor, style: styles.outlineStyle }; + }); + expect(feedbackFocusOutline).toEqual({ + color: highlight, + style: "solid", + }); + const focusTarget = page.locator("#focus-target"); await focusTarget.focus(); const focus = await focusTarget.evaluate((element) => { @@ -587,10 +693,29 @@ test.describe("state core user preferences", () => { width: Number.parseFloat(styles.outlineWidth), }; }); + const feedbackOutline = await page + .locator("#feedback-focus") + .evaluate((element) => { + const styles = window.getComputedStyle(element); + return { + style: styles.outlineStyle, + width: Number.parseFloat(styles.outlineWidth), + }; + }); + + await page.locator("#feedback-focus").focus(); + const focusedFeedbackWidth = await page + .locator("#feedback-focus") + .evaluate((element) => + Number.parseFloat(window.getComputedStyle(element).outlineWidth), + ); expect(focusWidth).toBeGreaterThanOrEqual(3); expect(persistentOutline.style).toBe("solid"); expect(persistentOutline.width).toBeGreaterThanOrEqual(2); + expect(feedbackOutline.style).toBe("solid"); + expect(feedbackOutline.width).toBeGreaterThanOrEqual(2); + expect(focusedFeedbackWidth).toBeGreaterThanOrEqual(3); }); test("greater contrast prioritizes focus over a persistent state outline", async ({ @@ -642,6 +767,16 @@ test.describe("state core motion composition", () => { element.setAttribute("aria-selected", "true"), ), }, + { + name: "feedback", + expectedTranslateY: 0, + activate: async () => + page + .locator("#composed") + .evaluate((element) => + element.setAttribute("data-surface-feedback", "success"), + ), + }, { name: "disabled", expectedTranslateY: 0, diff --git a/wiki/API-Reference.md b/wiki/API-Reference.md index dd64c11..fb4baf8 100644 --- a/wiki/API-Reference.md +++ b/wiki/API-Reference.md @@ -1,6 +1,6 @@ # API Reference -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. +Interactive Surface CSS 1.7.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.6.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.6.0. +The preset and compatibility stylesheet are generated from the same authored modules and are behaviorally equivalent in 1.7.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. @@ -61,7 +61,7 @@ The standalone preset additionally provides neutral paint, borders, radii, varia ### Interaction precedence -Disabled > busy/loading > transient `:active` > pressed/selected/current > `:hover` > base. The `:focus-visible` ring is orthogonal to this ordering: it remains visible on every focusable non-disabled state without replacing the active state-layer, lift, or shadow feedback. +Disabled > busy/loading > feedback > transient `:active` > pressed/selected/current > `:hover` > base. The `:focus-visible` ring is orthogonal to this ordering: it remains visible on every focusable non-disabled state without replacing the active state-layer, lift, or shadow feedback. The host transition tuple is controlled by `--interactive-surface-transition-property`, `--interactive-surface-transition-duration`, `--interactive-surface-transition-easing`, and `--interactive-surface-transition-delay`. Existing public motion/easing tokens and their generic fallbacks remain supported. @@ -101,6 +101,22 @@ Applications must update these values. The package only styles the resulting sta Busy and loading feedback outranks transient `:active`; the other persistent hooks outrank hover but remain below transient `:active`. +## Outcome feedback + +Outcome feedback renders an application-reported result: + +| Hook | Default motion | +| ----------------------------------- | --------------------------- | +| `data-surface-feedback="error"` | Restrained horizontal shake | +| `data-surface-feedback="success"` | One upward settle | +| `data-surface-feedback="attention"` | Two gentle upward nudges | + +The application must add, remove, and replay the attribute; CSS does not determine the outcome or create an accessibility announcement. + +The feedback motion contract is controlled by `--interactive-surface-feedback-duration`, `--interactive-surface-feedback-easing`, `--interactive-surface-feedback-distance`, `--interactive-surface-feedback-layer-opacity`, `--interactive-surface-feedback-error-color`, `--interactive-surface-feedback-success-color`, and `--interactive-surface-feedback-attention-color`. + +Unknown and empty values are unstyled. Feedback is suppressed while disabled or busy/loading and outranks transient active, persistent, and hover feedback once eligible. + ## Disabled states | Selector or hook | Behavior | diff --git a/wiki/Accessibility.md b/wiki/Accessibility.md index 883c280..fa6c5a0 100644 --- a/wiki/Accessibility.md +++ b/wiki/Accessibility.md @@ -64,7 +64,13 @@ function activateControl(event) { } ``` -Disabled > busy/loading > transient `:active` > pressed/selected/current > `:hover` > base. This order prevents hover from erasing persistent meaning, prevents a transient press from erasing busy feedback, and makes every disabled form authoritative. +Disabled > busy/loading > feedback > transient `:active` > pressed/selected/current > `:hover` > base. This order prevents hover from erasing persistent meaning, prevents a transient press from erasing busy feedback, and makes every disabled form authoritative. + +## Outcome feedback + +Outcome motion and color are supplementary. Pair `data-surface-feedback` with visible result text, an associated error message, or an appropriate `aria-live` status region. Do not use the feedback attribute as a replacement for accessible status content, and do not use `aria-invalid` as a generic failed-button state. + +Applications must remove feedback through their own timer or state transition. Do not rely only on `animationend`, because `prefers-reduced-motion` users may receive no animation event. ## Reduced motion diff --git a/wiki/FAQ.md b/wiki/FAQ.md index a0e3a45..79711ed 100644 --- a/wiki/FAQ.md +++ b/wiki/FAQ.md @@ -38,13 +38,17 @@ The state layer supports `.is-active`, `aria-pressed="true"`, `aria-pressed="mix Yes. Interactive Surface uses the individual `translate` longhand for lift and preserves consumer-owned `transform`, `scale`, and `rotate`. +## Does CSS know when my action failed? + +No. Application code decides the outcome, applies `data-surface-feedback="error"`, `"success"`, or `"attention"`, announces the result, and removes the attribute after the feedback window. + ## What does `.icon-only` do? 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.6.0 published? +## Is 1.7.0 published? -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. +The repository describes 1.7.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 95edb61..e91e622 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.6.0 release candidate. +This is the shortest reliable path to the Interactive Surface CSS 1.7.0 release candidate. ## Install @@ -46,6 +46,21 @@ Use ` +Changes could not be saved. +``` + +Application code applies and removes `data-surface-feedback`; pair the motion with visible text or a live status region. + ## Disable controls correctly ```html @@ -61,11 +76,11 @@ Pin the release candidate version: ```html ``` -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. +The unpkg equivalent is `https://unpkg.com/interactive-surface-css@1.7.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 3f499e3..fe75d98 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -1,8 +1,8 @@ # Interactive Surface CSS -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. +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, outcome feedback, reduced-motion, and forced-colors treatment without requiring a component runtime. -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. +Version 1.7.0 is a release candidate until it is published. It preserves every established 1.x import and selector while adding CSS-only `data-surface-feedback="error|success|attention"` outcomes to the focused `state-core.css` and complete `standalone-preset.css` entry points. ## Choose the layer you need @@ -14,7 +14,7 @@ Version 1.6.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.6.0 entry points +## 1.7.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 c7f93af..42ebe88 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.6.0 release candidate. Existing 1.x imports remain supported. +These instructions target the Interactive Surface CSS 1.7.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 ``` @@ -149,6 +149,82 @@ Layout Style CSS owns structure and geometry. The [Interface Systems Lab](https: CSS provides visible state but no runtime state machine. Applications must update `aria-pressed`, `aria-current`, `aria-selected`, and `aria-busy`. Prefer native `disabled`; consumers must suppress activation for `aria-disabled="true"` and `.is-disabled` controls. +For outcome feedback, apply `data-surface-feedback="error"`, `"success"`, or `"attention"` only after the application knows the result. Pair it with visible text or a live status region. + +```js +const feedbackGenerations = new WeakMap(); + +function showSurfaceFeedback(control, outcome, visibleFor = 600) { + const generation = (feedbackGenerations.get(control) ?? 0) + 1; + feedbackGenerations.set(control, generation); + control.removeAttribute("data-surface-feedback"); + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (feedbackGenerations.get(control) !== generation) return; + + control.setAttribute("data-surface-feedback", outcome); + window.setTimeout(() => { + if ( + feedbackGenerations.get(control) === generation && + control.getAttribute("data-surface-feedback") === outcome + ) { + control.removeAttribute("data-surface-feedback"); + } + }, visibleFor); + }); + }); +} +``` + +```jsx +function SaveButton({ saveChanges }) { + const [feedback, setFeedback] = React.useState(); + const [status, setStatus] = React.useState(""); + const clearTimer = React.useRef(); + + React.useEffect(() => () => window.clearTimeout(clearTimer.current), []); + + async function save() { + window.clearTimeout(clearTimer.current); + setFeedback(undefined); + await new Promise((resolve) => requestAnimationFrame(resolve)); + + let outcome; + + try { + await saveChanges(); + outcome = "success"; + setStatus("Changes saved."); + } catch { + outcome = "error"; + setStatus("Changes could not be saved."); + } + + setFeedback(outcome); + clearTimer.current = window.setTimeout( + () => + setFeedback((current) => (current === outcome ? undefined : current)), + 600, + ); + } + + return ( + <> + + {status} + + ); +} +``` + Interaction lift uses the individual `translate` property and composes with consumer-owned `transform`, `scale`, and `rotate`. ## Next references diff --git a/wiki/Publishing-and-Releases.md b/wiki/Publishing-and-Releases.md index 218ddb5..ee6dc6c 100644 --- a/wiki/Publishing-and-Releases.md +++ b/wiki/Publishing-and-Releases.md @@ -1,6 +1,6 @@ # Publishing and Releases -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. +Interactive Surface CSS 1.7.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,12 +13,15 @@ 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.6.0 release-candidate checklist +## 1.7.0 release-candidate checklist -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: +1. Confirm `package.json` and `package-lock.json` identify `1.7.0`. +2. Confirm the 1.7.0 changelog entry describes the three feedback hooks, seven feedback tokens, preference behavior, manifest parity, package validation, and full-browser coverage. +3. Confirm `data-surface-feedback="error|success|attention"` works in `state-core.css`, `standalone-preset.css`, and `interactive-surface.css`. +4. Confirm reduced motion, higher contrast, forced colors, busy/loading suppression, disabled suppression, and focus priority remain covered. +5. Confirm manifest, README, embedded README, wiki, changelog, and generated bundles agree on the candidate contract. +6. Build public stylesheets and verify generated parity. +7. Run deterministic validation: ```bash npm run validate @@ -32,15 +35,15 @@ See the [npm publish workflow](https://github.com/Foscat/Interactive-Surface-CSS 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: +8. Run the supported browser matrix: ```bash npm run validate:full ``` -6. Inspect the actual packed tarball and confirm only intended public files are present. -7. Review the final branch diff and resolve every release-blocking finding. -8. Obtain explicit approval before publishing, tagging, or creating the GitHub Release. +9. Inspect the actual packed tarball and confirm only intended public files are present. +10. Review the final branch diff and resolve every release-blocking finding. +11. Obtain explicit approval before publishing, tagging, or creating the GitHub Release. ## Validation tiers @@ -52,7 +55,7 @@ The deterministic publish guard avoids downloading browser binaries. The separat ## 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: +The U-I bootstrap gives the Interactive Surface CSS 1.7.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. @@ -64,19 +67,19 @@ The U-I bootstrap SHA is deliberately stable: the Interactive Surface candidate ## Release identity -The tag, GitHub Release, package version, lockfile version, and changelog heading must agree. For this candidate, the expected tag is `v1.6.0`. +The tag, GitHub Release, package version, lockfile version, and changelog heading must agree. For this candidate, the expected tag is `v1.7.0`. ## Distribution verification After an approved publish, verify: - `https://registry.npmjs.org/interactive-surface-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` +- `https://cdn.jsdelivr.net/npm/interactive-surface-css@1.7.0/interactive-surface.css` +- `https://cdn.jsdelivr.net/npm/interactive-surface-css@1.7.0/state-core.css` +- `https://cdn.jsdelivr.net/npm/interactive-surface-css@1.7.0/standalone-preset.css` +- `https://unpkg.com/interactive-surface-css@1.7.0/interactive-surface.css` +- `https://unpkg.com/interactive-surface-css@1.7.0/state-core.css` +- `https://unpkg.com/interactive-surface-css@1.7.0/standalone-preset.css` Do not treat a local pack or a successful workflow validation as proof that these live URLs are available. @@ -95,4 +98,4 @@ npm publish --access public - Minor: backward-compatible capabilities or entry points. - Major: intentional breaking changes. -The 1.6.0 minor release adds optional shared semantic fallbacks while preserving the focused entry points, complete 1.x contract, and compatibility ranges. +The 1.7.0 minor release adds CSS-only semantic feedback outcomes while preserving the focused entry points, complete 1.x contract, and compatibility ranges. The 1.6.0 U-I bootstrap revision remains the immutable fixture history for coordinated ecosystem release validation. diff --git a/wiki/Roadmap.md b/wiki/Roadmap.md index a570a71..f2f9674 100644 --- a/wiki/Roadmap.md +++ b/wiki/Roadmap.md @@ -1,14 +1,14 @@ # Roadmap -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. +Interactive Surface CSS 1.7.0 is a release candidate. The work in this branch adds CSS-only semantic feedback outcomes without expanding into layout, theme, tooltip, or runtime ownership. -## 1.6.0 release candidate +## 1.7.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`. -- Remove implicit UI-host lift compensation and cross-package transition importance from the standalone preset. -- Enforce disabled, busy/loading, transient active, persistent, hover, and base precedence while keeping focus-visible orthogonal. -- Preserve state meaning under reduced motion, higher contrast, and forced colors. +- Add `data-surface-feedback="error"`, `"success"`, and `"attention"` as application-controlled outcome hooks. +- Expose duration, easing, distance, layer-opacity, and outcome-color feedback tokens from `state-core.css`. +- Enforce disabled, busy/loading, feedback, transient active, persistent, hover, and base precedence while keeping focus-visible orthogonal. +- Preserve feedback meaning under reduced motion, higher contrast, and forced colors. - Compose interaction `translate` with consumer `transform`, `scale`, and `rotate`. - Keep UI Style Kit optional and preserve standalone package behavior. - Align generated artifacts, package metadata, README, wiki, changelog, and release assertions. @@ -16,10 +16,17 @@ Interactive Surface CSS 1.6.0 is a release candidate. The work in this branch ad Publication, tagging, and a GitHub Release remain separate approval-gated steps. -## After 1.6.0 +## Completed history + +- 1.6.0 added optional shared semantic fallbacks for standalone paint, control geometry, focus, and default motion. + +## After 1.7.0 Potential follow-up work must be driven by demonstrated consumer need: +- decide whether tooltip feedback should be attribute-triggered, ARIA-owned, or companion-owned +- decide whether tooltip motion needs a dedicated reduced-motion static state +- decide which package owns tooltip geometry before adding any selector - add targeted visual-regression baselines if state combinations become difficult to review - expand framework examples without shipping framework wrappers - add migration notes only when token evolution requires them diff --git a/wiki/Testing-and-Quality.md b/wiki/Testing-and-Quality.md index a6bfabf..10cbda0 100644 --- a/wiki/Testing-and-Quality.md +++ b/wiki/Testing-and-Quality.md @@ -1,6 +1,6 @@ # Testing and Quality -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. +The 1.7.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.6.0 release candidate before publication. Browser downloads intentionally remain outside `prepublishOnly`. +The exact script graph is finalized as part of the 1.7.0 release candidate before publication. Browser downloads intentionally remain outside `prepublishOnly`. ## Contract coverage @@ -44,6 +44,7 @@ Deterministic tests verify: - preserved 1.x selectors, JavaScript entries, stylesheet paths, data hooks, ARIA hooks, and token families - public transition property, duration, easing, and delay tokens +- feedback hooks, tokens, and namespaced keyframes - absence of implicit UI-host lift compensation and cross-package importance - resolvable `state-core.css`, `standalone-preset.css`, and compatibility bundles - generated-root and distribution parity @@ -59,8 +60,9 @@ Playwright verifies: - fine-pointer hover and transient press - exact default, public, active, legacy-fallback, and file-selector transition tuples - keyboard focus visibility -- disabled > busy/loading > transient active > persistent > hover > base precedence +- disabled > busy/loading > feedback > transient active > persistent > hover > base precedence - pressed true and mixed, non-false current, selected, busy, loading, and established active states +- feedback replay, unknown-value behavior, and transform composition - native, ARIA, and class-disabled precedence - static state meaning under reduced motion - forced-colors and higher-contrast affordances diff --git a/wiki/Token-Reference.md b/wiki/Token-Reference.md index bfe651c..1ac9303 100644 --- a/wiki/Token-Reference.md +++ b/wiki/Token-Reference.md @@ -54,6 +54,20 @@ The core's lift fallbacks are neutral. The standalone preset opts into the estab Supplying a public duration or easing value overrides both the default and transient-press fallback. The core owns this mechanics tuple; companion theme packages do not need a transition override. +### Semantic outcome feedback + +| Token | Core fallback | Standalone default | +| ------------------------------------------------ | ------------------------------ | ------------------ | +| `--interactive-surface-feedback-duration` | `240ms` | Core value | +| `--interactive-surface-feedback-easing` | `cubic-bezier(0.2, 0, 0.2, 1)` | Core value | +| `--interactive-surface-feedback-distance` | `3px` | Core value | +| `--interactive-surface-feedback-layer-opacity` | `0.18` | Core value | +| `--interactive-surface-feedback-error-color` | `currentcolor` | `rgb(185 28 28)` | +| `--interactive-surface-feedback-success-color` | `currentcolor` | `rgb(21 128 61)` | +| `--interactive-surface-feedback-attention-color` | `currentcolor` | `rgb(180 83 9)` | + +The application owns outcome detection and attribute cleanup. Scope these tokens on a control or an exact feedback selector to customize a profile without adding generic motion utilities. + ### Persistent-state emphasis - `--interactive-surface-darken-hover`