diff --git a/.github/workflows/browser-tests.yml b/.github/workflows/browser-tests.yml index 742f1e1..53c1189 100644 --- a/.github/workflows/browser-tests.yml +++ b/.github/workflows/browser-tests.yml @@ -20,16 +20,16 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - - name: Use Node.js 24 - uses: actions/setup-node@v5 + - name: Use Node.js 22 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: - node-version: 24 + node-version: 22 cache: npm - name: Install dependencies run: npm ci - name: Run browser validation - run: npm run validate:browsers \ No newline at end of file + run: npm run validate:browsers diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 28b336d..f65d541 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -12,25 +12,35 @@ concurrency: jobs: quality: - name: Package quality checks + name: Package quality checks (Node ${{ matrix.node-version }}) runs-on: ubuntu-latest timeout-minutes: 10 + strategy: + fail-fast: false + # Run the full deterministic release gate on the minimum supported major and the preferred LTS major. + matrix: + include: + - node-version: 20 + validation-script: validate:node20 + - node-version: 22 + validation-script: validate:ci + permissions: contents: read steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - - name: Use Node.js 24 - uses: actions/setup-node@v5 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: - node-version: 24 + node-version: ${{ matrix.node-version }} cache: npm - name: Install dependencies run: npm ci - name: Validate package - run: npm run validate:ci \ No newline at end of file + run: npm run ${{ matrix.validation-script }} diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 072318c..223601d 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -3,7 +3,6 @@ name: Publish to npm on: release: types: [published] - workflow_dispatch: concurrency: group: npm-publish-${{ github.ref }} @@ -21,15 +20,40 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + ref: ${{ github.event.release.tag_name }} - - name: Use Node.js 24 - uses: actions/setup-node@v5 + - name: Use Node.js 22 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: - node-version: 24 + node-version: 22 registry-url: https://registry.npmjs.org/ cache: npm + - name: Validate release metadata + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + node <<'NODE' + const { readFileSync } = require("node:fs"); + + const manifest = JSON.parse(readFileSync("package.json", "utf8")); + const changelog = readFileSync("CHANGELOG.md", "utf8"); + const expectedTag = `v${manifest.version}`; + const escapedVersion = manifest.version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const releaseHeadings = + changelog.match(new RegExp(`^## ${escapedVersion} - \\d{4}-\\d{2}-\\d{2}\\r?$`, "gm")) ?? []; + + if (process.env.RELEASE_TAG !== expectedTag) { + throw new Error(`Release tag ${process.env.RELEASE_TAG} must equal ${expectedTag}.`); + } + + if (releaseHeadings.length !== 1) { + throw new Error(`Expected exactly one dated changelog heading for ${manifest.version}.`); + } + NODE + - name: Install dependencies run: npm ci @@ -39,4 +63,4 @@ jobs: - name: Publish to npm run: npm publish --provenance --access public --ignore-scripts env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} \ No newline at end of file + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/wiki-sync.yml b/.github/workflows/wiki-sync.yml index 296701a..8eee985 100644 --- a/.github/workflows/wiki-sync.yml +++ b/.github/workflows/wiki-sync.yml @@ -16,10 +16,10 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Checkout wiki repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: repository: ${{ github.repository }}.wiki token: ${{ github.token }} diff --git a/.stylelintrc.cjs b/.stylelintrc.cjs index 92879b8..cb53706 100644 --- a/.stylelintrc.cjs +++ b/.stylelintrc.cjs @@ -7,6 +7,6 @@ module.exports = { "color-function-notation": null, "alpha-value-notation": null, "property-no-vendor-prefix": null, - "no-descending-specificity": null - } + "no-descending-specificity": null, + }, }; diff --git a/CHANGELOG.md b/CHANGELOG.md index 21e0333..54a0ef3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,46 @@ All notable changes to this package are documented in this file. ## Unreleased +## 1.4.0 - 2026-07-19 + +### Added + +- Added the state-only `state-core.css` entry point for consumers whose design system already owns paint and geometry. +- Added the complete `standalone-preset.css` entry point while preserving every established 1.x root, CSS, CommonJS, and ESM import. +- Added a state-first interaction lab with live action, toggle, navigation, selection, loading, disabled, variant, level, and icon-control examples. + +### Changed + +- Raised the npm engine contract and release validation matrix to Node.js 20+ with explicit Node.js 20 and Node.js 22 CI lanes. +- Split authored CSS into focused state-core and standalone-preset modules, then generate all public and distribution bundles from those sources. +- Made interaction lift composable with consumer and companion-library transforms by using token-driven individual translation. +- Reorganized the npm README, demo, and wiki around entry-point choice, semantic state recipes, and clear ecosystem ownership. + +### Fixed + +- Fixed native file input selector-button styling by mapping `input[type="file"].interactive-surface::file-selector-button` into the same token, hover, focus, active, and disabled state contract as the host surface. +- Fixed disabled-state precedence so native, ARIA, and class-based disabled surfaces cannot retain active, persistent, or loading feedback. +- Fixed UI Style Kit motion composition across supported bridge modifiers and stylesheet import orders. +- Fixed token-dialog focus containment, focus restoration, and stale status feedback between editing sessions. + +### Accessibility + +- Added semantic coverage for mixed pressed, non-false current, selected, busy, and loading states. +- Preserved static state meaning with reduced motion and strengthened focus and persistent-state affordances in forced-colors and greater-contrast modes. +- Kept hover feedback gated to hover-capable pointers while retaining press, focus, and persistent feedback for coarse-pointer users. + +### Documentation + +- Rebuilt the README as an npm-first guide with durable wiki links, pinned 1.4.0 examples, accessibility responsibilities, and migration-free compatibility guidance. +- Updated the wiki API, tokens, installation, testing, publishing, roadmap, FAQ, and contribution guidance to match the 1.4.0 package contract. +- Linked the Interface Systems Lab as the canonical integrated proof with `ui-style-kit-css` and `layout-style-css`. + +### Testing + +- Added generated-bundle, documentation, public-selector, state-core, and entry-point contract coverage. +- Expanded rendered browser coverage for semantic states, motion preferences, forced colors, pointer capabilities, companion-library integration, and the responsive demo. +- Added actual packed-tarball inspection and temporary-consumer resolution checks for every legacy and new public export. + ## 1.3.0 - 2026-07-08 ### Added diff --git a/README.md b/README.md index 874f026..3798e98 100644 --- a/README.md +++ b/README.md @@ -1,266 +1,214 @@ -# Interactive Surface +# Interactive Surface CSS -Interactive Surface is a framework-agnostic CSS interaction primitive for buttons, cards, icon controls, and similar click targets. +[![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) -It provides consistent hover, focus-visible, active, press, and disabled behavior with token-driven theming, accessibility guardrails, and minimal integration friction. +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. -## Documentation +Version 1.4.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. -Project docs live in this repository: +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. -- [Wiki Home](./wiki/Home.md) -- [Getting Started](./wiki/Getting-Started.md) -- [Installation and Usage](./wiki/Installation-and-Usage.md) -- [API Reference](./wiki/API-Reference.md) -- [Token Reference](./wiki/Token-Reference.md) -- [Accessibility](./wiki/Accessibility.md) -- [Testing and Quality](./wiki/Testing-and-Quality.md) -- [Publishing and Releases](./wiki/Publishing-and-Releases.md) -- [FAQ](./wiki/FAQ.md) -- [Roadmap](./wiki/Roadmap.md) +## Start here -Community and governance docs: +- [Live standalone demo](https://foscat.github.io/Interactive-Surface-CSS/) +- [Interface Systems Lab](https://foscat.github.io/interface-systems-lab/) — integrated proof with all three CSS libraries +- [GitHub Wiki](https://github.com/Foscat/Interactive-Surface-CSS/wiki/Home) +- [Installation guide](https://github.com/Foscat/Interactive-Surface-CSS/wiki/Installation-and-Usage) +- [API reference](https://github.com/Foscat/Interactive-Surface-CSS/wiki/API-Reference) +- [Token reference](https://github.com/Foscat/Interactive-Surface-CSS/wiki/Token-Reference) +- [Accessibility guide](https://github.com/Foscat/Interactive-Surface-CSS/wiki/Accessibility) -- [Contributing](./CONTRIBUTING.md) -- [Code of Conduct](./CODE_OF_CONDUCT.md) -- [Security Policy](./SECURITY.md) +## Ownership -## Package +Each package remains independently useful. Use one library, use two compatible libraries, or use all three according to the layers your application needs. -- Package name: `interactive-surface-css` -- Primary stylesheet: `interactive-surface.css` -- JavaScript entry: `index.js` (imports CSS for bundler-friendly usage) -- Live demo: `https://foscat.github.io/Interactive-Surface-CSS/` +| Library | Owns | Does not own | +| ------------------------- | ------------------------------------------------------------------------------ | ----------------------------------- | +| `interactive-surface-css` | Interaction states, focus visibility, state precedence, and interaction motion | Page layout or an application theme | +| `ui-style-kit-css` | Theme paint, modes, component appearance, and visual tokens | Page layout or interaction behavior | +| `layout-style-css` | Page structure, layout recipes, and geometry | Theme paint or interaction behavior | -Install: +## 60-second standalone setup + +Install the package: ```bash npm install interactive-surface-css ``` -Import: +Import the portable, direct CSS preset. It includes the neutral standalone presentation and the complete state layer: ```js -import "interactive-surface-css"; +import "interactive-surface-css/standalone-preset.css"; ``` -Or import CSS directly: +Add the base class to a native control: -```js -import "interactive-surface-css/interactive-surface.css"; +```html + ``` -Note: The JavaScript entry imports CSS, so it should be used in bundlers or toolchains that support CSS imports. If you want the most portable, framework-agnostic path, import `interactive-surface-css/interactive-surface.css` directly. The package supports both approaches to accommodate different project setups and preferences. - -Webpack: - -1. Install loaders: - - ```bash - npm install -D css-loader style-loader - ``` - -2. Configure `webpack.config.js`: - - ```js - module.exports = { - module: { - rules: [ - { - test: /\.css$/i, - use: ["style-loader", "css-loader"] - } - ] - } - }; - ``` - -3. Import in your app entry: - - ```js - import "interactive-surface-css"; - ``` - -CDN: +For a no-build page, pin the release: ```html - - + ``` -## Quick Start +The equivalent unpkg URL is `https://unpkg.com/interactive-surface-css@1.4.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. -```html - -``` +## Semantic recipes -```html - -``` +Prefer native elements, then reflect persistent application state through ARIA: ```html - -``` -```html - -``` -Live demo: [Interactive Surface Demo](https://foscat.github.io/Interactive-Surface-CSS/) + +Account -The demo page is a practical customization playground for this library: - -- It provides guided token editing controls instead of freehand CSS typing. -- It supports importing and exporting token CSS so teams can reuse exact values. -- It helps reduce manual entry mistakes when creating app-level theme overrides. - -## Class API - -Base: - -- `.interactive-surface` + + -Size modifiers: + + -- `.size-sm` -- `.size-lg` -- medium is default when no size class is set + + -State helpers: + + -- `.is-active` -- `.is-disabled` + + -Semantic states: + + +``` -- `[aria-pressed="true"]` -- `[aria-disabled="true"]` -- `:disabled` +Native `disabled` is preferred because the browser suppresses focus and activation. CSS can only communicate disabled-looking state: consumers must suppress activation for `aria-disabled="true"` and `.is-disabled` controls in their event handling. -Visual variants: +## Entry points -- `.variant-primary` -- `.variant-secondary` -- `.variant-accent` -- `.variant-subtle` -- `.variant-warning` -- `.variant-danger` +| Import | Presentation | Best for | +| ----------------------------------------------------------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| `import "interactive-surface-css/standalone-preset.css";` | State core plus neutral paint, variants, levels, icon sizing, and preset token defaults | New standalone integrations | +| `import "interactive-surface-css/state-core.css";` | Interaction mechanics and neutral core token fallbacks only | Existing design systems that already own paint and geometry | +| `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 | -Data attribute aliases: +`standalone-preset.css` and `interactive-surface.css` are generated from the same authored modules and are behaviorally equivalent in 1.4.0. The compatibility paths remain stable; no 1.x migration is required. -- `data-surface-variant="primary|secondary|accent|subtle|warning|danger"` -- `data-surface-level="1|2|3"` +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. -Use the class API when you prefer compact standalone markup. Use the data attributes when another library or runtime assigns semantic surface hooks. +## Compact API -Icon pattern: +| Kind | Public hooks | +| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Base | `.interactive-surface` | +| Size | `.size-sm`, default medium, `.size-lg` | +| 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` | +| 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 | -- `.icon-only` +Disabled state has highest visual precedence. 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. -## Token Contract +For selector details and responsibilities, see the [complete API reference](https://github.com/Foscat/Interactive-Surface-CSS/wiki/API-Reference). -Preferred token namespace: +## Customize tokens -- `--interactive-surface-*` +`state-core.css` provides neutral defaults for state-layer color and opacity, focus rings, motion, persistent states, disabled state, and loading state. `standalone-preset.css` additionally supplies base paint, lift and shadows, variants, surface levels, icon roles, and the 44 × 44px icon-control geometry. -The package also supports legacy fallback tokens and semantic fallback tokens. Full details and examples are in [Token Reference](./wiki/Token-Reference.md). +```css +.save-action { + --interactive-surface-focus-ring-color: rgb(0 95 115); + --interactive-surface-state-layer-color: rgb(0 45 55); + --interactive-surface-state-layer-hover-opacity: 0.1; + --interactive-surface-motion-default: 120ms; +} +``` -## Ecosystem Integration +All public custom properties use the `--interactive-surface-*` namespace. Established legacy and semantic fallback tokens remain supported. See the [token ownership tables and full reference](https://github.com/Foscat/Interactive-Surface-CSS/wiki/Token-Reference). -The three CSS libraries have separate ownership boundaries: +## Accessibility responsibilities -- `layout-style-css` owns structural layout recipes and page geometry. -- `ui-style-kit-css` owns visual themes, style systems, and mode-aware paint. -- `interactive-surface-css` owns interaction behavior on `.interactive-surface`. +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. -Use one, two, or all three packages based on the layer you need. Interactive Surface does not require either companion library. +Applications still own semantics and behavior: -## Integration with UI Style Kit CSS +- Use ` - - Tokens are shown with live values and color codes. - + - - - - -
-

Token map: color codes and vars

-

- These are the same variables consumed by interactive-surface.css. Each card - includes the variable and its current value in this demo theme. -

-
-
-
-
-

--interactive-surface-bg

- -
-

+ +
+
+

One system, clear boundaries

+

Ecosystem ownership

+

+ Each package remains useful on its own while owning one distinct + part of the interface contract. +

+
+
+
+

Layout Style CSS

+

structure and geometry

+

+ Responsive composition, spacing relationships, and page-level + layout. +

-
-
-
-

--interactive-surface-fg

- -
-

+
+

UI Style Kit CSS

+

visual paint and themes

+

Color, typography, borders, elevation, and theme expression.

-
-
-
-

--interactive-surface-border-color

- -
-

+
+

Interactive Surface CSS

+

interaction states

+

+ Focus, hover, active, pressed, selected, busy, and disabled + behavior. +

-
-
-
-

--interactive-surface-focus-ring-color

- -
-

+
+
+ + +
+
+

Live semantic proof

+

Interaction state lab

+

+ Use the controls, then inspect their native attributes. No + pseudo-class is faked. +

+
+ +
+
+

Action

+ + <button> +
+ +
+

Toggle

+ + aria-pressed
-
-
-
-

--accent-main

+ + + +
+

Selected

+
-
-

-
-
-
-
-

--accent-soft

-

+

+ Resting view +

+
-
-
- - .my-control { --interactive-surface-bg:rgb(15 79 127); --interactive-surface-fg:rgb(244 251 255); - --interactive-surface-focus-ring-color:rgb(30 91 240); } - -
-
- - - Editing Light theme token overrides. -
-
- -
-

Buttons and states

-

Hover, focus, active, pressed, and disabled are all handled by one class.

-
- - - - - - - - - -
-
- - -
-

Card surfaces

-

The same primitive works for card-like affordances with no extra runtime logic.

-
- - - - -
-
- - -
-

Icon micro controls

-

Role-based icon colors can change by mode while keeping a consistent hit target.

-
-
- -

light-icon: var(--interactive-surface-light-icon-color)

+ aria-busy
-
- -

dark-icon: var(--interactive-surface-dark-icon-color)

+ disabled
-
- -

accessibility-icon: var(--interactive-surface-accessibility-icon-color)

+ .variant-primary
-
- -

icon-only keeps 44px minimum target

+ data-surface-level="2"
-
- -

disabled + aria-disabled support

+ aria-label + icon-only
- -
-

Accessibility highlights

-

- Side-by-side comparison: left inherits the full interaction/accessibility model; right is a - plain control baseline. -

-
-
-

With interactive-surface

-
- - - -
-
    -
  • Focus ring is visible with :focus-visible fallback.
  • -
  • Pressed and disabled states map directly to ARIA attributes.
  • -
  • Reduced motion and forced colors are supported by default.
  • -
+ +
+
+

Built on browser semantics

+

Accessibility guidance

+
+
+
+

Start with native controls

+

+ Use buttons for actions and anchors for navigation so keyboard and + assistive technology behavior is preserved. +

-
-

Plain controls baseline

-
- - - -
-
    -
  • No unified hover/active model.
  • -
  • No tokenized theming contract.
  • -
  • Requires extra CSS to reach parity with contrast and motion handling.
  • -
+
+

Expose persistent state

+

+ Pair visual treatment with aria-pressed, + aria-selected, aria-current, or + aria-busy. +

+
+
+

Keep focus reliable

+

+ Disabled controls stay non-interactive, modal focus stays + contained, and closing restores the initiating control. +

- -
-

Creative composition ideas

-

Use the same primitive for launcher bars, filters, and dashboard cards.

-
-
-

Command launcher

-
- -
+ +
+
+

Choose the right ownership boundary

+

Entry points

+
+
+
+

Standalone preset

+

+ Complete paint, geometry, and interaction behavior for a + framework-agnostic page. +

+
@import "interactive-surface-css/standalone-preset.css";
+
+
+

State core

+

+ Interaction mechanics only when a host design system already owns + presentation. +

+
@import "interactive-surface-css/state-core.css";
+
+
+

Compatibility bundle

+

The established package entry point for current consumers.

+
@import "interactive-surface-css/interactive-surface.css";
-
-

Filter chip row

-
- - - +
+
+ + +
+
+

Advanced configuration

+

Token tools

+

+ Edit a value, import a small CSS override, or export the current + overrides for further testing. +

+
+ +
+
+
+

Surface background

+ --interactive-surface-bg
+

rgb(255 255 255)

+
-
-

Playlist card

-
+ +
+ + + +
- -
-

README and wiki links

-

- This section mirrors README content so GitHub Pages users can test the library and read docs without - switching pages. -

-
- - -
-

Loading README.md...

-
+ +
+
+

Reference

+

README reference

+

+ The repository README renders here with an embedded fallback for + local and offline use. +

+ +
+

Preparing the embedded README reference…

+
- + +
+ + + +
+
+

Advanced token override

+

Edit token

+
+ + +
+ + +
+
+
+ - - - + + - - diff --git a/interactive-surface-css-1.3.0.tgz b/interactive-surface-css-1.3.0.tgz new file mode 100644 index 0000000..bd1d4d0 Binary files /dev/null and b/interactive-surface-css-1.3.0.tgz differ diff --git a/interactive-surface.css b/interactive-surface.css index 0133725..add355d 100644 --- a/interactive-surface.css +++ b/interactive-surface.css @@ -1,87 +1,39 @@ -/* +/*! * interactive-surface.css - * - * Purpose: - * - Provide a standalone interaction primitive for clickable surfaces. - * - Keep behavior consistent across buttons, cards, and icon controls. - * - Allow zero-config usage with optional token-driven theming. - * - * State model: - * - base -> hover/focus-visible -> active -> press -> disabled + * Generated from: styles/standalone-preset.css, styles/state-core.css + * Do not edit directly; run node scripts/build.mjs. */ /* - * 1) Token contract - * - * Preferred token namespace: - * - --interactive-surface-* - * - * Legacy fallback tokens are still supported: - * - --lift-*, --shadow-*, --surface-darken-*, --motion-*, --ease-* - * - * Implementation note: - * - No global :root token defaults are set in this file. - * - Defaults are resolved inline to avoid import-order conflicts. + * Standalone paint and geometry for interactive surfaces. + * The generated preset appends state-core.css to this authored module. */ -/* 2) Base primitive and token resolution */ - -.interactive-surface { - /* Internal resolved tokens (public + legacy + hard fallback). */ - --_is-lift-base: var(--interactive-surface-lift-base, var(--lift-base, 0px)); - --_is-lift-hover: var(--interactive-surface-lift-hover, var(--lift-hover, -4px)); - --_is-lift-active: var(--interactive-surface-lift-active, var(--lift-active, -2px)); - - --_is-base-shadow: var(--interactive-surface-shadow-base, var(--shadow-base, 0 0 0 rgba(0, 0, 0, 0))); - --_is-shadow-base: var(--_is-base-shadow); - --_is-shadow-hover: var( - --interactive-surface-shadow-hover, - var(--shadow-hover, 0 10px 28px rgba(0, 0, 0, 0.35), 0 2px 8px rgba(0, 0, 0, 0.25)) - ); - --_is-shadow-active: var( - --interactive-surface-shadow-active, - var(--shadow-active, 0 6px 18px rgba(0, 0, 0, 0.3), 0 1px 4px rgba(0, 0, 0, 0.2)) - ); - - /* - * Legacy brightness tokens are preserved, but state rendering now uses - * a ::before state-layer pseudo-element instead of host-level filters so - * text and icons are less likely to lose contrast in consumer themes. - * The state layer is decoupled from box-shadow so shadow tokens set to - * `none` (e.g. in prefers-contrast: more) cannot invalidate the - * box-shadow declaration. - */ - --_is-darken-hover: var(--interactive-surface-darken-hover, var(--surface-darken-hover, 0.96)); - --_is-darken-active: var(--interactive-surface-darken-active, var(--surface-darken-active, 0.98)); - --_is-state-layer-base-opacity: var(--interactive-surface-state-layer-opacity, 0); - --_is-state-layer-hover-opacity: var( - --interactive-surface-state-layer-hover-opacity, - var(--interactive-surface-state-layer-opacity-hover, calc(1 - var(--_is-darken-hover))) +:is(.interactive-surface) { + /* Complete bundles retain the established lift and shadow fallbacks without imposing them on the core. */ + --_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-hover: var( + --shadow-hover, + 0 10px 28px rgb(0 0 0 / 0.35), + 0 2px 8px rgb(0 0 0 / 0.25) ); - --_is-state-layer-active-opacity: var( - --interactive-surface-state-layer-active-opacity, - var(--interactive-surface-state-layer-opacity-active, calc(1 - var(--_is-darken-active))) + --_is-preset-shadow-active: var( + --shadow-active, + 0 6px 18px rgb(0 0 0 / 0.3), + 0 1px 4px rgb(0 0 0 / 0.2) ); - --_is-state-layer-focus-opacity: var( - --interactive-surface-state-layer-focus-opacity, - var(--interactive-surface-state-layer-opacity-focus, var(--_is-state-layer-hover-opacity)) + --_is-base-bg: var( + --interactive-surface-bg, + var(--surface-bg, var(--bg-surface, rgb(248 250 252))) ); - --_is-state-layer-color: var(--interactive-surface-state-layer-color, rgb(0 0 0)); - - --_is-motion-default: var(--interactive-surface-motion-default, var(--motion-default, 140ms)); - --_is-motion-press: var(--interactive-surface-motion-press, var(--motion-press, 60ms)); - --_is-ease-standard: var( - --interactive-surface-ease-standard, - var(--ease-standard, cubic-bezier(0.2, 0, 0.2, 1)) - ); - --_is-ease-press: var( - --interactive-surface-ease-press, - var(--ease-press, cubic-bezier(0.4, 0, 0.6, 1)) - ); - - --_is-base-bg: var(--interactive-surface-bg, 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)))); + --_is-fg: var( + --interactive-surface-fg, + 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))) @@ -90,99 +42,139 @@ --_is-border-width: var(--interactive-surface-border-width, 1px); --_is-radius: var(--interactive-surface-radius, 0.75rem); - --_is-focus-ring-color: var(--interactive-surface-focus-ring-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); - --_is-disabled-opacity: var(--interactive-surface-disabled-opacity, 0.72); - --_is-tap-highlight: var(--interactive-surface-tap-highlight-color, rgb(11 99 246 / 0.18)); - - position: relative; - isolation: isolate; box-sizing: border-box; border: var(--_is-border-width) solid var(--_is-border-color); border-radius: var(--_is-radius); background-color: var(--_is-bg); color: var(--_is-fg); - cursor: pointer; text-decoration: none; font: inherit; -webkit-appearance: none; appearance: none; - touch-action: manipulation; - -webkit-tap-highlight-color: var(--_is-tap-highlight); background-clip: padding-box; - transform: translateY(var(--_is-lift-base)) translateZ(0) !important; - box-shadow: var(--_is-shadow-base); - transition: - transform var(--_is-motion-default) var(--_is-ease-standard), - box-shadow var(--_is-motion-default) var(--_is-ease-standard), - background-color var(--_is-motion-default) linear, - border-color var(--_is-motion-default) linear, - color var(--_is-motion-default) linear, - outline-color var(--_is-motion-default) linear; - will-change: auto; } /* - * UI Style Kit 2.x exposes surface depth through bridge level tokens. - * The attribute gates keep default surfaces on the existing base contract while - * letting bridge or standalone markup opt into semantic depth. + * UI Style Kit 2.0.1 contributes one pixel of transform-based hover lift and owns paint + * transitions. The preset offsets the resolved lift and protects the complete transition tuple. */ +:where([data-ui][data-theme][data-mode]) .interactive-surface { + --_is-preset-hover-lift-offset: 1px; + + /* stylelint-disable declaration-block-no-redundant-longhand-properties -- Each longhand must survive companion shorthands in either import order. */ + transition-property: + background-color, border-color, box-shadow, color, transform, translate, + outline-color !important; + transition-duration: var(--_is-transition-duration) !important; + transition-timing-function: var(--_is-transition-timing-function) !important; + transition-delay: var(--_is-transition-delay) !important; + /* stylelint-enable declaration-block-no-redundant-longhand-properties */ +} + +/* Surface levels consume bridge tokens while preserving standalone fallbacks. */ .interactive-surface[data-surface-level] { --_is-bg: var(--interactive-surface-level-bg, var(--_is-base-bg)); - --_is-border-color: var(--interactive-surface-level-border-color, var(--_is-base-border-color)); - --_is-shadow-base: var(--interactive-surface-level-shadow, var(--_is-base-shadow)); + --_is-border-color: var( + --interactive-surface-level-border-color, + var(--_is-base-border-color) + ); + --_is-shadow-base: var( + --interactive-surface-level-shadow, + var(--_is-base-shadow) + ); } .interactive-surface[data-surface-level="1"] { - --_is-bg: var(--interactive-surface-level-bg, var(--interactive-surface-level-1-bg, var(--_is-base-bg))); - --_is-border-color: var(--interactive-surface-level-border-color, var(--interactive-surface-level-1-border-color, var(--_is-base-border-color))); - --_is-shadow-base: var(--interactive-surface-level-shadow, var(--interactive-surface-level-1-shadow, var(--_is-base-shadow))); - --_is-state-layer-hover-opacity: var(--interactive-surface-state-layer-hover-opacity, var(--interactive-surface-level-1-hover-opacity, 0.08)); - --_is-state-layer-active-opacity: var(--interactive-surface-state-layer-active-opacity, var(--interactive-surface-level-1-active-opacity, 0.14)); - --_is-state-layer-focus-opacity: var(--interactive-surface-state-layer-focus-opacity, var(--interactive-surface-level-1-focus-opacity, 0.18)); + --_is-bg: var( + --interactive-surface-level-bg, + var(--interactive-surface-level-1-bg, var(--_is-base-bg)) + ); + --_is-border-color: var( + --interactive-surface-level-border-color, + var( + --interactive-surface-level-1-border-color, + var(--_is-base-border-color) + ) + ); + --_is-shadow-base: var( + --interactive-surface-level-shadow, + var(--interactive-surface-level-1-shadow, var(--_is-base-shadow)) + ); + --_is-state-layer-hover-opacity: var( + --interactive-surface-state-layer-hover-opacity, + var(--interactive-surface-level-1-hover-opacity, 0.08) + ); + --_is-state-layer-active-opacity: var( + --interactive-surface-state-layer-active-opacity, + var(--interactive-surface-level-1-active-opacity, 0.14) + ); + --_is-state-layer-focus-opacity: var( + --interactive-surface-state-layer-focus-opacity, + var(--interactive-surface-level-1-focus-opacity, 0.18) + ); } .interactive-surface[data-surface-level="2"] { - --_is-bg: var(--interactive-surface-level-bg, var(--interactive-surface-level-2-bg, var(--_is-base-bg))); - --_is-border-color: var(--interactive-surface-level-border-color, var(--interactive-surface-level-2-border-color, var(--_is-base-border-color))); - --_is-shadow-base: var(--interactive-surface-level-shadow, var( - --interactive-surface-level-2-shadow, - 0 8px 20px rgb(0 0 0 / 0.22) - )); - --_is-state-layer-hover-opacity: var(--interactive-surface-state-layer-hover-opacity, var(--interactive-surface-level-2-hover-opacity, 0.11)); - --_is-state-layer-active-opacity: var(--interactive-surface-state-layer-active-opacity, var(--interactive-surface-level-2-active-opacity, 0.18)); - --_is-state-layer-focus-opacity: var(--interactive-surface-state-layer-focus-opacity, var(--interactive-surface-level-2-focus-opacity, 0.22)); + --_is-bg: var( + --interactive-surface-level-bg, + var(--interactive-surface-level-2-bg, var(--_is-base-bg)) + ); + --_is-border-color: var( + --interactive-surface-level-border-color, + var( + --interactive-surface-level-2-border-color, + var(--_is-base-border-color) + ) + ); + --_is-shadow-base: var( + --interactive-surface-level-shadow, + var(--interactive-surface-level-2-shadow, 0 8px 20px rgb(0 0 0 / 0.22)) + ); + --_is-state-layer-hover-opacity: var( + --interactive-surface-state-layer-hover-opacity, + var(--interactive-surface-level-2-hover-opacity, 0.11) + ); + --_is-state-layer-active-opacity: var( + --interactive-surface-state-layer-active-opacity, + var(--interactive-surface-level-2-active-opacity, 0.18) + ); + --_is-state-layer-focus-opacity: var( + --interactive-surface-state-layer-focus-opacity, + var(--interactive-surface-level-2-focus-opacity, 0.22) + ); } .interactive-surface[data-surface-level="3"] { - --_is-bg: var(--interactive-surface-level-bg, var(--interactive-surface-level-3-bg, var(--_is-base-bg))); - --_is-border-color: var(--interactive-surface-level-border-color, var(--interactive-surface-level-3-border-color, var(--_is-base-border-color))); - --_is-shadow-base: var(--interactive-surface-level-shadow, var( - --interactive-surface-level-3-shadow, - 0 14px 34px rgb(0 0 0 / 0.3) - )); - --_is-state-layer-hover-opacity: var(--interactive-surface-state-layer-hover-opacity, var(--interactive-surface-level-3-hover-opacity, 0.14)); - --_is-state-layer-active-opacity: var(--interactive-surface-state-layer-active-opacity, var(--interactive-surface-level-3-active-opacity, 0.24)); - --_is-state-layer-focus-opacity: var(--interactive-surface-state-layer-focus-opacity, var(--interactive-surface-level-3-focus-opacity, 0.28)); -} - -/* 2a) State-layer pseudo-element */ - -.interactive-surface::before { - content: ""; - position: absolute; - inset: 0; - border-radius: inherit; - background-color: var(--_is-state-layer-color); - opacity: var(--_is-state-layer-base-opacity); - pointer-events: none; - z-index: -1; - transition: opacity var(--_is-motion-default) var(--_is-ease-standard); + --_is-bg: var( + --interactive-surface-level-bg, + var(--interactive-surface-level-3-bg, var(--_is-base-bg)) + ); + --_is-border-color: var( + --interactive-surface-level-border-color, + var( + --interactive-surface-level-3-border-color, + var(--_is-base-border-color) + ) + ); + --_is-shadow-base: var( + --interactive-surface-level-shadow, + var(--interactive-surface-level-3-shadow, 0 14px 34px rgb(0 0 0 / 0.3)) + ); + --_is-state-layer-hover-opacity: var( + --interactive-surface-state-layer-hover-opacity, + var(--interactive-surface-level-3-hover-opacity, 0.14) + ); + --_is-state-layer-active-opacity: var( + --interactive-surface-state-layer-active-opacity, + var(--interactive-surface-level-3-active-opacity, 0.24) + ); + --_is-state-layer-focus-opacity: var( + --interactive-surface-state-layer-focus-opacity, + var(--interactive-surface-level-3-focus-opacity, 0.28) + ); } -/* 2b) UI Style Kit compatibility ownership */ - +/* UI Style Kit hosts retain explicit ownership-compatible paint declarations. */ [data-ui] .interactive-surface { border: var(--_is-border-width) solid var(--_is-border-color); border-radius: var(--_is-radius); @@ -191,96 +183,38 @@ font: inherit; } -/* 3) Keyboard focus visibility */ - -.interactive-surface:focus { - outline: var(--_is-focus-ring-width) solid var(--_is-focus-ring-color); - outline-offset: var(--_is-focus-ring-offset); -} - -@supports selector(:focus-visible) { - .interactive-surface:focus { - outline: none; - } -} - -.interactive-surface:not(.is-disabled, :disabled, [aria-disabled="true"]):focus-visible { - transform: translateY(var(--_is-lift-hover)) translateZ(0) !important; - box-shadow: var(--_is-shadow-hover); - outline: var(--_is-focus-ring-width) solid var(--_is-focus-ring-color); - outline-offset: var(--_is-focus-ring-offset); -} - -.interactive-surface:not(.is-disabled, :disabled, [aria-disabled="true"]):focus-visible::before { - opacity: var(--_is-state-layer-focus-opacity); -} - -.interactive-surface[aria-disabled="true"]:focus-visible { - outline: var(--_is-focus-ring-width) solid var(--_is-focus-ring-color); - outline-offset: var(--_is-focus-ring-offset); -} - -/* 4) Active and toggled states */ - -.interactive-surface.is-active:not(.is-disabled, [aria-disabled="true"]), -.interactive-surface[aria-pressed="true"]:not(.is-disabled, :disabled, [aria-disabled="true"]), -.interactive-surface[aria-current="page"]:not(.is-disabled, :disabled, [aria-disabled="true"]) { - transform: translateY(var(--_is-lift-active)) translateZ(0) !important; - box-shadow: var(--_is-shadow-active); -} - -.interactive-surface.is-active:not(.is-disabled, [aria-disabled="true"])::before, -.interactive-surface[aria-pressed="true"]:not(.is-disabled, :disabled, [aria-disabled="true"])::before, -.interactive-surface[aria-current="page"]:not(.is-disabled, :disabled, [aria-disabled="true"])::before { - opacity: var(--_is-state-layer-active-opacity); -} - -/* 5) Pointer hover and press feedback */ - -@media (hover: hover) and (pointer: fine) { - .interactive-surface { - will-change: transform, box-shadow; - } - - .interactive-surface:not(.is-disabled, :disabled, [aria-disabled="true"]):hover { - transform: translateY(var(--_is-lift-hover)) translateZ(0) !important; - box-shadow: var(--_is-shadow-hover); - } - - .interactive-surface:not(.is-disabled, :disabled, [aria-disabled="true"]):hover::before { - opacity: var(--_is-state-layer-hover-opacity); - } -} - -.interactive-surface:not(.is-disabled, :disabled, [aria-disabled="true"]):active { - transform: translateY(var(--_is-lift-base)) translateZ(0) !important; - box-shadow: var(--_is-shadow-base); - transition-duration: var(--_is-motion-press); - transition-timing-function: var(--_is-ease-press); +/* File inputs have a native selector button; the preset maps it into the same surface token contract. */ +:is(input[type="file"].interactive-surface) { + min-height: 2.75rem; } -.interactive-surface:not(.is-disabled, :disabled, [aria-disabled="true"]):active::before { - opacity: 0; +:is(input[type="file"].interactive-surface)::file-selector-button { + margin-inline-end: 0.75rem; + padding: 0.55rem 0.85rem; + border: var(--_is-border-width) solid var(--_is-border-color); + border-radius: calc(var(--_is-radius) * 0.75); + background-color: var(--_is-bg); + color: var(--_is-fg); + font: inherit; + font-weight: 700; } -/* 6) Size modifiers */ - .interactive-surface.size-sm { --interactive-surface-lift-hover: -4px; --interactive-surface-lift-active: -2px; - --interactive-surface-shadow-hover: 0 6px 16px rgba(0, 0, 0, 0.28), 0 1px 4px rgba(0, 0, 0, 0.2); + --interactive-surface-shadow-hover: + 0 6px 16px rgba(0, 0, 0, 0.28), 0 1px 4px rgba(0, 0, 0, 0.2); --interactive-surface-shadow-active: 0 4px 10px rgba(0, 0, 0, 0.25); } .interactive-surface.size-lg { --interactive-surface-lift-hover: -8px; --interactive-surface-lift-active: -4px; - --interactive-surface-shadow-hover: 0 14px 36px rgba(0, 0, 0, 0.38), 0 3px 10px rgba(0, 0, 0, 0.3); + --interactive-surface-shadow-hover: + 0 14px 36px rgba(0, 0, 0, 0.38), 0 3px 10px rgba(0, 0, 0, 0.3); --interactive-surface-shadow-active: 0 8px 22px rgba(0, 0, 0, 0.32); } -/* 7) Icon-only modifier */ - .interactive-surface.icon-only { --interactive-surface-lift-hover: -3px; --interactive-surface-lift-active: -1.5px; @@ -296,58 +230,128 @@ border-radius: 0.5rem; } -/* Optional icon-role hooks for theme toggles and accessibility controls. */ +/* Icon-role hooks provide useful standalone defaults while remaining token-driven. */ .interactive-surface.icon-only .light-icon, .interactive-surface.icon-only [data-icon-role="light"] { - color: var(--interactive-surface-light-icon-color, var(--icon-light, rgb(212 175 55))); + color: var( + --interactive-surface-light-icon-color, + var(--icon-light, rgb(212 175 55)) + ); } .interactive-surface.icon-only .dark-icon, .interactive-surface.icon-only [data-icon-role="dark"] { - color: var(--interactive-surface-dark-icon-color, var(--icon-dark, rgb(0 0 0))); + color: var( + --interactive-surface-dark-icon-color, + var(--icon-dark, rgb(0 0 0)) + ); } .interactive-surface.icon-only .accessibility-icon, .interactive-surface.icon-only [data-icon-role="accessibility"] { - color: var(--interactive-surface-accessibility-icon-color, var(--icon-accessibility, rgb(59 130 246))); + color: var( + --interactive-surface-accessibility-icon-color, + var(--icon-accessibility, rgb(59 130 246)) + ); border-radius: 0; } @media (prefers-color-scheme: dark) { .interactive-surface.icon-only .light-icon, .interactive-surface.icon-only [data-icon-role="light"] { - color: var(--interactive-surface-light-icon-color-dark, var(--icon-light-dark, rgb(255 255 255))); + color: var( + --interactive-surface-light-icon-color-dark, + var(--icon-light-dark, rgb(255 255 255)) + ); } .interactive-surface.icon-only .dark-icon, .interactive-surface.icon-only [data-icon-role="dark"] { - color: var(--interactive-surface-dark-icon-color-dark, var(--icon-dark-dark, rgb(30 58 138))); + color: var( + --interactive-surface-dark-icon-color-dark, + var(--icon-dark-dark, rgb(30 58 138)) + ); } .interactive-surface.icon-only .accessibility-icon, .interactive-surface.icon-only [data-icon-role="accessibility"] { - color: var(--interactive-surface-accessibility-icon-color-dark, var(--icon-accessibility-dark, rgb(156 163 175))); + color: var( + --interactive-surface-accessibility-icon-color-dark, + var(--icon-accessibility-dark, rgb(156 163 175)) + ); } } -:where([data-theme="dark"], [data-mode="dark"], [data-mode="contrast"], .theme-dark, .dark-mode, .dark) .interactive-surface.icon-only .light-icon, -:where([data-theme="dark"], [data-mode="dark"], [data-mode="contrast"], .theme-dark, .dark-mode, .dark) +:where( + [data-theme="dark"], + [data-mode="dark"], + [data-mode="contrast"], + .theme-dark, + .dark-mode, + .dark + ) + .interactive-surface.icon-only + .light-icon, +:where( + [data-theme="dark"], + [data-mode="dark"], + [data-mode="contrast"], + .theme-dark, + .dark-mode, + .dark + ) .interactive-surface.icon-only [data-icon-role="light"] { - color: var(--interactive-surface-light-icon-color-dark, var(--icon-light-dark, rgb(255 255 255))); + color: var( + --interactive-surface-light-icon-color-dark, + var(--icon-light-dark, rgb(255 255 255)) + ); } -:where([data-theme="dark"], [data-mode="dark"], [data-mode="contrast"], .theme-dark, .dark-mode, .dark) .interactive-surface.icon-only .dark-icon, -:where([data-theme="dark"], [data-mode="dark"], [data-mode="contrast"], .theme-dark, .dark-mode, .dark) +:where( + [data-theme="dark"], + [data-mode="dark"], + [data-mode="contrast"], + .theme-dark, + .dark-mode, + .dark + ) + .interactive-surface.icon-only + .dark-icon, +:where( + [data-theme="dark"], + [data-mode="dark"], + [data-mode="contrast"], + .theme-dark, + .dark-mode, + .dark + ) .interactive-surface.icon-only [data-icon-role="dark"] { - color: var(--interactive-surface-dark-icon-color-dark, var(--icon-dark-dark, rgb(30 58 138))); + color: var( + --interactive-surface-dark-icon-color-dark, + var(--icon-dark-dark, rgb(30 58 138)) + ); } -:where([data-theme="dark"], [data-mode="dark"], [data-mode="contrast"], .theme-dark, .dark-mode, .dark) +:where( + [data-theme="dark"], + [data-mode="dark"], + [data-mode="contrast"], + .theme-dark, + .dark-mode, + .dark + ) .interactive-surface.icon-only .accessibility-icon, -:where([data-theme="dark"], [data-mode="dark"], [data-mode="contrast"], .theme-dark, .dark-mode, .dark) +:where( + [data-theme="dark"], + [data-mode="dark"], + [data-mode="contrast"], + .theme-dark, + .dark-mode, + .dark + ) .interactive-surface.icon-only [data-icon-role="accessibility"] { color: var( @@ -356,8 +360,6 @@ ); } -/* 8) Visual variant token modifiers */ - .interactive-surface.variant-primary, .interactive-surface[data-surface-variant="primary"] { --interactive-surface-bg: var( @@ -466,89 +468,412 @@ --interactive-surface-darken-active: 0.94; } -/* 9) Disabled states */ +/* System paint remains part of the standalone preset while core owns forced-color interaction mechanics. */ +@media (forced-colors: active) { + :is(.interactive-surface) { + background-color: ButtonFace; + color: ButtonText; + border-color: ButtonText; + } + + :is(.interactive-surface).is-disabled, + :is(.interactive-surface):disabled, + :is(.interactive-surface)[aria-disabled="true"] { + color: GrayText; + border-color: GrayText; + } + + :is(.interactive-surface[type="file"])::file-selector-button { + border-color: ButtonText; + background-color: ButtonFace; + color: ButtonText; + } + + :is(.interactive-surface[type="file"]):is( + .is-disabled, + :disabled, + [aria-disabled="true"] + )::file-selector-button { + border-color: GrayText; + color: GrayText; + } +} + +/* + * Interaction mechanics for interactive surfaces. + * Standalone paint and component geometry belong to the companion preset. + */ + +.interactive-surface { + /* Preset-only fallbacks keep the core neutral while preserving complete-bundle behavior. */ + --_is-lift-base: var( + --interactive-surface-lift-base, + var(--_is-preset-lift-base, 0px) + ); + --_is-lift-hover: var( + --interactive-surface-lift-hover, + var(--_is-preset-lift-hover, 0px) + ); + --_is-hover-lift: calc( + var(--_is-lift-hover) + var(--_is-preset-hover-lift-offset, 0px) + ); + --_is-lift-active: var( + --interactive-surface-lift-active, + var(--_is-preset-lift-active, 0px) + ); + --_is-base-shadow: var( + --interactive-surface-shadow-base, + var(--interactive-surface-level-shadow, var(--_is-preset-shadow-base, none)) + ); + --_is-shadow-base: var(--_is-base-shadow); + --_is-shadow-hover: var( + --interactive-surface-shadow-hover, + var(--_is-preset-shadow-hover, var(--_is-base-shadow)) + ); + --_is-shadow-active: var( + --interactive-surface-shadow-active, + var(--_is-preset-shadow-active, var(--_is-base-shadow)) + ); + --_is-darken-hover: var( + --interactive-surface-darken-hover, + var(--surface-darken-hover, 0.96) + ); + --_is-darken-active: var( + --interactive-surface-darken-active, + var(--surface-darken-active, 0.98) + ); + --_is-state-layer-base-opacity: var( + --interactive-surface-state-layer-opacity, + 0 + ); + --_is-state-layer-hover-opacity: var( + --interactive-surface-state-layer-hover-opacity, + var( + --interactive-surface-state-layer-opacity-hover, + calc(1 - var(--_is-darken-hover)) + ) + ); + --_is-state-layer-active-opacity: var( + --interactive-surface-state-layer-active-opacity, + var( + --interactive-surface-state-layer-opacity-active, + calc(1 - var(--_is-darken-active)) + ) + ); + --_is-state-layer-focus-opacity: var( + --interactive-surface-state-layer-focus-opacity, + var( + --interactive-surface-state-layer-opacity-focus, + var(--_is-state-layer-hover-opacity) + ) + ); + --_is-state-layer-color: var( + --interactive-surface-state-layer-color, + rgb(0 0 0) + ); + --_is-motion-default: var( + --interactive-surface-motion-default, + var(--motion-default, 140ms) + ); + --_is-motion-press: var( + --interactive-surface-motion-press, + var(--motion-press, 60ms) + ); + --_is-ease-standard: var( + --interactive-surface-ease-standard, + var(--ease-standard, cubic-bezier(0.2, 0, 0.2, 1)) + ); + --_is-ease-press: var( + --interactive-surface-ease-press, + var(--ease-press, cubic-bezier(0.4, 0, 0.6, 1)) + ); + + /* A private tuple lets complete presets preserve timing across competing shorthands. */ + --_is-transition-duration: var(--_is-motion-default); + --_is-transition-timing-function: var(--_is-ease-standard); + --_is-transition-delay: 0s; + --_is-focus-ring-color: var( + --interactive-surface-focus-ring-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); + --_is-disabled-opacity: var(--interactive-surface-disabled-opacity, 0.72); + --_is-tap-highlight: var( + --interactive-surface-tap-highlight-color, + rgb(11 99 246 / 0.18) + ); + + position: relative; + isolation: isolate; + cursor: pointer; + touch-action: manipulation; + -webkit-tap-highlight-color: var(--_is-tap-highlight); + translate: 0 var(--_is-lift-base); + box-shadow: var(--_is-shadow-base); + transition: + translate var(--_is-transition-duration) + var(--_is-transition-timing-function) var(--_is-transition-delay), + box-shadow var(--_is-transition-duration) + var(--_is-transition-timing-function) var(--_is-transition-delay), + outline-color var(--_is-transition-duration) linear + var(--_is-transition-delay); + will-change: auto; +} + +/* The isolated state layer changes interaction emphasis without filtering content. */ +.interactive-surface::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + background-color: var(--_is-state-layer-color); + opacity: var(--_is-state-layer-base-opacity); + pointer-events: none; + z-index: -1; + transition: opacity var(--_is-motion-default) var(--_is-ease-standard); +} + +.interactive-surface:focus { + outline: var(--_is-focus-ring-width) solid var(--_is-focus-ring-color); + outline-offset: var(--_is-focus-ring-offset); +} + +@supports selector(:focus-visible) { + .interactive-surface:focus { + outline: none; + } +} + +.interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):focus-visible { + translate: 0 var(--_is-lift-hover); + box-shadow: var(--_is-shadow-hover); + outline: var(--_is-focus-ring-width) solid var(--_is-focus-ring-color); + outline-offset: var(--_is-focus-ring-offset); +} + +.interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):focus-visible::before { + opacity: var(--_is-state-layer-focus-opacity); +} + +/* Native file inputs expose an inner button that needs the same interaction feedback as the host. */ +:where(input[type="file"].interactive-surface)::file-selector-button { + cursor: pointer; + transition: + background-color var(--_is-transition-duration) + var(--_is-transition-timing-function) var(--_is-transition-delay), + border-color var(--_is-transition-duration) + var(--_is-transition-timing-function) var(--_is-transition-delay), + box-shadow var(--_is-transition-duration) + var(--_is-transition-timing-function) var(--_is-transition-delay), + color var(--_is-transition-duration) var(--_is-transition-timing-function) + var(--_is-transition-delay); +} + +:where(input[type="file"].interactive-surface):not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):focus-visible::file-selector-button { + box-shadow: inset 0 0 0 999px + rgb(0 0 0 / var(--_is-state-layer-focus-opacity)); +} + +/* Zero-specificity state branches let later transient and focus selectors retain precedence. */ +.interactive-surface:where( + .is-active, + [aria-pressed="true"], + [aria-pressed="mixed"], + [aria-current]:not([aria-current="false"]), + [aria-selected="true"], + [aria-busy="true"], + .is-loading + ):not(:is(.is-disabled, :disabled, [aria-disabled="true"])) { + translate: 0 var(--_is-lift-active); + box-shadow: var(--_is-shadow-active); +} + +.interactive-surface:where( + .is-active, + [aria-pressed="true"], + [aria-pressed="mixed"], + [aria-current]:not([aria-current="false"]), + [aria-selected="true"], + [aria-busy="true"], + .is-loading + ):not(:is(.is-disabled, :disabled, [aria-disabled="true"]))::before { + opacity: var(--_is-state-layer-active-opacity); +} + +@media (hover: hover) and (pointer: fine) { + .interactive-surface { + will-change: translate, box-shadow; + } + + .interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):hover { + translate: 0 var(--_is-hover-lift); + box-shadow: var(--_is-shadow-hover); + } + + .interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):hover::before { + opacity: var(--_is-state-layer-hover-opacity); + } + + :where(input[type="file"].interactive-surface):not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):hover::file-selector-button { + box-shadow: inset 0 0 0 999px + rgb(0 0 0 / var(--_is-state-layer-hover-opacity)); + } +} + +.interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):active { + --_is-transition-duration: var(--_is-motion-press); + --_is-transition-timing-function: var(--_is-ease-press); + + translate: 0 var(--_is-lift-base); + box-shadow: var(--_is-shadow-base); +} + +.interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):active::before { + opacity: 0; + transition-duration: var(--_is-motion-press); + transition-timing-function: var(--_is-ease-press); +} + +:where(input[type="file"].interactive-surface):not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):active::file-selector-button { + box-shadow: inset 0 0 0 999px + rgb(0 0 0 / var(--_is-state-layer-active-opacity)); + transition-duration: var(--_is-motion-press); + transition-timing-function: var(--_is-ease-press); +} + +/* + * Disabled styling is visual only. Consumers must still suppress programmatic and keyboard + * activation for custom controls that use aria-disabled or the is-disabled class. + */ +.interactive-surface:is(.is-disabled, :disabled, [aria-disabled="true"]) { + --_is-transition-duration: 0s; -.interactive-surface.is-disabled, -.interactive-surface:disabled { cursor: not-allowed; pointer-events: none; - transform: none !important; + translate: none; box-shadow: none; opacity: var(--_is-disabled-opacity); + animation: none; transition: none; } -.interactive-surface[aria-disabled="true"] { +.interactive-surface:is( + .is-disabled, + :disabled, + [aria-disabled="true"] + )::before { + opacity: 0; + animation: none; + transition: none; +} + +:where(input[type="file"].interactive-surface):is( + .is-disabled, + :disabled, + [aria-disabled="true"] + )::file-selector-button { cursor: not-allowed; - pointer-events: none; - transform: none !important; box-shadow: none; - opacity: var(--_is-disabled-opacity); + transition: none; } -/* Guardrail: this class owns motion on the host element. */ -.interactive-surface, -.interactive-surface:hover, -.interactive-surface:focus-visible, -.interactive-surface:active, -.interactive-surface.is-active, -.interactive-surface[aria-current="page"] { - scale: 1 !important; - rotate: 0deg !important; - translate: 0 0 !important; +/* Focus remains visible on custom disabled widgets that stay in the keyboard tab order. */ +.interactive-surface:is(.is-disabled, [aria-disabled="true"]):focus-visible { + outline: var(--_is-focus-ring-width) solid var(--_is-focus-ring-color); + outline-offset: var(--_is-focus-ring-offset); } -/* 10) Reduced motion */ - @media (prefers-reduced-motion: reduce) { - .interactive-surface { - transform: none !important; + .interactive-surface, + .interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):hover, + .interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):focus-visible, + .interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):active, + .interactive-surface:where( + .is-active, + [aria-pressed="true"], + [aria-pressed="mixed"], + [aria-current]:not([aria-current="false"]), + [aria-selected="true"], + [aria-busy="true"], + .is-loading + ):not(:is(.is-disabled, :disabled, [aria-disabled="true"])) { + --_is-transition-duration: 0s; + + translate: none; + animation: none; transition: none; } .interactive-surface::before { + animation: none; transition: none; } +} - .interactive-surface:not(.is-disabled, :disabled, [aria-disabled="true"]):hover, - .interactive-surface:not(.is-disabled, :disabled, [aria-disabled="true"]):focus-visible, - .interactive-surface:not(.is-disabled, :disabled, [aria-disabled="true"]):active, - .interactive-surface.is-active:not(.is-disabled, [aria-disabled="true"]), - .interactive-surface[aria-pressed="true"]:not(.is-disabled, :disabled, [aria-disabled="true"]), - .interactive-surface[aria-current="page"]:not(.is-disabled, :disabled, [aria-disabled="true"]) { - transform: none !important; - box-shadow: none; +@media (prefers-contrast: more) { + .interactive-surface { + --_is-focus-ring-width: 3px; } - .interactive-surface:hover::before, - .interactive-surface:focus-visible::before, - .interactive-surface:active::before, - .interactive-surface.is-active::before, - .interactive-surface[aria-pressed="true"]::before, - .interactive-surface[aria-current="page"]::before { - opacity: 0; + .interactive-surface:where( + .is-active, + [aria-pressed="true"], + [aria-pressed="mixed"], + [aria-current]:not([aria-current="false"]), + [aria-selected="true"], + [aria-busy="true"], + .is-loading + ):not(:is(.is-disabled, :disabled, [aria-disabled="true"])) { + outline: 2px solid currentcolor; + outline-offset: 2px; } -} - -/* 11) Contrast and forced-colors support */ -@media (prefers-contrast: more) { - .interactive-surface { - --interactive-surface-darken-hover: 1; - --interactive-surface-darken-active: 1; - --interactive-surface-focus-ring-width: 3px; - --interactive-surface-border-width: 2px; - --interactive-surface-shadow-base: none; - --interactive-surface-shadow-hover: none; - --interactive-surface-shadow-active: none; + .interactive-surface:focus, + .interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):focus-visible, + .interactive-surface[aria-disabled="true"]:focus-visible { + outline: var(--_is-focus-ring-width) solid var(--_is-focus-ring-color); } } @media (forced-colors: active) { .interactive-surface { - background-color: ButtonFace; - color: ButtonText; - border-color: ButtonText; + --_is-lift-base: 0px; + --_is-lift-hover: 0px; + + /* Integration offsets must not bypass the forced-colors motion reset. */ + --_is-hover-lift: 0px; + --_is-lift-active: 0px; + --_is-shadow-base: none; + --_is-shadow-hover: none; + --_is-shadow-active: none; + box-shadow: none; } @@ -556,27 +881,35 @@ display: none; } - .interactive-surface:hover, - .interactive-surface:focus-visible, - .interactive-surface.is-active, - .interactive-surface[aria-pressed="true"], - .interactive-surface[aria-current="page"], - .interactive-surface:active { - transform: none !important; - box-shadow: none; + .interactive-surface:where( + .is-active, + [aria-pressed="true"], + [aria-pressed="mixed"], + [aria-current]:not([aria-current="false"]), + [aria-selected="true"], + [aria-busy="true"], + .is-loading + ):not(:is(.is-disabled, :disabled, [aria-disabled="true"])) { + outline: 2px solid ButtonText; + outline-offset: 2px; } .interactive-surface:focus, - .interactive-surface:focus-visible { + .interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):focus-visible { outline: 2px solid Highlight; outline-offset: 2px; } - .interactive-surface.is-disabled, - .interactive-surface:disabled, - .interactive-surface[aria-disabled="true"] { - color: GrayText; - border-color: GrayText; + .interactive-surface:is(.is-disabled, :disabled, [aria-disabled="true"]) { + outline: 1px solid GrayText; + outline-offset: 2px; opacity: 1; } + + .interactive-surface[aria-disabled="true"]:focus-visible { + outline: 2px solid GrayText; + outline-offset: 2px; + } } diff --git a/package-lock.json b/package-lock.json index ba58fcf..9148cd4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,22 +1,23 @@ { "name": "interactive-surface-css", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "interactive-surface-css", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "devDependencies": { "@playwright/test": "^1.57.0", "clean-css": "~5.3.3", + "prettier": "3.9.5", "stylelint": "^17.14.0", "stylelint-config-standard": "^40.0.0", "ui-style-kit-css": "2.0.1" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/@babel/code-frame": { @@ -1261,6 +1262,22 @@ "dev": true, "license": "MIT" }, + "node_modules/prettier": { + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", + "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/qified": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", diff --git a/package.json b/package.json index e59db05..f297bd1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "interactive-surface-css", - "version": "1.3.0", + "version": "1.4.0", "description": "A framework-agnostic, accessible CSS interaction primitive for buttons, cards, and icon controls with token-driven theming.", "keywords": [ "css", @@ -40,6 +40,8 @@ "style": "./interactive-surface.css" }, "./interactive-surface.css": "./interactive-surface.css", + "./state-core.css": "./state-core.css", + "./standalone-preset.css": "./standalone-preset.css", "./index.html": "./index.html", "./index.cjs": "./index.cjs", "./package.json": "./package.json" @@ -47,35 +49,61 @@ "sideEffects": [ "./index.js", "./index.cjs", - "*.css" + "*.css", + "./demo/demo.css" ], "files": [ "index.js", "index.cjs", "interactive-surface.css", + "state-core.css", + "standalone-preset.css", + "assets/android-chrome-192x192.png", + "assets/android-chrome-512x512.png", + "assets/apple-touch-icon.png", + "assets/browserconfig.xml", + "assets/favicon-16x16.png", + "assets/favicon-32x32.png", + "assets/favicon-48x48.png", + "assets/favicon-64x64.png", + "assets/favicon.ico", + "assets/maskable-icon-192x192.png", + "assets/maskable-icon-512x512.png", + "assets/mstile-150x150.png", + "assets/site.webmanifest", + "demo/demo.css", + "demo/demo.js", "index.html", "README.md", "CHANGELOG.md", "LICENSE" ], "engines": { - "node": ">=18" + "node": ">=20" }, "scripts": { "check:no-hex-colors": "node ./scripts/check-no-hex-colors.mjs", - "lint:css": "stylelint interactive-surface.css", + "check:generated": "node ./scripts/build.mjs check", + "check:public": "node ./scripts/build.mjs check-public", + "lint:css": "stylelint \"styles/**/*.css\" \"*.css\" \"demo/**/*.css\"", "pack:dry": "npm pack --dry-run --cache ./.npm-cache", "bundle": "node ./scripts/build.mjs bundle", "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: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", "test:install:chromium": "playwright install chromium --with-deps --only-shell", - "validate:publish": "npm run check:no-hex-colors && npm run lint:css && npm run build && npm run pack:dry", - "validate:ci": "npm run validate:publish", - "validate:browsers": "npm run validate:publish && npm run test:install:chromium && npm run test:chromium", - "validate:full": "npm run validate:publish && npm run test:install && npm test", + "audit": "npm audit", + "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: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" }, @@ -84,13 +112,13 @@ "clean-css": "~5.3.3", "stylelint": "^17.14.0", "stylelint-config-standard": "^40.0.0", + "prettier": "3.9.5", "ui-style-kit-css": "2.0.1" }, "publishConfig": { "access": "public" }, "directories": { - "doc": "docs", "test": "tests" } } diff --git a/playwright.config.mjs b/playwright.config.mjs index 50ba6d0..ec726ca 100644 --- a/playwright.config.mjs +++ b/playwright.config.mjs @@ -7,26 +7,26 @@ export default defineConfig({ reporter: [["list"]], use: { trace: "on-first-retry", - screenshot: "only-on-failure" + screenshot: "only-on-failure", }, projects: [ { name: "chromium", use: { - ...devices["Desktop Chrome"] - } + ...devices["Desktop Chrome"], + }, }, { name: "firefox", use: { - ...devices["Desktop Firefox"] - } + ...devices["Desktop Firefox"], + }, }, { name: "webkit", use: { - ...devices["Desktop Safari"] - } - } - ] + ...devices["Desktop Safari"], + }, + }, + ], }); diff --git a/scripts/build.mjs b/scripts/build.mjs index 2ba73af..3c7bd16 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -1,37 +1,187 @@ -import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; import CleanCSS from "clean-css"; -const sourceFile = "interactive-surface.css"; -const bundleFile = "dist/interactive-surface.css"; -const minifiedFile = "dist/interactive-surface.min.css"; +const scriptPath = fileURLToPath(import.meta.url); +const projectRoot = path.resolve(path.dirname(scriptPath), ".."); +const sourcePaths = { + core: "styles/state-core.css", + preset: "styles/standalone-preset.css", +}; -async function bundle() { - await mkdir("dist", { recursive: true }); - await copyFile(sourceFile, bundleFile); +function normalize(css) { + return css.replace(/\r\n?/g, "\n"); } -async function minify() { - await mkdir("dist", { recursive: true }); - const source = await readFile(bundleFile, "utf8"); - const result = new CleanCSS({ level: 2 }).minify(source); +function withGeneratedHeader(bundleName, sources, css) { + const header = [ + "/*!", + ` * ${bundleName}`, + ` * Generated from: ${sources.join(", ")}`, + " * Do not edit directly; run node scripts/build.mjs.", + " */", + ].join("\n"); + + return `${header}\n\n${normalize(css).trim()}\n`; +} + +function minify(bundleName, css) { + const result = new CleanCSS({ level: 2 }).minify(css); if (result.errors.length > 0) { - throw new Error(result.errors.join("\n")); + throw new Error( + `Unable to minify ${bundleName}:\n${result.errors.join("\n")}`, + ); + } + + return `${result.styles}\n`; +} + +function createNormalAndMinifiedMaps(publicBundles) { + const bundleMap = {}; + + for (const [bundleName, css] of Object.entries(publicBundles)) { + const stem = path.basename(bundleName, ".css"); + + bundleMap[bundleName] = css; + bundleMap[`dist/${bundleName}`] = css; + bundleMap[`dist/${stem}.min.css`] = minify(bundleName, css); + } + + return bundleMap; +} + +async function readSource(sourcePath) { + try { + return await readFile(path.join(projectRoot, sourcePath), "utf8"); + } catch (error) { + throw new Error(`Unable to read source ${sourcePath}: ${error.message}`, { + cause: error, + }); + } +} + +export async function createBundleMap() { + const core = normalize(await readSource(sourcePaths.core)); + const preset = normalize(await readSource(sourcePaths.preset)); + const complete = `${preset.trim()}\n\n${core.trim()}\n`; + + return createNormalAndMinifiedMaps({ + "state-core.css": withGeneratedHeader( + "state-core.css", + [sourcePaths.core], + core, + ), + "standalone-preset.css": withGeneratedHeader( + "standalone-preset.css", + [sourcePaths.preset, sourcePaths.core], + complete, + ), + "interactive-surface.css": withGeneratedHeader( + "interactive-surface.css", + [sourcePaths.preset, sourcePaths.core], + complete, + ), + }); +} + +export async function buildBundles(bundleMap, outputDirectory = projectRoot) { + for (const [target, css] of Object.entries(bundleMap)) { + const targetPath = path.join(outputDirectory, target); + + await mkdir(path.dirname(targetPath), { recursive: true }); + await writeFile(targetPath, css, "utf8"); + } +} + +export async function verifyBundles(bundleMap, outputDirectory = projectRoot) { + const staleTargets = []; + + for (const [target, expected] of Object.entries(bundleMap)) { + try { + const actual = await readFile(path.join(outputDirectory, target), "utf8"); + + if (normalize(actual) !== expected) { + staleTargets.push(target); + } + } catch (error) { + if (error.code === "ENOENT") { + staleTargets.push(target); + continue; + } + + throw new Error( + `Unable to verify generated target ${target}: ${error.message}`, + { cause: error }, + ); + } } - await writeFile(minifiedFile, result.styles); + if (staleTargets.length > 0) { + throw new Error(`Generated bundles are stale: ${staleTargets.join(", ")}`); + } +} + +export async function verifyPublicBundles( + bundleMap, + outputDirectory = projectRoot, +) { + const publicBundles = Object.fromEntries( + Object.entries(bundleMap).filter(([target]) => !target.startsWith("dist/")), + ); + + await verifyBundles(publicBundles, outputDirectory); } -const step = process.argv[2] ?? "build"; +function selectTargets(bundleMap, step) { + if (step === "bundle") { + return Object.fromEntries( + Object.entries(bundleMap).filter( + ([target]) => !target.endsWith(".min.css"), + ), + ); + } + + if (step === "minify") { + return Object.fromEntries( + Object.entries(bundleMap).filter(([target]) => + target.endsWith(".min.css"), + ), + ); + } + + return bundleMap; +} -if (step === "bundle") { - await bundle(); -} else if (step === "minify") { - await minify(); -} else if (step === "build") { - await bundle(); - await minify(); -} else { - console.error(`Unknown build step: ${step}`); - process.exit(1); -} \ No newline at end of file +async function run() { + const step = process.argv[2] ?? "build"; + const bundleMap = await createBundleMap(); + + if (["build", "bundle", "minify"].includes(step)) { + await buildBundles(selectTargets(bundleMap, step)); + return; + } + + if (step === "check") { + await verifyBundles(bundleMap); + return; + } + + if (step === "check-public") { + // Public roots are committed, while dist is intentionally absent from a clean checkout. + await verifyPublicBundles(bundleMap); + return; + } + + throw new Error(`Unknown build step: ${step}`); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === scriptPath) { + try { + await run(); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} diff --git a/scripts/check-no-hex-colors.mjs b/scripts/check-no-hex-colors.mjs index c7a2fe4..9d400e4 100644 --- a/scripts/check-no-hex-colors.mjs +++ b/scripts/check-no-hex-colors.mjs @@ -2,35 +2,49 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const targetFile = path.resolve(__dirname, "..", "interactive-surface.css"); - -const source = fs.readFileSync(targetFile, "utf8"); +const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(scriptDirectory, ".."); +const targetFiles = [ + "styles/state-core.css", + "styles/standalone-preset.css", + "state-core.css", + "standalone-preset.css", + "interactive-surface.css", +]; const hexPattern = /#[0-9a-fA-F]{3,8}\b/g; -const lines = source.split(/\r?\n/); const violations = []; -lines.forEach((line, index) => { - let match = hexPattern.exec(line); - while (match) { - violations.push({ - line: index + 1, - value: match[0] - }); - match = hexPattern.exec(line); - } - hexPattern.lastIndex = 0; +targetFiles.forEach((targetFile) => { + const source = fs.readFileSync(path.join(projectRoot, targetFile), "utf8"); + + source.split(/\r?\n/).forEach((line, index) => { + let match = hexPattern.exec(line); + + while (match) { + violations.push({ + file: targetFile, + line: index + 1, + value: match[0], + }); + match = hexPattern.exec(line); + } + + hexPattern.lastIndex = 0; + }); }); -if (!violations.length) { - console.log("No hex color literals found in interactive-surface.css."); +if (violations.length === 0) { + console.log( + `No hex color literals found in ${targetFiles.length} checked stylesheets.`, + ); process.exit(0); } -console.error("Hex color literals are not allowed in interactive-surface.css:"); +console.error("Hex color literals are not allowed:"); violations.forEach((violation) => { - console.error(`- line ${violation.line}: ${violation.value}`); + console.error(`- ${violation.file}:${violation.line}: ${violation.value}`); }); -console.error("Use CSS functional color notation instead (for example, rgb(...), rgb(... / ), or hsl(...))."); +console.error( + "Use CSS functional color notation instead (for example, rgb(...), rgb(... / ), or hsl(...)).", +); process.exit(1); diff --git a/standalone-preset.css b/standalone-preset.css new file mode 100644 index 0000000..11827b8 --- /dev/null +++ b/standalone-preset.css @@ -0,0 +1,915 @@ +/*! + * standalone-preset.css + * Generated from: styles/standalone-preset.css, styles/state-core.css + * Do not edit directly; run node scripts/build.mjs. + */ + +/* + * Standalone paint and geometry for interactive surfaces. + * The generated preset appends state-core.css to this authored module. + */ + +:is(.interactive-surface) { + /* Complete bundles retain the established lift and shadow fallbacks without imposing them on the core. */ + --_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-hover: var( + --shadow-hover, + 0 10px 28px rgb(0 0 0 / 0.35), + 0 2px 8px rgb(0 0 0 / 0.25) + ); + --_is-preset-shadow-active: var( + --shadow-active, + 0 6px 18px rgb(0 0 0 / 0.3), + 0 1px 4px rgb(0 0 0 / 0.2) + ); + --_is-base-bg: var( + --interactive-surface-bg, + 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))) + ); + --_is-base-border-color: var( + --interactive-surface-border-color, + 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); + + box-sizing: border-box; + border: var(--_is-border-width) solid var(--_is-border-color); + border-radius: var(--_is-radius); + background-color: var(--_is-bg); + color: var(--_is-fg); + text-decoration: none; + font: inherit; + -webkit-appearance: none; + appearance: none; + background-clip: padding-box; +} + +/* + * UI Style Kit 2.0.1 contributes one pixel of transform-based hover lift and owns paint + * transitions. The preset offsets the resolved lift and protects the complete transition tuple. + */ +:where([data-ui][data-theme][data-mode]) .interactive-surface { + --_is-preset-hover-lift-offset: 1px; + + /* stylelint-disable declaration-block-no-redundant-longhand-properties -- Each longhand must survive companion shorthands in either import order. */ + transition-property: + background-color, border-color, box-shadow, color, transform, translate, + outline-color !important; + transition-duration: var(--_is-transition-duration) !important; + transition-timing-function: var(--_is-transition-timing-function) !important; + transition-delay: var(--_is-transition-delay) !important; + /* stylelint-enable declaration-block-no-redundant-longhand-properties */ +} + +/* Surface levels consume bridge tokens while preserving standalone fallbacks. */ +.interactive-surface[data-surface-level] { + --_is-bg: var(--interactive-surface-level-bg, var(--_is-base-bg)); + --_is-border-color: var( + --interactive-surface-level-border-color, + var(--_is-base-border-color) + ); + --_is-shadow-base: var( + --interactive-surface-level-shadow, + var(--_is-base-shadow) + ); +} + +.interactive-surface[data-surface-level="1"] { + --_is-bg: var( + --interactive-surface-level-bg, + var(--interactive-surface-level-1-bg, var(--_is-base-bg)) + ); + --_is-border-color: var( + --interactive-surface-level-border-color, + var( + --interactive-surface-level-1-border-color, + var(--_is-base-border-color) + ) + ); + --_is-shadow-base: var( + --interactive-surface-level-shadow, + var(--interactive-surface-level-1-shadow, var(--_is-base-shadow)) + ); + --_is-state-layer-hover-opacity: var( + --interactive-surface-state-layer-hover-opacity, + var(--interactive-surface-level-1-hover-opacity, 0.08) + ); + --_is-state-layer-active-opacity: var( + --interactive-surface-state-layer-active-opacity, + var(--interactive-surface-level-1-active-opacity, 0.14) + ); + --_is-state-layer-focus-opacity: var( + --interactive-surface-state-layer-focus-opacity, + var(--interactive-surface-level-1-focus-opacity, 0.18) + ); +} + +.interactive-surface[data-surface-level="2"] { + --_is-bg: var( + --interactive-surface-level-bg, + var(--interactive-surface-level-2-bg, var(--_is-base-bg)) + ); + --_is-border-color: var( + --interactive-surface-level-border-color, + var( + --interactive-surface-level-2-border-color, + var(--_is-base-border-color) + ) + ); + --_is-shadow-base: var( + --interactive-surface-level-shadow, + var(--interactive-surface-level-2-shadow, 0 8px 20px rgb(0 0 0 / 0.22)) + ); + --_is-state-layer-hover-opacity: var( + --interactive-surface-state-layer-hover-opacity, + var(--interactive-surface-level-2-hover-opacity, 0.11) + ); + --_is-state-layer-active-opacity: var( + --interactive-surface-state-layer-active-opacity, + var(--interactive-surface-level-2-active-opacity, 0.18) + ); + --_is-state-layer-focus-opacity: var( + --interactive-surface-state-layer-focus-opacity, + var(--interactive-surface-level-2-focus-opacity, 0.22) + ); +} + +.interactive-surface[data-surface-level="3"] { + --_is-bg: var( + --interactive-surface-level-bg, + var(--interactive-surface-level-3-bg, var(--_is-base-bg)) + ); + --_is-border-color: var( + --interactive-surface-level-border-color, + var( + --interactive-surface-level-3-border-color, + var(--_is-base-border-color) + ) + ); + --_is-shadow-base: var( + --interactive-surface-level-shadow, + var(--interactive-surface-level-3-shadow, 0 14px 34px rgb(0 0 0 / 0.3)) + ); + --_is-state-layer-hover-opacity: var( + --interactive-surface-state-layer-hover-opacity, + var(--interactive-surface-level-3-hover-opacity, 0.14) + ); + --_is-state-layer-active-opacity: var( + --interactive-surface-state-layer-active-opacity, + var(--interactive-surface-level-3-active-opacity, 0.24) + ); + --_is-state-layer-focus-opacity: var( + --interactive-surface-state-layer-focus-opacity, + var(--interactive-surface-level-3-focus-opacity, 0.28) + ); +} + +/* UI Style Kit hosts retain explicit ownership-compatible paint declarations. */ +[data-ui] .interactive-surface { + border: var(--_is-border-width) solid var(--_is-border-color); + border-radius: var(--_is-radius); + background-color: var(--_is-bg); + color: var(--_is-fg); + font: inherit; +} + +/* File inputs have a native selector button; the preset maps it into the same surface token contract. */ +:is(input[type="file"].interactive-surface) { + min-height: 2.75rem; +} + +:is(input[type="file"].interactive-surface)::file-selector-button { + margin-inline-end: 0.75rem; + padding: 0.55rem 0.85rem; + border: var(--_is-border-width) solid var(--_is-border-color); + border-radius: calc(var(--_is-radius) * 0.75); + background-color: var(--_is-bg); + color: var(--_is-fg); + font: inherit; + font-weight: 700; +} + +.interactive-surface.size-sm { + --interactive-surface-lift-hover: -4px; + --interactive-surface-lift-active: -2px; + --interactive-surface-shadow-hover: + 0 6px 16px rgba(0, 0, 0, 0.28), 0 1px 4px rgba(0, 0, 0, 0.2); + --interactive-surface-shadow-active: 0 4px 10px rgba(0, 0, 0, 0.25); +} + +.interactive-surface.size-lg { + --interactive-surface-lift-hover: -8px; + --interactive-surface-lift-active: -4px; + --interactive-surface-shadow-hover: + 0 14px 36px rgba(0, 0, 0, 0.38), 0 3px 10px rgba(0, 0, 0, 0.3); + --interactive-surface-shadow-active: 0 8px 22px rgba(0, 0, 0, 0.32); +} + +.interactive-surface.icon-only { + --interactive-surface-lift-hover: -3px; + --interactive-surface-lift-active: -1.5px; + --interactive-surface-shadow-hover: 0 4px 10px rgba(0, 0, 0, 0.3); + --interactive-surface-shadow-active: 0 2px 6px rgba(0, 0, 0, 0.25); + + display: inline-flex; + justify-content: center; + align-items: center; + min-width: 44px; + min-height: 44px; + padding: 0; + border-radius: 0.5rem; +} + +/* Icon-role hooks provide useful standalone defaults while remaining token-driven. */ +.interactive-surface.icon-only .light-icon, +.interactive-surface.icon-only [data-icon-role="light"] { + color: var( + --interactive-surface-light-icon-color, + var(--icon-light, rgb(212 175 55)) + ); +} + +.interactive-surface.icon-only .dark-icon, +.interactive-surface.icon-only [data-icon-role="dark"] { + color: var( + --interactive-surface-dark-icon-color, + var(--icon-dark, rgb(0 0 0)) + ); +} + +.interactive-surface.icon-only .accessibility-icon, +.interactive-surface.icon-only [data-icon-role="accessibility"] { + color: var( + --interactive-surface-accessibility-icon-color, + var(--icon-accessibility, rgb(59 130 246)) + ); + border-radius: 0; +} + +@media (prefers-color-scheme: dark) { + .interactive-surface.icon-only .light-icon, + .interactive-surface.icon-only [data-icon-role="light"] { + color: var( + --interactive-surface-light-icon-color-dark, + var(--icon-light-dark, rgb(255 255 255)) + ); + } + + .interactive-surface.icon-only .dark-icon, + .interactive-surface.icon-only [data-icon-role="dark"] { + color: var( + --interactive-surface-dark-icon-color-dark, + var(--icon-dark-dark, rgb(30 58 138)) + ); + } + + .interactive-surface.icon-only .accessibility-icon, + .interactive-surface.icon-only [data-icon-role="accessibility"] { + color: var( + --interactive-surface-accessibility-icon-color-dark, + var(--icon-accessibility-dark, rgb(156 163 175)) + ); + } +} + +:where( + [data-theme="dark"], + [data-mode="dark"], + [data-mode="contrast"], + .theme-dark, + .dark-mode, + .dark + ) + .interactive-surface.icon-only + .light-icon, +:where( + [data-theme="dark"], + [data-mode="dark"], + [data-mode="contrast"], + .theme-dark, + .dark-mode, + .dark + ) + .interactive-surface.icon-only + [data-icon-role="light"] { + color: var( + --interactive-surface-light-icon-color-dark, + var(--icon-light-dark, rgb(255 255 255)) + ); +} + +:where( + [data-theme="dark"], + [data-mode="dark"], + [data-mode="contrast"], + .theme-dark, + .dark-mode, + .dark + ) + .interactive-surface.icon-only + .dark-icon, +:where( + [data-theme="dark"], + [data-mode="dark"], + [data-mode="contrast"], + .theme-dark, + .dark-mode, + .dark + ) + .interactive-surface.icon-only + [data-icon-role="dark"] { + color: var( + --interactive-surface-dark-icon-color-dark, + var(--icon-dark-dark, rgb(30 58 138)) + ); +} + +:where( + [data-theme="dark"], + [data-mode="dark"], + [data-mode="contrast"], + .theme-dark, + .dark-mode, + .dark + ) + .interactive-surface.icon-only + .accessibility-icon, +:where( + [data-theme="dark"], + [data-mode="dark"], + [data-mode="contrast"], + .theme-dark, + .dark-mode, + .dark + ) + .interactive-surface.icon-only + [data-icon-role="accessibility"] { + color: var( + --interactive-surface-accessibility-icon-color-dark, + var(--icon-accessibility-dark, rgb(156 163 175)) + ); +} + +.interactive-surface.variant-primary, +.interactive-surface[data-surface-variant="primary"] { + --interactive-surface-bg: var( + --interactive-surface-variant-primary-bg, + 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)) + ); + --interactive-surface-border-color: var( + --interactive-surface-variant-primary-border-color, + var(--variant-primary-border, var(--interactive-surface-bg)) + ); + --interactive-surface-darken-hover: 0.95; + --interactive-surface-darken-active: 0.97; +} + +.interactive-surface.variant-secondary, +.interactive-surface[data-surface-variant="secondary"] { + --interactive-surface-bg: var( + --interactive-surface-variant-secondary-bg, + var(--variant-secondary-bg, rgb(198 220 237)) + ); + --interactive-surface-fg: var( + --interactive-surface-variant-secondary-fg, + var(--variant-secondary-fg, rgb(20 48 70)) + ); + --interactive-surface-border-color: var( + --interactive-surface-variant-secondary-border-color, + var(--variant-secondary-border, rgb(155 183 206)) + ); + --interactive-surface-darken-hover: 0.97; + --interactive-surface-darken-active: 0.99; +} + +.interactive-surface.variant-accent, +.interactive-surface[data-surface-variant="accent"] { + --interactive-surface-bg: var( + --interactive-surface-variant-accent-bg, + var(--variant-accent-bg, rgb(201 240 237)) + ); + --interactive-surface-fg: var( + --interactive-surface-variant-accent-fg, + var(--variant-accent-fg, rgb(6 74 79)) + ); + --interactive-surface-border-color: var( + --interactive-surface-variant-accent-border-color, + var(--variant-accent-border, rgb(105 195 200)) + ); + --interactive-surface-darken-hover: 0.94; + --interactive-surface-darken-active: 0.96; +} + +.interactive-surface.variant-subtle, +.interactive-surface[data-surface-variant="subtle"] { + --interactive-surface-bg: var( + --interactive-surface-variant-subtle-bg, + var(--variant-subtle-bg, rgb(219 231 243)) + ); + --interactive-surface-fg: var( + --interactive-surface-variant-subtle-fg, + var(--variant-subtle-fg, rgb(26 52 75)) + ); + --interactive-surface-border-color: var( + --interactive-surface-variant-subtle-border-color, + var(--variant-subtle-border, rgb(173 191 206)) + ); + --interactive-surface-darken-hover: 0.98; + --interactive-surface-darken-active: 1; +} + +.interactive-surface.variant-warning, +.interactive-surface[data-surface-variant="warning"] { + --interactive-surface-bg: var( + --interactive-surface-variant-warning-bg, + var(--variant-warning-bg, rgb(255 227 186)) + ); + --interactive-surface-fg: var( + --interactive-surface-variant-warning-fg, + var(--variant-warning-fg, rgb(102 61 0)) + ); + --interactive-surface-border-color: var( + --interactive-surface-variant-warning-border-color, + var(--variant-warning-border, rgb(218 172 103)) + ); + --interactive-surface-darken-hover: 0.93; + --interactive-surface-darken-active: 0.95; +} + +.interactive-surface.variant-danger, +.interactive-surface[data-surface-variant="danger"] { + --interactive-surface-bg: var( + --interactive-surface-variant-danger-bg, + var(--variant-danger-bg, rgb(255 211 213)) + ); + --interactive-surface-fg: var( + --interactive-surface-variant-danger-fg, + var(--variant-danger-fg, rgb(116 32 32)) + ); + --interactive-surface-border-color: var( + --interactive-surface-variant-danger-border-color, + var(--variant-danger-border, rgb(215 125 130)) + ); + --interactive-surface-darken-hover: 0.92; + --interactive-surface-darken-active: 0.94; +} + +/* System paint remains part of the standalone preset while core owns forced-color interaction mechanics. */ +@media (forced-colors: active) { + :is(.interactive-surface) { + background-color: ButtonFace; + color: ButtonText; + border-color: ButtonText; + } + + :is(.interactive-surface).is-disabled, + :is(.interactive-surface):disabled, + :is(.interactive-surface)[aria-disabled="true"] { + color: GrayText; + border-color: GrayText; + } + + :is(.interactive-surface[type="file"])::file-selector-button { + border-color: ButtonText; + background-color: ButtonFace; + color: ButtonText; + } + + :is(.interactive-surface[type="file"]):is( + .is-disabled, + :disabled, + [aria-disabled="true"] + )::file-selector-button { + border-color: GrayText; + color: GrayText; + } +} + +/* + * Interaction mechanics for interactive surfaces. + * Standalone paint and component geometry belong to the companion preset. + */ + +.interactive-surface { + /* Preset-only fallbacks keep the core neutral while preserving complete-bundle behavior. */ + --_is-lift-base: var( + --interactive-surface-lift-base, + var(--_is-preset-lift-base, 0px) + ); + --_is-lift-hover: var( + --interactive-surface-lift-hover, + var(--_is-preset-lift-hover, 0px) + ); + --_is-hover-lift: calc( + var(--_is-lift-hover) + var(--_is-preset-hover-lift-offset, 0px) + ); + --_is-lift-active: var( + --interactive-surface-lift-active, + var(--_is-preset-lift-active, 0px) + ); + --_is-base-shadow: var( + --interactive-surface-shadow-base, + var(--interactive-surface-level-shadow, var(--_is-preset-shadow-base, none)) + ); + --_is-shadow-base: var(--_is-base-shadow); + --_is-shadow-hover: var( + --interactive-surface-shadow-hover, + var(--_is-preset-shadow-hover, var(--_is-base-shadow)) + ); + --_is-shadow-active: var( + --interactive-surface-shadow-active, + var(--_is-preset-shadow-active, var(--_is-base-shadow)) + ); + --_is-darken-hover: var( + --interactive-surface-darken-hover, + var(--surface-darken-hover, 0.96) + ); + --_is-darken-active: var( + --interactive-surface-darken-active, + var(--surface-darken-active, 0.98) + ); + --_is-state-layer-base-opacity: var( + --interactive-surface-state-layer-opacity, + 0 + ); + --_is-state-layer-hover-opacity: var( + --interactive-surface-state-layer-hover-opacity, + var( + --interactive-surface-state-layer-opacity-hover, + calc(1 - var(--_is-darken-hover)) + ) + ); + --_is-state-layer-active-opacity: var( + --interactive-surface-state-layer-active-opacity, + var( + --interactive-surface-state-layer-opacity-active, + calc(1 - var(--_is-darken-active)) + ) + ); + --_is-state-layer-focus-opacity: var( + --interactive-surface-state-layer-focus-opacity, + var( + --interactive-surface-state-layer-opacity-focus, + var(--_is-state-layer-hover-opacity) + ) + ); + --_is-state-layer-color: var( + --interactive-surface-state-layer-color, + rgb(0 0 0) + ); + --_is-motion-default: var( + --interactive-surface-motion-default, + var(--motion-default, 140ms) + ); + --_is-motion-press: var( + --interactive-surface-motion-press, + var(--motion-press, 60ms) + ); + --_is-ease-standard: var( + --interactive-surface-ease-standard, + var(--ease-standard, cubic-bezier(0.2, 0, 0.2, 1)) + ); + --_is-ease-press: var( + --interactive-surface-ease-press, + var(--ease-press, cubic-bezier(0.4, 0, 0.6, 1)) + ); + + /* A private tuple lets complete presets preserve timing across competing shorthands. */ + --_is-transition-duration: var(--_is-motion-default); + --_is-transition-timing-function: var(--_is-ease-standard); + --_is-transition-delay: 0s; + --_is-focus-ring-color: var( + --interactive-surface-focus-ring-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); + --_is-disabled-opacity: var(--interactive-surface-disabled-opacity, 0.72); + --_is-tap-highlight: var( + --interactive-surface-tap-highlight-color, + rgb(11 99 246 / 0.18) + ); + + position: relative; + isolation: isolate; + cursor: pointer; + touch-action: manipulation; + -webkit-tap-highlight-color: var(--_is-tap-highlight); + translate: 0 var(--_is-lift-base); + box-shadow: var(--_is-shadow-base); + transition: + translate var(--_is-transition-duration) + var(--_is-transition-timing-function) var(--_is-transition-delay), + box-shadow var(--_is-transition-duration) + var(--_is-transition-timing-function) var(--_is-transition-delay), + outline-color var(--_is-transition-duration) linear + var(--_is-transition-delay); + will-change: auto; +} + +/* The isolated state layer changes interaction emphasis without filtering content. */ +.interactive-surface::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + background-color: var(--_is-state-layer-color); + opacity: var(--_is-state-layer-base-opacity); + pointer-events: none; + z-index: -1; + transition: opacity var(--_is-motion-default) var(--_is-ease-standard); +} + +.interactive-surface:focus { + outline: var(--_is-focus-ring-width) solid var(--_is-focus-ring-color); + outline-offset: var(--_is-focus-ring-offset); +} + +@supports selector(:focus-visible) { + .interactive-surface:focus { + outline: none; + } +} + +.interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):focus-visible { + translate: 0 var(--_is-lift-hover); + box-shadow: var(--_is-shadow-hover); + outline: var(--_is-focus-ring-width) solid var(--_is-focus-ring-color); + outline-offset: var(--_is-focus-ring-offset); +} + +.interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):focus-visible::before { + opacity: var(--_is-state-layer-focus-opacity); +} + +/* Native file inputs expose an inner button that needs the same interaction feedback as the host. */ +:where(input[type="file"].interactive-surface)::file-selector-button { + cursor: pointer; + transition: + background-color var(--_is-transition-duration) + var(--_is-transition-timing-function) var(--_is-transition-delay), + border-color var(--_is-transition-duration) + var(--_is-transition-timing-function) var(--_is-transition-delay), + box-shadow var(--_is-transition-duration) + var(--_is-transition-timing-function) var(--_is-transition-delay), + color var(--_is-transition-duration) var(--_is-transition-timing-function) + var(--_is-transition-delay); +} + +:where(input[type="file"].interactive-surface):not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):focus-visible::file-selector-button { + box-shadow: inset 0 0 0 999px + rgb(0 0 0 / var(--_is-state-layer-focus-opacity)); +} + +/* Zero-specificity state branches let later transient and focus selectors retain precedence. */ +.interactive-surface:where( + .is-active, + [aria-pressed="true"], + [aria-pressed="mixed"], + [aria-current]:not([aria-current="false"]), + [aria-selected="true"], + [aria-busy="true"], + .is-loading + ):not(:is(.is-disabled, :disabled, [aria-disabled="true"])) { + translate: 0 var(--_is-lift-active); + box-shadow: var(--_is-shadow-active); +} + +.interactive-surface:where( + .is-active, + [aria-pressed="true"], + [aria-pressed="mixed"], + [aria-current]:not([aria-current="false"]), + [aria-selected="true"], + [aria-busy="true"], + .is-loading + ):not(:is(.is-disabled, :disabled, [aria-disabled="true"]))::before { + opacity: var(--_is-state-layer-active-opacity); +} + +@media (hover: hover) and (pointer: fine) { + .interactive-surface { + will-change: translate, box-shadow; + } + + .interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):hover { + translate: 0 var(--_is-hover-lift); + box-shadow: var(--_is-shadow-hover); + } + + .interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):hover::before { + opacity: var(--_is-state-layer-hover-opacity); + } + + :where(input[type="file"].interactive-surface):not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):hover::file-selector-button { + box-shadow: inset 0 0 0 999px + rgb(0 0 0 / var(--_is-state-layer-hover-opacity)); + } +} + +.interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):active { + --_is-transition-duration: var(--_is-motion-press); + --_is-transition-timing-function: var(--_is-ease-press); + + translate: 0 var(--_is-lift-base); + box-shadow: var(--_is-shadow-base); +} + +.interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):active::before { + opacity: 0; + transition-duration: var(--_is-motion-press); + transition-timing-function: var(--_is-ease-press); +} + +:where(input[type="file"].interactive-surface):not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):active::file-selector-button { + box-shadow: inset 0 0 0 999px + rgb(0 0 0 / var(--_is-state-layer-active-opacity)); + transition-duration: var(--_is-motion-press); + transition-timing-function: var(--_is-ease-press); +} + +/* + * Disabled styling is visual only. Consumers must still suppress programmatic and keyboard + * activation for custom controls that use aria-disabled or the is-disabled class. + */ +.interactive-surface:is(.is-disabled, :disabled, [aria-disabled="true"]) { + --_is-transition-duration: 0s; + + cursor: not-allowed; + pointer-events: none; + translate: none; + box-shadow: none; + opacity: var(--_is-disabled-opacity); + animation: none; + transition: none; +} + +.interactive-surface:is( + .is-disabled, + :disabled, + [aria-disabled="true"] + )::before { + opacity: 0; + animation: none; + transition: none; +} + +:where(input[type="file"].interactive-surface):is( + .is-disabled, + :disabled, + [aria-disabled="true"] + )::file-selector-button { + cursor: not-allowed; + box-shadow: none; + transition: none; +} + +/* Focus remains visible on custom disabled widgets that stay in the keyboard tab order. */ +.interactive-surface:is(.is-disabled, [aria-disabled="true"]):focus-visible { + outline: var(--_is-focus-ring-width) solid var(--_is-focus-ring-color); + outline-offset: var(--_is-focus-ring-offset); +} + +@media (prefers-reduced-motion: reduce) { + .interactive-surface, + .interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):hover, + .interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):focus-visible, + .interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):active, + .interactive-surface:where( + .is-active, + [aria-pressed="true"], + [aria-pressed="mixed"], + [aria-current]:not([aria-current="false"]), + [aria-selected="true"], + [aria-busy="true"], + .is-loading + ):not(:is(.is-disabled, :disabled, [aria-disabled="true"])) { + --_is-transition-duration: 0s; + + translate: none; + animation: none; + transition: none; + } + + .interactive-surface::before { + animation: none; + transition: none; + } +} + +@media (prefers-contrast: more) { + .interactive-surface { + --_is-focus-ring-width: 3px; + } + + .interactive-surface:where( + .is-active, + [aria-pressed="true"], + [aria-pressed="mixed"], + [aria-current]:not([aria-current="false"]), + [aria-selected="true"], + [aria-busy="true"], + .is-loading + ):not(:is(.is-disabled, :disabled, [aria-disabled="true"])) { + outline: 2px solid currentcolor; + outline-offset: 2px; + } + + .interactive-surface:focus, + .interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):focus-visible, + .interactive-surface[aria-disabled="true"]:focus-visible { + outline: var(--_is-focus-ring-width) solid var(--_is-focus-ring-color); + } +} + +@media (forced-colors: active) { + .interactive-surface { + --_is-lift-base: 0px; + --_is-lift-hover: 0px; + + /* Integration offsets must not bypass the forced-colors motion reset. */ + --_is-hover-lift: 0px; + --_is-lift-active: 0px; + --_is-shadow-base: none; + --_is-shadow-hover: none; + --_is-shadow-active: none; + + box-shadow: none; + } + + .interactive-surface::before { + display: none; + } + + .interactive-surface:where( + .is-active, + [aria-pressed="true"], + [aria-pressed="mixed"], + [aria-current]:not([aria-current="false"]), + [aria-selected="true"], + [aria-busy="true"], + .is-loading + ):not(:is(.is-disabled, :disabled, [aria-disabled="true"])) { + outline: 2px solid ButtonText; + outline-offset: 2px; + } + + .interactive-surface:focus, + .interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):focus-visible { + outline: 2px solid Highlight; + outline-offset: 2px; + } + + .interactive-surface:is(.is-disabled, :disabled, [aria-disabled="true"]) { + outline: 1px solid GrayText; + outline-offset: 2px; + opacity: 1; + } + + .interactive-surface[aria-disabled="true"]:focus-visible { + outline: 2px solid GrayText; + outline-offset: 2px; + } +} diff --git a/state-core.css b/state-core.css new file mode 100644 index 0000000..91a0850 --- /dev/null +++ b/state-core.css @@ -0,0 +1,420 @@ +/*! + * state-core.css + * Generated from: styles/state-core.css + * Do not edit directly; run node scripts/build.mjs. + */ + +/* + * Interaction mechanics for interactive surfaces. + * Standalone paint and component geometry belong to the companion preset. + */ + +.interactive-surface { + /* Preset-only fallbacks keep the core neutral while preserving complete-bundle behavior. */ + --_is-lift-base: var( + --interactive-surface-lift-base, + var(--_is-preset-lift-base, 0px) + ); + --_is-lift-hover: var( + --interactive-surface-lift-hover, + var(--_is-preset-lift-hover, 0px) + ); + --_is-hover-lift: calc( + var(--_is-lift-hover) + var(--_is-preset-hover-lift-offset, 0px) + ); + --_is-lift-active: var( + --interactive-surface-lift-active, + var(--_is-preset-lift-active, 0px) + ); + --_is-base-shadow: var( + --interactive-surface-shadow-base, + var(--interactive-surface-level-shadow, var(--_is-preset-shadow-base, none)) + ); + --_is-shadow-base: var(--_is-base-shadow); + --_is-shadow-hover: var( + --interactive-surface-shadow-hover, + var(--_is-preset-shadow-hover, var(--_is-base-shadow)) + ); + --_is-shadow-active: var( + --interactive-surface-shadow-active, + var(--_is-preset-shadow-active, var(--_is-base-shadow)) + ); + --_is-darken-hover: var( + --interactive-surface-darken-hover, + var(--surface-darken-hover, 0.96) + ); + --_is-darken-active: var( + --interactive-surface-darken-active, + var(--surface-darken-active, 0.98) + ); + --_is-state-layer-base-opacity: var( + --interactive-surface-state-layer-opacity, + 0 + ); + --_is-state-layer-hover-opacity: var( + --interactive-surface-state-layer-hover-opacity, + var( + --interactive-surface-state-layer-opacity-hover, + calc(1 - var(--_is-darken-hover)) + ) + ); + --_is-state-layer-active-opacity: var( + --interactive-surface-state-layer-active-opacity, + var( + --interactive-surface-state-layer-opacity-active, + calc(1 - var(--_is-darken-active)) + ) + ); + --_is-state-layer-focus-opacity: var( + --interactive-surface-state-layer-focus-opacity, + var( + --interactive-surface-state-layer-opacity-focus, + var(--_is-state-layer-hover-opacity) + ) + ); + --_is-state-layer-color: var( + --interactive-surface-state-layer-color, + rgb(0 0 0) + ); + --_is-motion-default: var( + --interactive-surface-motion-default, + var(--motion-default, 140ms) + ); + --_is-motion-press: var( + --interactive-surface-motion-press, + var(--motion-press, 60ms) + ); + --_is-ease-standard: var( + --interactive-surface-ease-standard, + var(--ease-standard, cubic-bezier(0.2, 0, 0.2, 1)) + ); + --_is-ease-press: var( + --interactive-surface-ease-press, + var(--ease-press, cubic-bezier(0.4, 0, 0.6, 1)) + ); + + /* A private tuple lets complete presets preserve timing across competing shorthands. */ + --_is-transition-duration: var(--_is-motion-default); + --_is-transition-timing-function: var(--_is-ease-standard); + --_is-transition-delay: 0s; + --_is-focus-ring-color: var( + --interactive-surface-focus-ring-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); + --_is-disabled-opacity: var(--interactive-surface-disabled-opacity, 0.72); + --_is-tap-highlight: var( + --interactive-surface-tap-highlight-color, + rgb(11 99 246 / 0.18) + ); + + position: relative; + isolation: isolate; + cursor: pointer; + touch-action: manipulation; + -webkit-tap-highlight-color: var(--_is-tap-highlight); + translate: 0 var(--_is-lift-base); + box-shadow: var(--_is-shadow-base); + transition: + translate var(--_is-transition-duration) + var(--_is-transition-timing-function) var(--_is-transition-delay), + box-shadow var(--_is-transition-duration) + var(--_is-transition-timing-function) var(--_is-transition-delay), + outline-color var(--_is-transition-duration) linear + var(--_is-transition-delay); + will-change: auto; +} + +/* The isolated state layer changes interaction emphasis without filtering content. */ +.interactive-surface::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + background-color: var(--_is-state-layer-color); + opacity: var(--_is-state-layer-base-opacity); + pointer-events: none; + z-index: -1; + transition: opacity var(--_is-motion-default) var(--_is-ease-standard); +} + +.interactive-surface:focus { + outline: var(--_is-focus-ring-width) solid var(--_is-focus-ring-color); + outline-offset: var(--_is-focus-ring-offset); +} + +@supports selector(:focus-visible) { + .interactive-surface:focus { + outline: none; + } +} + +.interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):focus-visible { + translate: 0 var(--_is-lift-hover); + box-shadow: var(--_is-shadow-hover); + outline: var(--_is-focus-ring-width) solid var(--_is-focus-ring-color); + outline-offset: var(--_is-focus-ring-offset); +} + +.interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):focus-visible::before { + opacity: var(--_is-state-layer-focus-opacity); +} + +/* Native file inputs expose an inner button that needs the same interaction feedback as the host. */ +:where(input[type="file"].interactive-surface)::file-selector-button { + cursor: pointer; + transition: + background-color var(--_is-transition-duration) + var(--_is-transition-timing-function) var(--_is-transition-delay), + border-color var(--_is-transition-duration) + var(--_is-transition-timing-function) var(--_is-transition-delay), + box-shadow var(--_is-transition-duration) + var(--_is-transition-timing-function) var(--_is-transition-delay), + color var(--_is-transition-duration) var(--_is-transition-timing-function) + var(--_is-transition-delay); +} + +:where(input[type="file"].interactive-surface):not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):focus-visible::file-selector-button { + box-shadow: inset 0 0 0 999px + rgb(0 0 0 / var(--_is-state-layer-focus-opacity)); +} + +/* Zero-specificity state branches let later transient and focus selectors retain precedence. */ +.interactive-surface:where( + .is-active, + [aria-pressed="true"], + [aria-pressed="mixed"], + [aria-current]:not([aria-current="false"]), + [aria-selected="true"], + [aria-busy="true"], + .is-loading + ):not(:is(.is-disabled, :disabled, [aria-disabled="true"])) { + translate: 0 var(--_is-lift-active); + box-shadow: var(--_is-shadow-active); +} + +.interactive-surface:where( + .is-active, + [aria-pressed="true"], + [aria-pressed="mixed"], + [aria-current]:not([aria-current="false"]), + [aria-selected="true"], + [aria-busy="true"], + .is-loading + ):not(:is(.is-disabled, :disabled, [aria-disabled="true"]))::before { + opacity: var(--_is-state-layer-active-opacity); +} + +@media (hover: hover) and (pointer: fine) { + .interactive-surface { + will-change: translate, box-shadow; + } + + .interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):hover { + translate: 0 var(--_is-hover-lift); + box-shadow: var(--_is-shadow-hover); + } + + .interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):hover::before { + opacity: var(--_is-state-layer-hover-opacity); + } + + :where(input[type="file"].interactive-surface):not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):hover::file-selector-button { + box-shadow: inset 0 0 0 999px + rgb(0 0 0 / var(--_is-state-layer-hover-opacity)); + } +} + +.interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):active { + --_is-transition-duration: var(--_is-motion-press); + --_is-transition-timing-function: var(--_is-ease-press); + + translate: 0 var(--_is-lift-base); + box-shadow: var(--_is-shadow-base); +} + +.interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):active::before { + opacity: 0; + transition-duration: var(--_is-motion-press); + transition-timing-function: var(--_is-ease-press); +} + +:where(input[type="file"].interactive-surface):not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):active::file-selector-button { + box-shadow: inset 0 0 0 999px + rgb(0 0 0 / var(--_is-state-layer-active-opacity)); + transition-duration: var(--_is-motion-press); + transition-timing-function: var(--_is-ease-press); +} + +/* + * Disabled styling is visual only. Consumers must still suppress programmatic and keyboard + * activation for custom controls that use aria-disabled or the is-disabled class. + */ +.interactive-surface:is(.is-disabled, :disabled, [aria-disabled="true"]) { + --_is-transition-duration: 0s; + + cursor: not-allowed; + pointer-events: none; + translate: none; + box-shadow: none; + opacity: var(--_is-disabled-opacity); + animation: none; + transition: none; +} + +.interactive-surface:is( + .is-disabled, + :disabled, + [aria-disabled="true"] + )::before { + opacity: 0; + animation: none; + transition: none; +} + +:where(input[type="file"].interactive-surface):is( + .is-disabled, + :disabled, + [aria-disabled="true"] + )::file-selector-button { + cursor: not-allowed; + box-shadow: none; + transition: none; +} + +/* Focus remains visible on custom disabled widgets that stay in the keyboard tab order. */ +.interactive-surface:is(.is-disabled, [aria-disabled="true"]):focus-visible { + outline: var(--_is-focus-ring-width) solid var(--_is-focus-ring-color); + outline-offset: var(--_is-focus-ring-offset); +} + +@media (prefers-reduced-motion: reduce) { + .interactive-surface, + .interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):hover, + .interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):focus-visible, + .interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):active, + .interactive-surface:where( + .is-active, + [aria-pressed="true"], + [aria-pressed="mixed"], + [aria-current]:not([aria-current="false"]), + [aria-selected="true"], + [aria-busy="true"], + .is-loading + ):not(:is(.is-disabled, :disabled, [aria-disabled="true"])) { + --_is-transition-duration: 0s; + + translate: none; + animation: none; + transition: none; + } + + .interactive-surface::before { + animation: none; + transition: none; + } +} + +@media (prefers-contrast: more) { + .interactive-surface { + --_is-focus-ring-width: 3px; + } + + .interactive-surface:where( + .is-active, + [aria-pressed="true"], + [aria-pressed="mixed"], + [aria-current]:not([aria-current="false"]), + [aria-selected="true"], + [aria-busy="true"], + .is-loading + ):not(:is(.is-disabled, :disabled, [aria-disabled="true"])) { + outline: 2px solid currentcolor; + outline-offset: 2px; + } + + .interactive-surface:focus, + .interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):focus-visible, + .interactive-surface[aria-disabled="true"]:focus-visible { + outline: var(--_is-focus-ring-width) solid var(--_is-focus-ring-color); + } +} + +@media (forced-colors: active) { + .interactive-surface { + --_is-lift-base: 0px; + --_is-lift-hover: 0px; + + /* Integration offsets must not bypass the forced-colors motion reset. */ + --_is-hover-lift: 0px; + --_is-lift-active: 0px; + --_is-shadow-base: none; + --_is-shadow-hover: none; + --_is-shadow-active: none; + + box-shadow: none; + } + + .interactive-surface::before { + display: none; + } + + .interactive-surface:where( + .is-active, + [aria-pressed="true"], + [aria-pressed="mixed"], + [aria-current]:not([aria-current="false"]), + [aria-selected="true"], + [aria-busy="true"], + .is-loading + ):not(:is(.is-disabled, :disabled, [aria-disabled="true"])) { + outline: 2px solid ButtonText; + outline-offset: 2px; + } + + .interactive-surface:focus, + .interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):focus-visible { + outline: 2px solid Highlight; + outline-offset: 2px; + } + + .interactive-surface:is(.is-disabled, :disabled, [aria-disabled="true"]) { + outline: 1px solid GrayText; + outline-offset: 2px; + opacity: 1; + } + + .interactive-surface[aria-disabled="true"]:focus-visible { + outline: 2px solid GrayText; + outline-offset: 2px; + } +} diff --git a/styles/standalone-preset.css b/styles/standalone-preset.css new file mode 100644 index 0000000..9ca5613 --- /dev/null +++ b/styles/standalone-preset.css @@ -0,0 +1,494 @@ +/* + * Standalone paint and geometry for interactive surfaces. + * The generated preset appends state-core.css to this authored module. + */ + +:is(.interactive-surface) { + /* Complete bundles retain the established lift and shadow fallbacks without imposing them on the core. */ + --_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-hover: var( + --shadow-hover, + 0 10px 28px rgb(0 0 0 / 0.35), + 0 2px 8px rgb(0 0 0 / 0.25) + ); + --_is-preset-shadow-active: var( + --shadow-active, + 0 6px 18px rgb(0 0 0 / 0.3), + 0 1px 4px rgb(0 0 0 / 0.2) + ); + --_is-base-bg: var( + --interactive-surface-bg, + 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))) + ); + --_is-base-border-color: var( + --interactive-surface-border-color, + 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); + + box-sizing: border-box; + border: var(--_is-border-width) solid var(--_is-border-color); + border-radius: var(--_is-radius); + background-color: var(--_is-bg); + color: var(--_is-fg); + text-decoration: none; + font: inherit; + -webkit-appearance: none; + appearance: none; + background-clip: padding-box; +} + +/* + * UI Style Kit 2.0.1 contributes one pixel of transform-based hover lift and owns paint + * transitions. The preset offsets the resolved lift and protects the complete transition tuple. + */ +:where([data-ui][data-theme][data-mode]) .interactive-surface { + --_is-preset-hover-lift-offset: 1px; + + /* stylelint-disable declaration-block-no-redundant-longhand-properties -- Each longhand must survive companion shorthands in either import order. */ + transition-property: + background-color, border-color, box-shadow, color, transform, translate, + outline-color !important; + transition-duration: var(--_is-transition-duration) !important; + transition-timing-function: var(--_is-transition-timing-function) !important; + transition-delay: var(--_is-transition-delay) !important; + /* stylelint-enable declaration-block-no-redundant-longhand-properties */ +} + +/* Surface levels consume bridge tokens while preserving standalone fallbacks. */ +.interactive-surface[data-surface-level] { + --_is-bg: var(--interactive-surface-level-bg, var(--_is-base-bg)); + --_is-border-color: var( + --interactive-surface-level-border-color, + var(--_is-base-border-color) + ); + --_is-shadow-base: var( + --interactive-surface-level-shadow, + var(--_is-base-shadow) + ); +} + +.interactive-surface[data-surface-level="1"] { + --_is-bg: var( + --interactive-surface-level-bg, + var(--interactive-surface-level-1-bg, var(--_is-base-bg)) + ); + --_is-border-color: var( + --interactive-surface-level-border-color, + var( + --interactive-surface-level-1-border-color, + var(--_is-base-border-color) + ) + ); + --_is-shadow-base: var( + --interactive-surface-level-shadow, + var(--interactive-surface-level-1-shadow, var(--_is-base-shadow)) + ); + --_is-state-layer-hover-opacity: var( + --interactive-surface-state-layer-hover-opacity, + var(--interactive-surface-level-1-hover-opacity, 0.08) + ); + --_is-state-layer-active-opacity: var( + --interactive-surface-state-layer-active-opacity, + var(--interactive-surface-level-1-active-opacity, 0.14) + ); + --_is-state-layer-focus-opacity: var( + --interactive-surface-state-layer-focus-opacity, + var(--interactive-surface-level-1-focus-opacity, 0.18) + ); +} + +.interactive-surface[data-surface-level="2"] { + --_is-bg: var( + --interactive-surface-level-bg, + var(--interactive-surface-level-2-bg, var(--_is-base-bg)) + ); + --_is-border-color: var( + --interactive-surface-level-border-color, + var( + --interactive-surface-level-2-border-color, + var(--_is-base-border-color) + ) + ); + --_is-shadow-base: var( + --interactive-surface-level-shadow, + var(--interactive-surface-level-2-shadow, 0 8px 20px rgb(0 0 0 / 0.22)) + ); + --_is-state-layer-hover-opacity: var( + --interactive-surface-state-layer-hover-opacity, + var(--interactive-surface-level-2-hover-opacity, 0.11) + ); + --_is-state-layer-active-opacity: var( + --interactive-surface-state-layer-active-opacity, + var(--interactive-surface-level-2-active-opacity, 0.18) + ); + --_is-state-layer-focus-opacity: var( + --interactive-surface-state-layer-focus-opacity, + var(--interactive-surface-level-2-focus-opacity, 0.22) + ); +} + +.interactive-surface[data-surface-level="3"] { + --_is-bg: var( + --interactive-surface-level-bg, + var(--interactive-surface-level-3-bg, var(--_is-base-bg)) + ); + --_is-border-color: var( + --interactive-surface-level-border-color, + var( + --interactive-surface-level-3-border-color, + var(--_is-base-border-color) + ) + ); + --_is-shadow-base: var( + --interactive-surface-level-shadow, + var(--interactive-surface-level-3-shadow, 0 14px 34px rgb(0 0 0 / 0.3)) + ); + --_is-state-layer-hover-opacity: var( + --interactive-surface-state-layer-hover-opacity, + var(--interactive-surface-level-3-hover-opacity, 0.14) + ); + --_is-state-layer-active-opacity: var( + --interactive-surface-state-layer-active-opacity, + var(--interactive-surface-level-3-active-opacity, 0.24) + ); + --_is-state-layer-focus-opacity: var( + --interactive-surface-state-layer-focus-opacity, + var(--interactive-surface-level-3-focus-opacity, 0.28) + ); +} + +/* UI Style Kit hosts retain explicit ownership-compatible paint declarations. */ +[data-ui] .interactive-surface { + border: var(--_is-border-width) solid var(--_is-border-color); + border-radius: var(--_is-radius); + background-color: var(--_is-bg); + color: var(--_is-fg); + font: inherit; +} + +/* File inputs have a native selector button; the preset maps it into the same surface token contract. */ +:is(input[type="file"].interactive-surface) { + min-height: 2.75rem; +} + +:is(input[type="file"].interactive-surface)::file-selector-button { + margin-inline-end: 0.75rem; + padding: 0.55rem 0.85rem; + border: var(--_is-border-width) solid var(--_is-border-color); + border-radius: calc(var(--_is-radius) * 0.75); + background-color: var(--_is-bg); + color: var(--_is-fg); + font: inherit; + font-weight: 700; +} + +.interactive-surface.size-sm { + --interactive-surface-lift-hover: -4px; + --interactive-surface-lift-active: -2px; + --interactive-surface-shadow-hover: + 0 6px 16px rgba(0, 0, 0, 0.28), 0 1px 4px rgba(0, 0, 0, 0.2); + --interactive-surface-shadow-active: 0 4px 10px rgba(0, 0, 0, 0.25); +} + +.interactive-surface.size-lg { + --interactive-surface-lift-hover: -8px; + --interactive-surface-lift-active: -4px; + --interactive-surface-shadow-hover: + 0 14px 36px rgba(0, 0, 0, 0.38), 0 3px 10px rgba(0, 0, 0, 0.3); + --interactive-surface-shadow-active: 0 8px 22px rgba(0, 0, 0, 0.32); +} + +.interactive-surface.icon-only { + --interactive-surface-lift-hover: -3px; + --interactive-surface-lift-active: -1.5px; + --interactive-surface-shadow-hover: 0 4px 10px rgba(0, 0, 0, 0.3); + --interactive-surface-shadow-active: 0 2px 6px rgba(0, 0, 0, 0.25); + + display: inline-flex; + justify-content: center; + align-items: center; + min-width: 44px; + min-height: 44px; + padding: 0; + border-radius: 0.5rem; +} + +/* Icon-role hooks provide useful standalone defaults while remaining token-driven. */ +.interactive-surface.icon-only .light-icon, +.interactive-surface.icon-only [data-icon-role="light"] { + color: var( + --interactive-surface-light-icon-color, + var(--icon-light, rgb(212 175 55)) + ); +} + +.interactive-surface.icon-only .dark-icon, +.interactive-surface.icon-only [data-icon-role="dark"] { + color: var( + --interactive-surface-dark-icon-color, + var(--icon-dark, rgb(0 0 0)) + ); +} + +.interactive-surface.icon-only .accessibility-icon, +.interactive-surface.icon-only [data-icon-role="accessibility"] { + color: var( + --interactive-surface-accessibility-icon-color, + var(--icon-accessibility, rgb(59 130 246)) + ); + border-radius: 0; +} + +@media (prefers-color-scheme: dark) { + .interactive-surface.icon-only .light-icon, + .interactive-surface.icon-only [data-icon-role="light"] { + color: var( + --interactive-surface-light-icon-color-dark, + var(--icon-light-dark, rgb(255 255 255)) + ); + } + + .interactive-surface.icon-only .dark-icon, + .interactive-surface.icon-only [data-icon-role="dark"] { + color: var( + --interactive-surface-dark-icon-color-dark, + var(--icon-dark-dark, rgb(30 58 138)) + ); + } + + .interactive-surface.icon-only .accessibility-icon, + .interactive-surface.icon-only [data-icon-role="accessibility"] { + color: var( + --interactive-surface-accessibility-icon-color-dark, + var(--icon-accessibility-dark, rgb(156 163 175)) + ); + } +} + +:where( + [data-theme="dark"], + [data-mode="dark"], + [data-mode="contrast"], + .theme-dark, + .dark-mode, + .dark + ) + .interactive-surface.icon-only + .light-icon, +:where( + [data-theme="dark"], + [data-mode="dark"], + [data-mode="contrast"], + .theme-dark, + .dark-mode, + .dark + ) + .interactive-surface.icon-only + [data-icon-role="light"] { + color: var( + --interactive-surface-light-icon-color-dark, + var(--icon-light-dark, rgb(255 255 255)) + ); +} + +:where( + [data-theme="dark"], + [data-mode="dark"], + [data-mode="contrast"], + .theme-dark, + .dark-mode, + .dark + ) + .interactive-surface.icon-only + .dark-icon, +:where( + [data-theme="dark"], + [data-mode="dark"], + [data-mode="contrast"], + .theme-dark, + .dark-mode, + .dark + ) + .interactive-surface.icon-only + [data-icon-role="dark"] { + color: var( + --interactive-surface-dark-icon-color-dark, + var(--icon-dark-dark, rgb(30 58 138)) + ); +} + +:where( + [data-theme="dark"], + [data-mode="dark"], + [data-mode="contrast"], + .theme-dark, + .dark-mode, + .dark + ) + .interactive-surface.icon-only + .accessibility-icon, +:where( + [data-theme="dark"], + [data-mode="dark"], + [data-mode="contrast"], + .theme-dark, + .dark-mode, + .dark + ) + .interactive-surface.icon-only + [data-icon-role="accessibility"] { + color: var( + --interactive-surface-accessibility-icon-color-dark, + var(--icon-accessibility-dark, rgb(156 163 175)) + ); +} + +.interactive-surface.variant-primary, +.interactive-surface[data-surface-variant="primary"] { + --interactive-surface-bg: var( + --interactive-surface-variant-primary-bg, + 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)) + ); + --interactive-surface-border-color: var( + --interactive-surface-variant-primary-border-color, + var(--variant-primary-border, var(--interactive-surface-bg)) + ); + --interactive-surface-darken-hover: 0.95; + --interactive-surface-darken-active: 0.97; +} + +.interactive-surface.variant-secondary, +.interactive-surface[data-surface-variant="secondary"] { + --interactive-surface-bg: var( + --interactive-surface-variant-secondary-bg, + var(--variant-secondary-bg, rgb(198 220 237)) + ); + --interactive-surface-fg: var( + --interactive-surface-variant-secondary-fg, + var(--variant-secondary-fg, rgb(20 48 70)) + ); + --interactive-surface-border-color: var( + --interactive-surface-variant-secondary-border-color, + var(--variant-secondary-border, rgb(155 183 206)) + ); + --interactive-surface-darken-hover: 0.97; + --interactive-surface-darken-active: 0.99; +} + +.interactive-surface.variant-accent, +.interactive-surface[data-surface-variant="accent"] { + --interactive-surface-bg: var( + --interactive-surface-variant-accent-bg, + var(--variant-accent-bg, rgb(201 240 237)) + ); + --interactive-surface-fg: var( + --interactive-surface-variant-accent-fg, + var(--variant-accent-fg, rgb(6 74 79)) + ); + --interactive-surface-border-color: var( + --interactive-surface-variant-accent-border-color, + var(--variant-accent-border, rgb(105 195 200)) + ); + --interactive-surface-darken-hover: 0.94; + --interactive-surface-darken-active: 0.96; +} + +.interactive-surface.variant-subtle, +.interactive-surface[data-surface-variant="subtle"] { + --interactive-surface-bg: var( + --interactive-surface-variant-subtle-bg, + var(--variant-subtle-bg, rgb(219 231 243)) + ); + --interactive-surface-fg: var( + --interactive-surface-variant-subtle-fg, + var(--variant-subtle-fg, rgb(26 52 75)) + ); + --interactive-surface-border-color: var( + --interactive-surface-variant-subtle-border-color, + var(--variant-subtle-border, rgb(173 191 206)) + ); + --interactive-surface-darken-hover: 0.98; + --interactive-surface-darken-active: 1; +} + +.interactive-surface.variant-warning, +.interactive-surface[data-surface-variant="warning"] { + --interactive-surface-bg: var( + --interactive-surface-variant-warning-bg, + var(--variant-warning-bg, rgb(255 227 186)) + ); + --interactive-surface-fg: var( + --interactive-surface-variant-warning-fg, + var(--variant-warning-fg, rgb(102 61 0)) + ); + --interactive-surface-border-color: var( + --interactive-surface-variant-warning-border-color, + var(--variant-warning-border, rgb(218 172 103)) + ); + --interactive-surface-darken-hover: 0.93; + --interactive-surface-darken-active: 0.95; +} + +.interactive-surface.variant-danger, +.interactive-surface[data-surface-variant="danger"] { + --interactive-surface-bg: var( + --interactive-surface-variant-danger-bg, + var(--variant-danger-bg, rgb(255 211 213)) + ); + --interactive-surface-fg: var( + --interactive-surface-variant-danger-fg, + var(--variant-danger-fg, rgb(116 32 32)) + ); + --interactive-surface-border-color: var( + --interactive-surface-variant-danger-border-color, + var(--variant-danger-border, rgb(215 125 130)) + ); + --interactive-surface-darken-hover: 0.92; + --interactive-surface-darken-active: 0.94; +} + +/* System paint remains part of the standalone preset while core owns forced-color interaction mechanics. */ +@media (forced-colors: active) { + :is(.interactive-surface) { + background-color: ButtonFace; + color: ButtonText; + border-color: ButtonText; + } + + :is(.interactive-surface).is-disabled, + :is(.interactive-surface):disabled, + :is(.interactive-surface)[aria-disabled="true"] { + color: GrayText; + border-color: GrayText; + } + + :is(.interactive-surface[type="file"])::file-selector-button { + border-color: ButtonText; + background-color: ButtonFace; + color: ButtonText; + } + + :is(.interactive-surface[type="file"]):is( + .is-disabled, + :disabled, + [aria-disabled="true"] + )::file-selector-button { + border-color: GrayText; + color: GrayText; + } +} diff --git a/styles/state-core.css b/styles/state-core.css new file mode 100644 index 0000000..5d860e0 --- /dev/null +++ b/styles/state-core.css @@ -0,0 +1,414 @@ +/* + * Interaction mechanics for interactive surfaces. + * Standalone paint and component geometry belong to the companion preset. + */ + +.interactive-surface { + /* Preset-only fallbacks keep the core neutral while preserving complete-bundle behavior. */ + --_is-lift-base: var( + --interactive-surface-lift-base, + var(--_is-preset-lift-base, 0px) + ); + --_is-lift-hover: var( + --interactive-surface-lift-hover, + var(--_is-preset-lift-hover, 0px) + ); + --_is-hover-lift: calc( + var(--_is-lift-hover) + var(--_is-preset-hover-lift-offset, 0px) + ); + --_is-lift-active: var( + --interactive-surface-lift-active, + var(--_is-preset-lift-active, 0px) + ); + --_is-base-shadow: var( + --interactive-surface-shadow-base, + var(--interactive-surface-level-shadow, var(--_is-preset-shadow-base, none)) + ); + --_is-shadow-base: var(--_is-base-shadow); + --_is-shadow-hover: var( + --interactive-surface-shadow-hover, + var(--_is-preset-shadow-hover, var(--_is-base-shadow)) + ); + --_is-shadow-active: var( + --interactive-surface-shadow-active, + var(--_is-preset-shadow-active, var(--_is-base-shadow)) + ); + --_is-darken-hover: var( + --interactive-surface-darken-hover, + var(--surface-darken-hover, 0.96) + ); + --_is-darken-active: var( + --interactive-surface-darken-active, + var(--surface-darken-active, 0.98) + ); + --_is-state-layer-base-opacity: var( + --interactive-surface-state-layer-opacity, + 0 + ); + --_is-state-layer-hover-opacity: var( + --interactive-surface-state-layer-hover-opacity, + var( + --interactive-surface-state-layer-opacity-hover, + calc(1 - var(--_is-darken-hover)) + ) + ); + --_is-state-layer-active-opacity: var( + --interactive-surface-state-layer-active-opacity, + var( + --interactive-surface-state-layer-opacity-active, + calc(1 - var(--_is-darken-active)) + ) + ); + --_is-state-layer-focus-opacity: var( + --interactive-surface-state-layer-focus-opacity, + var( + --interactive-surface-state-layer-opacity-focus, + var(--_is-state-layer-hover-opacity) + ) + ); + --_is-state-layer-color: var( + --interactive-surface-state-layer-color, + rgb(0 0 0) + ); + --_is-motion-default: var( + --interactive-surface-motion-default, + var(--motion-default, 140ms) + ); + --_is-motion-press: var( + --interactive-surface-motion-press, + var(--motion-press, 60ms) + ); + --_is-ease-standard: var( + --interactive-surface-ease-standard, + var(--ease-standard, cubic-bezier(0.2, 0, 0.2, 1)) + ); + --_is-ease-press: var( + --interactive-surface-ease-press, + var(--ease-press, cubic-bezier(0.4, 0, 0.6, 1)) + ); + + /* A private tuple lets complete presets preserve timing across competing shorthands. */ + --_is-transition-duration: var(--_is-motion-default); + --_is-transition-timing-function: var(--_is-ease-standard); + --_is-transition-delay: 0s; + --_is-focus-ring-color: var( + --interactive-surface-focus-ring-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); + --_is-disabled-opacity: var(--interactive-surface-disabled-opacity, 0.72); + --_is-tap-highlight: var( + --interactive-surface-tap-highlight-color, + rgb(11 99 246 / 0.18) + ); + + position: relative; + isolation: isolate; + cursor: pointer; + touch-action: manipulation; + -webkit-tap-highlight-color: var(--_is-tap-highlight); + translate: 0 var(--_is-lift-base); + box-shadow: var(--_is-shadow-base); + transition: + translate var(--_is-transition-duration) + var(--_is-transition-timing-function) var(--_is-transition-delay), + box-shadow var(--_is-transition-duration) + var(--_is-transition-timing-function) var(--_is-transition-delay), + outline-color var(--_is-transition-duration) linear + var(--_is-transition-delay); + will-change: auto; +} + +/* The isolated state layer changes interaction emphasis without filtering content. */ +.interactive-surface::before { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + background-color: var(--_is-state-layer-color); + opacity: var(--_is-state-layer-base-opacity); + pointer-events: none; + z-index: -1; + transition: opacity var(--_is-motion-default) var(--_is-ease-standard); +} + +.interactive-surface:focus { + outline: var(--_is-focus-ring-width) solid var(--_is-focus-ring-color); + outline-offset: var(--_is-focus-ring-offset); +} + +@supports selector(:focus-visible) { + .interactive-surface:focus { + outline: none; + } +} + +.interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):focus-visible { + translate: 0 var(--_is-lift-hover); + box-shadow: var(--_is-shadow-hover); + outline: var(--_is-focus-ring-width) solid var(--_is-focus-ring-color); + outline-offset: var(--_is-focus-ring-offset); +} + +.interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):focus-visible::before { + opacity: var(--_is-state-layer-focus-opacity); +} + +/* Native file inputs expose an inner button that needs the same interaction feedback as the host. */ +:where(input[type="file"].interactive-surface)::file-selector-button { + cursor: pointer; + transition: + background-color var(--_is-transition-duration) + var(--_is-transition-timing-function) var(--_is-transition-delay), + border-color var(--_is-transition-duration) + var(--_is-transition-timing-function) var(--_is-transition-delay), + box-shadow var(--_is-transition-duration) + var(--_is-transition-timing-function) var(--_is-transition-delay), + color var(--_is-transition-duration) var(--_is-transition-timing-function) + var(--_is-transition-delay); +} + +:where(input[type="file"].interactive-surface):not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):focus-visible::file-selector-button { + box-shadow: inset 0 0 0 999px + rgb(0 0 0 / var(--_is-state-layer-focus-opacity)); +} + +/* Zero-specificity state branches let later transient and focus selectors retain precedence. */ +.interactive-surface:where( + .is-active, + [aria-pressed="true"], + [aria-pressed="mixed"], + [aria-current]:not([aria-current="false"]), + [aria-selected="true"], + [aria-busy="true"], + .is-loading + ):not(:is(.is-disabled, :disabled, [aria-disabled="true"])) { + translate: 0 var(--_is-lift-active); + box-shadow: var(--_is-shadow-active); +} + +.interactive-surface:where( + .is-active, + [aria-pressed="true"], + [aria-pressed="mixed"], + [aria-current]:not([aria-current="false"]), + [aria-selected="true"], + [aria-busy="true"], + .is-loading + ):not(:is(.is-disabled, :disabled, [aria-disabled="true"]))::before { + opacity: var(--_is-state-layer-active-opacity); +} + +@media (hover: hover) and (pointer: fine) { + .interactive-surface { + will-change: translate, box-shadow; + } + + .interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):hover { + translate: 0 var(--_is-hover-lift); + box-shadow: var(--_is-shadow-hover); + } + + .interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):hover::before { + opacity: var(--_is-state-layer-hover-opacity); + } + + :where(input[type="file"].interactive-surface):not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):hover::file-selector-button { + box-shadow: inset 0 0 0 999px + rgb(0 0 0 / var(--_is-state-layer-hover-opacity)); + } +} + +.interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):active { + --_is-transition-duration: var(--_is-motion-press); + --_is-transition-timing-function: var(--_is-ease-press); + + translate: 0 var(--_is-lift-base); + box-shadow: var(--_is-shadow-base); +} + +.interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):active::before { + opacity: 0; + transition-duration: var(--_is-motion-press); + transition-timing-function: var(--_is-ease-press); +} + +:where(input[type="file"].interactive-surface):not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):active::file-selector-button { + box-shadow: inset 0 0 0 999px + rgb(0 0 0 / var(--_is-state-layer-active-opacity)); + transition-duration: var(--_is-motion-press); + transition-timing-function: var(--_is-ease-press); +} + +/* + * Disabled styling is visual only. Consumers must still suppress programmatic and keyboard + * activation for custom controls that use aria-disabled or the is-disabled class. + */ +.interactive-surface:is(.is-disabled, :disabled, [aria-disabled="true"]) { + --_is-transition-duration: 0s; + + cursor: not-allowed; + pointer-events: none; + translate: none; + box-shadow: none; + opacity: var(--_is-disabled-opacity); + animation: none; + transition: none; +} + +.interactive-surface:is( + .is-disabled, + :disabled, + [aria-disabled="true"] + )::before { + opacity: 0; + animation: none; + transition: none; +} + +:where(input[type="file"].interactive-surface):is( + .is-disabled, + :disabled, + [aria-disabled="true"] + )::file-selector-button { + cursor: not-allowed; + box-shadow: none; + transition: none; +} + +/* Focus remains visible on custom disabled widgets that stay in the keyboard tab order. */ +.interactive-surface:is(.is-disabled, [aria-disabled="true"]):focus-visible { + outline: var(--_is-focus-ring-width) solid var(--_is-focus-ring-color); + outline-offset: var(--_is-focus-ring-offset); +} + +@media (prefers-reduced-motion: reduce) { + .interactive-surface, + .interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):hover, + .interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):focus-visible, + .interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):active, + .interactive-surface:where( + .is-active, + [aria-pressed="true"], + [aria-pressed="mixed"], + [aria-current]:not([aria-current="false"]), + [aria-selected="true"], + [aria-busy="true"], + .is-loading + ):not(:is(.is-disabled, :disabled, [aria-disabled="true"])) { + --_is-transition-duration: 0s; + + translate: none; + animation: none; + transition: none; + } + + .interactive-surface::before { + animation: none; + transition: none; + } +} + +@media (prefers-contrast: more) { + .interactive-surface { + --_is-focus-ring-width: 3px; + } + + .interactive-surface:where( + .is-active, + [aria-pressed="true"], + [aria-pressed="mixed"], + [aria-current]:not([aria-current="false"]), + [aria-selected="true"], + [aria-busy="true"], + .is-loading + ):not(:is(.is-disabled, :disabled, [aria-disabled="true"])) { + outline: 2px solid currentcolor; + outline-offset: 2px; + } + + .interactive-surface:focus, + .interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):focus-visible, + .interactive-surface[aria-disabled="true"]:focus-visible { + outline: var(--_is-focus-ring-width) solid var(--_is-focus-ring-color); + } +} + +@media (forced-colors: active) { + .interactive-surface { + --_is-lift-base: 0px; + --_is-lift-hover: 0px; + + /* Integration offsets must not bypass the forced-colors motion reset. */ + --_is-hover-lift: 0px; + --_is-lift-active: 0px; + --_is-shadow-base: none; + --_is-shadow-hover: none; + --_is-shadow-active: none; + + box-shadow: none; + } + + .interactive-surface::before { + display: none; + } + + .interactive-surface:where( + .is-active, + [aria-pressed="true"], + [aria-pressed="mixed"], + [aria-current]:not([aria-current="false"]), + [aria-selected="true"], + [aria-busy="true"], + .is-loading + ):not(:is(.is-disabled, :disabled, [aria-disabled="true"])) { + outline: 2px solid ButtonText; + outline-offset: 2px; + } + + .interactive-surface:focus, + .interactive-surface:not( + :is(.is-disabled, :disabled, [aria-disabled="true"]) + ):focus-visible { + outline: 2px solid Highlight; + outline-offset: 2px; + } + + .interactive-surface:is(.is-disabled, :disabled, [aria-disabled="true"]) { + outline: 1px solid GrayText; + outline-offset: 2px; + opacity: 1; + } + + .interactive-surface[aria-disabled="true"]:focus-visible { + outline: 2px solid GrayText; + outline-offset: 2px; + } +} diff --git a/tests/build.test.mjs b/tests/build.test.mjs new file mode 100644 index 0000000..97cf39e --- /dev/null +++ b/tests/build.test.mjs @@ -0,0 +1,141 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + buildBundles, + createBundleMap, + verifyBundles, + verifyPublicBundles, +} from "../scripts/build.mjs"; + +const publicBundleNames = [ + "state-core.css", + "standalone-preset.css", + "interactive-surface.css", +]; + +function withoutGeneratedHeader(css) { + return css.replace(/^\/\*![\s\S]*?\*\/\s*/, ""); +} + +test("the bundle map exposes every public and distribution stylesheet", async () => { + const bundleMap = await createBundleMap(); + const publicKeys = Object.keys(bundleMap).filter( + (target) => !target.startsWith("dist/"), + ); + + assert.deepEqual(publicKeys, publicBundleNames); + + publicBundleNames.forEach((bundleName) => { + const stem = path.basename(bundleName, ".css"); + + assert.equal(bundleMap[`dist/${bundleName}`], bundleMap[bundleName]); + assert.ok( + bundleMap[`dist/${stem}.min.css`], + `Missing minified bundle: ${stem}.min.css`, + ); + }); + + assert.equal( + withoutGeneratedHeader(bundleMap["standalone-preset.css"]), + withoutGeneratedHeader(bundleMap["interactive-surface.css"]), + ); + assert.match( + bundleMap["state-core.css"], + /Generated from: styles\/state-core\.css/, + ); + assert.match( + bundleMap["standalone-preset.css"], + /Generated from: styles\/standalone-preset\.css, styles\/state-core\.css/, + ); + assert.match( + bundleMap["interactive-surface.css"], + /Generated from: styles\/standalone-preset\.css, styles\/state-core\.css/, + ); + assert.doesNotMatch( + bundleMap["state-core.css"], + /(?:background-color|color|border-color): (?:ButtonFace|ButtonText|GrayText)/, + ); + + ["standalone-preset.css", "interactive-surface.css"].forEach((bundleName) => { + const exactBaseSelectors = + bundleMap[bundleName].match(/^\.interactive-surface \{/gm) ?? []; + + assert.equal( + exactBaseSelectors.length, + 1, + `${bundleName} contains a duplicate base selector`, + ); + }); +}); + +test("bundle parity reports the stale target without mutating project files", async (t) => { + const outputDirectory = await mkdtemp( + path.join(tmpdir(), "interactive-surface-build-"), + ); + const bundleMap = await createBundleMap(); + + t.after(() => rm(outputDirectory, { force: true, recursive: true })); + + await buildBundles(bundleMap, outputDirectory); + await verifyBundles(bundleMap, outputDirectory); + + for (const [target, expected] of Object.entries(bundleMap)) { + assert.equal( + await readFile(path.join(outputDirectory, target), "utf8"), + expected, + ); + } + + // Git may materialize generated text with CRLF on Windows even when source output is normalized to LF. + const crlfTarget = "state-core.css"; + await writeFile( + path.join(outputDirectory, crlfTarget), + bundleMap[crlfTarget].replaceAll("\n", "\r\n"), + ); + await verifyBundles(bundleMap, outputDirectory); + + const staleTarget = "interactive-surface.css"; + await writeFile( + path.join(outputDirectory, staleTarget), + "/* stale fixture */\n", + ); + + await assert.rejects( + verifyBundles(bundleMap, outputDirectory), + new RegExp(staleTarget.replace(".", "\\.")), + ); +}); + +test("public parity can run before ignored distribution bundles exist", async (t) => { + const outputDirectory = await mkdtemp( + path.join(tmpdir(), "interactive-surface-public-build-"), + ); + const bundleMap = await createBundleMap(); + const publicBundleMap = Object.fromEntries( + Object.entries(bundleMap).filter(([target]) => !target.startsWith("dist/")), + ); + + t.after(() => rm(outputDirectory, { force: true, recursive: true })); + + // A clean checkout contains the committed public roots but intentionally excludes ignored dist output. + await buildBundles(publicBundleMap, outputDirectory); + await verifyPublicBundles(bundleMap, outputDirectory); + await assert.rejects( + verifyBundles(bundleMap, outputDirectory), + /dist\/state-core\.css/, + ); + + const staleTarget = "state-core.css"; + await writeFile( + path.join(outputDirectory, staleTarget), + "/* stale public fixture */\n", + ); + await assert.rejects( + verifyPublicBundles(bundleMap, outputDirectory), + new RegExp(staleTarget.replace(".", "\\.")), + ); +}); diff --git a/tests/documentation.test.mjs b/tests/documentation.test.mjs new file mode 100644 index 0000000..0f05323 --- /dev/null +++ b/tests/documentation.test.mjs @@ -0,0 +1,444 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const repositoryUrl = "https://github.com/Foscat/Interactive-Surface-CSS"; +const wikiUrl = `${repositoryUrl}/wiki`; +const labUrl = "https://foscat.github.io/interface-systems-lab/"; +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const read = (...segments) => + fs + .readFileSync(path.join(root, ...segments), "utf8") + .replaceAll("\r\n", "\n"); + +const readme = read("README.md"); +const index = read("index.html"); +const manifest = JSON.parse(read("package.json")); +const wiki = { + home: read("wiki", "Home.md"), + gettingStarted: read("wiki", "Getting-Started.md"), + installation: read("wiki", "Installation-and-Usage.md"), + api: read("wiki", "API-Reference.md"), + tokens: read("wiki", "Token-Reference.md"), + accessibility: read("wiki", "Accessibility.md"), + testing: read("wiki", "Testing-and-Quality.md"), + publishing: read("wiki", "Publishing-and-Releases.md"), + roadmap: read("wiki", "Roadmap.md"), + faq: read("wiki", "FAQ.md"), + contributing: read("wiki", "Contributing.md"), + sidebar: read("wiki", "_Sidebar.md"), + footer: read("wiki", "_Footer.md"), +}; +const allDocumentation = [readme, ...Object.values(wiki)].join("\n"); +const authoredCss = [ + read("styles", "state-core.css"), + read("styles", "standalone-preset.css"), +].join("\n"); +const publicTokens = new Set( + authoredCss.match(/--interactive-surface-[a-z0-9-]+/g), +); +const compatibilityTokens = new Set( + authoredCss + .match(/--[a-z][a-z0-9-]+/g) + .filter( + (token) => + !token.startsWith("--interactive-surface-") && + !token.startsWith("--_is-"), + ), +); + +// Documentation URLs are parsed locally so release checks never depend on network availability. +function assertPublicHttpsUrl(value) { + const url = new URL(value); + assert.equal(url.protocol, "https:"); + assert.ok(url.hostname.length > 0); +} + +function assertAppearsInOrder(source, values) { + let cursor = -1; + + for (const value of values) { + const next = source.indexOf(value, cursor + 1); + assert.ok( + next > cursor, + `${value} must appear in the documented npm-reader order`, + ); + cursor = next; + } +} + +// The machine-readable table keeps human-facing package resolution guidance synchronized with npm. +function readPackageResolutionRows(source) { + const match = source.match( + /\n([\s\S]*?)\n/, + ); + assert.ok( + match, + "API reference must contain the package resolution contract table", + ); + + return [...match[1].matchAll(/^\| `([^`]+)` \| `([^`]+)` \|$/gm)].map( + ([, key, value]) => [key, value], + ); +} + +test("README is an npm-first guide with durable project links", () => { + [ + "https://foscat.github.io/Interactive-Surface-CSS/", + labUrl, + `${wikiUrl}/Home`, + `${wikiUrl}/API-Reference`, + `${wikiUrl}/Accessibility`, + `${repositoryUrl}/blob/main/CHANGELOG.md`, + `${repositoryUrl}/blob/main/CONTRIBUTING.md`, + `${repositoryUrl}/blob/main/SECURITY.md`, + `${repositoryUrl}/blob/main/LICENSE`, + ].forEach((url) => { + assertPublicHttpsUrl(url); + assert.ok(readme.includes(url), `README is missing durable URL: ${url}`); + }); + + assertAppearsInOrder(readme, [ + "## Start here", + "## Ownership", + "## 60-second standalone setup", + "## Semantic recipes", + "## Entry points", + "## Compact API", + "## Customize tokens", + "## Accessibility responsibilities", + "## Pair with UI Style Kit CSS", + "## Use all three libraries", + "## Support and project links", + ]); + + for (const [, destination] of readme.matchAll(/\]\(([^)]+)\)/g)) { + assertPublicHttpsUrl(destination); + } +}); + +test("README documents every supported entry point and a pinned CDN setup", () => { + [ + 'import "interactive-surface-css";', + 'import "interactive-surface-css/interactive-surface.css";', + 'import "interactive-surface-css/state-core.css";', + 'import "interactive-surface-css/standalone-preset.css";', + `${manifest.name}@${manifest.version}/standalone-preset.css`, + ].forEach((value) => + assert.ok(readme.includes(value), `README is missing: ${value}`), + ); + + const pinnedVersions = [ + ...readme.matchAll(/interactive-surface-css@(\d+\.\d+\.\d+)\//g), + ].map((match) => match[1]); + assert.ok( + pinnedVersions.length >= 2, + "README must show pinned jsDelivr and unpkg examples", + ); + pinnedVersions.forEach((version) => assert.equal(version, manifest.version)); + ["https://cdn.jsdelivr.net/npm/", "https://unpkg.com/"].forEach((host) => + assert.ok( + readme.includes( + `${host}${manifest.name}@${manifest.version}/standalone-preset.css`, + ), + `README is missing the ${host} manifest-version CDN pin`, + ), + ); + + assert.match( + readme, + /@latest[^\n]*(opt-in|unpinned)|(opt-in|unpinned)[^\n]*@latest/i, + ); + assert.doesNotMatch(readme, /all three companion CSS libraries/i); + ["`main`", "`module`", "`style`", "`unpkg`", "`jsdelivr`"].forEach((field) => + assert.ok( + wiki.api.includes(field), + `API reference is missing package metadata field: ${field}`, + ), + ); +}); + +test("API package resolution matches package.json exactly", () => { + const manifestFields = ["main", "module", "style", "unpkg", "jsdelivr"]; + const expectedRows = [ + ...manifestFields.map((field) => [field, manifest[field]]), + ...Object.entries(manifest.exports).map(([key, target]) => [ + `exports[${JSON.stringify(key)}]`, + typeof target === "string" ? target : JSON.stringify(target), + ]), + ]; + + assert.deepEqual(readPackageResolutionRows(wiki.api), expectedRows); +}); + +test("README teaches the complete semantic and ecosystem contract", () => { + [ + 'aria-pressed="true"', + 'aria-current="page"', + 'aria-selected="true"', + 'aria-busy="true"', + "disabled", + 'aria-disabled="true"', + ".is-disabled", + 'input class="interactive-surface variant-subtle" type="file"', + "::file-selector-button", + "data-surface-variant", + "data-surface-level", + "icon-only", + "data-icon-role", + ].forEach((value) => + assert.ok( + readme.includes(value), + `README is missing semantic API: ${value}`, + ), + ); + + assert.match(readme, /native `disabled`[^\n]*preferred/i); + assert.match( + readme, + /suppress activation[^\n]*`aria-disabled="true"`[^\n]*`.is-disabled`/i, + ); + assert.match(readme, /use one[^\n]*use two[^\n]*use all three/i); + + const allThreeOrder = [ + 'import "ui-style-kit-css/with-bridge.css";', + 'import "interactive-surface-css/state-core.css";', + 'import "layout-style-css/bridge.css";', + 'import "layout-style-css";', + ]; + assertAppearsInOrder( + readme.slice(readme.indexOf("## Use all three libraries")), + allThreeOrder, + ); +}); + +test("wiki API, token, and accessibility references cover the 1.4.0 contract", () => { + assert.ok( + wiki.api.includes( + "`.size-sm`: default lift with a compact shadow profile.", + ), + "API reference must describe the size-sm lift accurately", + ); + assert.doesNotMatch(wiki.api, /\.size-sm[^\n]*compact lift/i); + + [ + 'aria-pressed="mixed"', + '[aria-current]:not([aria-current="false"])', + 'aria-selected="true"', + 'aria-busy="true"', + ".is-loading", + ":disabled", + 'aria-disabled="true"', + ".is-disabled", + 'input[type="file"].interactive-surface::file-selector-button', + "data-surface-variant", + "data-surface-level", + "data-icon-role", + ].forEach((value) => + assert.ok(wiki.api.includes(value), `API reference is missing: ${value}`), + ); + + [ + "Core defaults", + "Standalone preset defaults", + "--interactive-surface-state-layer-color", + "--interactive-surface-motion-default", + "--interactive-surface-disabled-opacity", + "--interactive-surface-variant-primary-bg", + "--interactive-surface-level-1-bg", + "--interactive-surface-light-icon-color", + ].forEach((value) => + assert.ok( + wiki.tokens.includes(value), + `Token reference is missing: ${value}`, + ), + ); + + for (const token of publicTokens) { + assert.ok( + wiki.tokens.includes(token), + `Token reference is missing authored token: ${token}`, + ); + } + + for (const token of compatibilityTokens) { + assert.ok( + wiki.tokens.includes(token), + `Token reference is missing compatibility token: ${token}`, + ); + } + + [ + 'aria-pressed="mixed"', + "aria-current", + "aria-selected", + "aria-busy", + "native `disabled`", + 'aria-disabled="true"', + "`.is-disabled`", + "suppress activation", + "reduced motion", + "forced colors", + ].forEach((value) => + assert.ok( + wiki.accessibility.toLowerCase().includes(value.toLowerCase()), + `Accessibility guide is missing: ${value}`, + ), + ); +}); + +test("wiki installation and quality guidance matches the release-candidate package", () => { + [ + wiki.home, + wiki.gettingStarted, + wiki.installation, + wiki.publishing, + wiki.roadmap, + ].forEach((document) => { + assert.ok( + document.includes("1.4.0"), + "Release-facing wiki page must identify 1.4.0", + ); + assert.match(document, /release candidate/i); + }); + + const normalizeUrlForComparison = (value) => { + const parsed = new URL(value); + const normalizedPath = parsed.pathname.endsWith("/") + ? parsed.pathname + : `${parsed.pathname}/`; + return `${parsed.origin}${normalizedPath}`; + }; + + const expectedLabUrl = normalizeUrlForComparison(labUrl); + const installationUrls = [...wiki.installation.matchAll(/https?:\/\/[^\s)>"'`]+/gi)] + .map((match) => match[0]) + .map((candidate) => { + try { + return normalizeUrlForComparison(candidate); + } catch { + return null; + } + }) + .filter(Boolean); + + assert.ok( + installationUrls.includes(expectedLabUrl), + "Installation page must include the exact interface systems lab URL", + ); + 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 "interactive-surface-css/state-core.css";', + 'import "layout-style-css/bridge.css";', + 'import "layout-style-css";', + ]); + + assert.match( + wiki.testing, + /`npm run (validate|validate:ci)`[^#]*static[^#]*package[^#]*audit/i, + ); + assert.match(wiki.testing, /`npm run validate:browsers`[^#]*Chromium/i); + assert.match( + wiki.testing, + /`npm run validate:full`[^#]*Chromium[^#]*Firefox[^#]*WebKit/i, + ); + assert.match(readme, /Node\.js 20\+/); + assert.match(wiki.testing, /Node\.js 20[^#]*Node\.js 22/i); + assert.match(wiki.testing, /`npm run validate:node20`/); + assert.doesNotMatch(allDocumentation, /Node\.js 18|validate:node18/); +}); + +test("public docs contain no stale 1.x guidance", () => { + const expectedLab = new URL(labUrl); + const hasExpectedLabLink = (document) => + [...document.matchAll(/\]\(([^)]+)\)/g)].some(([, destination]) => { + try { + const parsed = new URL(destination); + return ( + parsed.origin === expectedLab.origin && + (parsed.pathname === expectedLab.pathname || + parsed.pathname.startsWith(expectedLab.pathname)) + ); + } catch { + return false; + } + }); + + assert.ok(hasExpectedLabLink(wiki.sidebar)); + assert.ok(hasExpectedLabLink(wiki.footer)); + assert.doesNotMatch(allDocumentation, /\.surface-card/); + assert.doesNotMatch( + allDocumentation, + /only transform-based motion owner|owns transform-based motion|warn against extra transforms/i, + ); + assert.doesNotMatch( + allDocumentation, + /interactive-surface-css@latest\/(?![^\n]*(?:opt-in|unpinned))/i, + ); +}); + +test("every maintained wiki surface points at current documentation", () => { + [wiki.faq, wiki.contributing, wiki.sidebar, wiki.footer].forEach( + (document) => { + assert.ok( + document.includes(wikiUrl), + "Wiki navigation must use an absolute GitHub Wiki URL", + ); + }, + ); + + assert.ok( + markdownContainsExactPublicUrl(wiki.sidebar, labUrl), + "Wiki sidebar must include lab URL as an exact Markdown link destination", + ); + assert.ok( + markdownContainsExactPublicUrl(wiki.footer, labUrl), + "Wiki footer must include lab URL as an exact Markdown link destination", + ); +}); + +const markdownContainsExactPublicUrl = (document, expectedUrl) => { + const expected = new URL(expectedUrl); + + for (const [, destination] of document.matchAll(/\]\(([^)]+)\)/g)) { + let parsed; + try { + parsed = new URL(destination); + } catch { + continue; + } + + if ( + parsed.origin === expected.origin && + parsed.pathname === expected.pathname && + parsed.search === "" && + parsed.hash === "" + ) { + return true; + } + } + + return false; +}; + +test("public Markdown links have deterministic absolute URL shapes", () => { + let linkCount = 0; + + for (const [, destination] of allDocumentation.matchAll(/\]\(([^)]+)\)/g)) { + assertPublicHttpsUrl(destination); + linkCount += 1; + } + + assert.ok(linkCount > 0, "Documentation must expose public links"); +}); + +test("the demo's offline README fallback cannot drift from README.md", () => { + const match = index.match( + /', + () => ``, + ); +const fullReadmeExampleHtml = exampleHtml.replace( + /(', + ); + expect(sourceHtml).not.toContain('id="demoThemeStyles"'); + expect(sourceHtml).not.toMatch( + /]+href="[^"]*ISC(?:%20| )logo\.png"/i, + ); + expect(sourceHtml).not.toMatch(/]+href="data:image\/svg\+xml/i); + [ + '', + '', + '', + '', + '', + '', + '', + '', + ].forEach((assetReference) => expect(sourceHtml).toContain(assetReference)); + expect(sourceHtml).toContain(' +## Use one, use two, or use all three - -``` +Use one library, use two compatible libraries, or use all three according to the ownership layers you need. -### Svelte +### Interactive Surface alone -```svelte - - - +```js +import "interactive-surface-css/standalone-preset.css"; ``` -## Using with UI Style Kit CSS - -`interactive-surface-css` and `ui-style-kit-css` can be used independently or together. - -UI Style Kit owns visual theme tokens. Interactive Surface owns interaction behavior on `.interactive-surface`. - -With `ui-style-kit-css` 2.x, the default full bundle does not include the bridge. Use the opt-in bridge bundle when you want runtime UI switching and Interactive Surface token mapping in one import: +### With UI Style Kit CSS ```js import "ui-style-kit-css/with-bridge.css"; -import "interactive-surface-css/interactive-surface.css"; +import "interactive-surface-css/state-core.css"; ``` -With per-style UI Style Kit imports, include the bridge: +UI Style Kit owns paint and theme modes; Interactive Surface owns interaction states. + +### With all three libraries ```js -import "ui-style-kit-css/styles/minimal-saas.css"; -import "ui-style-kit-css/interactive-surface-bridge"; -import "interactive-surface-css/interactive-surface.css"; +import "ui-style-kit-css/with-bridge.css"; +import "interactive-surface-css/state-core.css"; +import "layout-style-css/bridge.css"; +import "layout-style-css"; ``` -This order is also supported: +Layout Style CSS owns structure and geometry. The [Interface Systems Lab](https://foscat.github.io/interface-systems-lab/) demonstrates the complete composition. -```js -import "interactive-surface-css/interactive-surface.css"; -import "ui-style-kit-css/styles/minimal-saas.css"; -import "ui-style-kit-css/interactive-surface-bridge"; -``` +## State and activation responsibilities -The bridge maps UI Style Kit theme roles into Interactive Surface tokens. UI Style Kit owns those visual token values; Interactive Surface owns the hover, focus, pressed, selected, disabled, and reduced-motion behavior on `.interactive-surface`. `data-surface-variant` and `data-surface-level` are safe standalone hooks, so bridge-generated markup keeps meaningful interaction behavior without requiring companion class names. +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. -## Motion guardrail +Interaction lift uses the individual `translate` property and composes with consumer-owned `transform`, `scale`, and `rotate`. -`interactive-surface` owns transform-based motion on the host element. +## Next references -Avoid applying additional `transform`, `translate`, `scale`, or `rotate` rules directly to the same node. If you need extra animation, put it on a child element instead. +- [API Reference](https://github.com/Foscat/Interactive-Surface-CSS/wiki/API-Reference) +- [Token Reference](https://github.com/Foscat/Interactive-Surface-CSS/wiki/Token-Reference) +- [Accessibility](https://github.com/Foscat/Interactive-Surface-CSS/wiki/Accessibility) diff --git a/wiki/Publishing-and-Releases.md b/wiki/Publishing-and-Releases.md index a30bd3b..1ad46e9 100644 --- a/wiki/Publishing-and-Releases.md +++ b/wiki/Publishing-and-Releases.md @@ -1,65 +1,78 @@ # Publishing and Releases -This page documents the release workflow for Interactive Surface CSS. +Interactive Surface CSS 1.4.0 is a release candidate until the package is published. Preparing this branch does not authorize an npm publish, Git tag, or GitHub Release. -## Publish Path +## Release ownership -Primary path: GitHub Release -> GitHub Actions -> npm publish. +The repository's intended path is: -Workflow file: +1. Merge an approved, fully verified release candidate. +2. Create the matching GitHub Release and version tag. +3. Let the npm publish workflow validate the release identity and publish with provenance. +4. Verify npm and pinned CDN distribution. -- `.github/workflows/npm-publish.yml` +See the [npm publish workflow](https://github.com/Foscat/Interactive-Surface-CSS/blob/main/.github/workflows/npm-publish.yml) for the executable release rules. -## Required Repository Secret +## 1.4.0 release-candidate checklist -Add this secret in GitHub repository settings: +1. Confirm `package.json` and `package-lock.json` identify `1.4.0`. +2. Confirm the 1.4.0 changelog entry describes entry points, state behavior, compatibility, accessibility, demo, and documentation. +3. Build public stylesheets and verify generated parity. +4. Run deterministic validation: -- `NPM_TOKEN`: npm token with publish rights for the package + ```bash + npm run validate + ``` -## Release Checklist +5. Run the supported browser matrix: -1. Update code and docs. -2. Bump `version` in `package.json`. -3. Update `CHANGELOG.md`. -4. Run local checks: + ```bash + npm run validate:full + ``` -```bash -npm run validate -``` +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. + +## Validation tiers + +- `validate` and `validate:ci`: deterministic static, generated, contract, package, and audit checks. +- `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. + +## Release identity + +The tag, GitHub Release, package version, lockfile version, and changelog heading must agree. For this candidate, the expected tag is `v1.4.0`. -5. Push changes to `main`. -6. Create and publish a GitHub Release for that version tag (for example `v1.2.3`). -7. Confirm `Publish to npm` workflow succeeds. -8. Verify distribution: +## Distribution verification + +After an approved publish, verify: - `https://registry.npmjs.org/interactive-surface-css` -- `https://cdn.jsdelivr.net/npm/interactive-surface-css@/interactive-surface.css` -- `https://unpkg.com/interactive-surface-css@/interactive-surface.css` +- `https://cdn.jsdelivr.net/npm/interactive-surface-css@1.4.0/interactive-surface.css` +- `https://cdn.jsdelivr.net/npm/interactive-surface-css@1.4.0/state-core.css` +- `https://cdn.jsdelivr.net/npm/interactive-surface-css@1.4.0/standalone-preset.css` +- `https://unpkg.com/interactive-surface-css@1.4.0/interactive-surface.css` +- `https://unpkg.com/interactive-surface-css@1.4.0/state-core.css` +- `https://unpkg.com/interactive-surface-css@1.4.0/standalone-preset.css` + +Do not treat a local pack or a successful workflow validation as proof that these live URLs are available. -## Manual Fallback +## Manual fallback -If GitHub Actions is unavailable, publish from a trusted local machine: +If the automated release path is unavailable, publishing from a trusted local machine still requires the same verification and explicit approval: ```bash npm adduser npm publish --access public ``` -The `prepublishOnly` guard intentionally avoids browser downloads so `npm publish` does not stall on a fresh Playwright cache. Use `npm run validate` for the full local release check, including Playwright browser installation and tests. - -## Versioning Guidance - -Use semantic versioning: - -- `x.y.z` patch for fixes -- minor for backward-compatible additions -- major for breaking behavior - -## Release Notes +## Versioning -For each release, include: +- Patch: backward-compatible fixes. +- Minor: backward-compatible capabilities or entry points. +- Major: intentional breaking changes. -- summary of changes -- API/token behavior changes -- accessibility notes -- migration notes for breaking changes +The 1.4.0 minor release adds focused entry points and semantic-state coverage while preserving the 1.x contract. diff --git a/wiki/Roadmap.md b/wiki/Roadmap.md index 5de53f8..a0ed48b 100644 --- a/wiki/Roadmap.md +++ b/wiki/Roadmap.md @@ -1,33 +1,41 @@ # Roadmap -This page captures sensible next steps for the package without over-expanding its scope. +Interactive Surface CSS 1.4.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. -## Near-term improvements +## 1.4.0 release candidate -- Expand example coverage for links, toggles, and card-like surfaces -- Add more documentation around semantic usage patterns -- Add package badges and npm usage guidance after publish -- Add visual regression snapshots if the package grows in complexity +- Preserve every existing 1.x import, selector, hook, and token fallback. +- Add `state-core.css` for design-system integrations. +- Add `standalone-preset.css` for complete standalone use. +- Generate the compatibility bundle from the same authored modules. +- Cover pressed mixed, non-false current, selected, busy, loading, and disabled precedence. +- Preserve state meaning under reduced motion, higher contrast, and forced colors. +- Compose interaction `translate` with consumer `transform`, `scale`, and `rotate`. +- Turn the demo into a state-first interaction lab. +- Publish an npm-first README and aligned wiki. +- Lock the package, audit, Chromium, and full-browser release gates. -## Possible future enhancements +Publication, tagging, and a GitHub Release remain separate approval-gated steps. -- Additional documented size profiles if demand is real -- More formal migration notes for token evolution -- Optional companion examples for React, Vue, and Svelte integrations -- Theme recipe examples for light, dark, and brand-accent variants +## After 1.4.0 -## Guardrails for future work +Potential follow-up work must be driven by demonstrated consumer need: + +- 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 +- add state recipes for new semantic platform behavior when browsers expose stable primitives + +## Permanent guardrails The package should remain: -- small -- framework-agnostic +- small and framework-agnostic +- compatible with plain HTML and existing design systems - accessibility-forward - token-driven -- focused on interaction surfaces rather than becoming a full utility framework - -## What should probably stay out +- independently useful +- complementary to `ui-style-kit-css` and `layout-style-css` +- focused on interaction states rather than page layout or application theming -- large bundles of unrelated helper classes -- framework-specific wrappers in the main package -- brittle visual flourishes that reduce accessibility or predictability +The [Interface Systems Lab](https://foscat.github.io/interface-systems-lab/) remains the canonical proof of the three-library composition. diff --git a/wiki/Testing-and-Quality.md b/wiki/Testing-and-Quality.md index 39d7b2b..73f1f93 100644 --- a/wiki/Testing-and-Quality.md +++ b/wiki/Testing-and-Quality.md @@ -1,87 +1,79 @@ # Testing and Quality -The repository includes both CSS linting and Playwright-based behavioral tests. +The 1.4.0 release candidate uses layered gates so contributors can choose fast deterministic checks or the complete cross-browser suite without confusing the two. -## Available scripts +## Validation tiers -```bash -npm run check:no-hex-colors -npm run lint:css -npm run test:install -npm test -npm run test:chromium -npm run pack:dry -npm run validate -``` - -## What each script does - -### `npm run check:no-hex-colors` +### `npm run validate` and `npm run validate:ci` -Runs a guard script that fails if hex color literals are present in `interactive-surface.css`. +The deterministic release gate covers static checks, generated-bundle parity, CSS linting, documentation and public contracts, the packed-package contract, a dry package inspection, and the npm audit. It does not install or launch browsers. -### `npm run lint:css` +Use this tier for normal CI and before requesting review. -Runs Stylelint against `interactive-surface.css`. +CI runs the full deterministic tier on Node.js 20 and Node.js 22. The Node.js 20 lane is the minimum supported npm and contributor runtime through `npm run validate:node20`; the Node.js 22 lane is the preferred release-validation runtime through `npm run validate:ci`. Official workflow actions are pinned to reviewed commit revisions. -### `npm run test:install` +### `npm run validate:browsers` -Installs the Chromium, Firefox, and WebKit browser binaries (and their OS-level dependencies) required by the Playwright projects. Run this before the browser tests on a fresh machine or CI environment. +This runs the deterministic validation tier plus browser installation and the Chromium Playwright project. It is the focused rendered-behavior gate. -### `npm test` +### `npm run validate:full` -Runs the Playwright suite using the project config. +This runs the deterministic validation tier plus the complete Playwright matrix in Chromium, Firefox, and WebKit. It is the final local release gate when all supported browser binaries are available. -### `npm run test:chromium` +## Focused commands -Runs the Playwright suite in Chromium only. Useful for quick local verification. +| Command | Purpose | +| ----------------------------- | -------------------------------------------------------------------------- | +| `npm run check:no-hex-colors` | Reject disallowed hex color literals | +| `npm run check:public` | Verify committed public roots before generating ignored distribution files | +| `npm run check:generated` | Verify public and distribution bundles after a build | +| `npm run lint:css` | Run Stylelint over authored, generated, and demo CSS | +| `npm run test:contracts` | Run deterministic public, build, and documentation contracts | +| `npm run test:chromium` | Run the selected Playwright tests in Chromium | +| `npm test` | Run the configured Chromium, Firefox, and WebKit projects | +| `npm run pack:dry` | Inspect the npm tarball allowlist without publishing | +| `npm audit` | Check the dependency tree against the npm advisory database | -### `npm run pack:dry` +The exact script graph is finalized as part of the 1.4.0 release candidate before publication. Browser downloads intentionally remain outside `prepublishOnly`. -Runs `npm pack --dry-run` using the local npm cache folder so the final published package contents can be inspected before release. +## Contract coverage -### `npm run validate` +Deterministic tests verify: -Runs the full release validation sequence: color guard, CSS linting, Playwright browser installation, Playwright tests, and the dry package check. +- preserved 1.x selectors, JavaScript entries, stylesheet paths, data hooks, ARIA hooks, and token families +- resolvable `state-core.css`, `standalone-preset.css`, and compatibility bundles +- generated-root and distribution parity +- state-core ownership boundaries +- packed-package contents +- npm-safe README and wiki guidance +- exact README and embedded demo-fallback parity -## Behavioral test coverage in the repo +## Rendered behavior coverage -Current Playwright tests validate: +Playwright verifies: -- standalone fallback style resolution +- fine-pointer hover and transient press - keyboard focus visibility -- `aria-pressed` active styling -- `aria-disabled` non-interactivity -- `data-surface-variant` parity with `.variant-*` classes -- `data-surface-level` standalone depth and state defaults -- reduced-motion transform removal -- icon-only minimum target size -- index demo page rendering and control count +- pressed true and mixed, non-false current, selected, busy, loading, and established active states +- native, ARIA, and class-disabled precedence +- static state meaning under reduced motion +- forced-colors and higher-contrast affordances +- consumer transform composition +- coarse-pointer behavior +- standalone icon target sizing +- UI Style Kit integration in supported import orders +- demo responsiveness, semantic state workflows, dialog focus containment, and error reporting -## Relevant test files +## Documentation URL policy -- `tests/interactive-surface.spec.ts` -- `tests/example.spec.ts` +Documentation tests validate URL shape locally and never fetch live pages. README wiki links use absolute GitHub Wiki URLs because npm renders the README outside the repository. Repository files excluded from the package use absolute GitHub blob URLs. -## Why Playwright is used here +## Before publishing -This library is visual and state-driven. Browser-level validation is more useful than unit-testing implementation details because the value of the package is expressed through computed styles and real browser interaction behavior. - -## Recommended local release check - -Run this sequence before publishing: +Run: ```bash -npm run validate +npm run validate:full ``` -## Package contents check - -Review the dry-run output and confirm that only intended public files are included: - -- `index.js` -- `interactive-surface.css` -- `README.md` -- `CHANGELOG.md` -- `LICENSE` -- `index.html` +Then review the packed-file list and any environment-specific browser skips. A green local gate does not publish, tag, or create a GitHub Release. diff --git a/wiki/Token-Reference.md b/wiki/Token-Reference.md index e717311..b2ca453 100644 --- a/wiki/Token-Reference.md +++ b/wiki/Token-Reference.md @@ -1,10 +1,34 @@ # Token Reference -Interactive Surface CSS uses CSS custom properties for theming. +Interactive Surface CSS resolves public custom properties where `.interactive-surface` is used. It does not declare a global `:root` token set, so installing the package does not overwrite an application's global theme. -## Preferred token namespace +All preferred public properties use the `--interactive-surface-*` namespace. Existing 1.x legacy and semantic fallback names remain supported. -These are the primary public tokens: +## Core defaults + +`state-core.css` owns interaction mechanics and supplies neutral fallbacks that do not define a component theme. + +### State layer + +- `--interactive-surface-state-layer-color` +- `--interactive-surface-state-layer-opacity` +- `--interactive-surface-state-layer-hover-opacity` +- `--interactive-surface-state-layer-focus-opacity` +- `--interactive-surface-state-layer-active-opacity` + +The UI Style Kit bridge aliases remain recognized: + +- `--interactive-surface-state-layer-opacity-hover` +- `--interactive-surface-state-layer-opacity-focus` +- `--interactive-surface-state-layer-opacity-active` + +### Focus + +- `--interactive-surface-focus-ring-color` +- `--interactive-surface-focus-ring-width` +- `--interactive-surface-focus-ring-offset` + +### Motion and depth - `--interactive-surface-lift-base` - `--interactive-surface-lift-hover` @@ -12,157 +36,132 @@ These are the primary public tokens: - `--interactive-surface-shadow-base` - `--interactive-surface-shadow-hover` - `--interactive-surface-shadow-active` -- `--interactive-surface-darken-hover` -- `--interactive-surface-darken-active` - `--interactive-surface-motion-default` - `--interactive-surface-motion-press` - `--interactive-surface-ease-standard` - `--interactive-surface-ease-press` -- `--interactive-surface-bg` -- `--interactive-surface-fg` -- `--interactive-surface-border-color` -- `--interactive-surface-border-width` -- `--interactive-surface-radius` -- `--interactive-surface-focus-ring-color` -- `--interactive-surface-focus-ring-width` -- `--interactive-surface-focus-ring-offset` + +The core's lift fallbacks are neutral. The standalone preset opts into the established lift and shadow values. + +### Persistent-state emphasis + +- `--interactive-surface-darken-hover` +- `--interactive-surface-darken-active` + +These feed state-layer opacity fallback calculations. The state layer changes emphasis without applying a filter to consumer content. + +### Disabled and touch + - `--interactive-surface-disabled-opacity` - `--interactive-surface-tap-highlight-color` -## Legacy fallback tokens - -The stylesheet still recognizes legacy token names: - -- `--lift-base` -- `--lift-hover` -- `--lift-active` -- `--shadow-base` -- `--shadow-hover` -- `--shadow-active` -- `--surface-darken-hover` -- `--surface-darken-active` -- `--motion-default` -- `--motion-press` -- `--ease-standard` -- `--ease-press` - -## Semantic color fallbacks - -The stylesheet also recognizes these semantic fallbacks: - -- `--surface-bg` -- `--bg-surface` -- `--surface-fg` -- `--text-primary` -- `--surface-border` -- `--border-color` -- `--focus-ring` +Loading state uses the same state-layer and active-depth token contract. Consumers can scope overrides to `[aria-busy="true"]` or `.is-loading` when a distinct loading treatment is needed. -## UI Style Kit CSS bridge +## Standalone preset defaults -When used with `ui-style-kit-css` 2.x, the UI Style Kit bridge maps active `data-ui`, `data-theme`, and `data-mode` roles into this package's public `--interactive-surface-*` contract. +`standalone-preset.css` adds paint, variant, level, icon-role, lift, shadow, and target-geometry defaults. `interactive-surface.css` contains the same defaults for compatibility. + +### Base paint + +- `--interactive-surface-bg` +- `--interactive-surface-fg` +- `--interactive-surface-border-color` +- `--interactive-surface-border-width` +- `--interactive-surface-radius` -Interactive Surface does not depend on UI Style Kit token names directly. It continues to resolve values through its own token contract, so the package remains usable without UI Style Kit. +### Variants -The bridge can also provide state-layer aliases for visible hover, focus, and active feedback: +| Variant | Background | Foreground | Border | +| --------- | -------------------------------------------- | -------------------------------------------- | ------------------------------------------------------ | +| Primary | `--interactive-surface-variant-primary-bg` | `--interactive-surface-variant-primary-fg` | `--interactive-surface-variant-primary-border-color` | +| Secondary | `--interactive-surface-variant-secondary-bg` | `--interactive-surface-variant-secondary-fg` | `--interactive-surface-variant-secondary-border-color` | +| Accent | `--interactive-surface-variant-accent-bg` | `--interactive-surface-variant-accent-fg` | `--interactive-surface-variant-accent-border-color` | +| Subtle | `--interactive-surface-variant-subtle-bg` | `--interactive-surface-variant-subtle-fg` | `--interactive-surface-variant-subtle-border-color` | +| Warning | `--interactive-surface-variant-warning-bg` | `--interactive-surface-variant-warning-fg` | `--interactive-surface-variant-warning-border-color` | +| Danger | `--interactive-surface-variant-danger-bg` | `--interactive-surface-variant-danger-fg` | `--interactive-surface-variant-danger-border-color` | -- `--interactive-surface-state-layer-opacity-hover` -- `--interactive-surface-state-layer-opacity-focus` -- `--interactive-surface-state-layer-opacity-active` +### Surface levels -When a surface includes `data-surface-level`, Interactive Surface reads these optional depth tokens: +Generic bridge-facing level tokens: - `--interactive-surface-level-bg` - `--interactive-surface-level-border-color` - `--interactive-surface-level-shadow` -The package also exposes per-level defaults that bridge styles or app themes can override: +| Level | Paint and depth | State opacity | +| ----- | ------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | `--interactive-surface-level-1-bg`, `--interactive-surface-level-1-border-color`, `--interactive-surface-level-1-shadow` | `--interactive-surface-level-1-hover-opacity`, `--interactive-surface-level-1-focus-opacity`, `--interactive-surface-level-1-active-opacity` | +| 2 | `--interactive-surface-level-2-bg`, `--interactive-surface-level-2-border-color`, `--interactive-surface-level-2-shadow` | `--interactive-surface-level-2-hover-opacity`, `--interactive-surface-level-2-focus-opacity`, `--interactive-surface-level-2-active-opacity` | +| 3 | `--interactive-surface-level-3-bg`, `--interactive-surface-level-3-border-color`, `--interactive-surface-level-3-shadow` | `--interactive-surface-level-3-hover-opacity`, `--interactive-surface-level-3-focus-opacity`, `--interactive-surface-level-3-active-opacity` | -- `--interactive-surface-level-1-bg` -- `--interactive-surface-level-1-border-color` -- `--interactive-surface-level-1-shadow` -- `--interactive-surface-level-2-bg` -- `--interactive-surface-level-2-border-color` -- `--interactive-surface-level-2-shadow` -- `--interactive-surface-level-3-bg` -- `--interactive-surface-level-3-border-color` -- `--interactive-surface-level-3-shadow` +### Icon roles -The matching state-opacity hooks are `--interactive-surface-level--hover-opacity`, `--interactive-surface-level--active-opacity`, and `--interactive-surface-level--focus-opacity`. +- `--interactive-surface-light-icon-color` +- `--interactive-surface-light-icon-color-dark` +- `--interactive-surface-dark-icon-color` +- `--interactive-surface-dark-icon-color-dark` +- `--interactive-surface-accessibility-icon-color` +- `--interactive-surface-accessibility-icon-color-dark` -## Important implementation detail +These values apply to the matching `data-icon-role` hooks and preserved icon-role classes inside `.icon-only`. -The package does **not** define global `:root` tokens. +## Existing fallback tokens -Instead, values are resolved inline through fallback chains. That makes the stylesheet safer to drop into an existing app without unexpectedly redefining the global token layer. +### Legacy interaction fallbacks -## Color notation standard +- `--lift-base`, `--lift-hover`, `--lift-active` +- `--shadow-base`, `--shadow-hover`, `--shadow-active` +- `--surface-darken-hover`, `--surface-darken-active` +- `--motion-default`, `--motion-press` +- `--ease-standard`, `--ease-press` -Use functional color notation for token values: +### Semantic paint fallbacks -- `rgb(255 255 255)` -- `rgb(15 23 42 / 80%)` -- `hsl(200deg 100% 50%)` +- `--surface-bg`, `--bg-surface` +- `--surface-fg`, `--text-primary` +- `--surface-border`, `--border-color` +- `--focus-ring` -Avoid hex literals in examples and production token files so palette updates are easier to audit and automate. +### Preserved icon fallbacks -## Starter theme example +- `--icon-light`, `--icon-light-dark` +- `--icon-dark`, `--icon-dark-dark` +- `--icon-accessibility`, `--icon-accessibility-dark` -```css -:root { - --interactive-surface-bg: rgb(255 255 255); - --interactive-surface-fg: rgb(16 42 67); - --interactive-surface-border-color: rgb(213 223 232); - --interactive-surface-radius: 14px; - --interactive-surface-focus-ring-color: rgb(0 126 138); - - --interactive-surface-lift-hover: -4px; - --interactive-surface-lift-active: -2px; - --interactive-surface-shadow-hover: 0 10px 28px rgb(0 0 0 / 0.35); - --interactive-surface-shadow-active: 0 6px 18px rgb(0 0 0 / 0.3); -} -``` +### Preserved variant fallbacks -## Component-scoped override example +- `--variant-primary-bg`, `--variant-primary-fg`, `--variant-primary-border` +- `--variant-secondary-bg`, `--variant-secondary-fg`, `--variant-secondary-border` +- `--variant-accent-bg`, `--variant-accent-fg`, `--variant-accent-border` +- `--variant-subtle-bg`, `--variant-subtle-fg`, `--variant-subtle-border` +- `--variant-warning-bg`, `--variant-warning-fg`, `--variant-warning-border` +- `--variant-danger-bg`, `--variant-danger-fg`, `--variant-danger-border` + +These non-namespaced families are compatibility-only. Prefer the namespaced properties in new code. + +## Starter override ```css -.product-card .interactive-surface { - --interactive-surface-lift-hover: -6px; - --interactive-surface-shadow-hover: 0 14px 30px rgb(0 0 0 / 32%); +.account-action { + --interactive-surface-bg: rgb(245 250 252); + --interactive-surface-fg: rgb(17 45 55); + --interactive-surface-border-color: rgb(160 190 200); + --interactive-surface-focus-ring-color: rgb(0 95 115); + --interactive-surface-state-layer-color: rgb(0 45 55); + --interactive-surface-state-layer-hover-opacity: 0.1; + --interactive-surface-motion-default: 120ms; } ``` -## Token intent guidance - -### Appearance tokens +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. -Use these to match brand or theme: +## UI Style Kit bridge -- background -- foreground -- border color -- radius -- focus ring color +`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: -### Motion and depth tokens - -Use these to tune tactile behavior: - -- lift values -- shadow values -- motion duration -- easing curves -- hover and active brightness modifiers - -### Accessibility-related tokens - -Use these carefully: - -- focus ring width -- focus ring offset -- disabled opacity -- tap highlight color - -Avoid reducing focus visibility below practical usability. +```js +import "ui-style-kit-css/with-bridge.css"; +import "interactive-surface-css/state-core.css"; +``` -`--interactive-surface-tap-highlight-color` controls the native mobile tap flash color applied via `-webkit-tap-highlight-color`. The default is a semi-transparent blue (`rgb(11 99 246 / 0.18)`). Set to `transparent` to suppress the flash entirely (not recommended for touch-only contexts), or set to a brand color to align tap feedback with your theme. +UI Style Kit owns the mapped paint values. Interactive Surface owns state behavior and remains usable without the bridge. diff --git a/wiki/_Footer.md b/wiki/_Footer.md index 3f337c0..f1bf777 100644 --- a/wiki/_Footer.md +++ b/wiki/_Footer.md @@ -1 +1 @@ -[GitHub Repository](https://github.com/Foscat/Interactive-Surface-CSS) | [README](https://github.com/Foscat/Interactive-Surface-CSS/blob/main/README.md) +[GitHub Repository](https://github.com/Foscat/Interactive-Surface-CSS) · [README](https://github.com/Foscat/Interactive-Surface-CSS/blob/main/README.md) · [Wiki Home](https://github.com/Foscat/Interactive-Surface-CSS/wiki/Home) · [Standalone Demo](https://foscat.github.io/Interactive-Surface-CSS/) · [Interface Systems Lab](https://foscat.github.io/interface-systems-lab/) diff --git a/wiki/_Sidebar.md b/wiki/_Sidebar.md index e6fa0aa..448b912 100644 --- a/wiki/_Sidebar.md +++ b/wiki/_Sidebar.md @@ -1,13 +1,18 @@ ## Interactive Surface CSS -- [Home](Home) -- [Getting Started](Getting-Started) -- [Installation and Usage](Installation-and-Usage) -- [API Reference](API-Reference) -- [Token Reference](Token-Reference) -- [Accessibility](Accessibility) -- [Testing and Quality](Testing-and-Quality) -- [Publishing and Releases](Publishing-and-Releases) -- [Contributing](Contributing) -- [FAQ](FAQ) -- [Roadmap](Roadmap) +- [Home](https://github.com/Foscat/Interactive-Surface-CSS/wiki/Home) +- [Getting Started](https://github.com/Foscat/Interactive-Surface-CSS/wiki/Getting-Started) +- [Installation and Usage](https://github.com/Foscat/Interactive-Surface-CSS/wiki/Installation-and-Usage) +- [API Reference](https://github.com/Foscat/Interactive-Surface-CSS/wiki/API-Reference) +- [Token Reference](https://github.com/Foscat/Interactive-Surface-CSS/wiki/Token-Reference) +- [Accessibility](https://github.com/Foscat/Interactive-Surface-CSS/wiki/Accessibility) +- [Testing and Quality](https://github.com/Foscat/Interactive-Surface-CSS/wiki/Testing-and-Quality) +- [Publishing and Releases](https://github.com/Foscat/Interactive-Surface-CSS/wiki/Publishing-and-Releases) +- [Contributing](https://github.com/Foscat/Interactive-Surface-CSS/wiki/Contributing) +- [FAQ](https://github.com/Foscat/Interactive-Surface-CSS/wiki/FAQ) +- [Roadmap](https://github.com/Foscat/Interactive-Surface-CSS/wiki/Roadmap) + +## Live examples + +- [Standalone state lab](https://foscat.github.io/Interactive-Surface-CSS/) +- [Interface Systems Lab](https://foscat.github.io/interface-systems-lab/)