From 5707e79d858f8de6412e23dc5796d1bffce9dd7d Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Thu, 20 Aug 2026 18:15:46 -0700 Subject: [PATCH 001/246] =?UTF-8?q?docs(windmod):=20open=20the=20react-win?= =?UTF-8?q?dmod=20campaign=20=E2=80=94=20plan,=20ledger,=20upstream-block?= =?UTF-8?q?=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approved plan for @fluentui/react-windmod-preview (headless components styled with Tailwind v4 + CSS Modules) and @fluentui/react-tailwind-theme-preview (Fluent themes as a Tailwind CSS layer, ported from the closed styling/tailwind-css-modules campaign). Pilot: Button + Tooltip + ThemeProvider. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- .gitignore | 5 + migration/windmod/BLOCKED_ON_UPSTREAM.md | 15 +++ migration/windmod/LEDGER.md | 27 +++++ migration/windmod/PLAN.md | 124 +++++++++++++++++++++++ 4 files changed, 171 insertions(+) create mode 100644 migration/windmod/BLOCKED_ON_UPSTREAM.md create mode 100644 migration/windmod/LEDGER.md create mode 100644 migration/windmod/PLAN.md diff --git a/.gitignore b/.gitignore index 758faab0dd1e62..aaa1ec55c8d9c4 100644 --- a/.gitignore +++ b/.gitignore @@ -160,3 +160,8 @@ gulp-cache .cursor/rules/nx-rules.mdc .github/instructions/nx.instructions.md .playwright-cli + +# session scratch space (never committed) +.scratch/ +graphify-out/ +packages/graphify-out/ diff --git a/migration/windmod/BLOCKED_ON_UPSTREAM.md b/migration/windmod/BLOCKED_ON_UPSTREAM.md new file mode 100644 index 00000000000000..62cd03ec3348bf --- /dev/null +++ b/migration/windmod/BLOCKED_ON_UPSTREAM.md @@ -0,0 +1,15 @@ +# Blocked on upstream headless coverage + +Components in @fluentui/react-components with NO counterpart in +@fluentui/react-headless-components-preview (as of master 06cbcbe0b1, +headless v0.2.5). We adopt them as upstream ships them; the export-map diff +script (Phase 0/2) flags additions on every upstream sync. + +- Table / DataGrid (react-table) +- Tree (react-tree) +- List (react-list) +- Carousel (react-carousel) +- Virtualizer (react-virtualizer) +- Text — typography components (react-text) +- Compat packages out of scope entirely: datepicker-compat, timepicker-compat, + calendar-compat, migration shims, deprecated react-alert/react-infobutton. diff --git a/migration/windmod/LEDGER.md b/migration/windmod/LEDGER.md new file mode 100644 index 00000000000000..e5f50f4184727c --- /dev/null +++ b/migration/windmod/LEDGER.md @@ -0,0 +1,27 @@ +# Ledger + +Status flow: `planned` → `pilot` → `audited` → `converted` → `validated` (zero-tolerance VR vs Griffel suite). + +## Infra + +| Item | Status | Notes | +| -------------------------------------- | ------- | ------------------------------------------- | +| Branch styling/react-windmod | done | off master 06cbcbe0b1 | +| PLAN.md committed | done | | +| react-tailwind-theme-preview port | planned | source: branch styling/tailwind-css-modules | +| react-windmod-preview scaffold | planned | library/ + stories/ | +| ThemeProvider | planned | display:contents, verified viable | +| Stories Storybook + Tailwind v4 wiring | planned | | +| VR harness adaptation | planned | after pilot review | + +## Components (pilot) + +| Component | Status | Notes | +| --------- | ------ | ---------------------------------------------------- | +| Button | pilot | variants w/o data-* (appearance/size/shape) | +| Tooltip | pilot | top-layer popover=hint + positioning + ThemeProvider | + +## Components (remaining headless coverage) + +Not yet enumerated — Phase 2 audit populates this section from the headless +export map (~53 components). diff --git a/migration/windmod/PLAN.md b/migration/windmod/PLAN.md new file mode 100644 index 00000000000000..166adc32a52f3e --- /dev/null +++ b/migration/windmod/PLAN.md @@ -0,0 +1,124 @@ +# react-windmod-preview — Campaign Plan + +_Approved by the user 2026-08-20. This file is the durable source of truth; the +conversation is not. Resume protocol: read this file, then LEDGER.md, then the +latest report in reports/._ + +## What we are building + +Two production-quality packages under `packages/react-components/`, merge-ready +in discipline (beachball, changelogs, conformance, docs) even though upstream +has not asked for them: + +1. **`@fluentui/react-tailwind-theme-preview`** — Fluent themes/tokens as a + Tailwind v4 + CSS layer. Ported from the closed branch's + `react-tailwind-theme` (`styling/tailwind-css-modules`): `tokens.css` + (@theme inline, 467 tokens → utilities), `themes.css` (7 theme classes × + 433 tokens, generated with lockstep assertions), `variants.css` (catalog + already named after the headless data-* vocabulary), the `fui.*` cascade + layer order, and the CSS-Modules build pipeline. Standalone value: teams + styling raw headless components get real Fluent themes. +2. **`@fluentui/react-windmod-preview`** — styled Fluent components composing + `@fluentui/react-headless-components-preview` hooks with Tailwind v4 + CSS + Modules, visually parity-matched to the Griffel suite. `library/` + + `stories/` split and subpath exports mirror the headless preview 1:1, plus a + root barrel; unstylable/infra subpaths (`/positioning`, `/utils`) are + re-exported pass-through so one package imports everything. + +## Settled decisions (user-confirmed) + +- **Name:** react-windmod-preview (portmanteau: tailWIND + CSS MODules). +- **Theme layer is its own package** (react-tailwind-theme-preview). +- **Per-component architecture — hook composition, not component wrapping:** + `useX(props, ref)` (headless; emits data-* state attrs) → `useXStyles(state)` + (ours: module class-map onto every slot's className, clsx-merged with + consumer classNames; JS variant branching on state where headless emits no + data-* — e.g. Button appearance/size/shape; group marker per D15/D16) → + `renderX(state)` (headless re-export). Full state access; upstream tactical + edits (e.g. adding data-appearance) become optional in-pattern PRs, never + blockers. +- **ThemeProvider renders a `display:contents` div** carrying the theme class + (`fui-theme-web-light`, …) and composes the headless Provider context + (dir/targetDocument). Verified in Chrome 2026-08-20: vars on a + display:contents element inherit to children INCLUDING open top-layer + popovers inside it, and nested providers override element-locally + (probe: .scratch/display-contents-probe/). +- **No portal special-casing needed:** headless surfaces use the native + top-layer (PopoverSurface ``, Tooltip + `popover="hint"`, Toaster `popover="manual"`, MenuPopover `popover="auto"`, + non-modal Dialog `popover="manual"`). Top-layer keeps DOM ancestry → theme + vars cascade in. +- **Icons: `@fluentui/react-icons/headless`** — published upstream since + 2.0.334 (verified in node_modules). Griffel-free; never import the classic + Griffel entrypoints. +- **Tokens stay kebab-case** (`--color-neutral-foreground-1`). Self-contained + namespace; deliberately NOT interoperable with classic FluentProvider + camelCase theming. Documented as a design decision. +- **Parity bar: zero-tolerance VR** against the Griffel suite's rendering, + webLightTheme default, like the previous campaign. +- **Coverage = whatever headless ships.** We track upstream; see + BLOCKED_ON_UPSTREAM.md. Currently absent upstream: Table/DataGrid, Tree, + List, Carousel, Virtualizer, Text (typography). +- **Communication:** chat carries deltas/decisions/findings only; long-form + lives here (user feedback 2026-08-20). + +## Phases + +- **Phase 0 — Scaffold** (branch `styling/react-windmod` off master): + both packages per repo conventions (nx, tsconfig, eslint, beachball), theme + package ported + generator re-verified against current @fluentui/tokens, + stories Storybook wired with the Tailwind v4 pipeline. +- **Phase 1 — Pilot (GATE: user review): Button + Tooltip + ThemeProvider.** + Tooltip forces the theme provider, top-layer and positioning path; Button + covers variant-branching without data-*. Side-by-side stories vs the Griffel + suite for review. A third component only if the user asks after review. +- **Phase 2 — Audit matrix:** all ~53 headless components × {DOM/slot parity + vs styled twin, data-* coverage vs module.css selector needs, top-layer CSS + adaptation, port complexity} → batch plan + optional-upstream-PR list. +- **Phase 3 — Batch conversion** under the established protocol: full contract + per batch, batch+seam validation only, chunked per-package commits; full + sweeps only at phase boundaries. +- **Phase 4 — Ship polish:** 7 theme classes exercised (VR subset per theme), + bundle-isolation gate (adopt @fluentui/verify-bundle-isolation — no Griffel + in our bundles), docs, metrics, PR assembly. + +## Reuse map (source: branch `styling/tailwind-css-modules`) + +- react-tailwind-theme package → ported wholesale, renamed. +- Converted `useXStyles` hooks + `*.module.css` per component → primary + styling source; re-targeted onto headless state objects. Top-layer surfaces + (popover/dialog/menu/tooltip/toast) need real adaptation, not a port — the + old CSS assumed portaled DOM + positioning wrappers. +- CSS Modules build executor (commits c9942bcca9 + cc63894a05 on the branch) + → compile module.css → dist/styles.css + class-map JS. +- VR harness (migration/griffel-to-tailwind/validation/) → adapt for + cross-package A/B (our story render vs Griffel suite story render). +- DECISIONS.md (D2 layers, D15/D16 class-name & data-* contract, D20 nonce, + …) → contract carries forward unless a decision here supersedes it. + +## Risk register + +1. Top-layer CSS adaptation (::backdrop, top-layer stacking, headless's own + usePositioning) — sized in Phase 2; late batches. Tooltip in the pilot + de-risks the pattern early. +2. Headless is v0.2.x and moving — pin exact versions per batch; re-run seam + checks on upstream sync; export-map diff script flags new components. +3. Icons: verify-bundle-isolation gate catches any Griffel leak via icon + imports. Fork branch `feature/headless-promotion` + (fluentui-system-icons) is reference only. +4. Variant-prop defaults (e.g. Button appearance='secondary') are applied + inside base hooks — our styles hooks branch on RESOLVED state, never + replicate defaults from props. + +## Working-docs hygiene + +Everything under `migration/windmod/` is working state, committed on this +branch, and STRIPPED in a single marked commit at PR-assembly time (Phase 4). +The eventual PR contains only the two packages + stories + docs/app wiring. + +## Files here + +- PLAN.md (this file) +- LEDGER.md — per-component status (pilot → audited → converted → validated) +- BLOCKED_ON_UPSTREAM.md — components with no headless counterpart +- reports/ — per-phase/batch reports From b9db9d8c696705266801de770c1d5f4c0ed6ad41 Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Thu, 20 Aug 2026 18:37:36 -0700 Subject: [PATCH 002/246] feat(windmod): port the Tailwind+CSS-Modules build infra and the theme package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Infra, ported from the closed styling/tailwind-css-modules campaign: the css-modules compiler in the workspace-plugin build executor (module.css → dist/styles.css + class maps; no-op for packages without module.css), the ident/group-marker scripts, jest proxy+serializer wiring, and the storybook CSS-Modules/Tailwind rules + once-per-document theme emission. @fluentui/react-tailwind-theme-preview: the theme package ported wholesale and made self-contained — theme-values.json snapshot and the theme class-name constants now live in the package (shared packages untouched; the generator consumes tokens.ts as a name inventory only, since the kebab-case namespace is deliberately not interoperable with FluentProvider's camelCase variables). Snapshot + tokens.css/themes.css regenerated against current master tokens; verify passes; dist builds (175,694 bytes incl. 7 theme classes + headless icons rules at fui.components.l1). react-icons bumped 2.0.311 → 2.0.337 for the /headless entrypoints (Griffel- free icon factories + attribute stylesheet). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- .storybook/main.js | 19 +- .storybook/preview.js | 4 + jest.preset.js | 28 +- package.json | 3 + .../react-tailwind-theme-preview/LICENSE | 15 + .../react-tailwind-theme-preview/README.md | 88 + .../react-tailwind-theme-preview/build.js | 64 + .../react-tailwind-theme-preview/css/emit.css | 48 + .../css/index.css | 147 + .../css/themes.css | 3106 ++++++++++++++++ .../css/tokens.css | 1047 ++++++ .../css/utilities.css | 165 + .../css/variants.css | 658 ++++ .../react-tailwind-theme-preview/package.json | 61 + .../scripts/generate-theme-values.js | 119 + .../scripts/generate-tokens-css.js | 1182 ++++++ .../scripts/probe-spacing-behavior.mjs | 165 + .../theme-class-names.d.mts | 28 + .../theme-class-names.mjs | 41 + .../theme-values.json | 3235 +++++++++++++++++ .../css-modules/globalize-group-markers.js | 117 + scripts/css-modules/ident.js | 247 ++ scripts/jest/src/css-modules/proxy.js | 43 + scripts/jest/src/css-modules/serializer.js | 70 + scripts/storybook/src/rules.js | 160 + scripts/storybook/src/tailwind-theme.css | 55 + .../src/executors/build/executor.ts | 5 + .../executors/build/lib/css-modules.spec.ts | 330 ++ .../src/executors/build/lib/css-modules.ts | 505 +++ yarn.lock | 470 ++- 30 files changed, 12217 insertions(+), 8 deletions(-) create mode 100644 packages/react-components/react-tailwind-theme-preview/LICENSE create mode 100644 packages/react-components/react-tailwind-theme-preview/README.md create mode 100644 packages/react-components/react-tailwind-theme-preview/build.js create mode 100644 packages/react-components/react-tailwind-theme-preview/css/emit.css create mode 100644 packages/react-components/react-tailwind-theme-preview/css/index.css create mode 100644 packages/react-components/react-tailwind-theme-preview/css/themes.css create mode 100644 packages/react-components/react-tailwind-theme-preview/css/tokens.css create mode 100644 packages/react-components/react-tailwind-theme-preview/css/utilities.css create mode 100644 packages/react-components/react-tailwind-theme-preview/css/variants.css create mode 100644 packages/react-components/react-tailwind-theme-preview/package.json create mode 100644 packages/react-components/react-tailwind-theme-preview/scripts/generate-theme-values.js create mode 100644 packages/react-components/react-tailwind-theme-preview/scripts/generate-tokens-css.js create mode 100644 packages/react-components/react-tailwind-theme-preview/scripts/probe-spacing-behavior.mjs create mode 100644 packages/react-components/react-tailwind-theme-preview/theme-class-names.d.mts create mode 100644 packages/react-components/react-tailwind-theme-preview/theme-class-names.mjs create mode 100644 packages/react-components/react-tailwind-theme-preview/theme-values.json create mode 100644 scripts/css-modules/globalize-group-markers.js create mode 100644 scripts/css-modules/ident.js create mode 100644 scripts/jest/src/css-modules/proxy.js create mode 100644 scripts/jest/src/css-modules/serializer.js create mode 100644 scripts/storybook/src/tailwind-theme.css create mode 100644 tools/workspace-plugin/src/executors/build/lib/css-modules.spec.ts create mode 100644 tools/workspace-plugin/src/executors/build/lib/css-modules.ts diff --git a/.storybook/main.js b/.storybook/main.js index b1fd67c4353024..a6d608eb23bbcf 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -47,11 +47,28 @@ module.exports = /** @type {import('./types').StorybookConfig} */ ({ test: /\.stories\.tsx$/, include: /stories/, }, + /** + * windmod-preview stories import `*.module.css`; without this the sandbox export + * drops those imports and every exported story opens unstyled. + */ + cssModules: true, }, }), ], webpackFinal: config => { - registerRules({ config, rules: [rules.swcRule] }); + /** + * Narrow storybook's own implicit `/\.css$/` rule FIRST, then add ours — webpack applies + * every matching rule, so the builder's plain style-loader/css-loader pair has to stop + * matching `*.module.css` (it would hand back an empty class map) and the Tailwind theme + * entry (it would emit `@import … source(none)` verbatim) before these are registered. + * + * windmod-preview `*.module.css` files open with `@reference '#theme'` and use `@apply`, + * which are not valid CSS until Tailwind's PostCSS pass has run. Without this wiring + * this storybook — and the package storybooks and docsites that compose it — render + * windmod components unstyled. + */ + rules.excludeTailwindCssFromDefaultCssRule(config); + registerRules({ config, rules: [rules.swcRule, rules.cssModulesRule, rules.tailwindThemeRule] }); registerTsPaths({ config, configFile: tsConfigPath }); if ((process.env.CI || process.env.TF_BUILD) && config.plugins) { diff --git a/.storybook/preview.js b/.storybook/preview.js index 04fd8067731b17..2e47ac00def69d 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -1,3 +1,7 @@ +// Emits the shared Tailwind theme layer once per document (see the file's header). +// Required by windmod-preview `*.module.css`. Package/app previews import this module, +// so the side effect reaches all of them. +import '../scripts/storybook/src/tailwind-theme.css'; import '../packages/react-components/react-storybook-addon/src/styles.css'; import '../packages/react-components/react-storybook-addon-export-to-sandbox/src/styles.css'; import { withLinks } from '@storybook/addon-links'; diff --git a/jest.preset.js b/jest.preset.js index 0c6f7edd5081cb..6184b96c78a438 100644 --- a/jest.preset.js +++ b/jest.preset.js @@ -26,6 +26,27 @@ const reactDepsPaths = { '^react-test-renderer$': path.join(__dirname, 'node_modules/react-test-renderer'), }; +/** + * CSS Modules support (react-windmod-preview / react-tailwind-theme-preview). + * + * Jest resolves `@fluentui/*` to SOURCE via the tsconfig path aliases above, so any suite + * that renders a windmod component evaluates `import styles from './X.module.css'`. + * Without a mapper those suites fail to *run*, so the mapper is repo-wide rather than + * per-package (it only matches `*.module.css`, which no Griffel package imports). + * + * Resolved by path (not via `require('@fluentui/scripts-jest')`) to keep this file free + * of workspace requires: scripts/jest pulls in @fluentui/scripts-monorepo, which walks + * the repo on load. + * + * Caveat: Jest MERGES `moduleNameMapper` from a preset, but a project-level + * `snapshotSerializers` REPLACES the preset's array — packages that declare their own + * serializers AND consume windmod classes must list the css-modules serializer themselves. + */ +const cssModulesJest = { + moduleNameMapperTarget: require.resolve('./scripts/jest/src/css-modules/proxy.js'), + snapshotSerializer: require.resolve('./scripts/jest/src/css-modules/serializer.js'), +}; + /** * @type {import('@jest/types').Config.InitialOptions} */ @@ -37,7 +58,12 @@ const baseConfig = { moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], testPathIgnorePatterns: ['/node_modules/', '/lib/', '/lib-commonjs/', '/dist/'], testEnvironment: 'jsdom', - moduleNameMapper: { ...tsPathAliases, ...reactDepsPaths }, + moduleNameMapper: { + ...tsPathAliases, + ...reactDepsPaths, + '\\.module\\.css$': cssModulesJest.moduleNameMapperTarget, + }, + snapshotSerializers: [cssModulesJest.snapshotSerializer], cacheDirectory: '/node_modules/.cache/jest', clearMocks: true, watchPlugins: ['jest-watch-typeahead/filename', 'jest-watch-typeahead/testname'], diff --git a/package.json b/package.json index 78ee53bc7172fc..89d079a5e0eb91 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "@swc/core": "1.11.24", "@swc/helpers": "0.5.1", "@swc/jest": "0.2.38", + "@tailwindcss/postcss": "^4.3.2", "@testing-library/dom": "10.4.0", "@testing-library/jest-dom": "5.16.5", "@testing-library/react": "16.3.0", @@ -182,6 +183,7 @@ "circular-dependency-plugin": "5.2.2", "clean-css": "4.2.3", "clean-webpack-plugin": "4.0.0", + "clsx": "2.1.1", "connect-history-api-fallback": "1.6.0", "copy-webpack-plugin": "12.0.2", "cross-env": "^5.1.4", @@ -304,6 +306,7 @@ "swc-loader": "0.2.6", "swc-plugin-de-indent-template-literal": "1.5.0", "syncpack": "10.6.1", + "tailwindcss": "^4.3.2", "terser": "5.39.1", "terser-webpack-plugin": "5.3.10", "through2": "4.0.2", diff --git a/packages/react-components/react-tailwind-theme-preview/LICENSE b/packages/react-components/react-tailwind-theme-preview/LICENSE new file mode 100644 index 00000000000000..553831e4d0c925 --- /dev/null +++ b/packages/react-components/react-tailwind-theme-preview/LICENSE @@ -0,0 +1,15 @@ +@fluentui/react-tailwind-theme-preview + +Copyright (c) Microsoft Corporation + +All rights reserved. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the ""Software""), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Note: Usage of the fonts and icons referenced in Fluent UI React is subject to the terms listed at https://aka.ms/fluentui-assets-license diff --git a/packages/react-components/react-tailwind-theme-preview/README.md b/packages/react-components/react-tailwind-theme-preview/README.md new file mode 100644 index 00000000000000..bcfbee800d66ef --- /dev/null +++ b/packages/react-components/react-tailwind-theme-preview/README.md @@ -0,0 +1,88 @@ +# @fluentui/react-tailwind-theme-preview + +**Shared Tailwind theme layer for [Fluent UI React](https://react.fluentui.dev)** + +Fluent UI React v9 component packages ship plain, precompiled CSS (`/dist/styles.css`, +reached automatically through each package's generated class map). That compiled CSS references +theme-level custom properties — the design tokens, the cascade `@layer` order, `--base-scale`, +`--spacing` and the four stroke widths. Something has to emit those **once per document**. That is +this package: it ships the theme classes (`.fui-theme-web-light`, `.fui-theme-web-dark`, +`.fui-theme-teams-light`, …, each containing only custom-property declarations) plus the web-light +values as `:root, :host` defaults. (It deliberately registers **no** `@property` rules: a non-empty custom-property +registry puts Blink's transition-start path on a page-global slow branch — see +`migration/griffel-to-tailwind/reports/perf-property-remedy.md`.) + +> ⚠ This package exports no JavaScript. It is CSS only. + +## Usage + +Install it alongside the components you use: + +```sh +npm install @fluentui/react-components @fluentui/react-tailwind-theme-preview +``` + +Import the emitted stylesheet **exactly once**, at your document root, before your own styles: + +```js +// src/main.jsx (or _app.tsx, root layout, etc.) +import '@fluentui/react-tailwind-theme-preview/styles.css'; +``` + +Then use components normally — you never import a component stylesheet yourself: + +```jsx +import { FluentProvider, webLightThemeClassName, Button } from '@fluentui/react-components'; + +export default function App() { + return ( + + + + ); +} +``` + +`FluentProvider` applies the theme class you pass via `themeClassName` to its root (and propagates +it to portals); the token **values** come from this package's static CSS. Because a theme class is +just custom-property declarations, putting it on any DOM node themes that subtree, and nested +providers inherit the parent's class when the prop is omitted. + +### If you skip the import + +The theme's custom properties are missing entirely: color tokens resolve to nothing and numeric +spacing utilities compute to `0px` — components render unthemed with collapsed metrics. If +components look unstyled or padding and gaps look collapsed, this import is missing. + +## Layering + +All Fluent styles live in one cascade-layer family: + +```css +@layer fui.theme, fui.base, fui.components, fui.components.l1, fui.components.l2, fui.components.l3, + fui.components.l4, fui.components.l5, fui.utilities; +``` + +- **Plain CSS consumers** need no setup. Unlayered CSS beats every layer, so your own selectors win + by default — no specificity games required. +- **Tailwind consumers** who want their own utilities to beat Fluent component styles should declare + the `fui` layer before importing Tailwind (cascade layer order is first-appearance): + + ```css + @layer fui.theme, fui.base, fui.components, fui.utilities; + @import 'tailwindcss'; + ``` + +Levels `l3`–`l5` inside `fui.components` are deliberately left empty and are yours to use for +app-global, per-page and one-off overrides that should still lose to your unlayered CSS. + +## Subpath exports + +| Subpath | What it is | +| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `@fluentui/react-tailwind-theme-preview/styles.css` | The emitted, plain-CSS root artifact. **This is the consumer entry point.** | +| `@fluentui/react-tailwind-theme-preview` | `css/index.css` — Tailwind **source**, for `@reference`/`@import` from a Tailwind v4 build. Not plain CSS. | +| `@fluentui/react-tailwind-theme-preview/css/*` | The individual source layers (`index.css`, `tokens.css`, `variants.css`, `utilities.css`) for advanced setups. | + +Only `styles.css` is consumable without a Tailwind toolchain; the `css/*` entries require Tailwind +v4 to compile. diff --git a/packages/react-components/react-tailwind-theme-preview/build.js b/packages/react-components/react-tailwind-theme-preview/build.js new file mode 100644 index 00000000000000..2a74fbf70da177 --- /dev/null +++ b/packages/react-components/react-tailwind-theme-preview/build.js @@ -0,0 +1,64 @@ +/** + * Emits the theme root artifact: `css/emit.css` → `dist/styles.css`, plain CSS. + * + * DECISIONS.md D13 — the document imports the theme stylesheet EXACTLY ONCE; component + * packages' `dist/styles.css` contain component rules only and never embed this emission. + * The output holds the `@layer` order statement, the `@theme` custom properties + * (`--base-scale`, `--spacing`), the 4 stroke widths (`--spacing-thin` … `--spacing-thickest`, + * the only token registrations that emit a variable — border/outline widths do not consume + * the `--spacing-*` namespace, so modules reference them directly). The focus-knob + * `@property` registrations were REMOVED (Blink registry transition-start penalty — + * reports/perf-property-remedy.md); the artifact must contain ZERO `@property` rules. + * Everything a component sheet references, nothing a component sheet duplicates. + * + * NO `project.json` on purpose. The workspace plugin creates its nodes from `project.json` + * files and unconditionally infers `clean` / `format` / `type-check` targets (plus a + * `build` target overriding this one, once the project is tagged `vNext`), none of which a + * CSS-only package with no `src/`, no tsconfig and no TypeScript can satisfy. nx still sees + * this package — it is inferred from `package.json` (project name + * `@fluentui/react-tailwind-theme-preview`, targets `build` / `generate-tokens-css` / + * `verify-tokens-css` from the scripts above) — it just carries no nx tags. + * + * PACKAGING (Phase 3 / D13, settled): the theme reaches consumers as this published package, + * not as a suite-level convenience stylesheet. Consumers import + * `@fluentui/react-tailwind-theme-preview/styles.css` exactly once at their document root; see + * README.md. + * + * `dist/` is gitignored, and the release pipeline builds by nx tag (`nx run-many -t build -p + * tag:vNext`), which does not select an untagged project. So the emission is ALSO wired to + * the `prepack` script: `npm pack` and `npm publish` run it, which is what guarantees + * `dist/styles.css` is in the tarball regardless of whether a prior build step ran. + * + * Usage: node packages/react-components/react-tailwind-theme-preview/build.js + */ +const { mkdirSync, readFileSync, writeFileSync } = require('node:fs'); +const { join } = require('node:path'); + +const postcss = require('postcss'); +// Plugin CREATOR — must be invoked (see tools/workspace-plugin/.../lib/css-modules.ts). +const tailwindcss = require('@tailwindcss/postcss'); + +const packageRoot = __dirname; +const entry = join(packageRoot, 'css', 'emit.css'); +const outputDir = join(packageRoot, 'dist'); +const output = join(outputDir, 'styles.css'); + +async function main() { + const result = await postcss([tailwindcss()]).process(readFileSync(entry, 'utf8'), { + from: entry, + to: output, + map: false, + }); + + mkdirSync(outputDir, { recursive: true }); + writeFileSync(output, result.css); + + console.log( + `🎨 @fluentui/react-tailwind-theme-preview: emitted dist/styles.css (${Buffer.byteLength(result.css)} bytes)`, + ); +} + +main().catch(error => { + console.error(error); + process.exit(1); +}); diff --git a/packages/react-components/react-tailwind-theme-preview/css/emit.css b/packages/react-components/react-tailwind-theme-preview/css/emit.css new file mode 100644 index 00000000000000..9f9294fb2ea233 --- /dev/null +++ b/packages/react-components/react-tailwind-theme-preview/css/emit.css @@ -0,0 +1,48 @@ +/* + * The EMISSION entry for the shared theme layer (DECISIONS.md D13). + * + * `css/index.css` is the `@reference '#theme'` target: component `*.module.css` files + * reference it to get the layer order, `@theme` variables, custom variants and focus + * utilities — and `@reference` emits NOTHING, which is exactly why component packages' + * compiled `dist/styles.css` contain zero theme declarations. + * + * Something still has to emit those declarations once per document, or every numeric + * spacing utility resolves against a missing `--base-scale` and computes to 0px. That is + * this file: compiled by `build.js` into the standalone plain-CSS root artifact + * (`dist/styles.css`) which the consuming document imports exactly once — never bundled + * into a component package. + * + * What it emits: `--base-scale`, `--spacing` (NO focus-knob `@property` registrations — + * removed for the Blink registry transition-start penalty, see + * migration/griffel-to-tailwind/reports/perf-property-remedy.md), the 4 canonical + * `--stroke-width-*` variables (literal base-scale values — deliberately NOT + * `--spacing`-coupled), the 4 private `--spacing-thin/…` hooks aliasing them, and the 22 + * spacing custom properties (numeric-axis aliases, `calc(var(--spacing) * N)`). These + * come from the generated `css/tokens.css` and are the ONLY token registrations that also + * emit variables — border/outline/ring/decoration widths do not consume the `--spacing-*` + * namespace, so modules author them as a direct `var(--spacing-thin)`, which needs a real + * variable; and the `tokens.*` JS reference strings resolve against these canonical names + * (the old camelCase variables no longer exist in emitted CSS — option B). Every other + * Fluent token stays an `@theme inline` name that emits nothing. + * + * `source(none)` is mandatory. Without it Tailwind auto-detects content, scans this + * package, and dumps its findings into the utilities layer, which outranks every + * `fui.components.*` layer (CONVERSION_GUIDE.md "Toolchain traps"). + */ +@import './index.css' source(none); + +/* + * Headless icons stylesheet (@fluentui/react-icons/headless), inlined into dist/styles.css at + * build time so the consuming document's single theme import also covers icons. The icon + * factories express styling as attributes (`data-fui-icon`, `data-fui-icon-rtl`, + * `data-fui-icon-hidden`, `data-fui-icon-font`) and the package ships the rules UNLAYERED + * by default — cascade layers are compared before specificity, so unlayered icon rules + * would beat every layered `fui.*` rule no matter how specific. Correct behavior inside + * the fluentui layering system requires assigning them a layer at import time; the + * user-decided altitude is `fui.components.l1`, the lowest component layer + * (griffel-zero-plan.md amendment D27): icon state rules arbitrate with base component + * rules by in-file source order, and l2+, utilities, and unlayered consumer CSS still win. + * The storybook/VR/docsite harness gets the identical import via + * scripts/storybook/src/tailwind-theme.css (documents that never load this artifact). + */ +@import '@fluentui/react-icons/headless/styles.css' layer(fui.components.l1); diff --git a/packages/react-components/react-tailwind-theme-preview/css/index.css b/packages/react-components/react-tailwind-theme-preview/css/index.css new file mode 100644 index 00000000000000..beb8d3058ac08b --- /dev/null +++ b/packages/react-components/react-tailwind-theme-preview/css/index.css @@ -0,0 +1,147 @@ +/** + * @fluentui/react-tailwind-theme-preview — the `#theme` reference target for component + * `*.module.css` files (see migration/griffel-to-tailwind/CONVERSION_GUIDE.md). + * + * Library constraints (differ deliberately from an app-level Tailwind setup): + * - NO preflight: a component library must not ship global resets. Only Tailwind's + * theme + utilities are imported; utilities reach consumers exclusively inlined + * through `@apply` in component modules. + * - Fluent design tokens are registered with `@theme inline` (./tokens.css, generated), + * which substitutes `var(--tokenName)` INTO each utility rather than aliasing it at + * `:root`. FluentProvider still owns the values — they stay scoped CSS custom + * properties resolved per-element, so per-provider and nested-provider theming are + * unchanged. A plain (non-inline) `@theme` alias would freeze resolution at `:root` + * and is FORBIDDEN. Literal `var(--tokenName)` remains valid everywhere. + * - The ONLY nyt-games theme carry-over is --base-scale (user requirement). + */ + +/* + * ONE layer family (settled with user 2026-07-27): the nyt-games layer structure, + * carried under the `fui` root. Mapping from nyt-games / Tailwind conventions: + * nyt `theme` → fui.theme (the @theme custom-property emission) + * nyt `base` → fui.base (levelless; hosts makeResetStyles output — + * Griffel's reset bucket lost to everything, so this layer + * sits below all component levels. Also the sanctioned slot + * for preflight if an app ever wants it; the library itself + * ships NO global resets.) + * nyt `components.lN` → fui.components.lN (implementation altitude: + * l1 = base library components, l2 = library compositions + * (styles applied over another component's hook output), + * l3–l5 = consumer space: app-global / page / bespoke. + * Winner order WITHIN a level = in-file source order; all + * selectors are :where()-flat. Rules written directly into + * `fui.components` (or any level) beat that layer's + * sublayers — probe-verified; use sparingly.) + * nyt `utilities` → fui.utilities (top of family: utilities beat component + * styles by design, but lose to unlayered consumer CSS) + * Verified caller-side: tailwindcss/utilities.css is a bare `@tailwind utilities;` with + * no layer name — cascade layers are assigned only by these import modifiers. Tailwind + * v4's own `components` layer receives no emissions (probe-verified). + */ +@layer fui.theme, fui.base, fui.components, fui.components.l1, fui.components.l2, fui.components.l3, fui.components.l4, fui.components.l5, fui.utilities; + +@import 'tailwindcss/theme.css' layer(fui.theme); +@import 'tailwindcss/utilities.css' layer(fui.utilities); + +@import './variants.css'; +@import './utilities.css'; + +@theme static { + --base-scale: calc(1rem / 16px); + + /* + * DUAL SPACING (settled with user 2026-07-27) — both halves are live: + * + * 1. NUMERIC. Every numeric spacing/sizing utility (p-12, gap-8, w-64, …) reads as px + * but computes to rem via --base-scale: identical pixels at the default 16px root, + * scaling with user font-size preferences (intentional accessibility deviation). + * `--spacing-*: initial` drops Tailwind's own named spacing scale (px, 0.5, …) so + * only the multiplier below survives. + * 2. NAMED. ./tokens.css re-populates the namespace with the 22 Fluent spacing steps + * (p-horizontal-m, gap-vertical-s, …) PLUS the 4 stroke widths (p-thin, w-thick, + * gap-thicker, h-thickest). `initial` clears only what is registered BEFORE it, so + * the LATER import survives this reset — probe-verified, and the reason ./tokens.css + * must stay imported after this block. + * + * The 22 spacing keys are ALIASES OF THE NUMERIC AXIS — `calc(var(--spacing) * 12)`, + * the exact shape `p-12` compiles to — so named and numeric spacing are one scaling + * system with ONE density knob: the `--spacing` base below (theming Phase 1, settled + * with user 2026-07-29). Overriding `--spacing` on any subtree rescales both forms + * identically. Stroke widths are the deliberate exception: their PUBLIC variables are + * `--stroke-width-thin/thick/thicker/thickest`, literal `calc( * var(--base-scale))` + * — borders must not thin when layout density changes — and the `--spacing-thin/…` keys + * are PRIVATE hooks aliasing them for utility generation. No form reads the old + * camelCase names (gone — option B single vocabulary), so FluentProvider + * spacing/strokeWidth overrides do not reach utility-sourced spacing; all 7 shipped + * themes carry identical values for both sets (asserted: + * .scratch/layer-probe/assert-theme-spacing.js and assert-theme-stroke-width.js), so no + * shipped theme changes behavior. + */ + --spacing-*: initial; + --spacing: calc(1px * var(--base-scale)); + + /* + * Zero out Tailwind's default palette/typography so a stray `text-red-500` or + * `font-sans` fails the build instead of silently diverging from Fluent tokens. + * Structural utilities (flex, grid, items-*, overflow-*, …) remain available. + * + * `initial` clears whatever is registered at THIS point in the theme resolution, so + * ./tokens.css must be imported AFTER this block — see the note on the import below. + */ + --color-*: initial; + --font-*: initial; + --text-*: initial; + --font-weight-*: initial; + --tracking-*: initial; + --leading-*: initial; + --radius-*: initial; + --shadow-*: initial; + --inset-shadow-*: initial; + --drop-shadow-*: initial; + --blur-*: initial; + --animate-*: initial; +} + +/* + * Fluent design tokens as Tailwind namespaces — GENERATED, do not edit by hand: + * node packages/react-components/react-tailwind-theme-preview/scripts/generate-tokens-css.js + * + * Gives every Fluent token a utility name (`bg-neutral-background-1`, `text-base-300`, + * `shadow-8`, `rounded-medium`, `leading-base-300`, `ease-easy-ease`, `duration-normal`, + * `z-popup`, …) that compiles to a DIRECT `var(--fluentToken)` reference via + * `@theme inline`. Literal `var(--tokenName)` authoring stays valid and unchanged. + * + * SPACING IS THE ONE EXCEPTION: `p-horizontal-m` / `gap-vertical-s` compile to the + * numeric-axis alias `calc(var(--spacing) * N)` and `w-thin` to + * `var(--stroke-width-thin)` — never to any old camelCase token variable (those names no + * longer exist in emitted CSS; option B) — see the dual-spacing note in the + * `@theme static` block above. + * + * ALL SPACING-NAMESPACE KEYS ARE ALSO EMITTED as real custom properties, together with + * the 4 canonical `--stroke-width-*` variables — a `@layer fui.theme { :root, :host { … } }` + * block at the end of the generated file, the only declarations it contains. Two reasons: + * (a) border, outline, ring, divide, underline-offset and decoration widths do NOT + * consume the `--spacing-*` namespace in v4 (`border-thin` does not exist; `border-2` is + * a hardcoded px literal, not a theme lookup), so modules author those properties as a + * direct `var(--spacing-thin)`, which needs a variable that exists; (b) the `tokens.*` + * JS constants in @fluentui/tokens are var() reference strings against these canonical + * names (charts inline styles etc.). Emitted once per document via ./emit.css exactly + * like `--base-scale`; `@reference` keeps it out of every component module. Probe: + * .scratch/layer-probe/check-stroke-namespace.mjs (consumes/does-not table) and + * check-stroke-emission.mjs (emission end to end). + * + * ORDER IS LOAD-BEARING: this import must stay AFTER the `@theme static` block above. + * Placed before it, `--color-*: initial` (and friends) wipe every registration and the + * utilities silently stop compiling — probe-verified, not theoretical. + */ +@import './tokens.css'; + +/* + * SHIPPED THEME CLASSES (generated — theming Phase 2b). One class per shipped theme + * (.fui-theme-web-dark, …), each carrying ONLY the theme-variant canonical custom + * properties inside `@layer fui.theme`. A theme class on any DOM node themes that + * subtree; the `:root, :host` defaults above stay web light. `@reference` drops these + * rules from component modules (plain style rules emit nothing under reference), so the + * classes ship once per document via ./emit.css / dist/styles.css. + */ +@import './themes.css'; diff --git a/packages/react-components/react-tailwind-theme-preview/css/themes.css b/packages/react-components/react-tailwind-theme-preview/css/themes.css new file mode 100644 index 00000000000000..4e530e50dc7995 --- /dev/null +++ b/packages/react-components/react-tailwind-theme-preview/css/themes.css @@ -0,0 +1,3106 @@ +/* + * DO NOT EDIT — generated file. + * + * Generator: packages/react-components/react-tailwind-theme-preview/scripts/generate-tokens-css.js + * Source: @fluentui/tokens@1.0.0-alpha.24 (packages/tokens/theme-values.json) + * Regenerate: node packages/react-components/react-tailwind-theme-preview/scripts/generate-tokens-css.js + * Verify: node packages/react-components/react-tailwind-theme-preview/scripts/generate-tokens-css.js --check + * + * SHIPPED THEME CLASSES (theming Phase 2b). Applying one of these classes to any + * DOM node themes that subtree — custom properties cascade. They contain ONLY + * custom-property declarations, in the same fui.theme layer as the web-light + * defaults at :root/:host (css/tokens.css), which they override element-locally. + * The JS constants for these class names ship from @fluentui/tokens / + * @fluentui/react-theme (webLightThemeClassName, …) — the generator asserts the + * lockstep on every run. + * + * The 26 spacing/strokeWidth tokens are deliberately absent: theme-invariant, + * emitted once at :root/:host in density-knob form (see css/tokens.css). + */ + +/* webLightTheme — 433 tokens */ +@layer fui.theme { + .fui-theme-web-light { + --color-neutral-foreground-1: #242424; + --color-neutral-foreground-1-hover: #242424; + --color-neutral-foreground-1-pressed: #242424; + --color-neutral-foreground-1-selected: #242424; + --color-neutral-foreground-2: #424242; + --color-neutral-foreground-2-hover: #242424; + --color-neutral-foreground-2-pressed: #242424; + --color-neutral-foreground-2-selected: #242424; + --color-neutral-foreground-2-brand-hover: #0f6cbd; + --color-neutral-foreground-2-brand-pressed: #115ea3; + --color-neutral-foreground-2-brand-selected: #0f6cbd; + --color-neutral-foreground-3: #616161; + --color-neutral-foreground-3-hover: #424242; + --color-neutral-foreground-3-pressed: #424242; + --color-neutral-foreground-3-selected: #424242; + --color-neutral-foreground-3-brand-hover: #0f6cbd; + --color-neutral-foreground-3-brand-pressed: #115ea3; + --color-neutral-foreground-3-brand-selected: #0f6cbd; + --color-neutral-foreground-4: #707070; + --color-neutral-foreground-5: #616161; + --color-neutral-foreground-5-hover: #242424; + --color-neutral-foreground-5-pressed: #242424; + --color-neutral-foreground-5-selected: #242424; + --color-neutral-foreground-disabled: #bdbdbd; + --color-brand-foreground-link: #115ea3; + --color-brand-foreground-link-hover: #0f548c; + --color-brand-foreground-link-pressed: #0c3b5e; + --color-brand-foreground-link-selected: #115ea3; + --color-neutral-foreground-2-link: #424242; + --color-neutral-foreground-2-link-hover: #242424; + --color-neutral-foreground-2-link-pressed: #242424; + --color-neutral-foreground-2-link-selected: #242424; + --color-compound-brand-foreground-1: #0f6cbd; + --color-compound-brand-foreground-1-hover: #115ea3; + --color-compound-brand-foreground-1-pressed: #0f548c; + --color-neutral-foreground-on-brand: #ffffff; + --color-neutral-foreground-inverted: #ffffff; + --color-neutral-foreground-inverted-hover: #ffffff; + --color-neutral-foreground-inverted-pressed: #ffffff; + --color-neutral-foreground-inverted-selected: #ffffff; + --color-neutral-foreground-inverted-2: #ffffff; + --color-neutral-foreground-static-inverted: #ffffff; + --color-neutral-foreground-inverted-link: #ffffff; + --color-neutral-foreground-inverted-link-hover: #ffffff; + --color-neutral-foreground-inverted-link-pressed: #ffffff; + --color-neutral-foreground-inverted-link-selected: #ffffff; + --color-neutral-foreground-inverted-disabled: rgba(255, 255, 255, 0.4); + --color-brand-foreground-1: #0f6cbd; + --color-brand-foreground-2: #115ea3; + --color-brand-foreground-2-hover: #0f548c; + --color-brand-foreground-2-pressed: #0a2e4a; + --color-neutral-foreground-1-static: #242424; + --color-brand-foreground-inverted: #479ef5; + --color-brand-foreground-inverted-hover: #62abf5; + --color-brand-foreground-inverted-pressed: #479ef5; + --color-brand-foreground-on-light: #0f6cbd; + --color-brand-foreground-on-light-hover: #115ea3; + --color-brand-foreground-on-light-pressed: #0e4775; + --color-brand-foreground-on-light-selected: #0f548c; + --color-neutral-background-1: #ffffff; + --color-neutral-background-1-hover: #f5f5f5; + --color-neutral-background-1-pressed: #e0e0e0; + --color-neutral-background-1-selected: #ebebeb; + --color-neutral-background-2: #fafafa; + --color-neutral-background-2-hover: #f0f0f0; + --color-neutral-background-2-pressed: #dbdbdb; + --color-neutral-background-2-selected: #e6e6e6; + --color-neutral-background-3: #f5f5f5; + --color-neutral-background-3-hover: #ebebeb; + --color-neutral-background-3-pressed: #d6d6d6; + --color-neutral-background-3-selected: #e0e0e0; + --color-neutral-background-4: #f0f0f0; + --color-neutral-background-4-hover: #fafafa; + --color-neutral-background-4-pressed: #f5f5f5; + --color-neutral-background-4-selected: #ffffff; + --color-neutral-background-5: #ebebeb; + --color-neutral-background-5-hover: #f5f5f5; + --color-neutral-background-5-pressed: #f0f0f0; + --color-neutral-background-5-selected: #fafafa; + --color-neutral-background-6: #e6e6e6; + --color-neutral-background-7: #00000000; + --color-neutral-background-7-hover: #ebebeb; + --color-neutral-background-7-pressed: #d6d6d6; + --color-neutral-background-7-selected: #00000000; + --color-neutral-background-8: #fcfcfc; + --color-neutral-background-inverted: #292929; + --color-neutral-background-inverted-hover: #3d3d3d; + --color-neutral-background-inverted-pressed: #1f1f1f; + --color-neutral-background-inverted-selected: #383838; + --color-neutral-background-static: #333333; + --color-neutral-background-alpha: rgba(255, 255, 255, 0.5); + --color-neutral-background-alpha-2: rgba(255, 255, 255, 0.8); + --color-subtle-background: transparent; + --color-subtle-background-hover: #f5f5f5; + --color-subtle-background-pressed: #e0e0e0; + --color-subtle-background-selected: #ebebeb; + --color-subtle-background-light-alpha-hover: rgba(255, 255, 255, 0.7); + --color-subtle-background-light-alpha-pressed: rgba(255, 255, 255, 0.5); + --color-subtle-background-light-alpha-selected: transparent; + --color-subtle-background-inverted: transparent; + --color-subtle-background-inverted-hover: rgba(0, 0, 0, 0.1); + --color-subtle-background-inverted-pressed: rgba(0, 0, 0, 0.3); + --color-subtle-background-inverted-selected: rgba(0, 0, 0, 0.2); + --color-transparent-background: transparent; + --color-transparent-background-hover: transparent; + --color-transparent-background-pressed: transparent; + --color-transparent-background-selected: transparent; + --color-neutral-background-disabled: #f0f0f0; + --color-neutral-background-disabled-2: #ffffff; + --color-neutral-background-inverted-disabled: rgba(255, 255, 255, 0.1); + --color-neutral-stencil-1: #e6e6e6; + --color-neutral-stencil-2: #fafafa; + --color-neutral-stencil-1-alpha: rgba(0, 0, 0, 0.1); + --color-neutral-stencil-2-alpha: rgba(0, 0, 0, 0.05); + --color-background-overlay: rgba(0, 0, 0, 0.4); + --color-scrollbar-overlay: rgba(0, 0, 0, 0.5); + --color-brand-background: #0f6cbd; + --color-brand-background-hover: #115ea3; + --color-brand-background-pressed: #0c3b5e; + --color-brand-background-selected: #0f548c; + --color-compound-brand-background: #0f6cbd; + --color-compound-brand-background-hover: #115ea3; + --color-compound-brand-background-pressed: #0f548c; + --color-brand-background-static: #0f6cbd; + --color-brand-background-2: #ebf3fc; + --color-brand-background-2-hover: #cfe4fa; + --color-brand-background-2-pressed: #96c6fa; + --color-brand-background-3-static: #0f548c; + --color-brand-background-4-static: #0c3b5e; + --color-brand-background-inverted: #ffffff; + --color-brand-background-inverted-hover: #ebf3fc; + --color-brand-background-inverted-pressed: #b4d6fa; + --color-brand-background-inverted-selected: #cfe4fa; + --color-neutral-card-background: #fafafa; + --color-neutral-card-background-hover: #ffffff; + --color-neutral-card-background-pressed: #f5f5f5; + --color-neutral-card-background-selected: #ebebeb; + --color-neutral-card-background-disabled: #f0f0f0; + --color-neutral-stroke-accessible: #616161; + --color-neutral-stroke-accessible-hover: #575757; + --color-neutral-stroke-accessible-pressed: #4d4d4d; + --color-neutral-stroke-accessible-selected: #0f6cbd; + --color-neutral-stroke-1: #d1d1d1; + --color-neutral-stroke-1-hover: #c7c7c7; + --color-neutral-stroke-1-pressed: #b3b3b3; + --color-neutral-stroke-1-selected: #bdbdbd; + --color-neutral-stroke-2: #e0e0e0; + --color-neutral-stroke-3: #f0f0f0; + --color-neutral-stroke-4: #ebebeb; + --color-neutral-stroke-4-hover: #e0e0e0; + --color-neutral-stroke-4-pressed: #d6d6d6; + --color-neutral-stroke-4-selected: #ebebeb; + --color-neutral-stroke-subtle: #e0e0e0; + --color-neutral-stroke-on-brand: #ffffff; + --color-neutral-stroke-on-brand-2: #ffffff; + --color-neutral-stroke-on-brand-2-hover: #ffffff; + --color-neutral-stroke-on-brand-2-pressed: #ffffff; + --color-neutral-stroke-on-brand-2-selected: #ffffff; + --color-brand-stroke-1: #0f6cbd; + --color-brand-stroke-2: #b4d6fa; + --color-brand-stroke-2-hover: #77b7f7; + --color-brand-stroke-2-pressed: #0f6cbd; + --color-brand-stroke-2-contrast: #b4d6fa; + --color-compound-brand-stroke: #0f6cbd; + --color-compound-brand-stroke-hover: #115ea3; + --color-compound-brand-stroke-pressed: #0f548c; + --color-neutral-stroke-disabled: #e0e0e0; + --color-neutral-stroke-disabled-2: #ebebeb; + --color-neutral-stroke-inverted-disabled: rgba(255, 255, 255, 0.4); + --color-transparent-stroke: transparent; + --color-transparent-stroke-interactive: transparent; + --color-transparent-stroke-disabled: transparent; + --color-neutral-stroke-alpha: rgba(0, 0, 0, 0.05); + --color-neutral-stroke-alpha-2: rgba(255, 255, 255, 0.2); + --color-stroke-focus-1: #ffffff; + --color-stroke-focus-2: #000000; + --color-neutral-shadow-ambient: rgba(0, 0, 0, 0.12); + --color-neutral-shadow-key: rgba(0, 0, 0, 0.14); + --color-neutral-shadow-ambient-lighter: rgba(0, 0, 0, 0.06); + --color-neutral-shadow-key-lighter: rgba(0, 0, 0, 0.07); + --color-neutral-shadow-ambient-darker: rgba(0, 0, 0, 0.2); + --color-neutral-shadow-key-darker: rgba(0, 0, 0, 0.24); + --color-brand-shadow-ambient: rgba(0, 0, 0, 0.3); + --color-brand-shadow-key: rgba(0, 0, 0, 0.25); + --color-palette-red-background-1: #fdf6f6; + --color-palette-red-background-2: #f1bbbc; + --color-palette-red-background-3: #d13438; + --color-palette-red-border-active: #d13438; + --color-palette-red-border-1: #f1bbbc; + --color-palette-red-border-2: #d13438; + --color-palette-red-foreground-1: #bc2f32; + --color-palette-red-foreground-2: #751d1f; + --color-palette-red-foreground-3: #d13438; + --color-palette-red-foreground-inverted: #dc5e62; + --color-palette-green-background-1: #f1faf1; + --color-palette-green-background-2: #9fd89f; + --color-palette-green-background-3: #107c10; + --color-palette-green-border-active: #107c10; + --color-palette-green-border-1: #9fd89f; + --color-palette-green-border-2: #107c10; + --color-palette-green-foreground-1: #0e700e; + --color-palette-green-foreground-2: #094509; + --color-palette-green-foreground-3: #107c10; + --color-palette-green-foreground-inverted: #359b35; + --color-palette-dark-orange-background-1: #fdf6f3; + --color-palette-dark-orange-background-2: #f4bfab; + --color-palette-dark-orange-background-3: #da3b01; + --color-palette-dark-orange-border-active: #da3b01; + --color-palette-dark-orange-border-1: #f4bfab; + --color-palette-dark-orange-border-2: #da3b01; + --color-palette-dark-orange-foreground-1: #c43501; + --color-palette-dark-orange-foreground-2: #7a2101; + --color-palette-dark-orange-foreground-3: #da3b01; + --color-palette-yellow-background-1: #fffef5; + --color-palette-yellow-background-2: #fef7b2; + --color-palette-yellow-background-3: #fde300; + --color-palette-yellow-border-active: #fde300; + --color-palette-yellow-border-1: #fef7b2; + --color-palette-yellow-border-2: #fde300; + --color-palette-yellow-foreground-1: #817400; + --color-palette-yellow-foreground-2: #817400; + --color-palette-yellow-foreground-3: #fde300; + --color-palette-yellow-foreground-inverted: #fef7b2; + --color-palette-berry-background-1: #fdf5fc; + --color-palette-berry-background-2: #edbbe7; + --color-palette-berry-background-3: #c239b3; + --color-palette-berry-border-active: #c239b3; + --color-palette-berry-border-1: #edbbe7; + --color-palette-berry-border-2: #c239b3; + --color-palette-berry-foreground-1: #af33a1; + --color-palette-berry-foreground-2: #6d2064; + --color-palette-berry-foreground-3: #c239b3; + --color-palette-marigold-background-1: #fefbf4; + --color-palette-marigold-background-2: #f9e2ae; + --color-palette-marigold-background-3: #eaa300; + --color-palette-marigold-border-active: #eaa300; + --color-palette-marigold-border-1: #f9e2ae; + --color-palette-marigold-border-2: #eaa300; + --color-palette-marigold-foreground-1: #d39300; + --color-palette-marigold-foreground-2: #835b00; + --color-palette-marigold-foreground-3: #eaa300; + --color-palette-light-green-background-1: #f2fbf2; + --color-palette-light-green-background-2: #a7e3a5; + --color-palette-light-green-background-3: #13a10e; + --color-palette-light-green-border-active: #13a10e; + --color-palette-light-green-border-1: #a7e3a5; + --color-palette-light-green-border-2: #13a10e; + --color-palette-light-green-foreground-1: #11910d; + --color-palette-light-green-foreground-2: #0b5a08; + --color-palette-light-green-foreground-3: #13a10e; + --color-palette-anchor-background-2: #bcc3c7; + --color-palette-anchor-border-active: #394146; + --color-palette-anchor-foreground-2: #202427; + --color-palette-beige-background-2: #d7d4d4; + --color-palette-beige-border-active: #7a7574; + --color-palette-beige-foreground-2: #444241; + --color-palette-blue-background-2: #a9d3f2; + --color-palette-blue-border-active: #0078d4; + --color-palette-blue-foreground-2: #004377; + --color-palette-brass-background-2: #e0cea2; + --color-palette-brass-border-active: #986f0b; + --color-palette-brass-foreground-2: #553e06; + --color-palette-brown-background-2: #ddc3b0; + --color-palette-brown-border-active: #8e562e; + --color-palette-brown-foreground-2: #50301a; + --color-palette-cornflower-background-2: #c8d1fa; + --color-palette-cornflower-border-active: #4f6bed; + --color-palette-cornflower-foreground-2: #2c3c85; + --color-palette-cranberry-background-2: #eeacb2; + --color-palette-cranberry-border-active: #c50f1f; + --color-palette-cranberry-foreground-2: #6e0811; + --color-palette-dark-green-background-2: #9ad29a; + --color-palette-dark-green-border-active: #0b6a0b; + --color-palette-dark-green-foreground-2: #063b06; + --color-palette-dark-red-background-2: #d69ca5; + --color-palette-dark-red-border-active: #750b1c; + --color-palette-dark-red-foreground-2: #420610; + --color-palette-forest-background-2: #bdd99b; + --color-palette-forest-border-active: #498205; + --color-palette-forest-foreground-2: #294903; + --color-palette-gold-background-2: #ecdfa5; + --color-palette-gold-border-active: #c19c00; + --color-palette-gold-foreground-2: #6c5700; + --color-palette-grape-background-2: #d9a7e0; + --color-palette-grape-border-active: #881798; + --color-palette-grape-foreground-2: #4c0d55; + --color-palette-lavender-background-2: #d2ccf8; + --color-palette-lavender-border-active: #7160e8; + --color-palette-lavender-foreground-2: #3f3682; + --color-palette-light-teal-background-2: #a6e9ed; + --color-palette-light-teal-border-active: #00b7c3; + --color-palette-light-teal-foreground-2: #00666d; + --color-palette-lilac-background-2: #e6bfed; + --color-palette-lilac-border-active: #b146c2; + --color-palette-lilac-foreground-2: #63276d; + --color-palette-magenta-background-2: #eca5d1; + --color-palette-magenta-border-active: #bf0077; + --color-palette-magenta-foreground-2: #6b0043; + --color-palette-mink-background-2: #cecccb; + --color-palette-mink-border-active: #5d5a58; + --color-palette-mink-foreground-2: #343231; + --color-palette-navy-background-2: #a3b2e8; + --color-palette-navy-border-active: #0027b4; + --color-palette-navy-foreground-2: #001665; + --color-palette-peach-background-2: #ffddb3; + --color-palette-peach-border-active: #ff8c00; + --color-palette-peach-foreground-2: #8f4e00; + --color-palette-pink-background-2: #f7c0e3; + --color-palette-pink-border-active: #e43ba6; + --color-palette-pink-foreground-2: #80215d; + --color-palette-platinum-background-2: #cdd6d8; + --color-palette-platinum-border-active: #69797e; + --color-palette-platinum-foreground-2: #3b4447; + --color-palette-plum-background-2: #d696c0; + --color-palette-plum-border-active: #77004d; + --color-palette-plum-foreground-2: #43002b; + --color-palette-pumpkin-background-2: #efc4ad; + --color-palette-pumpkin-border-active: #ca5010; + --color-palette-pumpkin-foreground-2: #712d09; + --color-palette-purple-background-2: #c6b1de; + --color-palette-purple-border-active: #5c2e91; + --color-palette-purple-foreground-2: #341a51; + --color-palette-royal-blue-background-2: #9abfdc; + --color-palette-royal-blue-border-active: #004e8c; + --color-palette-royal-blue-foreground-2: #002c4e; + --color-palette-seafoam-background-2: #a8f0cd; + --color-palette-seafoam-border-active: #00cc6a; + --color-palette-seafoam-foreground-2: #00723b; + --color-palette-steel-background-2: #94c8d4; + --color-palette-steel-border-active: #005b70; + --color-palette-steel-foreground-2: #00333f; + --color-palette-teal-background-2: #9bd9db; + --color-palette-teal-border-active: #038387; + --color-palette-teal-foreground-2: #02494c; + --color-status-success-background-1: #f1faf1; + --color-status-success-background-2: #9fd89f; + --color-status-success-background-3: #107c10; + --color-status-success-foreground-1: #0e700e; + --color-status-success-foreground-2: #094509; + --color-status-success-foreground-3: #107c10; + --color-status-success-foreground-inverted: #54b054; + --color-status-success-border-active: #107c10; + --color-status-success-border-1: #9fd89f; + --color-status-success-border-2: #107c10; + --color-status-warning-background-1: #fff9f5; + --color-status-warning-background-2: #fdcfb4; + --color-status-warning-background-3: #f7630c; + --color-status-warning-foreground-1: #bc4b09; + --color-status-warning-foreground-2: #8a3707; + --color-status-warning-foreground-3: #bc4b09; + --color-status-warning-foreground-inverted: #faa06b; + --color-status-warning-border-active: #f7630c; + --color-status-warning-border-1: #fdcfb4; + --color-status-warning-border-2: #bc4b09; + --color-status-danger-background-1: #fdf3f4; + --color-status-danger-background-2: #eeacb2; + --color-status-danger-background-3: #c50f1f; + --color-status-danger-background-3-hover: #b10e1c; + --color-status-danger-background-3-pressed: #960b18; + --color-status-danger-foreground-1: #b10e1c; + --color-status-danger-foreground-2: #6e0811; + --color-status-danger-foreground-3: #c50f1f; + --color-status-danger-foreground-inverted: #dc626d; + --color-status-danger-border-active: #c50f1f; + --color-status-danger-border-1: #eeacb2; + --color-status-danger-border-2: #c50f1f; + --radius-none: 0; + --radius-small: 2px; + --radius-medium: 4px; + --radius-large: 6px; + --radius-x-large: 8px; + --radius-2-x-large: 12px; + --radius-3-x-large: 16px; + --radius-4-x-large: 24px; + --radius-5-x-large: 32px; + --radius-6-x-large: 40px; + --radius-circular: 10000px; + --font-base: 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', + sans-serif; + --font-monospace: Consolas, 'Courier New', Courier, monospace; + --font-numeric: Bahnschrift, 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, + 'Helvetica Neue', sans-serif; + --text-base-100: 10px; + --text-base-200: 12px; + --text-base-300: 14px; + --text-base-400: 16px; + --text-base-500: 20px; + --text-base-600: 24px; + --text-hero-700: 28px; + --text-hero-800: 32px; + --text-hero-900: 40px; + --text-hero-1000: 68px; + --font-weight-regular: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + --leading-base-100: 14px; + --leading-base-200: 16px; + --leading-base-300: 20px; + --leading-base-400: 22px; + --leading-base-500: 28px; + --leading-base-600: 32px; + --leading-hero-700: 36px; + --leading-hero-800: 40px; + --leading-hero-900: 52px; + --leading-hero-1000: 92px; + --shadow-2: 0 0 2px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.14); + --shadow-4: 0 0 2px rgba(0, 0, 0, 0.12), 0 2px 4px rgba(0, 0, 0, 0.14); + --shadow-8: 0 0 2px rgba(0, 0, 0, 0.12), 0 4px 8px rgba(0, 0, 0, 0.14); + --shadow-16: 0 0 2px rgba(0, 0, 0, 0.12), 0 8px 16px rgba(0, 0, 0, 0.14); + --shadow-28: 0 0 8px rgba(0, 0, 0, 0.12), 0 14px 28px rgba(0, 0, 0, 0.14); + --shadow-64: 0 0 8px rgba(0, 0, 0, 0.12), 0 32px 64px rgba(0, 0, 0, 0.14); + --shadow-2-brand: 0 0 2px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.25); + --shadow-4-brand: 0 0 2px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.25); + --shadow-8-brand: 0 0 2px rgba(0, 0, 0, 0.3), 0 4px 8px rgba(0, 0, 0, 0.25); + --shadow-16-brand: 0 0 2px rgba(0, 0, 0, 0.3), 0 8px 16px rgba(0, 0, 0, 0.25); + --shadow-28-brand: 0 0 8px rgba(0, 0, 0, 0.3), 0 14px 28px rgba(0, 0, 0, 0.25); + --shadow-64-brand: 0 0 8px rgba(0, 0, 0, 0.3), 0 32px 64px rgba(0, 0, 0, 0.25); + --duration-ultra-fast: 50ms; + --duration-faster: 100ms; + --duration-fast: 150ms; + --duration-normal: 200ms; + --duration-gentle: 250ms; + --duration-slow: 300ms; + --duration-slower: 400ms; + --duration-ultra-slow: 500ms; + --ease-accelerate-max: cubic-bezier(0.9, 0.1, 1, 0.2); + --ease-accelerate-mid: cubic-bezier(1, 0, 1, 1); + --ease-accelerate-min: cubic-bezier(0.8, 0, 0.78, 1); + --ease-decelerate-max: cubic-bezier(0.1, 0.9, 0.2, 1); + --ease-decelerate-mid: cubic-bezier(0, 0, 0, 1); + --ease-decelerate-min: cubic-bezier(0.33, 0, 0.1, 1); + --ease-easy-ease-max: cubic-bezier(0.8, 0, 0.2, 1); + --ease-easy-ease: cubic-bezier(0.33, 0, 0.67, 1); + --ease-linear: cubic-bezier(0, 0, 1, 1); + } +} + +/* webDarkTheme — 433 tokens */ +@layer fui.theme { + .fui-theme-web-dark { + --color-neutral-foreground-1: #ffffff; + --color-neutral-foreground-1-hover: #ffffff; + --color-neutral-foreground-1-pressed: #ffffff; + --color-neutral-foreground-1-selected: #ffffff; + --color-neutral-foreground-2: #d6d6d6; + --color-neutral-foreground-2-hover: #ffffff; + --color-neutral-foreground-2-pressed: #ffffff; + --color-neutral-foreground-2-selected: #ffffff; + --color-neutral-foreground-2-brand-hover: #479ef5; + --color-neutral-foreground-2-brand-pressed: #2886de; + --color-neutral-foreground-2-brand-selected: #479ef5; + --color-neutral-foreground-3: #adadad; + --color-neutral-foreground-3-hover: #d6d6d6; + --color-neutral-foreground-3-pressed: #d6d6d6; + --color-neutral-foreground-3-selected: #d6d6d6; + --color-neutral-foreground-3-brand-hover: #479ef5; + --color-neutral-foreground-3-brand-pressed: #2886de; + --color-neutral-foreground-3-brand-selected: #479ef5; + --color-neutral-foreground-4: #999999; + --color-neutral-foreground-5: #adadad; + --color-neutral-foreground-5-hover: #ffffff; + --color-neutral-foreground-5-pressed: #ffffff; + --color-neutral-foreground-5-selected: #ffffff; + --color-neutral-foreground-disabled: #5c5c5c; + --color-brand-foreground-link: #479ef5; + --color-brand-foreground-link-hover: #62abf5; + --color-brand-foreground-link-pressed: #2886de; + --color-brand-foreground-link-selected: #479ef5; + --color-neutral-foreground-2-link: #d6d6d6; + --color-neutral-foreground-2-link-hover: #ffffff; + --color-neutral-foreground-2-link-pressed: #ffffff; + --color-neutral-foreground-2-link-selected: #ffffff; + --color-compound-brand-foreground-1: #479ef5; + --color-compound-brand-foreground-1-hover: #62abf5; + --color-compound-brand-foreground-1-pressed: #2886de; + --color-neutral-foreground-on-brand: #ffffff; + --color-neutral-foreground-inverted: #242424; + --color-neutral-foreground-inverted-hover: #242424; + --color-neutral-foreground-inverted-pressed: #242424; + --color-neutral-foreground-inverted-selected: #242424; + --color-neutral-foreground-inverted-2: #242424; + --color-neutral-foreground-static-inverted: #ffffff; + --color-neutral-foreground-inverted-link: #ffffff; + --color-neutral-foreground-inverted-link-hover: #ffffff; + --color-neutral-foreground-inverted-link-pressed: #ffffff; + --color-neutral-foreground-inverted-link-selected: #ffffff; + --color-neutral-foreground-inverted-disabled: rgba(255, 255, 255, 0.4); + --color-brand-foreground-1: #479ef5; + --color-brand-foreground-2: #62abf5; + --color-brand-foreground-2-hover: #96c6fa; + --color-brand-foreground-2-pressed: #ebf3fc; + --color-neutral-foreground-1-static: #242424; + --color-brand-foreground-inverted: #0f6cbd; + --color-brand-foreground-inverted-hover: #115ea3; + --color-brand-foreground-inverted-pressed: #0f548c; + --color-brand-foreground-on-light: #0f6cbd; + --color-brand-foreground-on-light-hover: #115ea3; + --color-brand-foreground-on-light-pressed: #0e4775; + --color-brand-foreground-on-light-selected: #0f548c; + --color-neutral-background-1: #292929; + --color-neutral-background-1-hover: #3d3d3d; + --color-neutral-background-1-pressed: #1f1f1f; + --color-neutral-background-1-selected: #383838; + --color-neutral-background-2: #1f1f1f; + --color-neutral-background-2-hover: #333333; + --color-neutral-background-2-pressed: #141414; + --color-neutral-background-2-selected: #2e2e2e; + --color-neutral-background-3: #141414; + --color-neutral-background-3-hover: #292929; + --color-neutral-background-3-pressed: #0a0a0a; + --color-neutral-background-3-selected: #242424; + --color-neutral-background-4: #0a0a0a; + --color-neutral-background-4-hover: #1f1f1f; + --color-neutral-background-4-pressed: #000000; + --color-neutral-background-4-selected: #1a1a1a; + --color-neutral-background-5: #000000; + --color-neutral-background-5-hover: #141414; + --color-neutral-background-5-pressed: #050505; + --color-neutral-background-5-selected: #0f0f0f; + --color-neutral-background-6: #333333; + --color-neutral-background-7: #00000000; + --color-neutral-background-7-hover: #1a1a1a; + --color-neutral-background-7-pressed: #0a0a0a; + --color-neutral-background-7-selected: #00000000; + --color-neutral-background-8: #292929; + --color-neutral-background-inverted: #ffffff; + --color-neutral-background-inverted-hover: #f5f5f5; + --color-neutral-background-inverted-pressed: #e0e0e0; + --color-neutral-background-inverted-selected: #ebebeb; + --color-neutral-background-static: #3d3d3d; + --color-neutral-background-alpha: rgba(26, 26, 26, 0.5); + --color-neutral-background-alpha-2: rgba(31, 31, 31, 0.7); + --color-subtle-background: transparent; + --color-subtle-background-hover: #383838; + --color-subtle-background-pressed: #2e2e2e; + --color-subtle-background-selected: #333333; + --color-subtle-background-light-alpha-hover: rgba(36, 36, 36, 0.8); + --color-subtle-background-light-alpha-pressed: rgba(36, 36, 36, 0.5); + --color-subtle-background-light-alpha-selected: transparent; + --color-subtle-background-inverted: transparent; + --color-subtle-background-inverted-hover: rgba(0, 0, 0, 0.1); + --color-subtle-background-inverted-pressed: rgba(0, 0, 0, 0.3); + --color-subtle-background-inverted-selected: rgba(0, 0, 0, 0.2); + --color-transparent-background: transparent; + --color-transparent-background-hover: transparent; + --color-transparent-background-pressed: transparent; + --color-transparent-background-selected: transparent; + --color-neutral-background-disabled: #141414; + --color-neutral-background-disabled-2: #292929; + --color-neutral-background-inverted-disabled: rgba(255, 255, 255, 0.1); + --color-neutral-stencil-1: #575757; + --color-neutral-stencil-2: #333333; + --color-neutral-stencil-1-alpha: rgba(255, 255, 255, 0.1); + --color-neutral-stencil-2-alpha: rgba(255, 255, 255, 0.05); + --color-background-overlay: rgba(0, 0, 0, 0.5); + --color-scrollbar-overlay: rgba(255, 255, 255, 0.6); + --color-brand-background: #115ea3; + --color-brand-background-hover: #0f6cbd; + --color-brand-background-pressed: #0c3b5e; + --color-brand-background-selected: #0f548c; + --color-compound-brand-background: #479ef5; + --color-compound-brand-background-hover: #62abf5; + --color-compound-brand-background-pressed: #2886de; + --color-brand-background-static: #0f6cbd; + --color-brand-background-2: #082338; + --color-brand-background-2-hover: #0c3b5e; + --color-brand-background-2-pressed: #061724; + --color-brand-background-3-static: #0f548c; + --color-brand-background-4-static: #0c3b5e; + --color-brand-background-inverted: #ffffff; + --color-brand-background-inverted-hover: #ebf3fc; + --color-brand-background-inverted-pressed: #b4d6fa; + --color-brand-background-inverted-selected: #cfe4fa; + --color-neutral-card-background: #333333; + --color-neutral-card-background-hover: #3d3d3d; + --color-neutral-card-background-pressed: #2e2e2e; + --color-neutral-card-background-selected: #383838; + --color-neutral-card-background-disabled: #141414; + --color-neutral-stroke-accessible: #adadad; + --color-neutral-stroke-accessible-hover: #bdbdbd; + --color-neutral-stroke-accessible-pressed: #b3b3b3; + --color-neutral-stroke-accessible-selected: #479ef5; + --color-neutral-stroke-1: #666666; + --color-neutral-stroke-1-hover: #757575; + --color-neutral-stroke-1-pressed: #6b6b6b; + --color-neutral-stroke-1-selected: #707070; + --color-neutral-stroke-2: #525252; + --color-neutral-stroke-3: #3d3d3d; + --color-neutral-stroke-4: #3d3d3d; + --color-neutral-stroke-4-hover: #2e2e2e; + --color-neutral-stroke-4-pressed: #242424; + --color-neutral-stroke-4-selected: #3d3d3d; + --color-neutral-stroke-subtle: #0a0a0a; + --color-neutral-stroke-on-brand: #292929; + --color-neutral-stroke-on-brand-2: #ffffff; + --color-neutral-stroke-on-brand-2-hover: #ffffff; + --color-neutral-stroke-on-brand-2-pressed: #ffffff; + --color-neutral-stroke-on-brand-2-selected: #ffffff; + --color-brand-stroke-1: #479ef5; + --color-brand-stroke-2: #0e4775; + --color-brand-stroke-2-hover: #0e4775; + --color-brand-stroke-2-pressed: #0a2e4a; + --color-brand-stroke-2-contrast: #0e4775; + --color-compound-brand-stroke: #479ef5; + --color-compound-brand-stroke-hover: #62abf5; + --color-compound-brand-stroke-pressed: #2886de; + --color-neutral-stroke-disabled: #424242; + --color-neutral-stroke-disabled-2: #3d3d3d; + --color-neutral-stroke-inverted-disabled: rgba(255, 255, 255, 0.4); + --color-transparent-stroke: transparent; + --color-transparent-stroke-interactive: transparent; + --color-transparent-stroke-disabled: transparent; + --color-neutral-stroke-alpha: rgba(255, 255, 255, 0.1); + --color-neutral-stroke-alpha-2: rgba(255, 255, 255, 0.2); + --color-stroke-focus-1: #000000; + --color-stroke-focus-2: #ffffff; + --color-neutral-shadow-ambient: rgba(0, 0, 0, 0.24); + --color-neutral-shadow-key: rgba(0, 0, 0, 0.28); + --color-neutral-shadow-ambient-lighter: rgba(0, 0, 0, 0.12); + --color-neutral-shadow-key-lighter: rgba(0, 0, 0, 0.14); + --color-neutral-shadow-ambient-darker: rgba(0, 0, 0, 0.4); + --color-neutral-shadow-key-darker: rgba(0, 0, 0, 0.48); + --color-brand-shadow-ambient: rgba(0, 0, 0, 0.3); + --color-brand-shadow-key: rgba(0, 0, 0, 0.25); + --color-palette-red-background-1: #3f1011; + --color-palette-red-background-2: #751d1f; + --color-palette-red-background-3: #d13438; + --color-palette-red-border-active: #e37d80; + --color-palette-red-border-1: #d13438; + --color-palette-red-border-2: #e37d80; + --color-palette-red-foreground-1: #e37d80; + --color-palette-red-foreground-2: #f1bbbc; + --color-palette-red-foreground-3: #e37d80; + --color-palette-red-foreground-inverted: #d13438; + --color-palette-green-background-1: #052505; + --color-palette-green-background-2: #094509; + --color-palette-green-background-3: #107c10; + --color-palette-green-border-active: #54b054; + --color-palette-green-border-1: #107c10; + --color-palette-green-border-2: #9fd89f; + --color-palette-green-foreground-1: #54b054; + --color-palette-green-foreground-2: #9fd89f; + --color-palette-green-foreground-3: #9fd89f; + --color-palette-green-foreground-inverted: #107c10; + --color-palette-dark-orange-background-1: #411200; + --color-palette-dark-orange-background-2: #7a2101; + --color-palette-dark-orange-background-3: #da3b01; + --color-palette-dark-orange-border-active: #e9835e; + --color-palette-dark-orange-border-1: #da3b01; + --color-palette-dark-orange-border-2: #e9835e; + --color-palette-dark-orange-foreground-1: #e9835e; + --color-palette-dark-orange-foreground-2: #f4bfab; + --color-palette-dark-orange-foreground-3: #e9835e; + --color-palette-yellow-background-1: #4c4400; + --color-palette-yellow-background-2: #817400; + --color-palette-yellow-background-3: #fde300; + --color-palette-yellow-border-active: #feee66; + --color-palette-yellow-border-1: #fde300; + --color-palette-yellow-border-2: #fdea3d; + --color-palette-yellow-foreground-1: #feee66; + --color-palette-yellow-foreground-2: #fef7b2; + --color-palette-yellow-foreground-3: #fdea3d; + --color-palette-yellow-foreground-inverted: #817400; + --color-palette-berry-background-1: #3a1136; + --color-palette-berry-background-2: #6d2064; + --color-palette-berry-background-3: #c239b3; + --color-palette-berry-border-active: #da7ed0; + --color-palette-berry-border-1: #c239b3; + --color-palette-berry-border-2: #d161c4; + --color-palette-berry-foreground-1: #da7ed0; + --color-palette-berry-foreground-2: #edbbe7; + --color-palette-berry-foreground-3: #d161c4; + --color-palette-marigold-background-1: #463100; + --color-palette-marigold-background-2: #835b00; + --color-palette-marigold-background-3: #eaa300; + --color-palette-marigold-border-active: #f2c661; + --color-palette-marigold-border-1: #eaa300; + --color-palette-marigold-border-2: #efb839; + --color-palette-marigold-foreground-1: #f2c661; + --color-palette-marigold-foreground-2: #f9e2ae; + --color-palette-marigold-foreground-3: #efb839; + --color-palette-light-green-background-1: #063004; + --color-palette-light-green-background-2: #0b5a08; + --color-palette-light-green-background-3: #13a10e; + --color-palette-light-green-border-active: #5ec75a; + --color-palette-light-green-border-1: #13a10e; + --color-palette-light-green-border-2: #3db838; + --color-palette-light-green-foreground-1: #5ec75a; + --color-palette-light-green-foreground-2: #a7e3a5; + --color-palette-light-green-foreground-3: #3db838; + --color-palette-anchor-background-2: #202427; + --color-palette-anchor-border-active: #808a90; + --color-palette-anchor-foreground-2: #bcc3c7; + --color-palette-beige-background-2: #444241; + --color-palette-beige-border-active: #afabaa; + --color-palette-beige-foreground-2: #d7d4d4; + --color-palette-blue-background-2: #004377; + --color-palette-blue-border-active: #5caae5; + --color-palette-blue-foreground-2: #a9d3f2; + --color-palette-brass-background-2: #553e06; + --color-palette-brass-border-active: #c1a256; + --color-palette-brass-foreground-2: #e0cea2; + --color-palette-brown-background-2: #50301a; + --color-palette-brown-border-active: #bb8f6f; + --color-palette-brown-foreground-2: #ddc3b0; + --color-palette-cornflower-background-2: #2c3c85; + --color-palette-cornflower-border-active: #93a4f4; + --color-palette-cornflower-foreground-2: #c8d1fa; + --color-palette-cranberry-background-2: #6e0811; + --color-palette-cranberry-border-active: #dc626d; + --color-palette-cranberry-foreground-2: #eeacb2; + --color-palette-dark-green-background-2: #063b06; + --color-palette-dark-green-border-active: #4da64d; + --color-palette-dark-green-foreground-2: #9ad29a; + --color-palette-dark-red-background-2: #590815; + --color-palette-dark-red-border-active: #ac4f5e; + --color-palette-dark-red-foreground-2: #d69ca5; + --color-palette-forest-background-2: #294903; + --color-palette-forest-border-active: #85b44c; + --color-palette-forest-foreground-2: #bdd99b; + --color-palette-gold-background-2: #6c5700; + --color-palette-gold-border-active: #dac157; + --color-palette-gold-foreground-2: #ecdfa5; + --color-palette-grape-background-2: #4c0d55; + --color-palette-grape-border-active: #b55fc1; + --color-palette-grape-foreground-2: #d9a7e0; + --color-palette-lavender-background-2: #3f3682; + --color-palette-lavender-border-active: #a79cf1; + --color-palette-lavender-foreground-2: #d2ccf8; + --color-palette-light-teal-background-2: #00666d; + --color-palette-light-teal-border-active: #58d3db; + --color-palette-light-teal-foreground-2: #a6e9ed; + --color-palette-lilac-background-2: #63276d; + --color-palette-lilac-border-active: #cf87da; + --color-palette-lilac-foreground-2: #e6bfed; + --color-palette-magenta-background-2: #6b0043; + --color-palette-magenta-border-active: #d957a8; + --color-palette-magenta-foreground-2: #eca5d1; + --color-palette-mink-background-2: #343231; + --color-palette-mink-border-active: #9e9b99; + --color-palette-mink-foreground-2: #cecccb; + --color-palette-navy-background-2: #001665; + --color-palette-navy-border-active: #546fd2; + --color-palette-navy-foreground-2: #a3b2e8; + --color-palette-peach-background-2: #8f4e00; + --color-palette-peach-border-active: #ffba66; + --color-palette-peach-foreground-2: #ffddb3; + --color-palette-pink-background-2: #80215d; + --color-palette-pink-border-active: #ef85c8; + --color-palette-pink-foreground-2: #f7c0e3; + --color-palette-platinum-background-2: #3b4447; + --color-palette-platinum-border-active: #a0adb2; + --color-palette-platinum-foreground-2: #cdd6d8; + --color-palette-plum-background-2: #5a003b; + --color-palette-plum-border-active: #ad4589; + --color-palette-plum-foreground-2: #d696c0; + --color-palette-pumpkin-background-2: #712d09; + --color-palette-pumpkin-border-active: #df8e64; + --color-palette-pumpkin-foreground-2: #efc4ad; + --color-palette-purple-background-2: #341a51; + --color-palette-purple-border-active: #9470bd; + --color-palette-purple-foreground-2: #c6b1de; + --color-palette-royal-blue-background-2: #002c4e; + --color-palette-royal-blue-border-active: #4a89ba; + --color-palette-royal-blue-foreground-2: #9abfdc; + --color-palette-seafoam-background-2: #00723b; + --color-palette-seafoam-border-active: #5ae0a0; + --color-palette-seafoam-foreground-2: #a8f0cd; + --color-palette-steel-background-2: #00333f; + --color-palette-steel-border-active: #4496a9; + --color-palette-steel-foreground-2: #94c8d4; + --color-palette-teal-background-2: #02494c; + --color-palette-teal-border-active: #4cb4b7; + --color-palette-teal-foreground-2: #9bd9db; + --color-status-success-background-1: #052505; + --color-status-success-background-2: #094509; + --color-status-success-background-3: #107c10; + --color-status-success-foreground-1: #54b054; + --color-status-success-foreground-2: #9fd89f; + --color-status-success-foreground-3: #9fd89f; + --color-status-success-foreground-inverted: #0e700e; + --color-status-success-border-active: #54b054; + --color-status-success-border-1: #107c10; + --color-status-success-border-2: #9fd89f; + --color-status-warning-background-1: #4a1e04; + --color-status-warning-background-2: #8a3707; + --color-status-warning-background-3: #f7630c; + --color-status-warning-foreground-1: #faa06b; + --color-status-warning-foreground-2: #fdcfb4; + --color-status-warning-foreground-3: #f98845; + --color-status-warning-foreground-inverted: #bc4b09; + --color-status-warning-border-active: #faa06b; + --color-status-warning-border-1: #f7630c; + --color-status-warning-border-2: #f98845; + --color-status-danger-background-1: #3b0509; + --color-status-danger-background-2: #6e0811; + --color-status-danger-background-3: #c50f1f; + --color-status-danger-background-3-hover: #b10e1c; + --color-status-danger-background-3-pressed: #960b18; + --color-status-danger-foreground-1: #dc626d; + --color-status-danger-foreground-2: #eeacb2; + --color-status-danger-foreground-3: #eeacb2; + --color-status-danger-foreground-inverted: #b10e1c; + --color-status-danger-border-active: #dc626d; + --color-status-danger-border-1: #c50f1f; + --color-status-danger-border-2: #dc626d; + --radius-none: 0; + --radius-small: 2px; + --radius-medium: 4px; + --radius-large: 6px; + --radius-x-large: 8px; + --radius-2-x-large: 12px; + --radius-3-x-large: 16px; + --radius-4-x-large: 24px; + --radius-5-x-large: 32px; + --radius-6-x-large: 40px; + --radius-circular: 10000px; + --font-base: 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', + sans-serif; + --font-monospace: Consolas, 'Courier New', Courier, monospace; + --font-numeric: Bahnschrift, 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, + 'Helvetica Neue', sans-serif; + --text-base-100: 10px; + --text-base-200: 12px; + --text-base-300: 14px; + --text-base-400: 16px; + --text-base-500: 20px; + --text-base-600: 24px; + --text-hero-700: 28px; + --text-hero-800: 32px; + --text-hero-900: 40px; + --text-hero-1000: 68px; + --font-weight-regular: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + --leading-base-100: 14px; + --leading-base-200: 16px; + --leading-base-300: 20px; + --leading-base-400: 22px; + --leading-base-500: 28px; + --leading-base-600: 32px; + --leading-hero-700: 36px; + --leading-hero-800: 40px; + --leading-hero-900: 52px; + --leading-hero-1000: 92px; + --shadow-2: 0 0 2px rgba(0, 0, 0, 0.24), 0 1px 2px rgba(0, 0, 0, 0.28); + --shadow-4: 0 0 2px rgba(0, 0, 0, 0.24), 0 2px 4px rgba(0, 0, 0, 0.28); + --shadow-8: 0 0 2px rgba(0, 0, 0, 0.24), 0 4px 8px rgba(0, 0, 0, 0.28); + --shadow-16: 0 0 2px rgba(0, 0, 0, 0.24), 0 8px 16px rgba(0, 0, 0, 0.28); + --shadow-28: 0 0 8px rgba(0, 0, 0, 0.24), 0 14px 28px rgba(0, 0, 0, 0.28); + --shadow-64: 0 0 8px rgba(0, 0, 0, 0.24), 0 32px 64px rgba(0, 0, 0, 0.28); + --shadow-2-brand: 0 0 2px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.25); + --shadow-4-brand: 0 0 2px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.25); + --shadow-8-brand: 0 0 2px rgba(0, 0, 0, 0.3), 0 4px 8px rgba(0, 0, 0, 0.25); + --shadow-16-brand: 0 0 2px rgba(0, 0, 0, 0.3), 0 8px 16px rgba(0, 0, 0, 0.25); + --shadow-28-brand: 0 0 8px rgba(0, 0, 0, 0.3), 0 14px 28px rgba(0, 0, 0, 0.25); + --shadow-64-brand: 0 0 8px rgba(0, 0, 0, 0.3), 0 32px 64px rgba(0, 0, 0, 0.25); + --duration-ultra-fast: 50ms; + --duration-faster: 100ms; + --duration-fast: 150ms; + --duration-normal: 200ms; + --duration-gentle: 250ms; + --duration-slow: 300ms; + --duration-slower: 400ms; + --duration-ultra-slow: 500ms; + --ease-accelerate-max: cubic-bezier(0.9, 0.1, 1, 0.2); + --ease-accelerate-mid: cubic-bezier(1, 0, 1, 1); + --ease-accelerate-min: cubic-bezier(0.8, 0, 0.78, 1); + --ease-decelerate-max: cubic-bezier(0.1, 0.9, 0.2, 1); + --ease-decelerate-mid: cubic-bezier(0, 0, 0, 1); + --ease-decelerate-min: cubic-bezier(0.33, 0, 0.1, 1); + --ease-easy-ease-max: cubic-bezier(0.8, 0, 0.2, 1); + --ease-easy-ease: cubic-bezier(0.33, 0, 0.67, 1); + --ease-linear: cubic-bezier(0, 0, 1, 1); + } +} + +/* teamsLightTheme — 433 tokens */ +@layer fui.theme { + .fui-theme-teams-light { + --color-neutral-foreground-1: #242424; + --color-neutral-foreground-1-hover: #242424; + --color-neutral-foreground-1-pressed: #242424; + --color-neutral-foreground-1-selected: #242424; + --color-neutral-foreground-2: #424242; + --color-neutral-foreground-2-hover: #242424; + --color-neutral-foreground-2-pressed: #242424; + --color-neutral-foreground-2-selected: #242424; + --color-neutral-foreground-2-brand-hover: #5b5fc7; + --color-neutral-foreground-2-brand-pressed: #4f52b2; + --color-neutral-foreground-2-brand-selected: #5b5fc7; + --color-neutral-foreground-3: #616161; + --color-neutral-foreground-3-hover: #424242; + --color-neutral-foreground-3-pressed: #424242; + --color-neutral-foreground-3-selected: #424242; + --color-neutral-foreground-3-brand-hover: #5b5fc7; + --color-neutral-foreground-3-brand-pressed: #4f52b2; + --color-neutral-foreground-3-brand-selected: #5b5fc7; + --color-neutral-foreground-4: #707070; + --color-neutral-foreground-5: #616161; + --color-neutral-foreground-5-hover: #242424; + --color-neutral-foreground-5-pressed: #242424; + --color-neutral-foreground-5-selected: #242424; + --color-neutral-foreground-disabled: #bdbdbd; + --color-brand-foreground-link: #4f52b2; + --color-brand-foreground-link-hover: #444791; + --color-brand-foreground-link-pressed: #383966; + --color-brand-foreground-link-selected: #4f52b2; + --color-neutral-foreground-2-link: #424242; + --color-neutral-foreground-2-link-hover: #242424; + --color-neutral-foreground-2-link-pressed: #242424; + --color-neutral-foreground-2-link-selected: #242424; + --color-compound-brand-foreground-1: #5b5fc7; + --color-compound-brand-foreground-1-hover: #4f52b2; + --color-compound-brand-foreground-1-pressed: #444791; + --color-neutral-foreground-on-brand: #ffffff; + --color-neutral-foreground-inverted: #ffffff; + --color-neutral-foreground-inverted-hover: #ffffff; + --color-neutral-foreground-inverted-pressed: #ffffff; + --color-neutral-foreground-inverted-selected: #ffffff; + --color-neutral-foreground-inverted-2: #ffffff; + --color-neutral-foreground-static-inverted: #ffffff; + --color-neutral-foreground-inverted-link: #ffffff; + --color-neutral-foreground-inverted-link-hover: #ffffff; + --color-neutral-foreground-inverted-link-pressed: #ffffff; + --color-neutral-foreground-inverted-link-selected: #ffffff; + --color-neutral-foreground-inverted-disabled: rgba(255, 255, 255, 0.4); + --color-brand-foreground-1: #5b5fc7; + --color-brand-foreground-2: #4f52b2; + --color-brand-foreground-2-hover: #444791; + --color-brand-foreground-2-pressed: #333357; + --color-neutral-foreground-1-static: #242424; + --color-brand-foreground-inverted: #7f85f5; + --color-brand-foreground-inverted-hover: #9299f7; + --color-brand-foreground-inverted-pressed: #7f85f5; + --color-brand-foreground-on-light: #5b5fc7; + --color-brand-foreground-on-light-hover: #4f52b2; + --color-brand-foreground-on-light-pressed: #3d3e78; + --color-brand-foreground-on-light-selected: #444791; + --color-neutral-background-1: #ffffff; + --color-neutral-background-1-hover: #f5f5f5; + --color-neutral-background-1-pressed: #e0e0e0; + --color-neutral-background-1-selected: #ebebeb; + --color-neutral-background-2: #fafafa; + --color-neutral-background-2-hover: #f0f0f0; + --color-neutral-background-2-pressed: #dbdbdb; + --color-neutral-background-2-selected: #e6e6e6; + --color-neutral-background-3: #f5f5f5; + --color-neutral-background-3-hover: #ebebeb; + --color-neutral-background-3-pressed: #d6d6d6; + --color-neutral-background-3-selected: #e0e0e0; + --color-neutral-background-4: #f0f0f0; + --color-neutral-background-4-hover: #fafafa; + --color-neutral-background-4-pressed: #f5f5f5; + --color-neutral-background-4-selected: #ffffff; + --color-neutral-background-5: #ebebeb; + --color-neutral-background-5-hover: #f5f5f5; + --color-neutral-background-5-pressed: #f0f0f0; + --color-neutral-background-5-selected: #fafafa; + --color-neutral-background-6: #e6e6e6; + --color-neutral-background-7: #00000000; + --color-neutral-background-7-hover: #ebebeb; + --color-neutral-background-7-pressed: #d6d6d6; + --color-neutral-background-7-selected: #00000000; + --color-neutral-background-8: #fcfcfc; + --color-neutral-background-inverted: #292929; + --color-neutral-background-inverted-hover: #3d3d3d; + --color-neutral-background-inverted-pressed: #1f1f1f; + --color-neutral-background-inverted-selected: #383838; + --color-neutral-background-static: #333333; + --color-neutral-background-alpha: rgba(255, 255, 255, 0.5); + --color-neutral-background-alpha-2: rgba(255, 255, 255, 0.8); + --color-subtle-background: transparent; + --color-subtle-background-hover: #f5f5f5; + --color-subtle-background-pressed: #e0e0e0; + --color-subtle-background-selected: #ebebeb; + --color-subtle-background-light-alpha-hover: rgba(255, 255, 255, 0.7); + --color-subtle-background-light-alpha-pressed: rgba(255, 255, 255, 0.5); + --color-subtle-background-light-alpha-selected: transparent; + --color-subtle-background-inverted: transparent; + --color-subtle-background-inverted-hover: rgba(0, 0, 0, 0.1); + --color-subtle-background-inverted-pressed: rgba(0, 0, 0, 0.3); + --color-subtle-background-inverted-selected: rgba(0, 0, 0, 0.2); + --color-transparent-background: transparent; + --color-transparent-background-hover: transparent; + --color-transparent-background-pressed: transparent; + --color-transparent-background-selected: transparent; + --color-neutral-background-disabled: #f0f0f0; + --color-neutral-background-disabled-2: #ffffff; + --color-neutral-background-inverted-disabled: rgba(255, 255, 255, 0.1); + --color-neutral-stencil-1: #e6e6e6; + --color-neutral-stencil-2: #fafafa; + --color-neutral-stencil-1-alpha: rgba(0, 0, 0, 0.1); + --color-neutral-stencil-2-alpha: rgba(0, 0, 0, 0.05); + --color-background-overlay: rgba(0, 0, 0, 0.4); + --color-scrollbar-overlay: rgba(0, 0, 0, 0.5); + --color-brand-background: #5b5fc7; + --color-brand-background-hover: #4f52b2; + --color-brand-background-pressed: #383966; + --color-brand-background-selected: #444791; + --color-compound-brand-background: #5b5fc7; + --color-compound-brand-background-hover: #4f52b2; + --color-compound-brand-background-pressed: #444791; + --color-brand-background-static: #5b5fc7; + --color-brand-background-2: #e8ebfa; + --color-brand-background-2-hover: #dce0fa; + --color-brand-background-2-pressed: #b6bcfa; + --color-brand-background-3-static: #444791; + --color-brand-background-4-static: #383966; + --color-brand-background-inverted: #ffffff; + --color-brand-background-inverted-hover: #e8ebfa; + --color-brand-background-inverted-pressed: #c5cbfa; + --color-brand-background-inverted-selected: #dce0fa; + --color-neutral-card-background: #fafafa; + --color-neutral-card-background-hover: #ffffff; + --color-neutral-card-background-pressed: #f5f5f5; + --color-neutral-card-background-selected: #ebebeb; + --color-neutral-card-background-disabled: #f0f0f0; + --color-neutral-stroke-accessible: #616161; + --color-neutral-stroke-accessible-hover: #575757; + --color-neutral-stroke-accessible-pressed: #4d4d4d; + --color-neutral-stroke-accessible-selected: #5b5fc7; + --color-neutral-stroke-1: #d1d1d1; + --color-neutral-stroke-1-hover: #c7c7c7; + --color-neutral-stroke-1-pressed: #b3b3b3; + --color-neutral-stroke-1-selected: #bdbdbd; + --color-neutral-stroke-2: #e0e0e0; + --color-neutral-stroke-3: #f0f0f0; + --color-neutral-stroke-4: #ebebeb; + --color-neutral-stroke-4-hover: #e0e0e0; + --color-neutral-stroke-4-pressed: #d6d6d6; + --color-neutral-stroke-4-selected: #ebebeb; + --color-neutral-stroke-subtle: #e0e0e0; + --color-neutral-stroke-on-brand: #ffffff; + --color-neutral-stroke-on-brand-2: #ffffff; + --color-neutral-stroke-on-brand-2-hover: #ffffff; + --color-neutral-stroke-on-brand-2-pressed: #ffffff; + --color-neutral-stroke-on-brand-2-selected: #ffffff; + --color-brand-stroke-1: #5b5fc7; + --color-brand-stroke-2: #c5cbfa; + --color-brand-stroke-2-hover: #aab1fa; + --color-brand-stroke-2-pressed: #5b5fc7; + --color-brand-stroke-2-contrast: #c5cbfa; + --color-compound-brand-stroke: #5b5fc7; + --color-compound-brand-stroke-hover: #4f52b2; + --color-compound-brand-stroke-pressed: #444791; + --color-neutral-stroke-disabled: #e0e0e0; + --color-neutral-stroke-disabled-2: #ebebeb; + --color-neutral-stroke-inverted-disabled: rgba(255, 255, 255, 0.4); + --color-transparent-stroke: transparent; + --color-transparent-stroke-interactive: transparent; + --color-transparent-stroke-disabled: transparent; + --color-neutral-stroke-alpha: rgba(0, 0, 0, 0.05); + --color-neutral-stroke-alpha-2: rgba(255, 255, 255, 0.2); + --color-stroke-focus-1: #ffffff; + --color-stroke-focus-2: #000000; + --color-neutral-shadow-ambient: rgba(0, 0, 0, 0.12); + --color-neutral-shadow-key: rgba(0, 0, 0, 0.14); + --color-neutral-shadow-ambient-lighter: rgba(0, 0, 0, 0.06); + --color-neutral-shadow-key-lighter: rgba(0, 0, 0, 0.07); + --color-neutral-shadow-ambient-darker: rgba(0, 0, 0, 0.2); + --color-neutral-shadow-key-darker: rgba(0, 0, 0, 0.24); + --color-brand-shadow-ambient: rgba(0, 0, 0, 0.3); + --color-brand-shadow-key: rgba(0, 0, 0, 0.25); + --color-palette-red-background-1: #fdf6f6; + --color-palette-red-background-2: #f1bbbc; + --color-palette-red-background-3: #d13438; + --color-palette-red-border-active: #d13438; + --color-palette-red-border-1: #f1bbbc; + --color-palette-red-border-2: #d13438; + --color-palette-red-foreground-1: #bc2f32; + --color-palette-red-foreground-2: #751d1f; + --color-palette-red-foreground-3: #d13438; + --color-palette-red-foreground-inverted: #dc5e62; + --color-palette-green-background-1: #f1faf1; + --color-palette-green-background-2: #9fd89f; + --color-palette-green-background-3: #107c10; + --color-palette-green-border-active: #107c10; + --color-palette-green-border-1: #9fd89f; + --color-palette-green-border-2: #107c10; + --color-palette-green-foreground-1: #0e700e; + --color-palette-green-foreground-2: #094509; + --color-palette-green-foreground-3: #107c10; + --color-palette-green-foreground-inverted: #359b35; + --color-palette-dark-orange-background-1: #fdf6f3; + --color-palette-dark-orange-background-2: #f4bfab; + --color-palette-dark-orange-background-3: #da3b01; + --color-palette-dark-orange-border-active: #da3b01; + --color-palette-dark-orange-border-1: #f4bfab; + --color-palette-dark-orange-border-2: #da3b01; + --color-palette-dark-orange-foreground-1: #c43501; + --color-palette-dark-orange-foreground-2: #7a2101; + --color-palette-dark-orange-foreground-3: #da3b01; + --color-palette-yellow-background-1: #fffef5; + --color-palette-yellow-background-2: #fef7b2; + --color-palette-yellow-background-3: #fde300; + --color-palette-yellow-border-active: #fde300; + --color-palette-yellow-border-1: #fef7b2; + --color-palette-yellow-border-2: #fde300; + --color-palette-yellow-foreground-1: #817400; + --color-palette-yellow-foreground-2: #817400; + --color-palette-yellow-foreground-3: #fde300; + --color-palette-yellow-foreground-inverted: #fef7b2; + --color-palette-berry-background-1: #fdf5fc; + --color-palette-berry-background-2: #edbbe7; + --color-palette-berry-background-3: #c239b3; + --color-palette-berry-border-active: #c239b3; + --color-palette-berry-border-1: #edbbe7; + --color-palette-berry-border-2: #c239b3; + --color-palette-berry-foreground-1: #af33a1; + --color-palette-berry-foreground-2: #6d2064; + --color-palette-berry-foreground-3: #c239b3; + --color-palette-marigold-background-1: #fefbf4; + --color-palette-marigold-background-2: #f9e2ae; + --color-palette-marigold-background-3: #eaa300; + --color-palette-marigold-border-active: #eaa300; + --color-palette-marigold-border-1: #f9e2ae; + --color-palette-marigold-border-2: #eaa300; + --color-palette-marigold-foreground-1: #d39300; + --color-palette-marigold-foreground-2: #835b00; + --color-palette-marigold-foreground-3: #eaa300; + --color-palette-light-green-background-1: #f2fbf2; + --color-palette-light-green-background-2: #a7e3a5; + --color-palette-light-green-background-3: #13a10e; + --color-palette-light-green-border-active: #13a10e; + --color-palette-light-green-border-1: #a7e3a5; + --color-palette-light-green-border-2: #13a10e; + --color-palette-light-green-foreground-1: #11910d; + --color-palette-light-green-foreground-2: #0b5a08; + --color-palette-light-green-foreground-3: #13a10e; + --color-palette-anchor-background-2: #bcc3c7; + --color-palette-anchor-border-active: #394146; + --color-palette-anchor-foreground-2: #202427; + --color-palette-beige-background-2: #d7d4d4; + --color-palette-beige-border-active: #7a7574; + --color-palette-beige-foreground-2: #444241; + --color-palette-blue-background-2: #a9d3f2; + --color-palette-blue-border-active: #0078d4; + --color-palette-blue-foreground-2: #004377; + --color-palette-brass-background-2: #e0cea2; + --color-palette-brass-border-active: #986f0b; + --color-palette-brass-foreground-2: #553e06; + --color-palette-brown-background-2: #ddc3b0; + --color-palette-brown-border-active: #8e562e; + --color-palette-brown-foreground-2: #50301a; + --color-palette-cornflower-background-2: #c8d1fa; + --color-palette-cornflower-border-active: #4f6bed; + --color-palette-cornflower-foreground-2: #2c3c85; + --color-palette-cranberry-background-2: #eeacb2; + --color-palette-cranberry-border-active: #c50f1f; + --color-palette-cranberry-foreground-2: #6e0811; + --color-palette-dark-green-background-2: #9ad29a; + --color-palette-dark-green-border-active: #0b6a0b; + --color-palette-dark-green-foreground-2: #063b06; + --color-palette-dark-red-background-2: #d69ca5; + --color-palette-dark-red-border-active: #750b1c; + --color-palette-dark-red-foreground-2: #420610; + --color-palette-forest-background-2: #bdd99b; + --color-palette-forest-border-active: #498205; + --color-palette-forest-foreground-2: #294903; + --color-palette-gold-background-2: #ecdfa5; + --color-palette-gold-border-active: #c19c00; + --color-palette-gold-foreground-2: #6c5700; + --color-palette-grape-background-2: #d9a7e0; + --color-palette-grape-border-active: #881798; + --color-palette-grape-foreground-2: #4c0d55; + --color-palette-lavender-background-2: #d2ccf8; + --color-palette-lavender-border-active: #7160e8; + --color-palette-lavender-foreground-2: #3f3682; + --color-palette-light-teal-background-2: #a6e9ed; + --color-palette-light-teal-border-active: #00b7c3; + --color-palette-light-teal-foreground-2: #00666d; + --color-palette-lilac-background-2: #e6bfed; + --color-palette-lilac-border-active: #b146c2; + --color-palette-lilac-foreground-2: #63276d; + --color-palette-magenta-background-2: #eca5d1; + --color-palette-magenta-border-active: #bf0077; + --color-palette-magenta-foreground-2: #6b0043; + --color-palette-mink-background-2: #cecccb; + --color-palette-mink-border-active: #5d5a58; + --color-palette-mink-foreground-2: #343231; + --color-palette-navy-background-2: #a3b2e8; + --color-palette-navy-border-active: #0027b4; + --color-palette-navy-foreground-2: #001665; + --color-palette-peach-background-2: #ffddb3; + --color-palette-peach-border-active: #ff8c00; + --color-palette-peach-foreground-2: #8f4e00; + --color-palette-pink-background-2: #f7c0e3; + --color-palette-pink-border-active: #e43ba6; + --color-palette-pink-foreground-2: #80215d; + --color-palette-platinum-background-2: #cdd6d8; + --color-palette-platinum-border-active: #69797e; + --color-palette-platinum-foreground-2: #3b4447; + --color-palette-plum-background-2: #d696c0; + --color-palette-plum-border-active: #77004d; + --color-palette-plum-foreground-2: #43002b; + --color-palette-pumpkin-background-2: #efc4ad; + --color-palette-pumpkin-border-active: #ca5010; + --color-palette-pumpkin-foreground-2: #712d09; + --color-palette-purple-background-2: #c6b1de; + --color-palette-purple-border-active: #5c2e91; + --color-palette-purple-foreground-2: #341a51; + --color-palette-royal-blue-background-2: #9abfdc; + --color-palette-royal-blue-border-active: #004e8c; + --color-palette-royal-blue-foreground-2: #002c4e; + --color-palette-seafoam-background-2: #a8f0cd; + --color-palette-seafoam-border-active: #00cc6a; + --color-palette-seafoam-foreground-2: #00723b; + --color-palette-steel-background-2: #94c8d4; + --color-palette-steel-border-active: #005b70; + --color-palette-steel-foreground-2: #00333f; + --color-palette-teal-background-2: #9bd9db; + --color-palette-teal-border-active: #038387; + --color-palette-teal-foreground-2: #02494c; + --color-status-success-background-1: #f1faf1; + --color-status-success-background-2: #9fd89f; + --color-status-success-background-3: #107c10; + --color-status-success-foreground-1: #0e700e; + --color-status-success-foreground-2: #094509; + --color-status-success-foreground-3: #107c10; + --color-status-success-foreground-inverted: #54b054; + --color-status-success-border-active: #107c10; + --color-status-success-border-1: #9fd89f; + --color-status-success-border-2: #107c10; + --color-status-warning-background-1: #fff9f5; + --color-status-warning-background-2: #fdcfb4; + --color-status-warning-background-3: #f7630c; + --color-status-warning-foreground-1: #bc4b09; + --color-status-warning-foreground-2: #8a3707; + --color-status-warning-foreground-3: #bc4b09; + --color-status-warning-foreground-inverted: #faa06b; + --color-status-warning-border-active: #f7630c; + --color-status-warning-border-1: #fdcfb4; + --color-status-warning-border-2: #bc4b09; + --color-status-danger-background-1: #fdf3f4; + --color-status-danger-background-2: #eeacb2; + --color-status-danger-background-3: #c50f1f; + --color-status-danger-background-3-hover: #b10e1c; + --color-status-danger-background-3-pressed: #960b18; + --color-status-danger-foreground-1: #b10e1c; + --color-status-danger-foreground-2: #6e0811; + --color-status-danger-foreground-3: #c50f1f; + --color-status-danger-foreground-inverted: #dc626d; + --color-status-danger-border-active: #c50f1f; + --color-status-danger-border-1: #eeacb2; + --color-status-danger-border-2: #c50f1f; + --radius-none: 0; + --radius-small: 2px; + --radius-medium: 4px; + --radius-large: 6px; + --radius-x-large: 8px; + --radius-2-x-large: 12px; + --radius-3-x-large: 16px; + --radius-4-x-large: 24px; + --radius-5-x-large: 32px; + --radius-6-x-large: 40px; + --radius-circular: 10000px; + --font-base: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, 'Apple Color Emoji', 'Segoe UI Emoji', + sans-serif; + --font-monospace: Consolas, 'Courier New', Courier, monospace; + --font-numeric: Bahnschrift, 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, + 'Helvetica Neue', sans-serif; + --text-base-100: 10px; + --text-base-200: 12px; + --text-base-300: 14px; + --text-base-400: 16px; + --text-base-500: 20px; + --text-base-600: 24px; + --text-hero-700: 28px; + --text-hero-800: 32px; + --text-hero-900: 40px; + --text-hero-1000: 68px; + --font-weight-regular: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + --leading-base-100: 14px; + --leading-base-200: 16px; + --leading-base-300: 20px; + --leading-base-400: 22px; + --leading-base-500: 28px; + --leading-base-600: 32px; + --leading-hero-700: 36px; + --leading-hero-800: 40px; + --leading-hero-900: 52px; + --leading-hero-1000: 92px; + --shadow-2: 0 0 2px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.14); + --shadow-4: 0 0 2px rgba(0, 0, 0, 0.12), 0 2px 4px rgba(0, 0, 0, 0.14); + --shadow-8: 0 0 2px rgba(0, 0, 0, 0.12), 0 4px 8px rgba(0, 0, 0, 0.14); + --shadow-16: 0 0 2px rgba(0, 0, 0, 0.12), 0 8px 16px rgba(0, 0, 0, 0.14); + --shadow-28: 0 0 8px rgba(0, 0, 0, 0.12), 0 14px 28px rgba(0, 0, 0, 0.14); + --shadow-64: 0 0 8px rgba(0, 0, 0, 0.12), 0 32px 64px rgba(0, 0, 0, 0.14); + --shadow-2-brand: 0 0 2px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.25); + --shadow-4-brand: 0 0 2px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.25); + --shadow-8-brand: 0 0 2px rgba(0, 0, 0, 0.3), 0 4px 8px rgba(0, 0, 0, 0.25); + --shadow-16-brand: 0 0 2px rgba(0, 0, 0, 0.3), 0 8px 16px rgba(0, 0, 0, 0.25); + --shadow-28-brand: 0 0 8px rgba(0, 0, 0, 0.3), 0 14px 28px rgba(0, 0, 0, 0.25); + --shadow-64-brand: 0 0 8px rgba(0, 0, 0, 0.3), 0 32px 64px rgba(0, 0, 0, 0.25); + --duration-ultra-fast: 50ms; + --duration-faster: 100ms; + --duration-fast: 150ms; + --duration-normal: 200ms; + --duration-gentle: 250ms; + --duration-slow: 300ms; + --duration-slower: 400ms; + --duration-ultra-slow: 500ms; + --ease-accelerate-max: cubic-bezier(0.9, 0.1, 1, 0.2); + --ease-accelerate-mid: cubic-bezier(1, 0, 1, 1); + --ease-accelerate-min: cubic-bezier(0.8, 0, 0.78, 1); + --ease-decelerate-max: cubic-bezier(0.1, 0.9, 0.2, 1); + --ease-decelerate-mid: cubic-bezier(0, 0, 0, 1); + --ease-decelerate-min: cubic-bezier(0.33, 0, 0.1, 1); + --ease-easy-ease-max: cubic-bezier(0.8, 0, 0.2, 1); + --ease-easy-ease: cubic-bezier(0.33, 0, 0.67, 1); + --ease-linear: cubic-bezier(0, 0, 1, 1); + } +} + +/* teamsDarkTheme — 433 tokens */ +@layer fui.theme { + .fui-theme-teams-dark { + --color-neutral-foreground-1: #ffffff; + --color-neutral-foreground-1-hover: #ffffff; + --color-neutral-foreground-1-pressed: #ffffff; + --color-neutral-foreground-1-selected: #ffffff; + --color-neutral-foreground-2: #d6d6d6; + --color-neutral-foreground-2-hover: #ffffff; + --color-neutral-foreground-2-pressed: #ffffff; + --color-neutral-foreground-2-selected: #ffffff; + --color-neutral-foreground-2-brand-hover: #7f85f5; + --color-neutral-foreground-2-brand-pressed: #7579eb; + --color-neutral-foreground-2-brand-selected: #7f85f5; + --color-neutral-foreground-3: #adadad; + --color-neutral-foreground-3-hover: #d6d6d6; + --color-neutral-foreground-3-pressed: #d6d6d6; + --color-neutral-foreground-3-selected: #d6d6d6; + --color-neutral-foreground-3-brand-hover: #7f85f5; + --color-neutral-foreground-3-brand-pressed: #7579eb; + --color-neutral-foreground-3-brand-selected: #7f85f5; + --color-neutral-foreground-4: #999999; + --color-neutral-foreground-5: #adadad; + --color-neutral-foreground-5-hover: #ffffff; + --color-neutral-foreground-5-pressed: #ffffff; + --color-neutral-foreground-5-selected: #ffffff; + --color-neutral-foreground-disabled: #5c5c5c; + --color-brand-foreground-link: #7f85f5; + --color-brand-foreground-link-hover: #9299f7; + --color-brand-foreground-link-pressed: #7579eb; + --color-brand-foreground-link-selected: #7f85f5; + --color-neutral-foreground-2-link: #d6d6d6; + --color-neutral-foreground-2-link-hover: #ffffff; + --color-neutral-foreground-2-link-pressed: #ffffff; + --color-neutral-foreground-2-link-selected: #ffffff; + --color-compound-brand-foreground-1: #7f85f5; + --color-compound-brand-foreground-1-hover: #9299f7; + --color-compound-brand-foreground-1-pressed: #7579eb; + --color-neutral-foreground-on-brand: #ffffff; + --color-neutral-foreground-inverted: #242424; + --color-neutral-foreground-inverted-hover: #242424; + --color-neutral-foreground-inverted-pressed: #242424; + --color-neutral-foreground-inverted-selected: #242424; + --color-neutral-foreground-inverted-2: #242424; + --color-neutral-foreground-static-inverted: #ffffff; + --color-neutral-foreground-inverted-link: #ffffff; + --color-neutral-foreground-inverted-link-hover: #ffffff; + --color-neutral-foreground-inverted-link-pressed: #ffffff; + --color-neutral-foreground-inverted-link-selected: #ffffff; + --color-neutral-foreground-inverted-disabled: rgba(255, 255, 255, 0.4); + --color-brand-foreground-1: #7f85f5; + --color-brand-foreground-2: #aab1fa; + --color-brand-foreground-2-hover: #b6bcfa; + --color-brand-foreground-2-pressed: #e8ebfa; + --color-neutral-foreground-1-static: #242424; + --color-brand-foreground-inverted: #5b5fc7; + --color-brand-foreground-inverted-hover: #4f52b2; + --color-brand-foreground-inverted-pressed: #444791; + --color-brand-foreground-on-light: #5b5fc7; + --color-brand-foreground-on-light-hover: #4f52b2; + --color-brand-foreground-on-light-pressed: #3d3e78; + --color-brand-foreground-on-light-selected: #444791; + --color-neutral-background-1: #292929; + --color-neutral-background-1-hover: #3d3d3d; + --color-neutral-background-1-pressed: #1f1f1f; + --color-neutral-background-1-selected: #383838; + --color-neutral-background-2: #242424; + --color-neutral-background-2-hover: #383838; + --color-neutral-background-2-pressed: #1a1a1a; + --color-neutral-background-2-selected: #333333; + --color-neutral-background-3: #1f1f1f; + --color-neutral-background-3-hover: #333333; + --color-neutral-background-3-pressed: #141414; + --color-neutral-background-3-selected: #2e2e2e; + --color-neutral-background-4: #141414; + --color-neutral-background-4-hover: #292929; + --color-neutral-background-4-pressed: #0a0a0a; + --color-neutral-background-4-selected: #242424; + --color-neutral-background-5: #0a0a0a; + --color-neutral-background-5-hover: #1f1f1f; + --color-neutral-background-5-pressed: #000000; + --color-neutral-background-5-selected: #1a1a1a; + --color-neutral-background-6: #333333; + --color-neutral-background-7: #00000000; + --color-neutral-background-7-hover: #1a1a1a; + --color-neutral-background-7-pressed: #0a0a0a; + --color-neutral-background-7-selected: #00000000; + --color-neutral-background-8: #292929; + --color-neutral-background-inverted: #ffffff; + --color-neutral-background-inverted-hover: #f5f5f5; + --color-neutral-background-inverted-pressed: #e0e0e0; + --color-neutral-background-inverted-selected: #ebebeb; + --color-neutral-background-static: #3d3d3d; + --color-neutral-background-alpha: rgba(26, 26, 26, 0.5); + --color-neutral-background-alpha-2: rgba(31, 31, 31, 0.7); + --color-subtle-background: transparent; + --color-subtle-background-hover: #383838; + --color-subtle-background-pressed: #2e2e2e; + --color-subtle-background-selected: #333333; + --color-subtle-background-light-alpha-hover: rgba(36, 36, 36, 0.8); + --color-subtle-background-light-alpha-pressed: rgba(36, 36, 36, 0.5); + --color-subtle-background-light-alpha-selected: transparent; + --color-subtle-background-inverted: transparent; + --color-subtle-background-inverted-hover: rgba(0, 0, 0, 0.1); + --color-subtle-background-inverted-pressed: rgba(0, 0, 0, 0.3); + --color-subtle-background-inverted-selected: rgba(0, 0, 0, 0.2); + --color-transparent-background: transparent; + --color-transparent-background-hover: transparent; + --color-transparent-background-pressed: transparent; + --color-transparent-background-selected: transparent; + --color-neutral-background-disabled: #141414; + --color-neutral-background-disabled-2: #292929; + --color-neutral-background-inverted-disabled: rgba(255, 255, 255, 0.1); + --color-neutral-stencil-1: #575757; + --color-neutral-stencil-2: #333333; + --color-neutral-stencil-1-alpha: rgba(255, 255, 255, 0.1); + --color-neutral-stencil-2-alpha: rgba(255, 255, 255, 0.05); + --color-background-overlay: rgba(0, 0, 0, 0.5); + --color-scrollbar-overlay: rgba(255, 255, 255, 0.6); + --color-brand-background: #4f52b2; + --color-brand-background-hover: #5b5fc7; + --color-brand-background-pressed: #383966; + --color-brand-background-selected: #444791; + --color-compound-brand-background: #7f85f5; + --color-compound-brand-background-hover: #9299f7; + --color-compound-brand-background-pressed: #7579eb; + --color-brand-background-static: #5b5fc7; + --color-brand-background-2: #2f2f4a; + --color-brand-background-2-hover: #383966; + --color-brand-background-2-pressed: #2b2b40; + --color-brand-background-3-static: #444791; + --color-brand-background-4-static: #383966; + --color-brand-background-inverted: #ffffff; + --color-brand-background-inverted-hover: #e8ebfa; + --color-brand-background-inverted-pressed: #c5cbfa; + --color-brand-background-inverted-selected: #dce0fa; + --color-neutral-card-background: #333333; + --color-neutral-card-background-hover: #3d3d3d; + --color-neutral-card-background-pressed: #2e2e2e; + --color-neutral-card-background-selected: #383838; + --color-neutral-card-background-disabled: #141414; + --color-neutral-stroke-accessible: #adadad; + --color-neutral-stroke-accessible-hover: #bdbdbd; + --color-neutral-stroke-accessible-pressed: #b3b3b3; + --color-neutral-stroke-accessible-selected: #7f85f5; + --color-neutral-stroke-1: #666666; + --color-neutral-stroke-1-hover: #757575; + --color-neutral-stroke-1-pressed: #6b6b6b; + --color-neutral-stroke-1-selected: #707070; + --color-neutral-stroke-2: #525252; + --color-neutral-stroke-3: #3d3d3d; + --color-neutral-stroke-4: #3d3d3d; + --color-neutral-stroke-4-hover: #2e2e2e; + --color-neutral-stroke-4-pressed: #242424; + --color-neutral-stroke-4-selected: #3d3d3d; + --color-neutral-stroke-subtle: #0a0a0a; + --color-neutral-stroke-on-brand: #292929; + --color-neutral-stroke-on-brand-2: #ffffff; + --color-neutral-stroke-on-brand-2-hover: #ffffff; + --color-neutral-stroke-on-brand-2-pressed: #ffffff; + --color-neutral-stroke-on-brand-2-selected: #ffffff; + --color-brand-stroke-1: #7f85f5; + --color-brand-stroke-2: #3d3e78; + --color-brand-stroke-2-hover: #3d3e78; + --color-brand-stroke-2-pressed: #333357; + --color-brand-stroke-2-contrast: #3d3e78; + --color-compound-brand-stroke: #7579eb; + --color-compound-brand-stroke-hover: #7f85f5; + --color-compound-brand-stroke-pressed: #5b5fc7; + --color-neutral-stroke-disabled: #424242; + --color-neutral-stroke-disabled-2: #3d3d3d; + --color-neutral-stroke-inverted-disabled: rgba(255, 255, 255, 0.4); + --color-transparent-stroke: transparent; + --color-transparent-stroke-interactive: transparent; + --color-transparent-stroke-disabled: transparent; + --color-neutral-stroke-alpha: rgba(255, 255, 255, 0.1); + --color-neutral-stroke-alpha-2: rgba(255, 255, 255, 0.2); + --color-stroke-focus-1: #000000; + --color-stroke-focus-2: #ffffff; + --color-neutral-shadow-ambient: rgba(0, 0, 0, 0.24); + --color-neutral-shadow-key: rgba(0, 0, 0, 0.28); + --color-neutral-shadow-ambient-lighter: rgba(0, 0, 0, 0.12); + --color-neutral-shadow-key-lighter: rgba(0, 0, 0, 0.14); + --color-neutral-shadow-ambient-darker: rgba(0, 0, 0, 0.4); + --color-neutral-shadow-key-darker: rgba(0, 0, 0, 0.48); + --color-brand-shadow-ambient: rgba(0, 0, 0, 0.3); + --color-brand-shadow-key: rgba(0, 0, 0, 0.25); + --color-palette-red-background-1: #3f1011; + --color-palette-red-background-2: #751d1f; + --color-palette-red-background-3: #d13438; + --color-palette-red-border-active: #e37d80; + --color-palette-red-border-1: #d13438; + --color-palette-red-border-2: #e37d80; + --color-palette-red-foreground-1: #e37d80; + --color-palette-red-foreground-2: #f1bbbc; + --color-palette-red-foreground-3: #e37d80; + --color-palette-red-foreground-inverted: #d13438; + --color-palette-green-background-1: #052505; + --color-palette-green-background-2: #094509; + --color-palette-green-background-3: #107c10; + --color-palette-green-border-active: #54b054; + --color-palette-green-border-1: #107c10; + --color-palette-green-border-2: #9fd89f; + --color-palette-green-foreground-1: #54b054; + --color-palette-green-foreground-2: #9fd89f; + --color-palette-green-foreground-3: #9fd89f; + --color-palette-green-foreground-inverted: #107c10; + --color-palette-dark-orange-background-1: #411200; + --color-palette-dark-orange-background-2: #7a2101; + --color-palette-dark-orange-background-3: #da3b01; + --color-palette-dark-orange-border-active: #e9835e; + --color-palette-dark-orange-border-1: #da3b01; + --color-palette-dark-orange-border-2: #e9835e; + --color-palette-dark-orange-foreground-1: #e9835e; + --color-palette-dark-orange-foreground-2: #f4bfab; + --color-palette-dark-orange-foreground-3: #e9835e; + --color-palette-yellow-background-1: #4c4400; + --color-palette-yellow-background-2: #817400; + --color-palette-yellow-background-3: #fde300; + --color-palette-yellow-border-active: #feee66; + --color-palette-yellow-border-1: #fde300; + --color-palette-yellow-border-2: #fdea3d; + --color-palette-yellow-foreground-1: #feee66; + --color-palette-yellow-foreground-2: #fef7b2; + --color-palette-yellow-foreground-3: #fdea3d; + --color-palette-yellow-foreground-inverted: #817400; + --color-palette-berry-background-1: #3a1136; + --color-palette-berry-background-2: #6d2064; + --color-palette-berry-background-3: #c239b3; + --color-palette-berry-border-active: #da7ed0; + --color-palette-berry-border-1: #c239b3; + --color-palette-berry-border-2: #d161c4; + --color-palette-berry-foreground-1: #da7ed0; + --color-palette-berry-foreground-2: #edbbe7; + --color-palette-berry-foreground-3: #d161c4; + --color-palette-marigold-background-1: #463100; + --color-palette-marigold-background-2: #835b00; + --color-palette-marigold-background-3: #eaa300; + --color-palette-marigold-border-active: #f2c661; + --color-palette-marigold-border-1: #eaa300; + --color-palette-marigold-border-2: #efb839; + --color-palette-marigold-foreground-1: #f2c661; + --color-palette-marigold-foreground-2: #f9e2ae; + --color-palette-marigold-foreground-3: #efb839; + --color-palette-light-green-background-1: #063004; + --color-palette-light-green-background-2: #0b5a08; + --color-palette-light-green-background-3: #13a10e; + --color-palette-light-green-border-active: #5ec75a; + --color-palette-light-green-border-1: #13a10e; + --color-palette-light-green-border-2: #3db838; + --color-palette-light-green-foreground-1: #5ec75a; + --color-palette-light-green-foreground-2: #a7e3a5; + --color-palette-light-green-foreground-3: #3db838; + --color-palette-anchor-background-2: #202427; + --color-palette-anchor-border-active: #808a90; + --color-palette-anchor-foreground-2: #bcc3c7; + --color-palette-beige-background-2: #444241; + --color-palette-beige-border-active: #afabaa; + --color-palette-beige-foreground-2: #d7d4d4; + --color-palette-blue-background-2: #004377; + --color-palette-blue-border-active: #5caae5; + --color-palette-blue-foreground-2: #a9d3f2; + --color-palette-brass-background-2: #553e06; + --color-palette-brass-border-active: #c1a256; + --color-palette-brass-foreground-2: #e0cea2; + --color-palette-brown-background-2: #50301a; + --color-palette-brown-border-active: #bb8f6f; + --color-palette-brown-foreground-2: #ddc3b0; + --color-palette-cornflower-background-2: #2c3c85; + --color-palette-cornflower-border-active: #93a4f4; + --color-palette-cornflower-foreground-2: #c8d1fa; + --color-palette-cranberry-background-2: #6e0811; + --color-palette-cranberry-border-active: #dc626d; + --color-palette-cranberry-foreground-2: #eeacb2; + --color-palette-dark-green-background-2: #063b06; + --color-palette-dark-green-border-active: #4da64d; + --color-palette-dark-green-foreground-2: #9ad29a; + --color-palette-dark-red-background-2: #590815; + --color-palette-dark-red-border-active: #ac4f5e; + --color-palette-dark-red-foreground-2: #d69ca5; + --color-palette-forest-background-2: #294903; + --color-palette-forest-border-active: #85b44c; + --color-palette-forest-foreground-2: #bdd99b; + --color-palette-gold-background-2: #6c5700; + --color-palette-gold-border-active: #dac157; + --color-palette-gold-foreground-2: #ecdfa5; + --color-palette-grape-background-2: #4c0d55; + --color-palette-grape-border-active: #b55fc1; + --color-palette-grape-foreground-2: #d9a7e0; + --color-palette-lavender-background-2: #3f3682; + --color-palette-lavender-border-active: #a79cf1; + --color-palette-lavender-foreground-2: #d2ccf8; + --color-palette-light-teal-background-2: #00666d; + --color-palette-light-teal-border-active: #58d3db; + --color-palette-light-teal-foreground-2: #a6e9ed; + --color-palette-lilac-background-2: #63276d; + --color-palette-lilac-border-active: #cf87da; + --color-palette-lilac-foreground-2: #e6bfed; + --color-palette-magenta-background-2: #6b0043; + --color-palette-magenta-border-active: #d957a8; + --color-palette-magenta-foreground-2: #eca5d1; + --color-palette-mink-background-2: #343231; + --color-palette-mink-border-active: #9e9b99; + --color-palette-mink-foreground-2: #cecccb; + --color-palette-navy-background-2: #001665; + --color-palette-navy-border-active: #546fd2; + --color-palette-navy-foreground-2: #a3b2e8; + --color-palette-peach-background-2: #8f4e00; + --color-palette-peach-border-active: #ffba66; + --color-palette-peach-foreground-2: #ffddb3; + --color-palette-pink-background-2: #80215d; + --color-palette-pink-border-active: #ef85c8; + --color-palette-pink-foreground-2: #f7c0e3; + --color-palette-platinum-background-2: #3b4447; + --color-palette-platinum-border-active: #a0adb2; + --color-palette-platinum-foreground-2: #cdd6d8; + --color-palette-plum-background-2: #5a003b; + --color-palette-plum-border-active: #ad4589; + --color-palette-plum-foreground-2: #d696c0; + --color-palette-pumpkin-background-2: #712d09; + --color-palette-pumpkin-border-active: #df8e64; + --color-palette-pumpkin-foreground-2: #efc4ad; + --color-palette-purple-background-2: #341a51; + --color-palette-purple-border-active: #9470bd; + --color-palette-purple-foreground-2: #c6b1de; + --color-palette-royal-blue-background-2: #002c4e; + --color-palette-royal-blue-border-active: #4a89ba; + --color-palette-royal-blue-foreground-2: #9abfdc; + --color-palette-seafoam-background-2: #00723b; + --color-palette-seafoam-border-active: #5ae0a0; + --color-palette-seafoam-foreground-2: #a8f0cd; + --color-palette-steel-background-2: #00333f; + --color-palette-steel-border-active: #4496a9; + --color-palette-steel-foreground-2: #94c8d4; + --color-palette-teal-background-2: #02494c; + --color-palette-teal-border-active: #4cb4b7; + --color-palette-teal-foreground-2: #9bd9db; + --color-status-success-background-1: #052505; + --color-status-success-background-2: #094509; + --color-status-success-background-3: #107c10; + --color-status-success-foreground-1: #54b054; + --color-status-success-foreground-2: #9fd89f; + --color-status-success-foreground-3: #9fd89f; + --color-status-success-foreground-inverted: #0e700e; + --color-status-success-border-active: #54b054; + --color-status-success-border-1: #107c10; + --color-status-success-border-2: #9fd89f; + --color-status-warning-background-1: #4a1e04; + --color-status-warning-background-2: #8a3707; + --color-status-warning-background-3: #f7630c; + --color-status-warning-foreground-1: #faa06b; + --color-status-warning-foreground-2: #fdcfb4; + --color-status-warning-foreground-3: #f98845; + --color-status-warning-foreground-inverted: #bc4b09; + --color-status-warning-border-active: #faa06b; + --color-status-warning-border-1: #f7630c; + --color-status-warning-border-2: #f98845; + --color-status-danger-background-1: #3b0509; + --color-status-danger-background-2: #6e0811; + --color-status-danger-background-3: #c50f1f; + --color-status-danger-background-3-hover: #b10e1c; + --color-status-danger-background-3-pressed: #960b18; + --color-status-danger-foreground-1: #dc626d; + --color-status-danger-foreground-2: #eeacb2; + --color-status-danger-foreground-3: #eeacb2; + --color-status-danger-foreground-inverted: #b10e1c; + --color-status-danger-border-active: #dc626d; + --color-status-danger-border-1: #c50f1f; + --color-status-danger-border-2: #dc626d; + --radius-none: 0; + --radius-small: 2px; + --radius-medium: 4px; + --radius-large: 6px; + --radius-x-large: 8px; + --radius-2-x-large: 12px; + --radius-3-x-large: 16px; + --radius-4-x-large: 24px; + --radius-5-x-large: 32px; + --radius-6-x-large: 40px; + --radius-circular: 10000px; + --font-base: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, 'Apple Color Emoji', 'Segoe UI Emoji', + sans-serif; + --font-monospace: Consolas, 'Courier New', Courier, monospace; + --font-numeric: Bahnschrift, 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, + 'Helvetica Neue', sans-serif; + --text-base-100: 10px; + --text-base-200: 12px; + --text-base-300: 14px; + --text-base-400: 16px; + --text-base-500: 20px; + --text-base-600: 24px; + --text-hero-700: 28px; + --text-hero-800: 32px; + --text-hero-900: 40px; + --text-hero-1000: 68px; + --font-weight-regular: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + --leading-base-100: 14px; + --leading-base-200: 16px; + --leading-base-300: 20px; + --leading-base-400: 22px; + --leading-base-500: 28px; + --leading-base-600: 32px; + --leading-hero-700: 36px; + --leading-hero-800: 40px; + --leading-hero-900: 52px; + --leading-hero-1000: 92px; + --shadow-2: 0 0 2px rgba(0, 0, 0, 0.24), 0 1px 2px rgba(0, 0, 0, 0.28); + --shadow-4: 0 0 2px rgba(0, 0, 0, 0.24), 0 2px 4px rgba(0, 0, 0, 0.28); + --shadow-8: 0 0 2px rgba(0, 0, 0, 0.24), 0 4px 8px rgba(0, 0, 0, 0.28); + --shadow-16: 0 0 2px rgba(0, 0, 0, 0.24), 0 8px 16px rgba(0, 0, 0, 0.28); + --shadow-28: 0 0 8px rgba(0, 0, 0, 0.24), 0 14px 28px rgba(0, 0, 0, 0.28); + --shadow-64: 0 0 8px rgba(0, 0, 0, 0.24), 0 32px 64px rgba(0, 0, 0, 0.28); + --shadow-2-brand: 0 0 2px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.25); + --shadow-4-brand: 0 0 2px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.25); + --shadow-8-brand: 0 0 2px rgba(0, 0, 0, 0.3), 0 4px 8px rgba(0, 0, 0, 0.25); + --shadow-16-brand: 0 0 2px rgba(0, 0, 0, 0.3), 0 8px 16px rgba(0, 0, 0, 0.25); + --shadow-28-brand: 0 0 8px rgba(0, 0, 0, 0.3), 0 14px 28px rgba(0, 0, 0, 0.25); + --shadow-64-brand: 0 0 8px rgba(0, 0, 0, 0.3), 0 32px 64px rgba(0, 0, 0, 0.25); + --duration-ultra-fast: 50ms; + --duration-faster: 100ms; + --duration-fast: 150ms; + --duration-normal: 200ms; + --duration-gentle: 250ms; + --duration-slow: 300ms; + --duration-slower: 400ms; + --duration-ultra-slow: 500ms; + --ease-accelerate-max: cubic-bezier(0.9, 0.1, 1, 0.2); + --ease-accelerate-mid: cubic-bezier(1, 0, 1, 1); + --ease-accelerate-min: cubic-bezier(0.8, 0, 0.78, 1); + --ease-decelerate-max: cubic-bezier(0.1, 0.9, 0.2, 1); + --ease-decelerate-mid: cubic-bezier(0, 0, 0, 1); + --ease-decelerate-min: cubic-bezier(0.33, 0, 0.1, 1); + --ease-easy-ease-max: cubic-bezier(0.8, 0, 0.2, 1); + --ease-easy-ease: cubic-bezier(0.33, 0, 0.67, 1); + --ease-linear: cubic-bezier(0, 0, 1, 1); + } +} + +/* teamsHighContrastTheme — 433 tokens */ +@layer fui.theme { + .fui-theme-teams-high-contrast { + --color-neutral-foreground-1: #ffffff; + --color-neutral-foreground-1-hover: #000000; + --color-neutral-foreground-1-pressed: #000000; + --color-neutral-foreground-1-selected: #000000; + --color-neutral-foreground-2: #ffffff; + --color-neutral-foreground-2-hover: #000000; + --color-neutral-foreground-2-pressed: #000000; + --color-neutral-foreground-2-selected: #000000; + --color-neutral-foreground-2-brand-hover: #000000; + --color-neutral-foreground-2-brand-pressed: #000000; + --color-neutral-foreground-2-brand-selected: #000000; + --color-neutral-foreground-3: #ffffff; + --color-neutral-foreground-3-hover: #000000; + --color-neutral-foreground-3-pressed: #000000; + --color-neutral-foreground-3-selected: #000000; + --color-neutral-foreground-3-brand-hover: #000000; + --color-neutral-foreground-3-brand-pressed: #000000; + --color-neutral-foreground-3-brand-selected: #000000; + --color-neutral-foreground-4: #ffffff; + --color-neutral-foreground-5: #ffffff; + --color-neutral-foreground-5-hover: #000000; + --color-neutral-foreground-5-pressed: #000000; + --color-neutral-foreground-5-selected: #000000; + --color-neutral-foreground-disabled: #3ff23f; + --color-brand-foreground-link: #ffff00; + --color-brand-foreground-link-hover: #ffff00; + --color-brand-foreground-link-pressed: #ffff00; + --color-brand-foreground-link-selected: #ffff00; + --color-neutral-foreground-2-link: #ffff00; + --color-neutral-foreground-2-link-hover: #ffff00; + --color-neutral-foreground-2-link-pressed: #ffff00; + --color-neutral-foreground-2-link-selected: #ffff00; + --color-compound-brand-foreground-1: #1aebff; + --color-compound-brand-foreground-1-hover: #1aebff; + --color-compound-brand-foreground-1-pressed: #1aebff; + --color-neutral-foreground-on-brand: #000000; + --color-neutral-foreground-inverted: #000000; + --color-neutral-foreground-inverted-hover: #000000; + --color-neutral-foreground-inverted-pressed: #000000; + --color-neutral-foreground-inverted-selected: #000000; + --color-neutral-foreground-inverted-2: #ffffff; + --color-neutral-foreground-static-inverted: #ffffff; + --color-neutral-foreground-inverted-link: #ffff00; + --color-neutral-foreground-inverted-link-hover: #ffff00; + --color-neutral-foreground-inverted-link-pressed: #ffff00; + --color-neutral-foreground-inverted-link-selected: #ffff00; + --color-neutral-foreground-inverted-disabled: #3ff23f; + --color-brand-foreground-1: #ffffff; + --color-brand-foreground-2: #ffffff; + --color-brand-foreground-2-hover: #ffffff; + --color-brand-foreground-2-pressed: #ffffff; + --color-neutral-foreground-1-static: #000000; + --color-brand-foreground-inverted: #ffffff; + --color-brand-foreground-inverted-hover: #000000; + --color-brand-foreground-inverted-pressed: #000000; + --color-brand-foreground-on-light: #000000; + --color-brand-foreground-on-light-hover: #000000; + --color-brand-foreground-on-light-pressed: #000000; + --color-brand-foreground-on-light-selected: #000000; + --color-neutral-background-1: #000000; + --color-neutral-background-1-hover: #1aebff; + --color-neutral-background-1-pressed: #1aebff; + --color-neutral-background-1-selected: #1aebff; + --color-neutral-background-2: #000000; + --color-neutral-background-2-hover: #1aebff; + --color-neutral-background-2-pressed: #1aebff; + --color-neutral-background-2-selected: #1aebff; + --color-neutral-background-3: #000000; + --color-neutral-background-3-hover: #1aebff; + --color-neutral-background-3-pressed: #1aebff; + --color-neutral-background-3-selected: #1aebff; + --color-neutral-background-4: #000000; + --color-neutral-background-4-hover: #1aebff; + --color-neutral-background-4-pressed: #1aebff; + --color-neutral-background-4-selected: #1aebff; + --color-neutral-background-5: #000000; + --color-neutral-background-5-hover: #1aebff; + --color-neutral-background-5-pressed: #1aebff; + --color-neutral-background-5-selected: #1aebff; + --color-neutral-background-6: #000000; + --color-neutral-background-7: #000000; + --color-neutral-background-7-hover: #1aebff; + --color-neutral-background-7-pressed: #1aebff; + --color-neutral-background-7-selected: #1aebff; + --color-neutral-background-8: #000000; + --color-neutral-background-inverted: #000000; + --color-neutral-background-inverted-hover: #1aebff; + --color-neutral-background-inverted-pressed: #1aebff; + --color-neutral-background-inverted-selected: #1aebff; + --color-neutral-background-static: #000000; + --color-neutral-background-alpha: #000000; + --color-neutral-background-alpha-2: #000000; + --color-subtle-background: transparent; + --color-subtle-background-hover: #1aebff; + --color-subtle-background-pressed: #1aebff; + --color-subtle-background-selected: #1aebff; + --color-subtle-background-light-alpha-hover: #1aebff; + --color-subtle-background-light-alpha-pressed: #1aebff; + --color-subtle-background-light-alpha-selected: #1aebff; + --color-subtle-background-inverted: transparent; + --color-subtle-background-inverted-hover: #1aebff; + --color-subtle-background-inverted-pressed: #1aebff; + --color-subtle-background-inverted-selected: #1aebff; + --color-transparent-background: transparent; + --color-transparent-background-hover: #1aebff; + --color-transparent-background-pressed: #1aebff; + --color-transparent-background-selected: #1aebff; + --color-neutral-background-disabled: #000000; + --color-neutral-background-disabled-2: #3ff23f; + --color-neutral-background-inverted-disabled: #000000; + --color-neutral-stencil-1: #ffffff; + --color-neutral-stencil-2: #ffffff; + --color-neutral-stencil-1-alpha: #ffffff; + --color-neutral-stencil-2-alpha: #ffffff; + --color-background-overlay: rgba(0, 0, 0, 0.5); + --color-scrollbar-overlay: #ffffff; + --color-brand-background: #ffffff; + --color-brand-background-hover: #1aebff; + --color-brand-background-pressed: #1aebff; + --color-brand-background-selected: #1aebff; + --color-compound-brand-background: #1aebff; + --color-compound-brand-background-hover: #1aebff; + --color-compound-brand-background-pressed: #1aebff; + --color-brand-background-static: #000000; + --color-brand-background-2: #000000; + --color-brand-background-2-hover: #000000; + --color-brand-background-2-pressed: #000000; + --color-brand-background-3-static: #000000; + --color-brand-background-4-static: #000000; + --color-brand-background-inverted: #ffffff; + --color-brand-background-inverted-hover: #1aebff; + --color-brand-background-inverted-pressed: #1aebff; + --color-brand-background-inverted-selected: #1aebff; + --color-neutral-card-background: #000000; + --color-neutral-card-background-hover: #1aebff; + --color-neutral-card-background-pressed: #1aebff; + --color-neutral-card-background-selected: #1aebff; + --color-neutral-card-background-disabled: #000000; + --color-neutral-stroke-accessible: #ffffff; + --color-neutral-stroke-accessible-hover: #1aebff; + --color-neutral-stroke-accessible-pressed: #1aebff; + --color-neutral-stroke-accessible-selected: #1aebff; + --color-neutral-stroke-1: #ffffff; + --color-neutral-stroke-1-hover: #1aebff; + --color-neutral-stroke-1-pressed: #1aebff; + --color-neutral-stroke-1-selected: #1aebff; + --color-neutral-stroke-2: #ffffff; + --color-neutral-stroke-3: #ffffff; + --color-neutral-stroke-4: #ffffff; + --color-neutral-stroke-4-hover: #1aebff; + --color-neutral-stroke-4-pressed: #1aebff; + --color-neutral-stroke-4-selected: #1aebff; + --color-neutral-stroke-subtle: #ffffff; + --color-neutral-stroke-on-brand: #000000; + --color-neutral-stroke-on-brand-2: #ffffff; + --color-neutral-stroke-on-brand-2-hover: #ffffff; + --color-neutral-stroke-on-brand-2-pressed: #ffffff; + --color-neutral-stroke-on-brand-2-selected: #ffffff; + --color-brand-stroke-1: #ffffff; + --color-brand-stroke-2: #ffffff; + --color-brand-stroke-2-hover: #1aebff; + --color-brand-stroke-2-pressed: #1aebff; + --color-brand-stroke-2-contrast: #000000; + --color-compound-brand-stroke: #1aebff; + --color-compound-brand-stroke-hover: #1aebff; + --color-compound-brand-stroke-pressed: #1aebff; + --color-neutral-stroke-disabled: #3ff23f; + --color-neutral-stroke-disabled-2: #3ff23f; + --color-neutral-stroke-inverted-disabled: #3ff23f; + --color-transparent-stroke: #ffffff; + --color-transparent-stroke-interactive: #1aebff; + --color-transparent-stroke-disabled: #3ff23f; + --color-neutral-stroke-alpha: #ffffff; + --color-neutral-stroke-alpha-2: #000000; + --color-stroke-focus-1: #000000; + --color-stroke-focus-2: #1aebff; + --color-neutral-shadow-ambient: rgba(0, 0, 0, 0.24); + --color-neutral-shadow-key: rgba(0, 0, 0, 0.28); + --color-neutral-shadow-ambient-lighter: rgba(0, 0, 0, 0.12); + --color-neutral-shadow-key-lighter: rgba(0, 0, 0, 0.14); + --color-neutral-shadow-ambient-darker: rgba(0, 0, 0, 0.4); + --color-neutral-shadow-key-darker: rgba(0, 0, 0, 0.48); + --color-brand-shadow-ambient: rgba(0, 0, 0, 0.3); + --color-brand-shadow-key: rgba(0, 0, 0, 0.25); + --color-palette-red-background-1: #000000; + --color-palette-red-background-2: #000000; + --color-palette-red-background-3: #ffffff; + --color-palette-red-border-active: #1aebff; + --color-palette-red-border-1: #ffffff; + --color-palette-red-border-2: #ffffff; + --color-palette-red-foreground-1: #ffffff; + --color-palette-red-foreground-2: #ffffff; + --color-palette-red-foreground-3: #ffffff; + --color-palette-red-foreground-inverted: #ffffff; + --color-palette-green-background-1: #000000; + --color-palette-green-background-2: #000000; + --color-palette-green-background-3: #ffffff; + --color-palette-green-border-active: #1aebff; + --color-palette-green-border-1: #ffffff; + --color-palette-green-border-2: #ffffff; + --color-palette-green-foreground-1: #ffffff; + --color-palette-green-foreground-2: #ffffff; + --color-palette-green-foreground-3: #ffffff; + --color-palette-green-foreground-inverted: #ffffff; + --color-palette-dark-orange-background-1: #000000; + --color-palette-dark-orange-background-2: #000000; + --color-palette-dark-orange-background-3: #ffffff; + --color-palette-dark-orange-border-active: #1aebff; + --color-palette-dark-orange-border-1: #ffffff; + --color-palette-dark-orange-border-2: #ffffff; + --color-palette-dark-orange-foreground-1: #ffffff; + --color-palette-dark-orange-foreground-2: #ffffff; + --color-palette-dark-orange-foreground-3: #ffffff; + --color-palette-yellow-background-1: #000000; + --color-palette-yellow-background-2: #000000; + --color-palette-yellow-background-3: #ffffff; + --color-palette-yellow-border-active: #1aebff; + --color-palette-yellow-border-1: #ffffff; + --color-palette-yellow-border-2: #ffffff; + --color-palette-yellow-foreground-1: #ffffff; + --color-palette-yellow-foreground-2: #ffffff; + --color-palette-yellow-foreground-3: #ffffff; + --color-palette-yellow-foreground-inverted: #ffffff; + --color-palette-berry-background-1: #000000; + --color-palette-berry-background-2: #000000; + --color-palette-berry-background-3: #ffffff; + --color-palette-berry-border-active: #1aebff; + --color-palette-berry-border-1: #ffffff; + --color-palette-berry-border-2: #ffffff; + --color-palette-berry-foreground-1: #ffffff; + --color-palette-berry-foreground-2: #ffffff; + --color-palette-berry-foreground-3: #ffffff; + --color-palette-marigold-background-1: #000000; + --color-palette-marigold-background-2: #000000; + --color-palette-marigold-background-3: #ffffff; + --color-palette-marigold-border-active: #1aebff; + --color-palette-marigold-border-1: #ffffff; + --color-palette-marigold-border-2: #ffffff; + --color-palette-marigold-foreground-1: #ffffff; + --color-palette-marigold-foreground-2: #ffffff; + --color-palette-marigold-foreground-3: #ffffff; + --color-palette-light-green-background-1: #000000; + --color-palette-light-green-background-2: #000000; + --color-palette-light-green-background-3: #ffffff; + --color-palette-light-green-border-active: #1aebff; + --color-palette-light-green-border-1: #ffffff; + --color-palette-light-green-border-2: #ffffff; + --color-palette-light-green-foreground-1: #ffffff; + --color-palette-light-green-foreground-2: #ffffff; + --color-palette-light-green-foreground-3: #ffffff; + --color-palette-anchor-background-2: #000000; + --color-palette-anchor-border-active: #1aebff; + --color-palette-anchor-foreground-2: #ffffff; + --color-palette-beige-background-2: #000000; + --color-palette-beige-border-active: #1aebff; + --color-palette-beige-foreground-2: #ffffff; + --color-palette-blue-background-2: #000000; + --color-palette-blue-border-active: #1aebff; + --color-palette-blue-foreground-2: #ffffff; + --color-palette-brass-background-2: #000000; + --color-palette-brass-border-active: #1aebff; + --color-palette-brass-foreground-2: #ffffff; + --color-palette-brown-background-2: #000000; + --color-palette-brown-border-active: #1aebff; + --color-palette-brown-foreground-2: #ffffff; + --color-palette-cornflower-background-2: #000000; + --color-palette-cornflower-border-active: #1aebff; + --color-palette-cornflower-foreground-2: #ffffff; + --color-palette-cranberry-background-2: #000000; + --color-palette-cranberry-border-active: #1aebff; + --color-palette-cranberry-foreground-2: #ffffff; + --color-palette-dark-green-background-2: #000000; + --color-palette-dark-green-border-active: #1aebff; + --color-palette-dark-green-foreground-2: #ffffff; + --color-palette-dark-red-background-2: #000000; + --color-palette-dark-red-border-active: #1aebff; + --color-palette-dark-red-foreground-2: #ffffff; + --color-palette-forest-background-2: #000000; + --color-palette-forest-border-active: #1aebff; + --color-palette-forest-foreground-2: #ffffff; + --color-palette-gold-background-2: #000000; + --color-palette-gold-border-active: #1aebff; + --color-palette-gold-foreground-2: #ffffff; + --color-palette-grape-background-2: #000000; + --color-palette-grape-border-active: #1aebff; + --color-palette-grape-foreground-2: #ffffff; + --color-palette-lavender-background-2: #000000; + --color-palette-lavender-border-active: #1aebff; + --color-palette-lavender-foreground-2: #ffffff; + --color-palette-light-teal-background-2: #000000; + --color-palette-light-teal-border-active: #1aebff; + --color-palette-light-teal-foreground-2: #ffffff; + --color-palette-lilac-background-2: #000000; + --color-palette-lilac-border-active: #1aebff; + --color-palette-lilac-foreground-2: #ffffff; + --color-palette-magenta-background-2: #000000; + --color-palette-magenta-border-active: #1aebff; + --color-palette-magenta-foreground-2: #ffffff; + --color-palette-mink-background-2: #000000; + --color-palette-mink-border-active: #1aebff; + --color-palette-mink-foreground-2: #ffffff; + --color-palette-navy-background-2: #000000; + --color-palette-navy-border-active: #1aebff; + --color-palette-navy-foreground-2: #ffffff; + --color-palette-peach-background-2: #000000; + --color-palette-peach-border-active: #1aebff; + --color-palette-peach-foreground-2: #ffffff; + --color-palette-pink-background-2: #000000; + --color-palette-pink-border-active: #1aebff; + --color-palette-pink-foreground-2: #ffffff; + --color-palette-platinum-background-2: #000000; + --color-palette-platinum-border-active: #1aebff; + --color-palette-platinum-foreground-2: #ffffff; + --color-palette-plum-background-2: #000000; + --color-palette-plum-border-active: #1aebff; + --color-palette-plum-foreground-2: #ffffff; + --color-palette-pumpkin-background-2: #000000; + --color-palette-pumpkin-border-active: #1aebff; + --color-palette-pumpkin-foreground-2: #ffffff; + --color-palette-purple-background-2: #000000; + --color-palette-purple-border-active: #1aebff; + --color-palette-purple-foreground-2: #ffffff; + --color-palette-royal-blue-background-2: #000000; + --color-palette-royal-blue-border-active: #1aebff; + --color-palette-royal-blue-foreground-2: #ffffff; + --color-palette-seafoam-background-2: #000000; + --color-palette-seafoam-border-active: #1aebff; + --color-palette-seafoam-foreground-2: #ffffff; + --color-palette-steel-background-2: #000000; + --color-palette-steel-border-active: #1aebff; + --color-palette-steel-foreground-2: #ffffff; + --color-palette-teal-background-2: #000000; + --color-palette-teal-border-active: #1aebff; + --color-palette-teal-foreground-2: #ffffff; + --color-status-success-background-1: #000000; + --color-status-success-background-2: #000000; + --color-status-success-background-3: #ffffff; + --color-status-success-foreground-1: #ffffff; + --color-status-success-foreground-2: #ffffff; + --color-status-success-foreground-3: #ffffff; + --color-status-success-foreground-inverted: #ffffff; + --color-status-success-border-active: #1aebff; + --color-status-success-border-1: #ffffff; + --color-status-success-border-2: #ffffff; + --color-status-warning-background-1: #000000; + --color-status-warning-background-2: #000000; + --color-status-warning-background-3: #ffffff; + --color-status-warning-foreground-1: #ffffff; + --color-status-warning-foreground-2: #ffffff; + --color-status-warning-foreground-3: #ffffff; + --color-status-warning-foreground-inverted: #ffffff; + --color-status-warning-border-active: #1aebff; + --color-status-warning-border-1: #ffffff; + --color-status-warning-border-2: #ffffff; + --color-status-danger-background-1: #000000; + --color-status-danger-background-2: #000000; + --color-status-danger-background-3: #ffffff; + --color-status-danger-background-3-hover: #1aebff; + --color-status-danger-background-3-pressed: #1aebff; + --color-status-danger-foreground-1: #ffffff; + --color-status-danger-foreground-2: #ffffff; + --color-status-danger-foreground-3: #ffffff; + --color-status-danger-foreground-inverted: #ffffff; + --color-status-danger-border-active: #1aebff; + --color-status-danger-border-1: #ffffff; + --color-status-danger-border-2: #ffffff; + --radius-none: 0; + --radius-small: 2px; + --radius-medium: 4px; + --radius-large: 6px; + --radius-x-large: 8px; + --radius-2-x-large: 12px; + --radius-3-x-large: 16px; + --radius-4-x-large: 24px; + --radius-5-x-large: 32px; + --radius-6-x-large: 40px; + --radius-circular: 10000px; + --font-base: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, 'Apple Color Emoji', 'Segoe UI Emoji', + sans-serif; + --font-monospace: Consolas, 'Courier New', Courier, monospace; + --font-numeric: Bahnschrift, 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, + 'Helvetica Neue', sans-serif; + --text-base-100: 10px; + --text-base-200: 12px; + --text-base-300: 14px; + --text-base-400: 16px; + --text-base-500: 20px; + --text-base-600: 24px; + --text-hero-700: 28px; + --text-hero-800: 32px; + --text-hero-900: 40px; + --text-hero-1000: 68px; + --font-weight-regular: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + --leading-base-100: 14px; + --leading-base-200: 16px; + --leading-base-300: 20px; + --leading-base-400: 22px; + --leading-base-500: 28px; + --leading-base-600: 32px; + --leading-hero-700: 36px; + --leading-hero-800: 40px; + --leading-hero-900: 52px; + --leading-hero-1000: 92px; + --shadow-2: 0 0 2px rgba(0, 0, 0, 0.24), 0 1px 2px rgba(0, 0, 0, 0.28); + --shadow-4: 0 0 2px rgba(0, 0, 0, 0.24), 0 2px 4px rgba(0, 0, 0, 0.28); + --shadow-8: 0 0 2px rgba(0, 0, 0, 0.24), 0 4px 8px rgba(0, 0, 0, 0.28); + --shadow-16: 0 0 2px rgba(0, 0, 0, 0.24), 0 8px 16px rgba(0, 0, 0, 0.28); + --shadow-28: 0 0 8px rgba(0, 0, 0, 0.24), 0 14px 28px rgba(0, 0, 0, 0.28); + --shadow-64: 0 0 8px rgba(0, 0, 0, 0.24), 0 32px 64px rgba(0, 0, 0, 0.28); + --shadow-2-brand: 0 0 2px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.25); + --shadow-4-brand: 0 0 2px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.25); + --shadow-8-brand: 0 0 2px rgba(0, 0, 0, 0.3), 0 4px 8px rgba(0, 0, 0, 0.25); + --shadow-16-brand: 0 0 2px rgba(0, 0, 0, 0.3), 0 8px 16px rgba(0, 0, 0, 0.25); + --shadow-28-brand: 0 0 8px rgba(0, 0, 0, 0.3), 0 14px 28px rgba(0, 0, 0, 0.25); + --shadow-64-brand: 0 0 8px rgba(0, 0, 0, 0.3), 0 32px 64px rgba(0, 0, 0, 0.25); + --duration-ultra-fast: 50ms; + --duration-faster: 100ms; + --duration-fast: 150ms; + --duration-normal: 200ms; + --duration-gentle: 250ms; + --duration-slow: 300ms; + --duration-slower: 400ms; + --duration-ultra-slow: 500ms; + --ease-accelerate-max: cubic-bezier(0.9, 0.1, 1, 0.2); + --ease-accelerate-mid: cubic-bezier(1, 0, 1, 1); + --ease-accelerate-min: cubic-bezier(0.8, 0, 0.78, 1); + --ease-decelerate-max: cubic-bezier(0.1, 0.9, 0.2, 1); + --ease-decelerate-mid: cubic-bezier(0, 0, 0, 1); + --ease-decelerate-min: cubic-bezier(0.33, 0, 0.1, 1); + --ease-easy-ease-max: cubic-bezier(0.8, 0, 0.2, 1); + --ease-easy-ease: cubic-bezier(0.33, 0, 0.67, 1); + --ease-linear: cubic-bezier(0, 0, 1, 1); + } +} + +/* teamsLightV21Theme — 433 tokens */ +@layer fui.theme { + .fui-theme-teams-light-v21 { + --color-neutral-foreground-1: #242424; + --color-neutral-foreground-1-hover: #242424; + --color-neutral-foreground-1-pressed: #242424; + --color-neutral-foreground-1-selected: #242424; + --color-neutral-foreground-2: #424242; + --color-neutral-foreground-2-hover: #242424; + --color-neutral-foreground-2-pressed: #242424; + --color-neutral-foreground-2-selected: #242424; + --color-neutral-foreground-2-brand-hover: #654cf5; + --color-neutral-foreground-2-brand-pressed: #5a40db; + --color-neutral-foreground-2-brand-selected: #654cf5; + --color-neutral-foreground-3: #616161; + --color-neutral-foreground-3-hover: #424242; + --color-neutral-foreground-3-pressed: #424242; + --color-neutral-foreground-3-selected: #424242; + --color-neutral-foreground-3-brand-hover: #654cf5; + --color-neutral-foreground-3-brand-pressed: #5a40db; + --color-neutral-foreground-3-brand-selected: #654cf5; + --color-neutral-foreground-4: #707070; + --color-neutral-foreground-5: #616161; + --color-neutral-foreground-5-hover: #242424; + --color-neutral-foreground-5-pressed: #242424; + --color-neutral-foreground-5-selected: #242424; + --color-neutral-foreground-disabled: #bdbdbd; + --color-brand-foreground-link: #5a40db; + --color-brand-foreground-link-hover: #4d3aba; + --color-brand-foreground-link-pressed: #3b3185; + --color-brand-foreground-link-selected: #5a40db; + --color-neutral-foreground-2-link: #424242; + --color-neutral-foreground-2-link-hover: #242424; + --color-neutral-foreground-2-link-pressed: #242424; + --color-neutral-foreground-2-link-selected: #242424; + --color-compound-brand-foreground-1: #654cf5; + --color-compound-brand-foreground-1-hover: #5a40db; + --color-compound-brand-foreground-1-pressed: #4d3aba; + --color-neutral-foreground-on-brand: #ffffff; + --color-neutral-foreground-inverted: #ffffff; + --color-neutral-foreground-inverted-hover: #ffffff; + --color-neutral-foreground-inverted-pressed: #ffffff; + --color-neutral-foreground-inverted-selected: #ffffff; + --color-neutral-foreground-inverted-2: #ffffff; + --color-neutral-foreground-static-inverted: #ffffff; + --color-neutral-foreground-inverted-link: #ffffff; + --color-neutral-foreground-inverted-link-hover: #ffffff; + --color-neutral-foreground-inverted-link-pressed: #ffffff; + --color-neutral-foreground-inverted-link-selected: #ffffff; + --color-neutral-foreground-inverted-disabled: rgba(255, 255, 255, 0.4); + --color-brand-foreground-1: #654cf5; + --color-brand-foreground-2: #5a40db; + --color-brand-foreground-2-hover: #4d3aba; + --color-brand-foreground-2-pressed: #352e70; + --color-neutral-foreground-1-static: #242424; + --color-brand-foreground-inverted: #887dff; + --color-brand-foreground-inverted-hover: #9791ff; + --color-brand-foreground-inverted-pressed: #887dff; + --color-brand-foreground-on-light: #654cf5; + --color-brand-foreground-on-light-hover: #5a40db; + --color-brand-foreground-on-light-pressed: #44359e; + --color-brand-foreground-on-light-selected: #4d3aba; + --color-neutral-background-1: #ffffff; + --color-neutral-background-1-hover: #f5f5f5; + --color-neutral-background-1-pressed: #e0e0e0; + --color-neutral-background-1-selected: #ebebeb; + --color-neutral-background-2: #fafafa; + --color-neutral-background-2-hover: #f0f0f0; + --color-neutral-background-2-pressed: #dbdbdb; + --color-neutral-background-2-selected: #e6e6e6; + --color-neutral-background-3: #f5f5f5; + --color-neutral-background-3-hover: #ebebeb; + --color-neutral-background-3-pressed: #d6d6d6; + --color-neutral-background-3-selected: #e0e0e0; + --color-neutral-background-4: #f0f0f0; + --color-neutral-background-4-hover: #fafafa; + --color-neutral-background-4-pressed: #f5f5f5; + --color-neutral-background-4-selected: #ffffff; + --color-neutral-background-5: #ebebeb; + --color-neutral-background-5-hover: #f5f5f5; + --color-neutral-background-5-pressed: #f0f0f0; + --color-neutral-background-5-selected: #fafafa; + --color-neutral-background-6: #e6e6e6; + --color-neutral-background-7: #00000000; + --color-neutral-background-7-hover: #ebebeb; + --color-neutral-background-7-pressed: #d6d6d6; + --color-neutral-background-7-selected: #00000000; + --color-neutral-background-8: #fcfcfc; + --color-neutral-background-inverted: #292929; + --color-neutral-background-inverted-hover: #3d3d3d; + --color-neutral-background-inverted-pressed: #1f1f1f; + --color-neutral-background-inverted-selected: #383838; + --color-neutral-background-static: #333333; + --color-neutral-background-alpha: rgba(255, 255, 255, 0.5); + --color-neutral-background-alpha-2: rgba(255, 255, 255, 0.8); + --color-subtle-background: transparent; + --color-subtle-background-hover: #f5f5f5; + --color-subtle-background-pressed: #e0e0e0; + --color-subtle-background-selected: #ebebeb; + --color-subtle-background-light-alpha-hover: rgba(255, 255, 255, 0.7); + --color-subtle-background-light-alpha-pressed: rgba(255, 255, 255, 0.5); + --color-subtle-background-light-alpha-selected: transparent; + --color-subtle-background-inverted: transparent; + --color-subtle-background-inverted-hover: rgba(0, 0, 0, 0.1); + --color-subtle-background-inverted-pressed: rgba(0, 0, 0, 0.3); + --color-subtle-background-inverted-selected: rgba(0, 0, 0, 0.2); + --color-transparent-background: transparent; + --color-transparent-background-hover: transparent; + --color-transparent-background-pressed: transparent; + --color-transparent-background-selected: transparent; + --color-neutral-background-disabled: #f0f0f0; + --color-neutral-background-disabled-2: #ffffff; + --color-neutral-background-inverted-disabled: rgba(255, 255, 255, 0.1); + --color-neutral-stencil-1: #e6e6e6; + --color-neutral-stencil-2: #fafafa; + --color-neutral-stencil-1-alpha: rgba(0, 0, 0, 0.1); + --color-neutral-stencil-2-alpha: rgba(0, 0, 0, 0.05); + --color-background-overlay: rgba(0, 0, 0, 0.4); + --color-scrollbar-overlay: rgba(0, 0, 0, 0.5); + --color-brand-background: #654cf5; + --color-brand-background-hover: #5a40db; + --color-brand-background-pressed: #3b3185; + --color-brand-background-selected: #4d3aba; + --color-compound-brand-background: #654cf5; + --color-compound-brand-background-hover: #5a40db; + --color-compound-brand-background-pressed: #4d3aba; + --color-brand-background-static: #654cf5; + --color-brand-background-2: #e8e8ff; + --color-brand-background-2-hover: #dcdbff; + --color-brand-background-2-pressed: #bab8ff; + --color-brand-background-3-static: #4d3aba; + --color-brand-background-4-static: #3b3185; + --color-brand-background-inverted: #ffffff; + --color-brand-background-inverted-hover: #e8e8ff; + --color-brand-background-inverted-pressed: #c8c7ff; + --color-brand-background-inverted-selected: #dcdbff; + --color-neutral-card-background: #fafafa; + --color-neutral-card-background-hover: #ffffff; + --color-neutral-card-background-pressed: #f5f5f5; + --color-neutral-card-background-selected: #ebebeb; + --color-neutral-card-background-disabled: #f0f0f0; + --color-neutral-stroke-accessible: #616161; + --color-neutral-stroke-accessible-hover: #575757; + --color-neutral-stroke-accessible-pressed: #4d4d4d; + --color-neutral-stroke-accessible-selected: #654cf5; + --color-neutral-stroke-1: #d1d1d1; + --color-neutral-stroke-1-hover: #c7c7c7; + --color-neutral-stroke-1-pressed: #b3b3b3; + --color-neutral-stroke-1-selected: #bdbdbd; + --color-neutral-stroke-2: #e0e0e0; + --color-neutral-stroke-3: #f0f0f0; + --color-neutral-stroke-4: #ebebeb; + --color-neutral-stroke-4-hover: #e0e0e0; + --color-neutral-stroke-4-pressed: #d6d6d6; + --color-neutral-stroke-4-selected: #ebebeb; + --color-neutral-stroke-subtle: #e0e0e0; + --color-neutral-stroke-on-brand: #ffffff; + --color-neutral-stroke-on-brand-2: #ffffff; + --color-neutral-stroke-on-brand-2-hover: #ffffff; + --color-neutral-stroke-on-brand-2-pressed: #ffffff; + --color-neutral-stroke-on-brand-2-selected: #ffffff; + --color-brand-stroke-1: #654cf5; + --color-brand-stroke-2: #c8c7ff; + --color-brand-stroke-2-hover: #aba8ff; + --color-brand-stroke-2-pressed: #654cf5; + --color-brand-stroke-2-contrast: #c8c7ff; + --color-compound-brand-stroke: #654cf5; + --color-compound-brand-stroke-hover: #5a40db; + --color-compound-brand-stroke-pressed: #4d3aba; + --color-neutral-stroke-disabled: #e0e0e0; + --color-neutral-stroke-disabled-2: #ebebeb; + --color-neutral-stroke-inverted-disabled: rgba(255, 255, 255, 0.4); + --color-transparent-stroke: transparent; + --color-transparent-stroke-interactive: transparent; + --color-transparent-stroke-disabled: transparent; + --color-neutral-stroke-alpha: rgba(0, 0, 0, 0.05); + --color-neutral-stroke-alpha-2: rgba(255, 255, 255, 0.2); + --color-stroke-focus-1: #ffffff; + --color-stroke-focus-2: #000000; + --color-neutral-shadow-ambient: rgba(0, 0, 0, 0.12); + --color-neutral-shadow-key: rgba(0, 0, 0, 0.14); + --color-neutral-shadow-ambient-lighter: rgba(0, 0, 0, 0.06); + --color-neutral-shadow-key-lighter: rgba(0, 0, 0, 0.07); + --color-neutral-shadow-ambient-darker: rgba(0, 0, 0, 0.2); + --color-neutral-shadow-key-darker: rgba(0, 0, 0, 0.24); + --color-brand-shadow-ambient: rgba(0, 0, 0, 0.3); + --color-brand-shadow-key: rgba(0, 0, 0, 0.25); + --color-palette-red-background-1: #fdf6f6; + --color-palette-red-background-2: #f1bbbc; + --color-palette-red-background-3: #d13438; + --color-palette-red-border-active: #d13438; + --color-palette-red-border-1: #f1bbbc; + --color-palette-red-border-2: #d13438; + --color-palette-red-foreground-1: #bc2f32; + --color-palette-red-foreground-2: #751d1f; + --color-palette-red-foreground-3: #d13438; + --color-palette-red-foreground-inverted: #dc5e62; + --color-palette-green-background-1: #f1faf1; + --color-palette-green-background-2: #9fd89f; + --color-palette-green-background-3: #107c10; + --color-palette-green-border-active: #107c10; + --color-palette-green-border-1: #9fd89f; + --color-palette-green-border-2: #107c10; + --color-palette-green-foreground-1: #0e700e; + --color-palette-green-foreground-2: #094509; + --color-palette-green-foreground-3: #107c10; + --color-palette-green-foreground-inverted: #359b35; + --color-palette-dark-orange-background-1: #fdf6f3; + --color-palette-dark-orange-background-2: #f4bfab; + --color-palette-dark-orange-background-3: #da3b01; + --color-palette-dark-orange-border-active: #da3b01; + --color-palette-dark-orange-border-1: #f4bfab; + --color-palette-dark-orange-border-2: #da3b01; + --color-palette-dark-orange-foreground-1: #c43501; + --color-palette-dark-orange-foreground-2: #7a2101; + --color-palette-dark-orange-foreground-3: #da3b01; + --color-palette-yellow-background-1: #fffef5; + --color-palette-yellow-background-2: #fef7b2; + --color-palette-yellow-background-3: #fde300; + --color-palette-yellow-border-active: #fde300; + --color-palette-yellow-border-1: #fef7b2; + --color-palette-yellow-border-2: #fde300; + --color-palette-yellow-foreground-1: #817400; + --color-palette-yellow-foreground-2: #817400; + --color-palette-yellow-foreground-3: #fde300; + --color-palette-yellow-foreground-inverted: #fef7b2; + --color-palette-berry-background-1: #fdf5fc; + --color-palette-berry-background-2: #edbbe7; + --color-palette-berry-background-3: #c239b3; + --color-palette-berry-border-active: #c239b3; + --color-palette-berry-border-1: #edbbe7; + --color-palette-berry-border-2: #c239b3; + --color-palette-berry-foreground-1: #af33a1; + --color-palette-berry-foreground-2: #6d2064; + --color-palette-berry-foreground-3: #c239b3; + --color-palette-marigold-background-1: #fefbf4; + --color-palette-marigold-background-2: #f9e2ae; + --color-palette-marigold-background-3: #eaa300; + --color-palette-marigold-border-active: #eaa300; + --color-palette-marigold-border-1: #f9e2ae; + --color-palette-marigold-border-2: #eaa300; + --color-palette-marigold-foreground-1: #d39300; + --color-palette-marigold-foreground-2: #835b00; + --color-palette-marigold-foreground-3: #eaa300; + --color-palette-light-green-background-1: #f2fbf2; + --color-palette-light-green-background-2: #a7e3a5; + --color-palette-light-green-background-3: #13a10e; + --color-palette-light-green-border-active: #13a10e; + --color-palette-light-green-border-1: #a7e3a5; + --color-palette-light-green-border-2: #13a10e; + --color-palette-light-green-foreground-1: #11910d; + --color-palette-light-green-foreground-2: #0b5a08; + --color-palette-light-green-foreground-3: #13a10e; + --color-palette-anchor-background-2: #bcc3c7; + --color-palette-anchor-border-active: #394146; + --color-palette-anchor-foreground-2: #202427; + --color-palette-beige-background-2: #d7d4d4; + --color-palette-beige-border-active: #7a7574; + --color-palette-beige-foreground-2: #444241; + --color-palette-blue-background-2: #a9d3f2; + --color-palette-blue-border-active: #0078d4; + --color-palette-blue-foreground-2: #004377; + --color-palette-brass-background-2: #e0cea2; + --color-palette-brass-border-active: #986f0b; + --color-palette-brass-foreground-2: #553e06; + --color-palette-brown-background-2: #ddc3b0; + --color-palette-brown-border-active: #8e562e; + --color-palette-brown-foreground-2: #50301a; + --color-palette-cornflower-background-2: #c8d1fa; + --color-palette-cornflower-border-active: #4f6bed; + --color-palette-cornflower-foreground-2: #2c3c85; + --color-palette-cranberry-background-2: #eeacb2; + --color-palette-cranberry-border-active: #c50f1f; + --color-palette-cranberry-foreground-2: #6e0811; + --color-palette-dark-green-background-2: #9ad29a; + --color-palette-dark-green-border-active: #0b6a0b; + --color-palette-dark-green-foreground-2: #063b06; + --color-palette-dark-red-background-2: #d69ca5; + --color-palette-dark-red-border-active: #750b1c; + --color-palette-dark-red-foreground-2: #420610; + --color-palette-forest-background-2: #bdd99b; + --color-palette-forest-border-active: #498205; + --color-palette-forest-foreground-2: #294903; + --color-palette-gold-background-2: #ecdfa5; + --color-palette-gold-border-active: #c19c00; + --color-palette-gold-foreground-2: #6c5700; + --color-palette-grape-background-2: #d9a7e0; + --color-palette-grape-border-active: #881798; + --color-palette-grape-foreground-2: #4c0d55; + --color-palette-lavender-background-2: #d2ccf8; + --color-palette-lavender-border-active: #7160e8; + --color-palette-lavender-foreground-2: #3f3682; + --color-palette-light-teal-background-2: #a6e9ed; + --color-palette-light-teal-border-active: #00b7c3; + --color-palette-light-teal-foreground-2: #00666d; + --color-palette-lilac-background-2: #e6bfed; + --color-palette-lilac-border-active: #b146c2; + --color-palette-lilac-foreground-2: #63276d; + --color-palette-magenta-background-2: #eca5d1; + --color-palette-magenta-border-active: #bf0077; + --color-palette-magenta-foreground-2: #6b0043; + --color-palette-mink-background-2: #cecccb; + --color-palette-mink-border-active: #5d5a58; + --color-palette-mink-foreground-2: #343231; + --color-palette-navy-background-2: #a3b2e8; + --color-palette-navy-border-active: #0027b4; + --color-palette-navy-foreground-2: #001665; + --color-palette-peach-background-2: #ffddb3; + --color-palette-peach-border-active: #ff8c00; + --color-palette-peach-foreground-2: #8f4e00; + --color-palette-pink-background-2: #f7c0e3; + --color-palette-pink-border-active: #e43ba6; + --color-palette-pink-foreground-2: #80215d; + --color-palette-platinum-background-2: #cdd6d8; + --color-palette-platinum-border-active: #69797e; + --color-palette-platinum-foreground-2: #3b4447; + --color-palette-plum-background-2: #d696c0; + --color-palette-plum-border-active: #77004d; + --color-palette-plum-foreground-2: #43002b; + --color-palette-pumpkin-background-2: #efc4ad; + --color-palette-pumpkin-border-active: #ca5010; + --color-palette-pumpkin-foreground-2: #712d09; + --color-palette-purple-background-2: #c6b1de; + --color-palette-purple-border-active: #5c2e91; + --color-palette-purple-foreground-2: #341a51; + --color-palette-royal-blue-background-2: #9abfdc; + --color-palette-royal-blue-border-active: #004e8c; + --color-palette-royal-blue-foreground-2: #002c4e; + --color-palette-seafoam-background-2: #a8f0cd; + --color-palette-seafoam-border-active: #00cc6a; + --color-palette-seafoam-foreground-2: #00723b; + --color-palette-steel-background-2: #94c8d4; + --color-palette-steel-border-active: #005b70; + --color-palette-steel-foreground-2: #00333f; + --color-palette-teal-background-2: #9bd9db; + --color-palette-teal-border-active: #038387; + --color-palette-teal-foreground-2: #02494c; + --color-status-success-background-1: #f1faf1; + --color-status-success-background-2: #9fd89f; + --color-status-success-background-3: #107c10; + --color-status-success-foreground-1: #0e700e; + --color-status-success-foreground-2: #094509; + --color-status-success-foreground-3: #107c10; + --color-status-success-foreground-inverted: #54b054; + --color-status-success-border-active: #107c10; + --color-status-success-border-1: #9fd89f; + --color-status-success-border-2: #107c10; + --color-status-warning-background-1: #fff9f5; + --color-status-warning-background-2: #fdcfb4; + --color-status-warning-background-3: #f7630c; + --color-status-warning-foreground-1: #bc4b09; + --color-status-warning-foreground-2: #8a3707; + --color-status-warning-foreground-3: #bc4b09; + --color-status-warning-foreground-inverted: #faa06b; + --color-status-warning-border-active: #f7630c; + --color-status-warning-border-1: #fdcfb4; + --color-status-warning-border-2: #bc4b09; + --color-status-danger-background-1: #fdf3f4; + --color-status-danger-background-2: #eeacb2; + --color-status-danger-background-3: #c50f1f; + --color-status-danger-background-3-hover: #b10e1c; + --color-status-danger-background-3-pressed: #960b18; + --color-status-danger-foreground-1: #b10e1c; + --color-status-danger-foreground-2: #6e0811; + --color-status-danger-foreground-3: #c50f1f; + --color-status-danger-foreground-inverted: #dc626d; + --color-status-danger-border-active: #c50f1f; + --color-status-danger-border-1: #eeacb2; + --color-status-danger-border-2: #c50f1f; + --radius-none: 0; + --radius-small: 2px; + --radius-medium: 4px; + --radius-large: 6px; + --radius-x-large: 8px; + --radius-2-x-large: 12px; + --radius-3-x-large: 16px; + --radius-4-x-large: 24px; + --radius-5-x-large: 32px; + --radius-6-x-large: 40px; + --radius-circular: 10000px; + --font-base: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, 'Apple Color Emoji', 'Segoe UI Emoji', + sans-serif; + --font-monospace: Consolas, 'Courier New', Courier, monospace; + --font-numeric: Bahnschrift, 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, + 'Helvetica Neue', sans-serif; + --text-base-100: 10px; + --text-base-200: 12px; + --text-base-300: 14px; + --text-base-400: 16px; + --text-base-500: 20px; + --text-base-600: 24px; + --text-hero-700: 28px; + --text-hero-800: 32px; + --text-hero-900: 40px; + --text-hero-1000: 68px; + --font-weight-regular: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + --leading-base-100: 14px; + --leading-base-200: 16px; + --leading-base-300: 20px; + --leading-base-400: 22px; + --leading-base-500: 28px; + --leading-base-600: 32px; + --leading-hero-700: 36px; + --leading-hero-800: 40px; + --leading-hero-900: 52px; + --leading-hero-1000: 92px; + --shadow-2: 0 0 2px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.14); + --shadow-4: 0 0 2px rgba(0, 0, 0, 0.12), 0 2px 4px rgba(0, 0, 0, 0.14); + --shadow-8: 0 0 2px rgba(0, 0, 0, 0.12), 0 4px 8px rgba(0, 0, 0, 0.14); + --shadow-16: 0 0 2px rgba(0, 0, 0, 0.12), 0 8px 16px rgba(0, 0, 0, 0.14); + --shadow-28: 0 0 8px rgba(0, 0, 0, 0.12), 0 14px 28px rgba(0, 0, 0, 0.14); + --shadow-64: 0 0 8px rgba(0, 0, 0, 0.12), 0 32px 64px rgba(0, 0, 0, 0.14); + --shadow-2-brand: 0 0 2px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.25); + --shadow-4-brand: 0 0 2px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.25); + --shadow-8-brand: 0 0 2px rgba(0, 0, 0, 0.3), 0 4px 8px rgba(0, 0, 0, 0.25); + --shadow-16-brand: 0 0 2px rgba(0, 0, 0, 0.3), 0 8px 16px rgba(0, 0, 0, 0.25); + --shadow-28-brand: 0 0 8px rgba(0, 0, 0, 0.3), 0 14px 28px rgba(0, 0, 0, 0.25); + --shadow-64-brand: 0 0 8px rgba(0, 0, 0, 0.3), 0 32px 64px rgba(0, 0, 0, 0.25); + --duration-ultra-fast: 50ms; + --duration-faster: 100ms; + --duration-fast: 150ms; + --duration-normal: 200ms; + --duration-gentle: 250ms; + --duration-slow: 300ms; + --duration-slower: 400ms; + --duration-ultra-slow: 500ms; + --ease-accelerate-max: cubic-bezier(0.9, 0.1, 1, 0.2); + --ease-accelerate-mid: cubic-bezier(1, 0, 1, 1); + --ease-accelerate-min: cubic-bezier(0.8, 0, 0.78, 1); + --ease-decelerate-max: cubic-bezier(0.1, 0.9, 0.2, 1); + --ease-decelerate-mid: cubic-bezier(0, 0, 0, 1); + --ease-decelerate-min: cubic-bezier(0.33, 0, 0.1, 1); + --ease-easy-ease-max: cubic-bezier(0.8, 0, 0.2, 1); + --ease-easy-ease: cubic-bezier(0.33, 0, 0.67, 1); + --ease-linear: cubic-bezier(0, 0, 1, 1); + } +} + +/* teamsDarkV21Theme — 433 tokens */ +@layer fui.theme { + .fui-theme-teams-dark-v21 { + --color-neutral-foreground-1: #ffffff; + --color-neutral-foreground-1-hover: #ffffff; + --color-neutral-foreground-1-pressed: #ffffff; + --color-neutral-foreground-1-selected: #ffffff; + --color-neutral-foreground-2: #d6d6d6; + --color-neutral-foreground-2-hover: #ffffff; + --color-neutral-foreground-2-pressed: #ffffff; + --color-neutral-foreground-2-selected: #ffffff; + --color-neutral-foreground-2-brand-hover: #887dff; + --color-neutral-foreground-2-brand-pressed: #7769fa; + --color-neutral-foreground-2-brand-selected: #887dff; + --color-neutral-foreground-3: #adadad; + --color-neutral-foreground-3-hover: #d6d6d6; + --color-neutral-foreground-3-pressed: #d6d6d6; + --color-neutral-foreground-3-selected: #d6d6d6; + --color-neutral-foreground-3-brand-hover: #887dff; + --color-neutral-foreground-3-brand-pressed: #7769fa; + --color-neutral-foreground-3-brand-selected: #887dff; + --color-neutral-foreground-4: #999999; + --color-neutral-foreground-5: #adadad; + --color-neutral-foreground-5-hover: #ffffff; + --color-neutral-foreground-5-pressed: #ffffff; + --color-neutral-foreground-5-selected: #ffffff; + --color-neutral-foreground-disabled: #5c5c5c; + --color-brand-foreground-link: #887dff; + --color-brand-foreground-link-hover: #9791ff; + --color-brand-foreground-link-pressed: #7769fa; + --color-brand-foreground-link-selected: #887dff; + --color-neutral-foreground-2-link: #d6d6d6; + --color-neutral-foreground-2-link-hover: #ffffff; + --color-neutral-foreground-2-link-pressed: #ffffff; + --color-neutral-foreground-2-link-selected: #ffffff; + --color-compound-brand-foreground-1: #887dff; + --color-compound-brand-foreground-1-hover: #9791ff; + --color-compound-brand-foreground-1-pressed: #7769fa; + --color-neutral-foreground-on-brand: #ffffff; + --color-neutral-foreground-inverted: #242424; + --color-neutral-foreground-inverted-hover: #242424; + --color-neutral-foreground-inverted-pressed: #242424; + --color-neutral-foreground-inverted-selected: #242424; + --color-neutral-foreground-inverted-2: #242424; + --color-neutral-foreground-static-inverted: #ffffff; + --color-neutral-foreground-inverted-link: #ffffff; + --color-neutral-foreground-inverted-link-hover: #ffffff; + --color-neutral-foreground-inverted-link-pressed: #ffffff; + --color-neutral-foreground-inverted-link-selected: #ffffff; + --color-neutral-foreground-inverted-disabled: rgba(255, 255, 255, 0.4); + --color-brand-foreground-1: #887dff; + --color-brand-foreground-2: #aba8ff; + --color-brand-foreground-2-hover: #bab8ff; + --color-brand-foreground-2-pressed: #e8e8ff; + --color-neutral-foreground-1-static: #242424; + --color-brand-foreground-inverted: #654cf5; + --color-brand-foreground-inverted-hover: #5a40db; + --color-brand-foreground-inverted-pressed: #4d3aba; + --color-brand-foreground-on-light: #654cf5; + --color-brand-foreground-on-light-hover: #5a40db; + --color-brand-foreground-on-light-pressed: #44359e; + --color-brand-foreground-on-light-selected: #4d3aba; + --color-neutral-background-1: #292929; + --color-neutral-background-1-hover: #3d3d3d; + --color-neutral-background-1-pressed: #1f1f1f; + --color-neutral-background-1-selected: #383838; + --color-neutral-background-2: #242424; + --color-neutral-background-2-hover: #383838; + --color-neutral-background-2-pressed: #1a1a1a; + --color-neutral-background-2-selected: #333333; + --color-neutral-background-3: #1f1f1f; + --color-neutral-background-3-hover: #333333; + --color-neutral-background-3-pressed: #141414; + --color-neutral-background-3-selected: #2e2e2e; + --color-neutral-background-4: #141414; + --color-neutral-background-4-hover: #292929; + --color-neutral-background-4-pressed: #0a0a0a; + --color-neutral-background-4-selected: #242424; + --color-neutral-background-5: #0a0a0a; + --color-neutral-background-5-hover: #1f1f1f; + --color-neutral-background-5-pressed: #000000; + --color-neutral-background-5-selected: #1a1a1a; + --color-neutral-background-6: #333333; + --color-neutral-background-7: #00000000; + --color-neutral-background-7-hover: #1a1a1a; + --color-neutral-background-7-pressed: #0a0a0a; + --color-neutral-background-7-selected: #00000000; + --color-neutral-background-8: #292929; + --color-neutral-background-inverted: #ffffff; + --color-neutral-background-inverted-hover: #f5f5f5; + --color-neutral-background-inverted-pressed: #e0e0e0; + --color-neutral-background-inverted-selected: #ebebeb; + --color-neutral-background-static: #3d3d3d; + --color-neutral-background-alpha: rgba(26, 26, 26, 0.5); + --color-neutral-background-alpha-2: rgba(31, 31, 31, 0.7); + --color-subtle-background: transparent; + --color-subtle-background-hover: #383838; + --color-subtle-background-pressed: #2e2e2e; + --color-subtle-background-selected: #333333; + --color-subtle-background-light-alpha-hover: rgba(36, 36, 36, 0.8); + --color-subtle-background-light-alpha-pressed: rgba(36, 36, 36, 0.5); + --color-subtle-background-light-alpha-selected: transparent; + --color-subtle-background-inverted: transparent; + --color-subtle-background-inverted-hover: rgba(0, 0, 0, 0.1); + --color-subtle-background-inverted-pressed: rgba(0, 0, 0, 0.3); + --color-subtle-background-inverted-selected: rgba(0, 0, 0, 0.2); + --color-transparent-background: transparent; + --color-transparent-background-hover: transparent; + --color-transparent-background-pressed: transparent; + --color-transparent-background-selected: transparent; + --color-neutral-background-disabled: #141414; + --color-neutral-background-disabled-2: #292929; + --color-neutral-background-inverted-disabled: rgba(255, 255, 255, 0.1); + --color-neutral-stencil-1: #575757; + --color-neutral-stencil-2: #333333; + --color-neutral-stencil-1-alpha: rgba(255, 255, 255, 0.1); + --color-neutral-stencil-2-alpha: rgba(255, 255, 255, 0.05); + --color-background-overlay: rgba(0, 0, 0, 0.5); + --color-scrollbar-overlay: rgba(255, 255, 255, 0.6); + --color-brand-background: #5a40db; + --color-brand-background-hover: #654cf5; + --color-brand-background-pressed: #3b3185; + --color-brand-background-selected: #4d3aba; + --color-compound-brand-background: #887dff; + --color-compound-brand-background-hover: #9791ff; + --color-compound-brand-background-pressed: #7769fa; + --color-brand-background-static: #654cf5; + --color-brand-background-2: #2f2a5e; + --color-brand-background-2-hover: #3b3185; + --color-brand-background-2-pressed: #29274f; + --color-brand-background-3-static: #4d3aba; + --color-brand-background-4-static: #3b3185; + --color-brand-background-inverted: #ffffff; + --color-brand-background-inverted-hover: #e8e8ff; + --color-brand-background-inverted-pressed: #c8c7ff; + --color-brand-background-inverted-selected: #dcdbff; + --color-neutral-card-background: #333333; + --color-neutral-card-background-hover: #3d3d3d; + --color-neutral-card-background-pressed: #2e2e2e; + --color-neutral-card-background-selected: #383838; + --color-neutral-card-background-disabled: #141414; + --color-neutral-stroke-accessible: #adadad; + --color-neutral-stroke-accessible-hover: #bdbdbd; + --color-neutral-stroke-accessible-pressed: #b3b3b3; + --color-neutral-stroke-accessible-selected: #887dff; + --color-neutral-stroke-1: #666666; + --color-neutral-stroke-1-hover: #757575; + --color-neutral-stroke-1-pressed: #6b6b6b; + --color-neutral-stroke-1-selected: #707070; + --color-neutral-stroke-2: #525252; + --color-neutral-stroke-3: #3d3d3d; + --color-neutral-stroke-4: #3d3d3d; + --color-neutral-stroke-4-hover: #2e2e2e; + --color-neutral-stroke-4-pressed: #242424; + --color-neutral-stroke-4-selected: #3d3d3d; + --color-neutral-stroke-subtle: #0a0a0a; + --color-neutral-stroke-on-brand: #292929; + --color-neutral-stroke-on-brand-2: #ffffff; + --color-neutral-stroke-on-brand-2-hover: #ffffff; + --color-neutral-stroke-on-brand-2-pressed: #ffffff; + --color-neutral-stroke-on-brand-2-selected: #ffffff; + --color-brand-stroke-1: #887dff; + --color-brand-stroke-2: #44359e; + --color-brand-stroke-2-hover: #44359e; + --color-brand-stroke-2-pressed: #352e70; + --color-brand-stroke-2-contrast: #44359e; + --color-compound-brand-stroke: #7769fa; + --color-compound-brand-stroke-hover: #887dff; + --color-compound-brand-stroke-pressed: #654cf5; + --color-neutral-stroke-disabled: #424242; + --color-neutral-stroke-disabled-2: #3d3d3d; + --color-neutral-stroke-inverted-disabled: rgba(255, 255, 255, 0.4); + --color-transparent-stroke: transparent; + --color-transparent-stroke-interactive: transparent; + --color-transparent-stroke-disabled: transparent; + --color-neutral-stroke-alpha: rgba(255, 255, 255, 0.1); + --color-neutral-stroke-alpha-2: rgba(255, 255, 255, 0.2); + --color-stroke-focus-1: #000000; + --color-stroke-focus-2: #ffffff; + --color-neutral-shadow-ambient: rgba(0, 0, 0, 0.24); + --color-neutral-shadow-key: rgba(0, 0, 0, 0.28); + --color-neutral-shadow-ambient-lighter: rgba(0, 0, 0, 0.12); + --color-neutral-shadow-key-lighter: rgba(0, 0, 0, 0.14); + --color-neutral-shadow-ambient-darker: rgba(0, 0, 0, 0.4); + --color-neutral-shadow-key-darker: rgba(0, 0, 0, 0.48); + --color-brand-shadow-ambient: rgba(0, 0, 0, 0.3); + --color-brand-shadow-key: rgba(0, 0, 0, 0.25); + --color-palette-red-background-1: #3f1011; + --color-palette-red-background-2: #751d1f; + --color-palette-red-background-3: #d13438; + --color-palette-red-border-active: #e37d80; + --color-palette-red-border-1: #d13438; + --color-palette-red-border-2: #e37d80; + --color-palette-red-foreground-1: #e37d80; + --color-palette-red-foreground-2: #f1bbbc; + --color-palette-red-foreground-3: #e37d80; + --color-palette-red-foreground-inverted: #d13438; + --color-palette-green-background-1: #052505; + --color-palette-green-background-2: #094509; + --color-palette-green-background-3: #107c10; + --color-palette-green-border-active: #54b054; + --color-palette-green-border-1: #107c10; + --color-palette-green-border-2: #9fd89f; + --color-palette-green-foreground-1: #54b054; + --color-palette-green-foreground-2: #9fd89f; + --color-palette-green-foreground-3: #9fd89f; + --color-palette-green-foreground-inverted: #107c10; + --color-palette-dark-orange-background-1: #411200; + --color-palette-dark-orange-background-2: #7a2101; + --color-palette-dark-orange-background-3: #da3b01; + --color-palette-dark-orange-border-active: #e9835e; + --color-palette-dark-orange-border-1: #da3b01; + --color-palette-dark-orange-border-2: #e9835e; + --color-palette-dark-orange-foreground-1: #e9835e; + --color-palette-dark-orange-foreground-2: #f4bfab; + --color-palette-dark-orange-foreground-3: #e9835e; + --color-palette-yellow-background-1: #4c4400; + --color-palette-yellow-background-2: #817400; + --color-palette-yellow-background-3: #fde300; + --color-palette-yellow-border-active: #feee66; + --color-palette-yellow-border-1: #fde300; + --color-palette-yellow-border-2: #fdea3d; + --color-palette-yellow-foreground-1: #feee66; + --color-palette-yellow-foreground-2: #fef7b2; + --color-palette-yellow-foreground-3: #fdea3d; + --color-palette-yellow-foreground-inverted: #817400; + --color-palette-berry-background-1: #3a1136; + --color-palette-berry-background-2: #6d2064; + --color-palette-berry-background-3: #c239b3; + --color-palette-berry-border-active: #da7ed0; + --color-palette-berry-border-1: #c239b3; + --color-palette-berry-border-2: #d161c4; + --color-palette-berry-foreground-1: #da7ed0; + --color-palette-berry-foreground-2: #edbbe7; + --color-palette-berry-foreground-3: #d161c4; + --color-palette-marigold-background-1: #463100; + --color-palette-marigold-background-2: #835b00; + --color-palette-marigold-background-3: #eaa300; + --color-palette-marigold-border-active: #f2c661; + --color-palette-marigold-border-1: #eaa300; + --color-palette-marigold-border-2: #efb839; + --color-palette-marigold-foreground-1: #f2c661; + --color-palette-marigold-foreground-2: #f9e2ae; + --color-palette-marigold-foreground-3: #efb839; + --color-palette-light-green-background-1: #063004; + --color-palette-light-green-background-2: #0b5a08; + --color-palette-light-green-background-3: #13a10e; + --color-palette-light-green-border-active: #5ec75a; + --color-palette-light-green-border-1: #13a10e; + --color-palette-light-green-border-2: #3db838; + --color-palette-light-green-foreground-1: #5ec75a; + --color-palette-light-green-foreground-2: #a7e3a5; + --color-palette-light-green-foreground-3: #3db838; + --color-palette-anchor-background-2: #202427; + --color-palette-anchor-border-active: #808a90; + --color-palette-anchor-foreground-2: #bcc3c7; + --color-palette-beige-background-2: #444241; + --color-palette-beige-border-active: #afabaa; + --color-palette-beige-foreground-2: #d7d4d4; + --color-palette-blue-background-2: #004377; + --color-palette-blue-border-active: #5caae5; + --color-palette-blue-foreground-2: #a9d3f2; + --color-palette-brass-background-2: #553e06; + --color-palette-brass-border-active: #c1a256; + --color-palette-brass-foreground-2: #e0cea2; + --color-palette-brown-background-2: #50301a; + --color-palette-brown-border-active: #bb8f6f; + --color-palette-brown-foreground-2: #ddc3b0; + --color-palette-cornflower-background-2: #2c3c85; + --color-palette-cornflower-border-active: #93a4f4; + --color-palette-cornflower-foreground-2: #c8d1fa; + --color-palette-cranberry-background-2: #6e0811; + --color-palette-cranberry-border-active: #dc626d; + --color-palette-cranberry-foreground-2: #eeacb2; + --color-palette-dark-green-background-2: #063b06; + --color-palette-dark-green-border-active: #4da64d; + --color-palette-dark-green-foreground-2: #9ad29a; + --color-palette-dark-red-background-2: #590815; + --color-palette-dark-red-border-active: #ac4f5e; + --color-palette-dark-red-foreground-2: #d69ca5; + --color-palette-forest-background-2: #294903; + --color-palette-forest-border-active: #85b44c; + --color-palette-forest-foreground-2: #bdd99b; + --color-palette-gold-background-2: #6c5700; + --color-palette-gold-border-active: #dac157; + --color-palette-gold-foreground-2: #ecdfa5; + --color-palette-grape-background-2: #4c0d55; + --color-palette-grape-border-active: #b55fc1; + --color-palette-grape-foreground-2: #d9a7e0; + --color-palette-lavender-background-2: #3f3682; + --color-palette-lavender-border-active: #a79cf1; + --color-palette-lavender-foreground-2: #d2ccf8; + --color-palette-light-teal-background-2: #00666d; + --color-palette-light-teal-border-active: #58d3db; + --color-palette-light-teal-foreground-2: #a6e9ed; + --color-palette-lilac-background-2: #63276d; + --color-palette-lilac-border-active: #cf87da; + --color-palette-lilac-foreground-2: #e6bfed; + --color-palette-magenta-background-2: #6b0043; + --color-palette-magenta-border-active: #d957a8; + --color-palette-magenta-foreground-2: #eca5d1; + --color-palette-mink-background-2: #343231; + --color-palette-mink-border-active: #9e9b99; + --color-palette-mink-foreground-2: #cecccb; + --color-palette-navy-background-2: #001665; + --color-palette-navy-border-active: #546fd2; + --color-palette-navy-foreground-2: #a3b2e8; + --color-palette-peach-background-2: #8f4e00; + --color-palette-peach-border-active: #ffba66; + --color-palette-peach-foreground-2: #ffddb3; + --color-palette-pink-background-2: #80215d; + --color-palette-pink-border-active: #ef85c8; + --color-palette-pink-foreground-2: #f7c0e3; + --color-palette-platinum-background-2: #3b4447; + --color-palette-platinum-border-active: #a0adb2; + --color-palette-platinum-foreground-2: #cdd6d8; + --color-palette-plum-background-2: #5a003b; + --color-palette-plum-border-active: #ad4589; + --color-palette-plum-foreground-2: #d696c0; + --color-palette-pumpkin-background-2: #712d09; + --color-palette-pumpkin-border-active: #df8e64; + --color-palette-pumpkin-foreground-2: #efc4ad; + --color-palette-purple-background-2: #341a51; + --color-palette-purple-border-active: #9470bd; + --color-palette-purple-foreground-2: #c6b1de; + --color-palette-royal-blue-background-2: #002c4e; + --color-palette-royal-blue-border-active: #4a89ba; + --color-palette-royal-blue-foreground-2: #9abfdc; + --color-palette-seafoam-background-2: #00723b; + --color-palette-seafoam-border-active: #5ae0a0; + --color-palette-seafoam-foreground-2: #a8f0cd; + --color-palette-steel-background-2: #00333f; + --color-palette-steel-border-active: #4496a9; + --color-palette-steel-foreground-2: #94c8d4; + --color-palette-teal-background-2: #02494c; + --color-palette-teal-border-active: #4cb4b7; + --color-palette-teal-foreground-2: #9bd9db; + --color-status-success-background-1: #052505; + --color-status-success-background-2: #094509; + --color-status-success-background-3: #107c10; + --color-status-success-foreground-1: #54b054; + --color-status-success-foreground-2: #9fd89f; + --color-status-success-foreground-3: #9fd89f; + --color-status-success-foreground-inverted: #0e700e; + --color-status-success-border-active: #54b054; + --color-status-success-border-1: #107c10; + --color-status-success-border-2: #9fd89f; + --color-status-warning-background-1: #4a1e04; + --color-status-warning-background-2: #8a3707; + --color-status-warning-background-3: #f7630c; + --color-status-warning-foreground-1: #faa06b; + --color-status-warning-foreground-2: #fdcfb4; + --color-status-warning-foreground-3: #f98845; + --color-status-warning-foreground-inverted: #bc4b09; + --color-status-warning-border-active: #faa06b; + --color-status-warning-border-1: #f7630c; + --color-status-warning-border-2: #f98845; + --color-status-danger-background-1: #3b0509; + --color-status-danger-background-2: #6e0811; + --color-status-danger-background-3: #c50f1f; + --color-status-danger-background-3-hover: #b10e1c; + --color-status-danger-background-3-pressed: #960b18; + --color-status-danger-foreground-1: #dc626d; + --color-status-danger-foreground-2: #eeacb2; + --color-status-danger-foreground-3: #eeacb2; + --color-status-danger-foreground-inverted: #b10e1c; + --color-status-danger-border-active: #dc626d; + --color-status-danger-border-1: #c50f1f; + --color-status-danger-border-2: #dc626d; + --radius-none: 0; + --radius-small: 2px; + --radius-medium: 4px; + --radius-large: 6px; + --radius-x-large: 8px; + --radius-2-x-large: 12px; + --radius-3-x-large: 16px; + --radius-4-x-large: 24px; + --radius-5-x-large: 32px; + --radius-6-x-large: 40px; + --radius-circular: 10000px; + --font-base: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, 'Apple Color Emoji', 'Segoe UI Emoji', + sans-serif; + --font-monospace: Consolas, 'Courier New', Courier, monospace; + --font-numeric: Bahnschrift, 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, + 'Helvetica Neue', sans-serif; + --text-base-100: 10px; + --text-base-200: 12px; + --text-base-300: 14px; + --text-base-400: 16px; + --text-base-500: 20px; + --text-base-600: 24px; + --text-hero-700: 28px; + --text-hero-800: 32px; + --text-hero-900: 40px; + --text-hero-1000: 68px; + --font-weight-regular: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + --leading-base-100: 14px; + --leading-base-200: 16px; + --leading-base-300: 20px; + --leading-base-400: 22px; + --leading-base-500: 28px; + --leading-base-600: 32px; + --leading-hero-700: 36px; + --leading-hero-800: 40px; + --leading-hero-900: 52px; + --leading-hero-1000: 92px; + --shadow-2: 0 0 2px rgba(0, 0, 0, 0.24), 0 1px 2px rgba(0, 0, 0, 0.28); + --shadow-4: 0 0 2px rgba(0, 0, 0, 0.24), 0 2px 4px rgba(0, 0, 0, 0.28); + --shadow-8: 0 0 2px rgba(0, 0, 0, 0.24), 0 4px 8px rgba(0, 0, 0, 0.28); + --shadow-16: 0 0 2px rgba(0, 0, 0, 0.24), 0 8px 16px rgba(0, 0, 0, 0.28); + --shadow-28: 0 0 8px rgba(0, 0, 0, 0.24), 0 14px 28px rgba(0, 0, 0, 0.28); + --shadow-64: 0 0 8px rgba(0, 0, 0, 0.24), 0 32px 64px rgba(0, 0, 0, 0.28); + --shadow-2-brand: 0 0 2px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.25); + --shadow-4-brand: 0 0 2px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.25); + --shadow-8-brand: 0 0 2px rgba(0, 0, 0, 0.3), 0 4px 8px rgba(0, 0, 0, 0.25); + --shadow-16-brand: 0 0 2px rgba(0, 0, 0, 0.3), 0 8px 16px rgba(0, 0, 0, 0.25); + --shadow-28-brand: 0 0 8px rgba(0, 0, 0, 0.3), 0 14px 28px rgba(0, 0, 0, 0.25); + --shadow-64-brand: 0 0 8px rgba(0, 0, 0, 0.3), 0 32px 64px rgba(0, 0, 0, 0.25); + --duration-ultra-fast: 50ms; + --duration-faster: 100ms; + --duration-fast: 150ms; + --duration-normal: 200ms; + --duration-gentle: 250ms; + --duration-slow: 300ms; + --duration-slower: 400ms; + --duration-ultra-slow: 500ms; + --ease-accelerate-max: cubic-bezier(0.9, 0.1, 1, 0.2); + --ease-accelerate-mid: cubic-bezier(1, 0, 1, 1); + --ease-accelerate-min: cubic-bezier(0.8, 0, 0.78, 1); + --ease-decelerate-max: cubic-bezier(0.1, 0.9, 0.2, 1); + --ease-decelerate-mid: cubic-bezier(0, 0, 0, 1); + --ease-decelerate-min: cubic-bezier(0.33, 0, 0.1, 1); + --ease-easy-ease-max: cubic-bezier(0.8, 0, 0.2, 1); + --ease-easy-ease: cubic-bezier(0.33, 0, 0.67, 1); + --ease-linear: cubic-bezier(0, 0, 1, 1); + } +} diff --git a/packages/react-components/react-tailwind-theme-preview/css/tokens.css b/packages/react-components/react-tailwind-theme-preview/css/tokens.css new file mode 100644 index 00000000000000..048dfcc70640b8 --- /dev/null +++ b/packages/react-components/react-tailwind-theme-preview/css/tokens.css @@ -0,0 +1,1047 @@ +/* + * DO NOT EDIT — generated file. + * + * Generator: packages/react-components/react-tailwind-theme-preview/scripts/generate-tokens-css.js + * Source: @fluentui/tokens@1.0.0-alpha.24 (packages/tokens/src/tokens.ts) + * Regenerate: node packages/react-components/react-tailwind-theme-preview/scripts/generate-tokens-css.js + * Verify: node packages/react-components/react-tailwind-theme-preview/scripts/generate-tokens-css.js --check + * + * 467 Fluent tokens: 467 registered, none excluded. + * + * `inline` is MANDATORY: it substitutes the canonical var(--token-name) into each + * utility, so values resolve per-element — the `:root, :host` defaults below, + * overridden per subtree by the theme classes (css/themes.css, theming Phase 2b). + * A plain `@theme` alias would freeze resolution at `:root`, breaking scoped + * theming. `reference` suppresses the self-referential aliases that plain + * `inline` would emit now that each runtime variable IS its theme key (theming + * Phase 2a) — the values are emitted in the fui.theme block below, not by JS. + * + * SPACING IS THE ONE EXCEPTION: --spacing-horizontal-* / --spacing-vertical-* are + * ALIASES OF THE NUMERIC AXIS — calc(var(--spacing) * N), the same shape p-12 + * compiles to — so named and numeric spacing are ONE system with ONE density knob + * (--spacing). Stroke widths are the deliberate exception: their PUBLIC variables + * are --stroke-width-thin/thick/thicker/thickest with literal + * calc( * var(--base-scale)) values (borders must not thin when layout density + * changes); --spacing-thin/… are PRIVATE hooks aliasing them for utility generation. + * Provider spacing/strokeWidth overrides do not reach these; all 7 shipped themes + * carry identical values. + * + * ORDER MATTERS: index.css imports this AFTER its `@theme static` block, whose + * `--color-*: initial` / `--spacing-*: initial` clear only what precedes them. + * + * Rationale, namespace mapping, emission reasons: see the generator source. + */ + +@theme inline reference { + /* Colors — 366 tokens → --color-* (bg-* text-* border-* fill-* stroke-* outline-* ring-* decoration-*) */ + --color-neutral-foreground-1: var(--color-neutral-foreground-1); + --color-neutral-foreground-1-hover: var(--color-neutral-foreground-1-hover); + --color-neutral-foreground-1-pressed: var(--color-neutral-foreground-1-pressed); + --color-neutral-foreground-1-selected: var(--color-neutral-foreground-1-selected); + --color-neutral-foreground-2: var(--color-neutral-foreground-2); + --color-neutral-foreground-2-hover: var(--color-neutral-foreground-2-hover); + --color-neutral-foreground-2-pressed: var(--color-neutral-foreground-2-pressed); + --color-neutral-foreground-2-selected: var(--color-neutral-foreground-2-selected); + --color-neutral-foreground-2-brand-hover: var(--color-neutral-foreground-2-brand-hover); + --color-neutral-foreground-2-brand-pressed: var(--color-neutral-foreground-2-brand-pressed); + --color-neutral-foreground-2-brand-selected: var(--color-neutral-foreground-2-brand-selected); + --color-neutral-foreground-3: var(--color-neutral-foreground-3); + --color-neutral-foreground-3-hover: var(--color-neutral-foreground-3-hover); + --color-neutral-foreground-3-pressed: var(--color-neutral-foreground-3-pressed); + --color-neutral-foreground-3-selected: var(--color-neutral-foreground-3-selected); + --color-neutral-foreground-3-brand-hover: var(--color-neutral-foreground-3-brand-hover); + --color-neutral-foreground-3-brand-pressed: var(--color-neutral-foreground-3-brand-pressed); + --color-neutral-foreground-3-brand-selected: var(--color-neutral-foreground-3-brand-selected); + --color-neutral-foreground-4: var(--color-neutral-foreground-4); + --color-neutral-foreground-5: var(--color-neutral-foreground-5); + --color-neutral-foreground-5-hover: var(--color-neutral-foreground-5-hover); + --color-neutral-foreground-5-pressed: var(--color-neutral-foreground-5-pressed); + --color-neutral-foreground-5-selected: var(--color-neutral-foreground-5-selected); + --color-neutral-foreground-disabled: var(--color-neutral-foreground-disabled); + --color-brand-foreground-link: var(--color-brand-foreground-link); + --color-brand-foreground-link-hover: var(--color-brand-foreground-link-hover); + --color-brand-foreground-link-pressed: var(--color-brand-foreground-link-pressed); + --color-brand-foreground-link-selected: var(--color-brand-foreground-link-selected); + --color-neutral-foreground-2-link: var(--color-neutral-foreground-2-link); + --color-neutral-foreground-2-link-hover: var(--color-neutral-foreground-2-link-hover); + --color-neutral-foreground-2-link-pressed: var(--color-neutral-foreground-2-link-pressed); + --color-neutral-foreground-2-link-selected: var(--color-neutral-foreground-2-link-selected); + --color-compound-brand-foreground-1: var(--color-compound-brand-foreground-1); + --color-compound-brand-foreground-1-hover: var(--color-compound-brand-foreground-1-hover); + --color-compound-brand-foreground-1-pressed: var(--color-compound-brand-foreground-1-pressed); + --color-neutral-foreground-on-brand: var(--color-neutral-foreground-on-brand); + --color-neutral-foreground-inverted: var(--color-neutral-foreground-inverted); + --color-neutral-foreground-inverted-hover: var(--color-neutral-foreground-inverted-hover); + --color-neutral-foreground-inverted-pressed: var(--color-neutral-foreground-inverted-pressed); + --color-neutral-foreground-inverted-selected: var(--color-neutral-foreground-inverted-selected); + --color-neutral-foreground-inverted-2: var(--color-neutral-foreground-inverted-2); + --color-neutral-foreground-static-inverted: var(--color-neutral-foreground-static-inverted); + --color-neutral-foreground-inverted-link: var(--color-neutral-foreground-inverted-link); + --color-neutral-foreground-inverted-link-hover: var(--color-neutral-foreground-inverted-link-hover); + --color-neutral-foreground-inverted-link-pressed: var(--color-neutral-foreground-inverted-link-pressed); + --color-neutral-foreground-inverted-link-selected: var(--color-neutral-foreground-inverted-link-selected); + --color-neutral-foreground-inverted-disabled: var(--color-neutral-foreground-inverted-disabled); + --color-brand-foreground-1: var(--color-brand-foreground-1); + --color-brand-foreground-2: var(--color-brand-foreground-2); + --color-brand-foreground-2-hover: var(--color-brand-foreground-2-hover); + --color-brand-foreground-2-pressed: var(--color-brand-foreground-2-pressed); + --color-neutral-foreground-1-static: var(--color-neutral-foreground-1-static); + --color-brand-foreground-inverted: var(--color-brand-foreground-inverted); + --color-brand-foreground-inverted-hover: var(--color-brand-foreground-inverted-hover); + --color-brand-foreground-inverted-pressed: var(--color-brand-foreground-inverted-pressed); + --color-brand-foreground-on-light: var(--color-brand-foreground-on-light); + --color-brand-foreground-on-light-hover: var(--color-brand-foreground-on-light-hover); + --color-brand-foreground-on-light-pressed: var(--color-brand-foreground-on-light-pressed); + --color-brand-foreground-on-light-selected: var(--color-brand-foreground-on-light-selected); + --color-neutral-background-1: var(--color-neutral-background-1); + --color-neutral-background-1-hover: var(--color-neutral-background-1-hover); + --color-neutral-background-1-pressed: var(--color-neutral-background-1-pressed); + --color-neutral-background-1-selected: var(--color-neutral-background-1-selected); + --color-neutral-background-2: var(--color-neutral-background-2); + --color-neutral-background-2-hover: var(--color-neutral-background-2-hover); + --color-neutral-background-2-pressed: var(--color-neutral-background-2-pressed); + --color-neutral-background-2-selected: var(--color-neutral-background-2-selected); + --color-neutral-background-3: var(--color-neutral-background-3); + --color-neutral-background-3-hover: var(--color-neutral-background-3-hover); + --color-neutral-background-3-pressed: var(--color-neutral-background-3-pressed); + --color-neutral-background-3-selected: var(--color-neutral-background-3-selected); + --color-neutral-background-4: var(--color-neutral-background-4); + --color-neutral-background-4-hover: var(--color-neutral-background-4-hover); + --color-neutral-background-4-pressed: var(--color-neutral-background-4-pressed); + --color-neutral-background-4-selected: var(--color-neutral-background-4-selected); + --color-neutral-background-5: var(--color-neutral-background-5); + --color-neutral-background-5-hover: var(--color-neutral-background-5-hover); + --color-neutral-background-5-pressed: var(--color-neutral-background-5-pressed); + --color-neutral-background-5-selected: var(--color-neutral-background-5-selected); + --color-neutral-background-6: var(--color-neutral-background-6); + --color-neutral-background-7: var(--color-neutral-background-7); + --color-neutral-background-7-hover: var(--color-neutral-background-7-hover); + --color-neutral-background-7-pressed: var(--color-neutral-background-7-pressed); + --color-neutral-background-7-selected: var(--color-neutral-background-7-selected); + --color-neutral-background-8: var(--color-neutral-background-8); + --color-neutral-background-inverted: var(--color-neutral-background-inverted); + --color-neutral-background-inverted-hover: var(--color-neutral-background-inverted-hover); + --color-neutral-background-inverted-pressed: var(--color-neutral-background-inverted-pressed); + --color-neutral-background-inverted-selected: var(--color-neutral-background-inverted-selected); + --color-neutral-background-static: var(--color-neutral-background-static); + --color-neutral-background-alpha: var(--color-neutral-background-alpha); + --color-neutral-background-alpha-2: var(--color-neutral-background-alpha-2); + --color-subtle-background: var(--color-subtle-background); + --color-subtle-background-hover: var(--color-subtle-background-hover); + --color-subtle-background-pressed: var(--color-subtle-background-pressed); + --color-subtle-background-selected: var(--color-subtle-background-selected); + --color-subtle-background-light-alpha-hover: var(--color-subtle-background-light-alpha-hover); + --color-subtle-background-light-alpha-pressed: var(--color-subtle-background-light-alpha-pressed); + --color-subtle-background-light-alpha-selected: var(--color-subtle-background-light-alpha-selected); + --color-subtle-background-inverted: var(--color-subtle-background-inverted); + --color-subtle-background-inverted-hover: var(--color-subtle-background-inverted-hover); + --color-subtle-background-inverted-pressed: var(--color-subtle-background-inverted-pressed); + --color-subtle-background-inverted-selected: var(--color-subtle-background-inverted-selected); + --color-transparent-background: var(--color-transparent-background); + --color-transparent-background-hover: var(--color-transparent-background-hover); + --color-transparent-background-pressed: var(--color-transparent-background-pressed); + --color-transparent-background-selected: var(--color-transparent-background-selected); + --color-neutral-background-disabled: var(--color-neutral-background-disabled); + --color-neutral-background-disabled-2: var(--color-neutral-background-disabled-2); + --color-neutral-background-inverted-disabled: var(--color-neutral-background-inverted-disabled); + --color-neutral-stencil-1: var(--color-neutral-stencil-1); + --color-neutral-stencil-2: var(--color-neutral-stencil-2); + --color-neutral-stencil-1-alpha: var(--color-neutral-stencil-1-alpha); + --color-neutral-stencil-2-alpha: var(--color-neutral-stencil-2-alpha); + --color-background-overlay: var(--color-background-overlay); + --color-scrollbar-overlay: var(--color-scrollbar-overlay); + --color-brand-background: var(--color-brand-background); + --color-brand-background-hover: var(--color-brand-background-hover); + --color-brand-background-pressed: var(--color-brand-background-pressed); + --color-brand-background-selected: var(--color-brand-background-selected); + --color-compound-brand-background: var(--color-compound-brand-background); + --color-compound-brand-background-hover: var(--color-compound-brand-background-hover); + --color-compound-brand-background-pressed: var(--color-compound-brand-background-pressed); + --color-brand-background-static: var(--color-brand-background-static); + --color-brand-background-2: var(--color-brand-background-2); + --color-brand-background-2-hover: var(--color-brand-background-2-hover); + --color-brand-background-2-pressed: var(--color-brand-background-2-pressed); + --color-brand-background-3-static: var(--color-brand-background-3-static); + --color-brand-background-4-static: var(--color-brand-background-4-static); + --color-brand-background-inverted: var(--color-brand-background-inverted); + --color-brand-background-inverted-hover: var(--color-brand-background-inverted-hover); + --color-brand-background-inverted-pressed: var(--color-brand-background-inverted-pressed); + --color-brand-background-inverted-selected: var(--color-brand-background-inverted-selected); + --color-neutral-card-background: var(--color-neutral-card-background); + --color-neutral-card-background-hover: var(--color-neutral-card-background-hover); + --color-neutral-card-background-pressed: var(--color-neutral-card-background-pressed); + --color-neutral-card-background-selected: var(--color-neutral-card-background-selected); + --color-neutral-card-background-disabled: var(--color-neutral-card-background-disabled); + --color-neutral-stroke-accessible: var(--color-neutral-stroke-accessible); + --color-neutral-stroke-accessible-hover: var(--color-neutral-stroke-accessible-hover); + --color-neutral-stroke-accessible-pressed: var(--color-neutral-stroke-accessible-pressed); + --color-neutral-stroke-accessible-selected: var(--color-neutral-stroke-accessible-selected); + --color-neutral-stroke-1: var(--color-neutral-stroke-1); + --color-neutral-stroke-1-hover: var(--color-neutral-stroke-1-hover); + --color-neutral-stroke-1-pressed: var(--color-neutral-stroke-1-pressed); + --color-neutral-stroke-1-selected: var(--color-neutral-stroke-1-selected); + --color-neutral-stroke-2: var(--color-neutral-stroke-2); + --color-neutral-stroke-3: var(--color-neutral-stroke-3); + --color-neutral-stroke-4: var(--color-neutral-stroke-4); + --color-neutral-stroke-4-hover: var(--color-neutral-stroke-4-hover); + --color-neutral-stroke-4-pressed: var(--color-neutral-stroke-4-pressed); + --color-neutral-stroke-4-selected: var(--color-neutral-stroke-4-selected); + --color-neutral-stroke-subtle: var(--color-neutral-stroke-subtle); + --color-neutral-stroke-on-brand: var(--color-neutral-stroke-on-brand); + --color-neutral-stroke-on-brand-2: var(--color-neutral-stroke-on-brand-2); + --color-neutral-stroke-on-brand-2-hover: var(--color-neutral-stroke-on-brand-2-hover); + --color-neutral-stroke-on-brand-2-pressed: var(--color-neutral-stroke-on-brand-2-pressed); + --color-neutral-stroke-on-brand-2-selected: var(--color-neutral-stroke-on-brand-2-selected); + --color-brand-stroke-1: var(--color-brand-stroke-1); + --color-brand-stroke-2: var(--color-brand-stroke-2); + --color-brand-stroke-2-hover: var(--color-brand-stroke-2-hover); + --color-brand-stroke-2-pressed: var(--color-brand-stroke-2-pressed); + --color-brand-stroke-2-contrast: var(--color-brand-stroke-2-contrast); + --color-compound-brand-stroke: var(--color-compound-brand-stroke); + --color-compound-brand-stroke-hover: var(--color-compound-brand-stroke-hover); + --color-compound-brand-stroke-pressed: var(--color-compound-brand-stroke-pressed); + --color-neutral-stroke-disabled: var(--color-neutral-stroke-disabled); + --color-neutral-stroke-disabled-2: var(--color-neutral-stroke-disabled-2); + --color-neutral-stroke-inverted-disabled: var(--color-neutral-stroke-inverted-disabled); + --color-transparent-stroke: var(--color-transparent-stroke); + --color-transparent-stroke-interactive: var(--color-transparent-stroke-interactive); + --color-transparent-stroke-disabled: var(--color-transparent-stroke-disabled); + --color-neutral-stroke-alpha: var(--color-neutral-stroke-alpha); + --color-neutral-stroke-alpha-2: var(--color-neutral-stroke-alpha-2); + --color-stroke-focus-1: var(--color-stroke-focus-1); + --color-stroke-focus-2: var(--color-stroke-focus-2); + --color-neutral-shadow-ambient: var(--color-neutral-shadow-ambient); + --color-neutral-shadow-key: var(--color-neutral-shadow-key); + --color-neutral-shadow-ambient-lighter: var(--color-neutral-shadow-ambient-lighter); + --color-neutral-shadow-key-lighter: var(--color-neutral-shadow-key-lighter); + --color-neutral-shadow-ambient-darker: var(--color-neutral-shadow-ambient-darker); + --color-neutral-shadow-key-darker: var(--color-neutral-shadow-key-darker); + --color-brand-shadow-ambient: var(--color-brand-shadow-ambient); + --color-brand-shadow-key: var(--color-brand-shadow-key); + --color-palette-red-background-1: var(--color-palette-red-background-1); + --color-palette-red-background-2: var(--color-palette-red-background-2); + --color-palette-red-background-3: var(--color-palette-red-background-3); + --color-palette-red-border-active: var(--color-palette-red-border-active); + --color-palette-red-border-1: var(--color-palette-red-border-1); + --color-palette-red-border-2: var(--color-palette-red-border-2); + --color-palette-red-foreground-1: var(--color-palette-red-foreground-1); + --color-palette-red-foreground-2: var(--color-palette-red-foreground-2); + --color-palette-red-foreground-3: var(--color-palette-red-foreground-3); + --color-palette-red-foreground-inverted: var(--color-palette-red-foreground-inverted); + --color-palette-green-background-1: var(--color-palette-green-background-1); + --color-palette-green-background-2: var(--color-palette-green-background-2); + --color-palette-green-background-3: var(--color-palette-green-background-3); + --color-palette-green-border-active: var(--color-palette-green-border-active); + --color-palette-green-border-1: var(--color-palette-green-border-1); + --color-palette-green-border-2: var(--color-palette-green-border-2); + --color-palette-green-foreground-1: var(--color-palette-green-foreground-1); + --color-palette-green-foreground-2: var(--color-palette-green-foreground-2); + --color-palette-green-foreground-3: var(--color-palette-green-foreground-3); + --color-palette-green-foreground-inverted: var(--color-palette-green-foreground-inverted); + --color-palette-dark-orange-background-1: var(--color-palette-dark-orange-background-1); + --color-palette-dark-orange-background-2: var(--color-palette-dark-orange-background-2); + --color-palette-dark-orange-background-3: var(--color-palette-dark-orange-background-3); + --color-palette-dark-orange-border-active: var(--color-palette-dark-orange-border-active); + --color-palette-dark-orange-border-1: var(--color-palette-dark-orange-border-1); + --color-palette-dark-orange-border-2: var(--color-palette-dark-orange-border-2); + --color-palette-dark-orange-foreground-1: var(--color-palette-dark-orange-foreground-1); + --color-palette-dark-orange-foreground-2: var(--color-palette-dark-orange-foreground-2); + --color-palette-dark-orange-foreground-3: var(--color-palette-dark-orange-foreground-3); + --color-palette-yellow-background-1: var(--color-palette-yellow-background-1); + --color-palette-yellow-background-2: var(--color-palette-yellow-background-2); + --color-palette-yellow-background-3: var(--color-palette-yellow-background-3); + --color-palette-yellow-border-active: var(--color-palette-yellow-border-active); + --color-palette-yellow-border-1: var(--color-palette-yellow-border-1); + --color-palette-yellow-border-2: var(--color-palette-yellow-border-2); + --color-palette-yellow-foreground-1: var(--color-palette-yellow-foreground-1); + --color-palette-yellow-foreground-2: var(--color-palette-yellow-foreground-2); + --color-palette-yellow-foreground-3: var(--color-palette-yellow-foreground-3); + --color-palette-yellow-foreground-inverted: var(--color-palette-yellow-foreground-inverted); + --color-palette-berry-background-1: var(--color-palette-berry-background-1); + --color-palette-berry-background-2: var(--color-palette-berry-background-2); + --color-palette-berry-background-3: var(--color-palette-berry-background-3); + --color-palette-berry-border-active: var(--color-palette-berry-border-active); + --color-palette-berry-border-1: var(--color-palette-berry-border-1); + --color-palette-berry-border-2: var(--color-palette-berry-border-2); + --color-palette-berry-foreground-1: var(--color-palette-berry-foreground-1); + --color-palette-berry-foreground-2: var(--color-palette-berry-foreground-2); + --color-palette-berry-foreground-3: var(--color-palette-berry-foreground-3); + --color-palette-marigold-background-1: var(--color-palette-marigold-background-1); + --color-palette-marigold-background-2: var(--color-palette-marigold-background-2); + --color-palette-marigold-background-3: var(--color-palette-marigold-background-3); + --color-palette-marigold-border-active: var(--color-palette-marigold-border-active); + --color-palette-marigold-border-1: var(--color-palette-marigold-border-1); + --color-palette-marigold-border-2: var(--color-palette-marigold-border-2); + --color-palette-marigold-foreground-1: var(--color-palette-marigold-foreground-1); + --color-palette-marigold-foreground-2: var(--color-palette-marigold-foreground-2); + --color-palette-marigold-foreground-3: var(--color-palette-marigold-foreground-3); + --color-palette-light-green-background-1: var(--color-palette-light-green-background-1); + --color-palette-light-green-background-2: var(--color-palette-light-green-background-2); + --color-palette-light-green-background-3: var(--color-palette-light-green-background-3); + --color-palette-light-green-border-active: var(--color-palette-light-green-border-active); + --color-palette-light-green-border-1: var(--color-palette-light-green-border-1); + --color-palette-light-green-border-2: var(--color-palette-light-green-border-2); + --color-palette-light-green-foreground-1: var(--color-palette-light-green-foreground-1); + --color-palette-light-green-foreground-2: var(--color-palette-light-green-foreground-2); + --color-palette-light-green-foreground-3: var(--color-palette-light-green-foreground-3); + --color-palette-anchor-background-2: var(--color-palette-anchor-background-2); + --color-palette-anchor-border-active: var(--color-palette-anchor-border-active); + --color-palette-anchor-foreground-2: var(--color-palette-anchor-foreground-2); + --color-palette-beige-background-2: var(--color-palette-beige-background-2); + --color-palette-beige-border-active: var(--color-palette-beige-border-active); + --color-palette-beige-foreground-2: var(--color-palette-beige-foreground-2); + --color-palette-blue-background-2: var(--color-palette-blue-background-2); + --color-palette-blue-border-active: var(--color-palette-blue-border-active); + --color-palette-blue-foreground-2: var(--color-palette-blue-foreground-2); + --color-palette-brass-background-2: var(--color-palette-brass-background-2); + --color-palette-brass-border-active: var(--color-palette-brass-border-active); + --color-palette-brass-foreground-2: var(--color-palette-brass-foreground-2); + --color-palette-brown-background-2: var(--color-palette-brown-background-2); + --color-palette-brown-border-active: var(--color-palette-brown-border-active); + --color-palette-brown-foreground-2: var(--color-palette-brown-foreground-2); + --color-palette-cornflower-background-2: var(--color-palette-cornflower-background-2); + --color-palette-cornflower-border-active: var(--color-palette-cornflower-border-active); + --color-palette-cornflower-foreground-2: var(--color-palette-cornflower-foreground-2); + --color-palette-cranberry-background-2: var(--color-palette-cranberry-background-2); + --color-palette-cranberry-border-active: var(--color-palette-cranberry-border-active); + --color-palette-cranberry-foreground-2: var(--color-palette-cranberry-foreground-2); + --color-palette-dark-green-background-2: var(--color-palette-dark-green-background-2); + --color-palette-dark-green-border-active: var(--color-palette-dark-green-border-active); + --color-palette-dark-green-foreground-2: var(--color-palette-dark-green-foreground-2); + --color-palette-dark-red-background-2: var(--color-palette-dark-red-background-2); + --color-palette-dark-red-border-active: var(--color-palette-dark-red-border-active); + --color-palette-dark-red-foreground-2: var(--color-palette-dark-red-foreground-2); + --color-palette-forest-background-2: var(--color-palette-forest-background-2); + --color-palette-forest-border-active: var(--color-palette-forest-border-active); + --color-palette-forest-foreground-2: var(--color-palette-forest-foreground-2); + --color-palette-gold-background-2: var(--color-palette-gold-background-2); + --color-palette-gold-border-active: var(--color-palette-gold-border-active); + --color-palette-gold-foreground-2: var(--color-palette-gold-foreground-2); + --color-palette-grape-background-2: var(--color-palette-grape-background-2); + --color-palette-grape-border-active: var(--color-palette-grape-border-active); + --color-palette-grape-foreground-2: var(--color-palette-grape-foreground-2); + --color-palette-lavender-background-2: var(--color-palette-lavender-background-2); + --color-palette-lavender-border-active: var(--color-palette-lavender-border-active); + --color-palette-lavender-foreground-2: var(--color-palette-lavender-foreground-2); + --color-palette-light-teal-background-2: var(--color-palette-light-teal-background-2); + --color-palette-light-teal-border-active: var(--color-palette-light-teal-border-active); + --color-palette-light-teal-foreground-2: var(--color-palette-light-teal-foreground-2); + --color-palette-lilac-background-2: var(--color-palette-lilac-background-2); + --color-palette-lilac-border-active: var(--color-palette-lilac-border-active); + --color-palette-lilac-foreground-2: var(--color-palette-lilac-foreground-2); + --color-palette-magenta-background-2: var(--color-palette-magenta-background-2); + --color-palette-magenta-border-active: var(--color-palette-magenta-border-active); + --color-palette-magenta-foreground-2: var(--color-palette-magenta-foreground-2); + --color-palette-mink-background-2: var(--color-palette-mink-background-2); + --color-palette-mink-border-active: var(--color-palette-mink-border-active); + --color-palette-mink-foreground-2: var(--color-palette-mink-foreground-2); + --color-palette-navy-background-2: var(--color-palette-navy-background-2); + --color-palette-navy-border-active: var(--color-palette-navy-border-active); + --color-palette-navy-foreground-2: var(--color-palette-navy-foreground-2); + --color-palette-peach-background-2: var(--color-palette-peach-background-2); + --color-palette-peach-border-active: var(--color-palette-peach-border-active); + --color-palette-peach-foreground-2: var(--color-palette-peach-foreground-2); + --color-palette-pink-background-2: var(--color-palette-pink-background-2); + --color-palette-pink-border-active: var(--color-palette-pink-border-active); + --color-palette-pink-foreground-2: var(--color-palette-pink-foreground-2); + --color-palette-platinum-background-2: var(--color-palette-platinum-background-2); + --color-palette-platinum-border-active: var(--color-palette-platinum-border-active); + --color-palette-platinum-foreground-2: var(--color-palette-platinum-foreground-2); + --color-palette-plum-background-2: var(--color-palette-plum-background-2); + --color-palette-plum-border-active: var(--color-palette-plum-border-active); + --color-palette-plum-foreground-2: var(--color-palette-plum-foreground-2); + --color-palette-pumpkin-background-2: var(--color-palette-pumpkin-background-2); + --color-palette-pumpkin-border-active: var(--color-palette-pumpkin-border-active); + --color-palette-pumpkin-foreground-2: var(--color-palette-pumpkin-foreground-2); + --color-palette-purple-background-2: var(--color-palette-purple-background-2); + --color-palette-purple-border-active: var(--color-palette-purple-border-active); + --color-palette-purple-foreground-2: var(--color-palette-purple-foreground-2); + --color-palette-royal-blue-background-2: var(--color-palette-royal-blue-background-2); + --color-palette-royal-blue-border-active: var(--color-palette-royal-blue-border-active); + --color-palette-royal-blue-foreground-2: var(--color-palette-royal-blue-foreground-2); + --color-palette-seafoam-background-2: var(--color-palette-seafoam-background-2); + --color-palette-seafoam-border-active: var(--color-palette-seafoam-border-active); + --color-palette-seafoam-foreground-2: var(--color-palette-seafoam-foreground-2); + --color-palette-steel-background-2: var(--color-palette-steel-background-2); + --color-palette-steel-border-active: var(--color-palette-steel-border-active); + --color-palette-steel-foreground-2: var(--color-palette-steel-foreground-2); + --color-palette-teal-background-2: var(--color-palette-teal-background-2); + --color-palette-teal-border-active: var(--color-palette-teal-border-active); + --color-palette-teal-foreground-2: var(--color-palette-teal-foreground-2); + --color-status-success-background-1: var(--color-status-success-background-1); + --color-status-success-background-2: var(--color-status-success-background-2); + --color-status-success-background-3: var(--color-status-success-background-3); + --color-status-success-foreground-1: var(--color-status-success-foreground-1); + --color-status-success-foreground-2: var(--color-status-success-foreground-2); + --color-status-success-foreground-3: var(--color-status-success-foreground-3); + --color-status-success-foreground-inverted: var(--color-status-success-foreground-inverted); + --color-status-success-border-active: var(--color-status-success-border-active); + --color-status-success-border-1: var(--color-status-success-border-1); + --color-status-success-border-2: var(--color-status-success-border-2); + --color-status-warning-background-1: var(--color-status-warning-background-1); + --color-status-warning-background-2: var(--color-status-warning-background-2); + --color-status-warning-background-3: var(--color-status-warning-background-3); + --color-status-warning-foreground-1: var(--color-status-warning-foreground-1); + --color-status-warning-foreground-2: var(--color-status-warning-foreground-2); + --color-status-warning-foreground-3: var(--color-status-warning-foreground-3); + --color-status-warning-foreground-inverted: var(--color-status-warning-foreground-inverted); + --color-status-warning-border-active: var(--color-status-warning-border-active); + --color-status-warning-border-1: var(--color-status-warning-border-1); + --color-status-warning-border-2: var(--color-status-warning-border-2); + --color-status-danger-background-1: var(--color-status-danger-background-1); + --color-status-danger-background-2: var(--color-status-danger-background-2); + --color-status-danger-background-3: var(--color-status-danger-background-3); + --color-status-danger-background-3-hover: var(--color-status-danger-background-3-hover); + --color-status-danger-background-3-pressed: var(--color-status-danger-background-3-pressed); + --color-status-danger-foreground-1: var(--color-status-danger-foreground-1); + --color-status-danger-foreground-2: var(--color-status-danger-foreground-2); + --color-status-danger-foreground-3: var(--color-status-danger-foreground-3); + --color-status-danger-foreground-inverted: var(--color-status-danger-foreground-inverted); + --color-status-danger-border-active: var(--color-status-danger-border-active); + --color-status-danger-border-1: var(--color-status-danger-border-1); + --color-status-danger-border-2: var(--color-status-danger-border-2); + + /* Border radii — 11 tokens → --radius-* (rounded-* rounded-s-* rounded-e-* …) */ + --radius-none: var(--radius-none); + --radius-small: var(--radius-small); + --radius-medium: var(--radius-medium); + --radius-large: var(--radius-large); + --radius-x-large: var(--radius-x-large); + --radius-2-x-large: var(--radius-2-x-large); + --radius-3-x-large: var(--radius-3-x-large); + --radius-4-x-large: var(--radius-4-x-large); + --radius-5-x-large: var(--radius-5-x-large); + --radius-6-x-large: var(--radius-6-x-large); + --radius-circular: var(--radius-circular); + + /* Font families — 3 tokens → --font-* (font-*) */ + --font-base: var(--font-base); + --font-monospace: var(--font-monospace); + --font-numeric: var(--font-numeric); + + /* Font sizes — 10 tokens → --text-* (text-*) */ + --text-base-100: var(--text-base-100); + --text-base-200: var(--text-base-200); + --text-base-300: var(--text-base-300); + --text-base-400: var(--text-base-400); + --text-base-500: var(--text-base-500); + --text-base-600: var(--text-base-600); + --text-hero-700: var(--text-hero-700); + --text-hero-800: var(--text-hero-800); + --text-hero-900: var(--text-hero-900); + --text-hero-1000: var(--text-hero-1000); + + /* Font weights — 4 tokens → --font-weight-* (font-*) */ + --font-weight-regular: var(--font-weight-regular); + --font-weight-medium: var(--font-weight-medium); + --font-weight-semibold: var(--font-weight-semibold); + --font-weight-bold: var(--font-weight-bold); + + /* Line heights — 10 tokens → --leading-* (leading-*) */ + --leading-base-100: var(--leading-base-100); + --leading-base-200: var(--leading-base-200); + --leading-base-300: var(--leading-base-300); + --leading-base-400: var(--leading-base-400); + --leading-base-500: var(--leading-base-500); + --leading-base-600: var(--leading-base-600); + --leading-hero-700: var(--leading-hero-700); + --leading-hero-800: var(--leading-hero-800); + --leading-hero-900: var(--leading-hero-900); + --leading-hero-1000: var(--leading-hero-1000); + + /* Shadows — 12 tokens → --shadow-* (shadow-*) */ + --shadow-2: var(--shadow-2); + --shadow-4: var(--shadow-4); + --shadow-8: var(--shadow-8); + --shadow-16: var(--shadow-16); + --shadow-28: var(--shadow-28); + --shadow-64: var(--shadow-64); + --shadow-2-brand: var(--shadow-2-brand); + --shadow-4-brand: var(--shadow-4-brand); + --shadow-8-brand: var(--shadow-8-brand); + --shadow-16-brand: var(--shadow-16-brand); + --shadow-28-brand: var(--shadow-28-brand); + --shadow-64-brand: var(--shadow-64-brand); + + /* Stroke widths — PRIVATE hooks aliasing the canonical --stroke-width-* variables — 4 tokens → --spacing-* (w-thin h-thick p-thin gap-thin … AND var(--spacing-thin) for border/outline widths) */ + --spacing-thin: var(--stroke-width-thin); + --spacing-thick: var(--stroke-width-thick); + --spacing-thicker: var(--stroke-width-thicker); + --spacing-thickest: var(--stroke-width-thickest); + + /* Horizontal spacing, inline axis — numeric-axis ALIASES (calc(var(--spacing) * N)) — 11 tokens → --spacing-horizontal-* (px-* ps-* pe-* mx-* gap-x-* …) */ + --spacing-horizontal-none: 0; + --spacing-horizontal-xxs: calc(var(--spacing) * 2); + --spacing-horizontal-xs: calc(var(--spacing) * 4); + --spacing-horizontal-s-nudge: calc(var(--spacing) * 6); + --spacing-horizontal-s: calc(var(--spacing) * 8); + --spacing-horizontal-m-nudge: calc(var(--spacing) * 10); + --spacing-horizontal-m: calc(var(--spacing) * 12); + --spacing-horizontal-l: calc(var(--spacing) * 16); + --spacing-horizontal-xl: calc(var(--spacing) * 20); + --spacing-horizontal-xxl: calc(var(--spacing) * 24); + --spacing-horizontal-xxxl: calc(var(--spacing) * 32); + + /* Vertical spacing, block axis — numeric-axis ALIASES (calc(var(--spacing) * N)) — 11 tokens → --spacing-vertical-* (py-* pt-* pb-* my-* gap-y-* …) */ + --spacing-vertical-none: 0; + --spacing-vertical-xxs: calc(var(--spacing) * 2); + --spacing-vertical-xs: calc(var(--spacing) * 4); + --spacing-vertical-s-nudge: calc(var(--spacing) * 6); + --spacing-vertical-s: calc(var(--spacing) * 8); + --spacing-vertical-m-nudge: calc(var(--spacing) * 10); + --spacing-vertical-m: calc(var(--spacing) * 12); + --spacing-vertical-l: calc(var(--spacing) * 16); + --spacing-vertical-xl: calc(var(--spacing) * 20); + --spacing-vertical-xxl: calc(var(--spacing) * 24); + --spacing-vertical-xxxl: calc(var(--spacing) * 32); + + /* Transition durations — 8 tokens → --transition-duration-* (duration-*) */ + --transition-duration-ultra-fast: var(--duration-ultra-fast); + --transition-duration-faster: var(--duration-faster); + --transition-duration-fast: var(--duration-fast); + --transition-duration-normal: var(--duration-normal); + --transition-duration-gentle: var(--duration-gentle); + --transition-duration-slow: var(--duration-slow); + --transition-duration-slower: var(--duration-slower); + --transition-duration-ultra-slow: var(--duration-ultra-slow); + + /* Easing curves — 9 tokens → --ease-* (ease-*) */ + --ease-accelerate-max: var(--ease-accelerate-max); + --ease-accelerate-mid: var(--ease-accelerate-mid); + --ease-accelerate-min: var(--ease-accelerate-min); + --ease-decelerate-max: var(--ease-decelerate-max); + --ease-decelerate-mid: var(--ease-decelerate-mid); + --ease-decelerate-min: var(--ease-decelerate-min); + --ease-easy-ease-max: var(--ease-easy-ease-max); + --ease-easy-ease: var(--ease-easy-ease); + --ease-linear: var(--ease-linear); + + /* z-index (theme-absent tokens — fallbacks carried verbatim) — 8 tokens → --z-index-* (z-*) */ + --z-index-background: var(--z-index-background, 0); + --z-index-content: var(--z-index-content, 1); + --z-index-overlay: var(--z-index-overlay, 1000); + --z-index-popup: var(--z-index-popup, 2000); + --z-index-messages: var(--z-index-messages, 3000); + --z-index-floating: var(--z-index-floating, 4000); + --z-index-priority: var(--z-index-priority, 5000); + --z-index-debug: var(--z-index-debug, 6000); +} + +/* + * REAL CUSTOM PROPERTIES — the one block in this file that emits declarations. + * + * `@theme inline` above registers utility NAMES and emits no variables, which is + * correct for every other namespace. The spacing namespace needs real variables: + * border-width, outline-width, ring-width, divide-*, underline-offset and + * text-decoration-thickness do NOT consume the --spacing-* namespace (v4 border + * widths are a fixed bare-number px progression), so modules author those as a + * direct var(--spacing-thin); and the `tokens.*` JS constants in @fluentui/tokens + * are var() reference strings against the canonical names emitted here (charts + * inline styles etc.). + * + * THE OLD camelCase NAMES (--colorNeutralBackground1, --spacingHorizontalM, …) + * ARE GONE for the ENTIRE token set (option B, theming Phase 2a): single + * vocabulary, documented major break for hand-written consumer CSS. Theming + * Phase 2b removed FluentProvider's runtime theme style tag, so this block (plus + * the theme classes in css/themes.css) is the ONLY writer of token values. + * + * Emitted ONCE PER DOCUMENT (D13): `@reference` drops this block, so component + * `*.module.css` output stays free of theme declarations; css/emit.css compiles it + * into dist/styles.css alongside --base-scale and --spacing, which utility-sourced + * spacing already depends on identically. + * + * `:root, :host` — not bare `:root` — matches the selector Tailwind emits its own + * `@theme` block on, so a shadow-DOM consumer that sees --base-scale/--spacing sees + * these too (verified in the compiled dist/styles.css). + */ +@layer fui.theme { + :root, + :host { + /* + * Stroke widths — PUBLIC set-contract. Literal base-scale values, deliberately + * NOT coupled to the --spacing density knob: borders must not thin when layout + * density changes. + */ + --stroke-width-thin: calc(1px * var(--base-scale)); + --stroke-width-thick: calc(2px * var(--base-scale)); + --stroke-width-thicker: calc(3px * var(--base-scale)); + --stroke-width-thickest: calc(4px * var(--base-scale)); + + /* + * PRIVATE internal hooks — Tailwind utility generation only (w-thin, p-thick, …) + * and module-authored var(--spacing-thin) border/outline widths. Do NOT set or + * read these from consumer code; the public contract is --stroke-width-*. + */ + --spacing-thin: var(--stroke-width-thin); + --spacing-thick: var(--stroke-width-thick); + --spacing-thicker: var(--stroke-width-thicker); + --spacing-thickest: var(--stroke-width-thickest); + + /* Spacing — numeric-axis aliases; --spacing (see css/index.css) is the density knob. */ + --spacing-horizontal-none: 0; + --spacing-horizontal-xxs: calc(var(--spacing) * 2); + --spacing-horizontal-xs: calc(var(--spacing) * 4); + --spacing-horizontal-s-nudge: calc(var(--spacing) * 6); + --spacing-horizontal-s: calc(var(--spacing) * 8); + --spacing-horizontal-m-nudge: calc(var(--spacing) * 10); + --spacing-horizontal-m: calc(var(--spacing) * 12); + --spacing-horizontal-l: calc(var(--spacing) * 16); + --spacing-horizontal-xl: calc(var(--spacing) * 20); + --spacing-horizontal-xxl: calc(var(--spacing) * 24); + --spacing-horizontal-xxxl: calc(var(--spacing) * 32); + --spacing-vertical-none: 0; + --spacing-vertical-xxs: calc(var(--spacing) * 2); + --spacing-vertical-xs: calc(var(--spacing) * 4); + --spacing-vertical-s-nudge: calc(var(--spacing) * 6); + --spacing-vertical-s: calc(var(--spacing) * 8); + --spacing-vertical-m-nudge: calc(var(--spacing) * 10); + --spacing-vertical-m: calc(var(--spacing) * 12); + --spacing-vertical-l: calc(var(--spacing) * 16); + --spacing-vertical-xl: calc(var(--spacing) * 20); + --spacing-vertical-xxl: calc(var(--spacing) * 24); + --spacing-vertical-xxxl: calc(var(--spacing) * 32); + + /* + * DEFAULT THEME VALUES (web light) — theming Phase 2b. Since the removal of + * FluentProvider's runtime theme style tag, these declarations are the sole + * default value source for the theme-variant canonical variables. Non-default + * themes are shipped as classes in css/themes.css (same layer); a theme class + * on any DOM node overrides these for that subtree. + */ + --color-neutral-foreground-1: #242424; + --color-neutral-foreground-1-hover: #242424; + --color-neutral-foreground-1-pressed: #242424; + --color-neutral-foreground-1-selected: #242424; + --color-neutral-foreground-2: #424242; + --color-neutral-foreground-2-hover: #242424; + --color-neutral-foreground-2-pressed: #242424; + --color-neutral-foreground-2-selected: #242424; + --color-neutral-foreground-2-brand-hover: #0f6cbd; + --color-neutral-foreground-2-brand-pressed: #115ea3; + --color-neutral-foreground-2-brand-selected: #0f6cbd; + --color-neutral-foreground-3: #616161; + --color-neutral-foreground-3-hover: #424242; + --color-neutral-foreground-3-pressed: #424242; + --color-neutral-foreground-3-selected: #424242; + --color-neutral-foreground-3-brand-hover: #0f6cbd; + --color-neutral-foreground-3-brand-pressed: #115ea3; + --color-neutral-foreground-3-brand-selected: #0f6cbd; + --color-neutral-foreground-4: #707070; + --color-neutral-foreground-5: #616161; + --color-neutral-foreground-5-hover: #242424; + --color-neutral-foreground-5-pressed: #242424; + --color-neutral-foreground-5-selected: #242424; + --color-neutral-foreground-disabled: #bdbdbd; + --color-brand-foreground-link: #115ea3; + --color-brand-foreground-link-hover: #0f548c; + --color-brand-foreground-link-pressed: #0c3b5e; + --color-brand-foreground-link-selected: #115ea3; + --color-neutral-foreground-2-link: #424242; + --color-neutral-foreground-2-link-hover: #242424; + --color-neutral-foreground-2-link-pressed: #242424; + --color-neutral-foreground-2-link-selected: #242424; + --color-compound-brand-foreground-1: #0f6cbd; + --color-compound-brand-foreground-1-hover: #115ea3; + --color-compound-brand-foreground-1-pressed: #0f548c; + --color-neutral-foreground-on-brand: #ffffff; + --color-neutral-foreground-inverted: #ffffff; + --color-neutral-foreground-inverted-hover: #ffffff; + --color-neutral-foreground-inverted-pressed: #ffffff; + --color-neutral-foreground-inverted-selected: #ffffff; + --color-neutral-foreground-inverted-2: #ffffff; + --color-neutral-foreground-static-inverted: #ffffff; + --color-neutral-foreground-inverted-link: #ffffff; + --color-neutral-foreground-inverted-link-hover: #ffffff; + --color-neutral-foreground-inverted-link-pressed: #ffffff; + --color-neutral-foreground-inverted-link-selected: #ffffff; + --color-neutral-foreground-inverted-disabled: rgba(255, 255, 255, 0.4); + --color-brand-foreground-1: #0f6cbd; + --color-brand-foreground-2: #115ea3; + --color-brand-foreground-2-hover: #0f548c; + --color-brand-foreground-2-pressed: #0a2e4a; + --color-neutral-foreground-1-static: #242424; + --color-brand-foreground-inverted: #479ef5; + --color-brand-foreground-inverted-hover: #62abf5; + --color-brand-foreground-inverted-pressed: #479ef5; + --color-brand-foreground-on-light: #0f6cbd; + --color-brand-foreground-on-light-hover: #115ea3; + --color-brand-foreground-on-light-pressed: #0e4775; + --color-brand-foreground-on-light-selected: #0f548c; + --color-neutral-background-1: #ffffff; + --color-neutral-background-1-hover: #f5f5f5; + --color-neutral-background-1-pressed: #e0e0e0; + --color-neutral-background-1-selected: #ebebeb; + --color-neutral-background-2: #fafafa; + --color-neutral-background-2-hover: #f0f0f0; + --color-neutral-background-2-pressed: #dbdbdb; + --color-neutral-background-2-selected: #e6e6e6; + --color-neutral-background-3: #f5f5f5; + --color-neutral-background-3-hover: #ebebeb; + --color-neutral-background-3-pressed: #d6d6d6; + --color-neutral-background-3-selected: #e0e0e0; + --color-neutral-background-4: #f0f0f0; + --color-neutral-background-4-hover: #fafafa; + --color-neutral-background-4-pressed: #f5f5f5; + --color-neutral-background-4-selected: #ffffff; + --color-neutral-background-5: #ebebeb; + --color-neutral-background-5-hover: #f5f5f5; + --color-neutral-background-5-pressed: #f0f0f0; + --color-neutral-background-5-selected: #fafafa; + --color-neutral-background-6: #e6e6e6; + --color-neutral-background-7: #00000000; + --color-neutral-background-7-hover: #ebebeb; + --color-neutral-background-7-pressed: #d6d6d6; + --color-neutral-background-7-selected: #00000000; + --color-neutral-background-8: #fcfcfc; + --color-neutral-background-inverted: #292929; + --color-neutral-background-inverted-hover: #3d3d3d; + --color-neutral-background-inverted-pressed: #1f1f1f; + --color-neutral-background-inverted-selected: #383838; + --color-neutral-background-static: #333333; + --color-neutral-background-alpha: rgba(255, 255, 255, 0.5); + --color-neutral-background-alpha-2: rgba(255, 255, 255, 0.8); + --color-subtle-background: transparent; + --color-subtle-background-hover: #f5f5f5; + --color-subtle-background-pressed: #e0e0e0; + --color-subtle-background-selected: #ebebeb; + --color-subtle-background-light-alpha-hover: rgba(255, 255, 255, 0.7); + --color-subtle-background-light-alpha-pressed: rgba(255, 255, 255, 0.5); + --color-subtle-background-light-alpha-selected: transparent; + --color-subtle-background-inverted: transparent; + --color-subtle-background-inverted-hover: rgba(0, 0, 0, 0.1); + --color-subtle-background-inverted-pressed: rgba(0, 0, 0, 0.3); + --color-subtle-background-inverted-selected: rgba(0, 0, 0, 0.2); + --color-transparent-background: transparent; + --color-transparent-background-hover: transparent; + --color-transparent-background-pressed: transparent; + --color-transparent-background-selected: transparent; + --color-neutral-background-disabled: #f0f0f0; + --color-neutral-background-disabled-2: #ffffff; + --color-neutral-background-inverted-disabled: rgba(255, 255, 255, 0.1); + --color-neutral-stencil-1: #e6e6e6; + --color-neutral-stencil-2: #fafafa; + --color-neutral-stencil-1-alpha: rgba(0, 0, 0, 0.1); + --color-neutral-stencil-2-alpha: rgba(0, 0, 0, 0.05); + --color-background-overlay: rgba(0, 0, 0, 0.4); + --color-scrollbar-overlay: rgba(0, 0, 0, 0.5); + --color-brand-background: #0f6cbd; + --color-brand-background-hover: #115ea3; + --color-brand-background-pressed: #0c3b5e; + --color-brand-background-selected: #0f548c; + --color-compound-brand-background: #0f6cbd; + --color-compound-brand-background-hover: #115ea3; + --color-compound-brand-background-pressed: #0f548c; + --color-brand-background-static: #0f6cbd; + --color-brand-background-2: #ebf3fc; + --color-brand-background-2-hover: #cfe4fa; + --color-brand-background-2-pressed: #96c6fa; + --color-brand-background-3-static: #0f548c; + --color-brand-background-4-static: #0c3b5e; + --color-brand-background-inverted: #ffffff; + --color-brand-background-inverted-hover: #ebf3fc; + --color-brand-background-inverted-pressed: #b4d6fa; + --color-brand-background-inverted-selected: #cfe4fa; + --color-neutral-card-background: #fafafa; + --color-neutral-card-background-hover: #ffffff; + --color-neutral-card-background-pressed: #f5f5f5; + --color-neutral-card-background-selected: #ebebeb; + --color-neutral-card-background-disabled: #f0f0f0; + --color-neutral-stroke-accessible: #616161; + --color-neutral-stroke-accessible-hover: #575757; + --color-neutral-stroke-accessible-pressed: #4d4d4d; + --color-neutral-stroke-accessible-selected: #0f6cbd; + --color-neutral-stroke-1: #d1d1d1; + --color-neutral-stroke-1-hover: #c7c7c7; + --color-neutral-stroke-1-pressed: #b3b3b3; + --color-neutral-stroke-1-selected: #bdbdbd; + --color-neutral-stroke-2: #e0e0e0; + --color-neutral-stroke-3: #f0f0f0; + --color-neutral-stroke-4: #ebebeb; + --color-neutral-stroke-4-hover: #e0e0e0; + --color-neutral-stroke-4-pressed: #d6d6d6; + --color-neutral-stroke-4-selected: #ebebeb; + --color-neutral-stroke-subtle: #e0e0e0; + --color-neutral-stroke-on-brand: #ffffff; + --color-neutral-stroke-on-brand-2: #ffffff; + --color-neutral-stroke-on-brand-2-hover: #ffffff; + --color-neutral-stroke-on-brand-2-pressed: #ffffff; + --color-neutral-stroke-on-brand-2-selected: #ffffff; + --color-brand-stroke-1: #0f6cbd; + --color-brand-stroke-2: #b4d6fa; + --color-brand-stroke-2-hover: #77b7f7; + --color-brand-stroke-2-pressed: #0f6cbd; + --color-brand-stroke-2-contrast: #b4d6fa; + --color-compound-brand-stroke: #0f6cbd; + --color-compound-brand-stroke-hover: #115ea3; + --color-compound-brand-stroke-pressed: #0f548c; + --color-neutral-stroke-disabled: #e0e0e0; + --color-neutral-stroke-disabled-2: #ebebeb; + --color-neutral-stroke-inverted-disabled: rgba(255, 255, 255, 0.4); + --color-transparent-stroke: transparent; + --color-transparent-stroke-interactive: transparent; + --color-transparent-stroke-disabled: transparent; + --color-neutral-stroke-alpha: rgba(0, 0, 0, 0.05); + --color-neutral-stroke-alpha-2: rgba(255, 255, 255, 0.2); + --color-stroke-focus-1: #ffffff; + --color-stroke-focus-2: #000000; + --color-neutral-shadow-ambient: rgba(0, 0, 0, 0.12); + --color-neutral-shadow-key: rgba(0, 0, 0, 0.14); + --color-neutral-shadow-ambient-lighter: rgba(0, 0, 0, 0.06); + --color-neutral-shadow-key-lighter: rgba(0, 0, 0, 0.07); + --color-neutral-shadow-ambient-darker: rgba(0, 0, 0, 0.2); + --color-neutral-shadow-key-darker: rgba(0, 0, 0, 0.24); + --color-brand-shadow-ambient: rgba(0, 0, 0, 0.3); + --color-brand-shadow-key: rgba(0, 0, 0, 0.25); + --color-palette-red-background-1: #fdf6f6; + --color-palette-red-background-2: #f1bbbc; + --color-palette-red-background-3: #d13438; + --color-palette-red-border-active: #d13438; + --color-palette-red-border-1: #f1bbbc; + --color-palette-red-border-2: #d13438; + --color-palette-red-foreground-1: #bc2f32; + --color-palette-red-foreground-2: #751d1f; + --color-palette-red-foreground-3: #d13438; + --color-palette-red-foreground-inverted: #dc5e62; + --color-palette-green-background-1: #f1faf1; + --color-palette-green-background-2: #9fd89f; + --color-palette-green-background-3: #107c10; + --color-palette-green-border-active: #107c10; + --color-palette-green-border-1: #9fd89f; + --color-palette-green-border-2: #107c10; + --color-palette-green-foreground-1: #0e700e; + --color-palette-green-foreground-2: #094509; + --color-palette-green-foreground-3: #107c10; + --color-palette-green-foreground-inverted: #359b35; + --color-palette-dark-orange-background-1: #fdf6f3; + --color-palette-dark-orange-background-2: #f4bfab; + --color-palette-dark-orange-background-3: #da3b01; + --color-palette-dark-orange-border-active: #da3b01; + --color-palette-dark-orange-border-1: #f4bfab; + --color-palette-dark-orange-border-2: #da3b01; + --color-palette-dark-orange-foreground-1: #c43501; + --color-palette-dark-orange-foreground-2: #7a2101; + --color-palette-dark-orange-foreground-3: #da3b01; + --color-palette-yellow-background-1: #fffef5; + --color-palette-yellow-background-2: #fef7b2; + --color-palette-yellow-background-3: #fde300; + --color-palette-yellow-border-active: #fde300; + --color-palette-yellow-border-1: #fef7b2; + --color-palette-yellow-border-2: #fde300; + --color-palette-yellow-foreground-1: #817400; + --color-palette-yellow-foreground-2: #817400; + --color-palette-yellow-foreground-3: #fde300; + --color-palette-yellow-foreground-inverted: #fef7b2; + --color-palette-berry-background-1: #fdf5fc; + --color-palette-berry-background-2: #edbbe7; + --color-palette-berry-background-3: #c239b3; + --color-palette-berry-border-active: #c239b3; + --color-palette-berry-border-1: #edbbe7; + --color-palette-berry-border-2: #c239b3; + --color-palette-berry-foreground-1: #af33a1; + --color-palette-berry-foreground-2: #6d2064; + --color-palette-berry-foreground-3: #c239b3; + --color-palette-marigold-background-1: #fefbf4; + --color-palette-marigold-background-2: #f9e2ae; + --color-palette-marigold-background-3: #eaa300; + --color-palette-marigold-border-active: #eaa300; + --color-palette-marigold-border-1: #f9e2ae; + --color-palette-marigold-border-2: #eaa300; + --color-palette-marigold-foreground-1: #d39300; + --color-palette-marigold-foreground-2: #835b00; + --color-palette-marigold-foreground-3: #eaa300; + --color-palette-light-green-background-1: #f2fbf2; + --color-palette-light-green-background-2: #a7e3a5; + --color-palette-light-green-background-3: #13a10e; + --color-palette-light-green-border-active: #13a10e; + --color-palette-light-green-border-1: #a7e3a5; + --color-palette-light-green-border-2: #13a10e; + --color-palette-light-green-foreground-1: #11910d; + --color-palette-light-green-foreground-2: #0b5a08; + --color-palette-light-green-foreground-3: #13a10e; + --color-palette-anchor-background-2: #bcc3c7; + --color-palette-anchor-border-active: #394146; + --color-palette-anchor-foreground-2: #202427; + --color-palette-beige-background-2: #d7d4d4; + --color-palette-beige-border-active: #7a7574; + --color-palette-beige-foreground-2: #444241; + --color-palette-blue-background-2: #a9d3f2; + --color-palette-blue-border-active: #0078d4; + --color-palette-blue-foreground-2: #004377; + --color-palette-brass-background-2: #e0cea2; + --color-palette-brass-border-active: #986f0b; + --color-palette-brass-foreground-2: #553e06; + --color-palette-brown-background-2: #ddc3b0; + --color-palette-brown-border-active: #8e562e; + --color-palette-brown-foreground-2: #50301a; + --color-palette-cornflower-background-2: #c8d1fa; + --color-palette-cornflower-border-active: #4f6bed; + --color-palette-cornflower-foreground-2: #2c3c85; + --color-palette-cranberry-background-2: #eeacb2; + --color-palette-cranberry-border-active: #c50f1f; + --color-palette-cranberry-foreground-2: #6e0811; + --color-palette-dark-green-background-2: #9ad29a; + --color-palette-dark-green-border-active: #0b6a0b; + --color-palette-dark-green-foreground-2: #063b06; + --color-palette-dark-red-background-2: #d69ca5; + --color-palette-dark-red-border-active: #750b1c; + --color-palette-dark-red-foreground-2: #420610; + --color-palette-forest-background-2: #bdd99b; + --color-palette-forest-border-active: #498205; + --color-palette-forest-foreground-2: #294903; + --color-palette-gold-background-2: #ecdfa5; + --color-palette-gold-border-active: #c19c00; + --color-palette-gold-foreground-2: #6c5700; + --color-palette-grape-background-2: #d9a7e0; + --color-palette-grape-border-active: #881798; + --color-palette-grape-foreground-2: #4c0d55; + --color-palette-lavender-background-2: #d2ccf8; + --color-palette-lavender-border-active: #7160e8; + --color-palette-lavender-foreground-2: #3f3682; + --color-palette-light-teal-background-2: #a6e9ed; + --color-palette-light-teal-border-active: #00b7c3; + --color-palette-light-teal-foreground-2: #00666d; + --color-palette-lilac-background-2: #e6bfed; + --color-palette-lilac-border-active: #b146c2; + --color-palette-lilac-foreground-2: #63276d; + --color-palette-magenta-background-2: #eca5d1; + --color-palette-magenta-border-active: #bf0077; + --color-palette-magenta-foreground-2: #6b0043; + --color-palette-mink-background-2: #cecccb; + --color-palette-mink-border-active: #5d5a58; + --color-palette-mink-foreground-2: #343231; + --color-palette-navy-background-2: #a3b2e8; + --color-palette-navy-border-active: #0027b4; + --color-palette-navy-foreground-2: #001665; + --color-palette-peach-background-2: #ffddb3; + --color-palette-peach-border-active: #ff8c00; + --color-palette-peach-foreground-2: #8f4e00; + --color-palette-pink-background-2: #f7c0e3; + --color-palette-pink-border-active: #e43ba6; + --color-palette-pink-foreground-2: #80215d; + --color-palette-platinum-background-2: #cdd6d8; + --color-palette-platinum-border-active: #69797e; + --color-palette-platinum-foreground-2: #3b4447; + --color-palette-plum-background-2: #d696c0; + --color-palette-plum-border-active: #77004d; + --color-palette-plum-foreground-2: #43002b; + --color-palette-pumpkin-background-2: #efc4ad; + --color-palette-pumpkin-border-active: #ca5010; + --color-palette-pumpkin-foreground-2: #712d09; + --color-palette-purple-background-2: #c6b1de; + --color-palette-purple-border-active: #5c2e91; + --color-palette-purple-foreground-2: #341a51; + --color-palette-royal-blue-background-2: #9abfdc; + --color-palette-royal-blue-border-active: #004e8c; + --color-palette-royal-blue-foreground-2: #002c4e; + --color-palette-seafoam-background-2: #a8f0cd; + --color-palette-seafoam-border-active: #00cc6a; + --color-palette-seafoam-foreground-2: #00723b; + --color-palette-steel-background-2: #94c8d4; + --color-palette-steel-border-active: #005b70; + --color-palette-steel-foreground-2: #00333f; + --color-palette-teal-background-2: #9bd9db; + --color-palette-teal-border-active: #038387; + --color-palette-teal-foreground-2: #02494c; + --color-status-success-background-1: #f1faf1; + --color-status-success-background-2: #9fd89f; + --color-status-success-background-3: #107c10; + --color-status-success-foreground-1: #0e700e; + --color-status-success-foreground-2: #094509; + --color-status-success-foreground-3: #107c10; + --color-status-success-foreground-inverted: #54b054; + --color-status-success-border-active: #107c10; + --color-status-success-border-1: #9fd89f; + --color-status-success-border-2: #107c10; + --color-status-warning-background-1: #fff9f5; + --color-status-warning-background-2: #fdcfb4; + --color-status-warning-background-3: #f7630c; + --color-status-warning-foreground-1: #bc4b09; + --color-status-warning-foreground-2: #8a3707; + --color-status-warning-foreground-3: #bc4b09; + --color-status-warning-foreground-inverted: #faa06b; + --color-status-warning-border-active: #f7630c; + --color-status-warning-border-1: #fdcfb4; + --color-status-warning-border-2: #bc4b09; + --color-status-danger-background-1: #fdf3f4; + --color-status-danger-background-2: #eeacb2; + --color-status-danger-background-3: #c50f1f; + --color-status-danger-background-3-hover: #b10e1c; + --color-status-danger-background-3-pressed: #960b18; + --color-status-danger-foreground-1: #b10e1c; + --color-status-danger-foreground-2: #6e0811; + --color-status-danger-foreground-3: #c50f1f; + --color-status-danger-foreground-inverted: #dc626d; + --color-status-danger-border-active: #c50f1f; + --color-status-danger-border-1: #eeacb2; + --color-status-danger-border-2: #c50f1f; + --radius-none: 0; + --radius-small: 2px; + --radius-medium: 4px; + --radius-large: 6px; + --radius-x-large: 8px; + --radius-2-x-large: 12px; + --radius-3-x-large: 16px; + --radius-4-x-large: 24px; + --radius-5-x-large: 32px; + --radius-6-x-large: 40px; + --radius-circular: 10000px; + --font-base: 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', + sans-serif; + --font-monospace: Consolas, 'Courier New', Courier, monospace; + --font-numeric: Bahnschrift, 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, + 'Helvetica Neue', sans-serif; + --text-base-100: 10px; + --text-base-200: 12px; + --text-base-300: 14px; + --text-base-400: 16px; + --text-base-500: 20px; + --text-base-600: 24px; + --text-hero-700: 28px; + --text-hero-800: 32px; + --text-hero-900: 40px; + --text-hero-1000: 68px; + --font-weight-regular: 400; + --font-weight-medium: 500; + --font-weight-semibold: 600; + --font-weight-bold: 700; + --leading-base-100: 14px; + --leading-base-200: 16px; + --leading-base-300: 20px; + --leading-base-400: 22px; + --leading-base-500: 28px; + --leading-base-600: 32px; + --leading-hero-700: 36px; + --leading-hero-800: 40px; + --leading-hero-900: 52px; + --leading-hero-1000: 92px; + --shadow-2: 0 0 2px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.14); + --shadow-4: 0 0 2px rgba(0, 0, 0, 0.12), 0 2px 4px rgba(0, 0, 0, 0.14); + --shadow-8: 0 0 2px rgba(0, 0, 0, 0.12), 0 4px 8px rgba(0, 0, 0, 0.14); + --shadow-16: 0 0 2px rgba(0, 0, 0, 0.12), 0 8px 16px rgba(0, 0, 0, 0.14); + --shadow-28: 0 0 8px rgba(0, 0, 0, 0.12), 0 14px 28px rgba(0, 0, 0, 0.14); + --shadow-64: 0 0 8px rgba(0, 0, 0, 0.12), 0 32px 64px rgba(0, 0, 0, 0.14); + --shadow-2-brand: 0 0 2px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.25); + --shadow-4-brand: 0 0 2px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.25); + --shadow-8-brand: 0 0 2px rgba(0, 0, 0, 0.3), 0 4px 8px rgba(0, 0, 0, 0.25); + --shadow-16-brand: 0 0 2px rgba(0, 0, 0, 0.3), 0 8px 16px rgba(0, 0, 0, 0.25); + --shadow-28-brand: 0 0 8px rgba(0, 0, 0, 0.3), 0 14px 28px rgba(0, 0, 0, 0.25); + --shadow-64-brand: 0 0 8px rgba(0, 0, 0, 0.3), 0 32px 64px rgba(0, 0, 0, 0.25); + --duration-ultra-fast: 50ms; + --duration-faster: 100ms; + --duration-fast: 150ms; + --duration-normal: 200ms; + --duration-gentle: 250ms; + --duration-slow: 300ms; + --duration-slower: 400ms; + --duration-ultra-slow: 500ms; + --ease-accelerate-max: cubic-bezier(0.9, 0.1, 1, 0.2); + --ease-accelerate-mid: cubic-bezier(1, 0, 1, 1); + --ease-accelerate-min: cubic-bezier(0.8, 0, 0.78, 1); + --ease-decelerate-max: cubic-bezier(0.1, 0.9, 0.2, 1); + --ease-decelerate-mid: cubic-bezier(0, 0, 0, 1); + --ease-decelerate-min: cubic-bezier(0.33, 0, 0.1, 1); + --ease-easy-ease-max: cubic-bezier(0.8, 0, 0.2, 1); + --ease-easy-ease: cubic-bezier(0.33, 0, 0.67, 1); + --ease-linear: cubic-bezier(0, 0, 1, 1); + } +} diff --git a/packages/react-components/react-tailwind-theme-preview/css/utilities.css b/packages/react-components/react-tailwind-theme-preview/css/utilities.css new file mode 100644 index 00000000000000..a4957e1d2fa59d --- /dev/null +++ b/packages/react-components/react-tailwind-theme-preview/css/utilities.css @@ -0,0 +1,165 @@ +/** + * Shared focus-ring utilities — the CSS replacement for react-tabster's Griffel focus + * factories (DECISIONS.md D6). Converted packages `@apply` these; the JS helpers + * (`createFocusOutlineStyle`, `createCustomFocusIndicatorStyle`) stay exported from + * react-tabster because unconverted and deprecated packages still call them. + * + * These are `@utility` (not plain classes) so component modules can `@apply` them INSIDE + * their own `@layer fui.*` blocks: `@apply` inlines a utility's declarations at the call + * site, so nothing is ever emitted into Tailwind's `@layer utilities` — which would + * outrank every `fui.*` layer (see CONVERSION_GUIDE.md "Toolchain traps"). + * + * ── Selector ──────────────────────────────────────────────────────────────────────── + * These utilities carry DECLARATIONS ONLY. The selector stays with the caller, which + * must wrap them in the shared `focus-visible-fui` / `focus-within-fui` variants + * (`[data-fui-focus-visible]` / `[data-fui-focus-within]`, still written by keyborg). + * That split is deliberate: `createCustomFocusIndicatorStyle`'s `customizeSelector` + * option (4 call sites) has no CSS equivalent, so the selector must stay authorable. + * + * ── Parameterisation ──────────────────────────────────────────────────────────────── + * The compiled Griffel output differs per component only in the values react-tabster's + * options expose (`outlineRadius`, `outlineColor`, `outlineWidth`, `outlineOffset`). + * Those become CSS custom properties here ("knobs"). With every knob unset the `var()` + * fallbacks reproduce the compiled defaults exactly — see the byte-comparisons below. + * + * ── Isolation (why each utility RESETS its knobs) ─────────────────────────────────── + * The knobs are deliberately UNREGISTERED (no `@property`), and each utility opens by + * resetting every knob it consumes to `initial` on the element it is applied to. + * Unregistered custom properties inherit, and `initial` on an unregistered custom + * property is the guaranteed-invalid value — so the resets keep the knobs self-scoped: + * a knob set on an ancestor can never leak into a nested component's focus indicator + * (SplitButton renders a Button inside a Button), and an unset knob still resolves to + * its `var()` fallback. To override a knob, set it on the consuming element in a + * declaration that beats the component's focus rule (after the `@apply` in the same + * rule, a higher layer, or unlayered consumer CSS) — or, for `fui-focus-outline`, on + * the `::after` pseudo-element itself, which the element-level resets never touch + * (that is where every in-repo caller sets its knobs). + * + * The knobs were previously registered with `@property … syntax:'*'; inherits:false`, + * which enforced both guarantees engine-side. The registrations were REMOVED + * (2026-08-04): any non-empty custom-property registry flips Blink's transition-start + * path onto a page-global slow branch costing ~9.4 µs per started CSS transition — + * paid by EVERY transition on the page, on properties unrelated to the registered + * ones, turning ~2.5× toggle-cost regressions in transition-heavy scenarios. See + * migration/griffel-to-tailwind/reports/perf-mechanism-diagnostic.md (mechanism + + * strip experiment) and reports/perf-property-remedy.md (this change and its gates). + * Do NOT reintroduce `@property` into shipped FluentUI CSS without re-running that + * perf gate. + */ + +/** + * `fui-focus-outline` — the DEFAULT ring produced by + * `createFocusOutlineStyle()` (react-tabster/src/focus/createFocusOutlineStyle.ts): + * a `::after` pseudo-element inset by the outline width. + * + * Byte-comparison against a compiled AOT consumer + * (react-accordion/library/lib-commonjs/components/AccordionHeader/ + * useAccordionHeaderStyles.styles.js:245-268, 341-353): + * + * [data-fui-focus-visible]{border-{top,right,left,bottom}-color:transparent} + * [data-fui-focus-visible]::after{content:""} + * [data-fui-focus-visible]::after{position:absolute} + * [data-fui-focus-visible]::after{pointer-events:none} + * [data-fui-focus-visible]::after{z-index:1} + * [data-fui-focus-visible]::after{border:2px solid var(--color-stroke-focus-2)} + * [data-fui-focus-visible]::after{border-radius:var(--radius-medium)} + * [data-fui-focus-visible]::after{top:calc(2px * -1)} (right/left/bottom idem) + * @media (forced-colors: active){…::after{border-{top,left,right,bottom}-color:Highlight}} + * + * The hardcoded `2px` is react-tabster's own FIXME ("tokens.strokeWidthThick causes some + * weird bugs") — it is reproduced verbatim, NOT "fixed" to a token (DECISIONS.md D6). + * + * `createFocusOutlineStyle` also emits `:focus{outline-style:none}` and + * `:focus-visible{outline-style:none}`. Those sit OUTSIDE the focus-visible selector, so + * they stay with the caller (Button already sets `outline-style: none` in its base). + * + * NOT exercised by any converted package yet — Button uses `fui-focus-ring` below. The + * first `createFocusOutlineStyle` conversion validates this one against its own AOT + * output before trusting it. + */ +@utility fui-focus-outline { + /* Knob isolation — see the header note. `initial` is the guaranteed-invalid value + for these unregistered properties: an ancestor's knob override stops here, the + `var()` fallbacks below stay live, and `::after`-level knob declarations (how all + in-repo callers parameterise this ring) are unaffected. */ + --fui-focus-outline-width: initial; + --fui-focus-outline-color: initial; + --fui-focus-outline-radius: initial; + --fui-focus-outline-offset: initial; + + border-top-color: transparent; + border-right-color: transparent; + border-bottom-color: transparent; + border-left-color: transparent; + + &::after { + content: ''; + position: absolute; + pointer-events: none; + z-index: 1; + + border: var(--fui-focus-outline-width, 2px) solid var(--fui-focus-outline-color, var(--color-stroke-focus-2)); + border-radius: var(--fui-focus-outline-radius, var(--radius-medium)); + + /* `getOutlinePosition()`: `calc( * -1)` with no offset, else + `calc(0px - - )`. Both branches collapse into one expression + because an unset `--fui-focus-outline-offset` falls back to `0px`. */ + top: calc(0px - var(--fui-focus-outline-width, 2px) - var(--fui-focus-outline-offset, 0px)); + right: calc(0px - var(--fui-focus-outline-width, 2px) - var(--fui-focus-outline-offset, 0px)); + bottom: calc(0px - var(--fui-focus-outline-width, 2px) - var(--fui-focus-outline-offset, 0px)); + left: calc(0px - var(--fui-focus-outline-width, 2px) - var(--fui-focus-outline-offset, 0px)); + } + + @media (forced-colors: active) { + &::after { + border-top-color: Highlight; + border-right-color: Highlight; + border-bottom-color: Highlight; + border-left-color: Highlight; + } + } +} + +/** + * `fui-focus-ring` — the Button-family indicator: no pseudo-element, the ring is drawn + * with the element's own border plus an inset `box-shadow`. Produced by + * `createCustomFocusIndicatorStyle({...})` with Button's option object. + * + * Byte-comparison against the compiled AOT output + * (react-button/library/lib-commonjs/components/Button/useButtonStyles.styles.js:41): + * + * .r1f29ykk[data-fui-focus-visible]{ + * border-color:var(--color-stroke-focus-2); + * border-radius:var(--radius-medium); + * border-width:1px; + * outline: solid var(--color-transparent-stroke); + * box-shadow:0 0 0 var(--color-stroke-focus-2) inset; + * z-index:1; + * } + * + * The two widths are shown as placeholders because upstream's AOT output references the + * raw `strokeWidthThick` / `strokeWidthThin` tokens, while the declarations below use the + * `--spacing-thick` / `--spacing-thin` equivalents required by the D4 addendum authoring + * rule (stroke widths live in the spacing namespace and must scale with `--base-scale`). + * Both resolve to 2px / 1px at the default root, so the computed values are unchanged. + * + * With all four knobs unset every declaration below computes to exactly that. The + * declaration ORDER is preserved too, which matters for `border-color` vs `border-width` + * against the `border:` shorthand a caller's base rule may set in a lower layer. + */ +@utility fui-focus-ring { + /* Knob isolation — see the header note. Same-element overrides must come AFTER the + `@apply` (or from a higher layer / unlayered consumer CSS) to beat these resets. */ + --fui-focus-ring-color: initial; + --fui-focus-ring-radius: initial; + --fui-focus-ring-width: initial; + --fui-focus-ring-inset-width: initial; + + border-color: var(--fui-focus-ring-color, var(--color-stroke-focus-2)); + border-radius: var(--fui-focus-ring-radius, var(--radius-medium)); + border-width: var(--fui-focus-ring-width, 1px); + outline: var(--spacing-thick) solid var(--color-transparent-stroke); + box-shadow: 0 0 0 var(--fui-focus-ring-inset-width, var(--spacing-thin)) + var(--fui-focus-ring-color, var(--color-stroke-focus-2)) inset; + z-index: 1; +} diff --git a/packages/react-components/react-tailwind-theme-preview/css/variants.css b/packages/react-components/react-tailwind-theme-preview/css/variants.css new file mode 100644 index 00000000000000..aeb42a4dba344f --- /dev/null +++ b/packages/react-components/react-tailwind-theme-preview/css/variants.css @@ -0,0 +1,658 @@ +/** + * Custom variant catalog — the single place state selectors are defined. + * Every variant is `:where()`-wrapped: zero added specificity, cascade decided by + * @layer order alone (DECISIONS.md D2). Names follow the headless-preview + * data-attribute vocabulary where one exists (reports/headless-precedent.md). + * + * Grow this catalog as packages convert; never define variants in component modules. + * + * ── COMPOSITION WITH NAMED GROUPS (DECISIONS.md D15) ────────────────────────────────── + * + * (Careful editing this block: a CSS comment ends at the first `*` immediately followed by a + * slash, so the group syntax is spelled `group-/` throughout rather than with + * an asterisk wildcard, which would terminate the comment and spill the prose into the + * stylesheet as invalid declarations.) + * + * Every SELECTOR variant below already composes as `group-/` for free — + * Tailwind v4 rewrites any `&:where(…)` variant into the group form, so a child module writes + * `@variant group-checked/fui-switch { … }` and get + * `.child:is(:where(.group\/fui-switch):where([data-checked], :checked) *)`. Verified + * against tailwindcss 4.3.3 for `checked`, `not-checked`, `size-small`, `disabled-control`, + * `hover`, `focus-within` and `rtl`. **This catalog needs NO additions to support named + * groups**, and a new entry becomes group-composable the moment it is added here. + * + * Two consequences that bite: + * + * 1. The `&:where(…)` shape is what makes composition work. An ancestor-form variant + * (`:where([x]) &`) still compiles on its own but SILENTLY stops composing under + * `group-*`. Keep every new entry in the canonical form. + * + * 2. `forced-colors` (below) is the ONE exception, because it is an AT-RULE variant — + * `@media (forced-colors: active)` has no element for `group-*` to scope. + * `group-forced-colors/fui-switch` is a hard build error + * (`Cannot use @variant with unknown variant`). Nest it the other way instead: + * + * @variant forced-colors { @variant group-checked/fui-switch { … } } + * + * Any future at-rule variant inherits the same restriction. + * + * ── WHAT IS DELIBERATELY NOT HERE ───────────────────────────────────────────────────── + * + * Five raw attribute-selector families in converted modules were examined for promotion and + * left raw. The cookbook bans variant DEFINITIONS in component modules, not attribute + * SELECTORS, so a raw selector is a legitimate authoring form; these are the cases where it + * is the better one. Recording them here so the next reader does not re-litigate: + * + * 1. `data-popper-placement` (react-popover PopoverSurface, react-tooltip Tooltip — 8 uses) + * is written ANCESTOR-form, `:where(:global([data-popper-placement^='top'])) &`. It is on + * the positioned WRAPPER, not on the styled element, and it is a prefix match. Consequence + * 1 above says an ancestor-form entry silently stops composing under `group-*`, so this + * cannot become a canonical entry at all. + * + * 2. `data-overflowing` / `data-overflow-menu` (react-overflow Overflow — 2 uses) are + * DESCENDANT selectors (`& [data-overflowing]`) that match arbitrary consumer children. + * A variant scopes `&`, so no entry can express them. + * + * 3. `data-size` on Avatar (123 uses across three modules) is written as multi-value bucket + * TABLES (`[data-size='16'], [data-size='20'], [data-size='24']`). The catalog names every + * one of those values individually, but only 4 of Avatar's 22 rules select a single value; + * naming the 18 unions would mean 18 write-once `-or-` entries and would fragment tables + * that are readable precisely because they are uniform. + * + * 4. `data-activedescendant-focusvisible` (react-combobox Option) gets no entry — D15.5 + * admits no additions. + * + * 5. Card's "has a description" gate is not an attribute at ALL — CardHeader keeps two + * mutually exclusive module-class maps. Promoting it would mean inventing + * `data-has-description` and rewriting the hook, which is out of scope for a CSS-only + * pass and against D15.6's fallback-only reading of `data-*`. + */ + +/* direction — computed direction, correct under nesting (DECISIONS.md D5) */ +@custom-variant rtl (&:where(:dir(rtl))); +@custom-variant ltr (&:where(:dir(ltr))); + +/* orientation (Divider, Slider, Tabs, Toolbar, …) */ +@custom-variant vertical (&:where([data-orientation='vertical'])); +@custom-variant horizontal (&:where([data-orientation='horizontal'])); + +/* content alignment along the main axis (Divider, …) */ +@custom-variant align-content-start (&:where([data-align-content='start'])); +@custom-variant align-content-center (&:where([data-align-content='center'])); +@custom-variant align-content-end (&:where([data-align-content='end'])); + +/* + * Edge placement of a panel-shaped surface — Drawer's + * `position: 'start' | 'end' | 'bottom'` (react-drawer shared/DrawerBase.types.ts, the + * complete union). `data-position` is one of the headless preview's 25 attribute names + * (reports/headless-precedent.md). + * + * Deliberately NOT folded into `data-orientation` above: `bottom` names an EDGE, not an + * axis, and InlineDrawer / OverlayDrawer branch on all three values independently. It is + * also the reason Persona's text placement got its own `data-text-position` further down — + * a generic `data-position` would have been ambiguous on a component that has both. + * + * `position-start-or-end` is the inline-axis pair the two drawers spell as one slice + * (`[data-position='start'], [data-position='end']`) — a union, hence the `-or-`. + */ +@custom-variant position-start (&:where([data-position='start'])); +@custom-variant position-end (&:where([data-position='end'])); +@custom-variant position-bottom (&:where([data-position='bottom'])); +@custom-variant position-start-or-end (&:where([data-position='start'], [data-position='end'])); + +/* + * "Where is the scrollable DrawerBody scrolled to" — DrawerHeader / DrawerFooter reveal a + * separator once the body has scrolled at all, and make it fully opaque while there is + * content hidden on their side of it. `data-scroll-state` is one of the headless preview's 25 + * attribute names and is stamped on exactly these elements there + * (react-headless-components-preview `DrawerHeader/useDrawerHeader.ts`), so the name is + * precedent, not invention. No native selector can see "the body is scrolled to the middle", + * so this is the fallback case D15.6 sanctions rather than a mirror of something CSS already + * knows. + * + * The four singles are `DrawerScrollState`'s complete union + * (react-drawer shared/DrawerBase.types.ts). Only the complement and the two `-or-` unions + * carry rules today; an unreferenced variant emits nothing. + */ +@custom-variant scroll-state-none (&:where([data-scroll-state='none'])); +@custom-variant scroll-state-top (&:where([data-scroll-state='top'])); +@custom-variant scroll-state-middle (&:where([data-scroll-state='middle'])); +@custom-variant scroll-state-bottom (&:where([data-scroll-state='bottom'])); +@custom-variant not-scroll-state-none (&:where(:not([data-scroll-state='none']))); +@custom-variant scroll-state-middle-or-top (&:where([data-scroll-state='middle'], [data-scroll-state='top'])); +@custom-variant scroll-state-middle-or-bottom ( + &:where([data-scroll-state='middle'], [data-scroll-state='bottom']) +); + +/* generic states (native + data- fallbacks) */ +@custom-variant disabled (&:where([disabled], [data-disabled], :disabled)); +@custom-variant enabled (&:where(:not([disabled], [data-disabled], :disabled))); +@custom-variant checked (&:where([data-checked], :checked)); +@custom-variant not-checked (&:where(:not([data-checked], :checked))); +@custom-variant selected (&:where([data-selected], [aria-selected='true'])); + +/* + * Complement of `selected` above, in the same shape as `not-checked` / `not-focused` / + * `not-empty`. Added by the react-carousel conversion: `CarouselNavButton` and + * `CarouselNavImageButton` branch `selected ? rootSelected : rootUnselected` — a + * boolean-ish PAIR where BOTH halves carry declarations, so the complement has to be + * expressible as a selector rather than as an absent class. + * + * No `data-selected` mirror was added for either component (DECISIONS.md D15.6, resolved): + * both stamp `aria-selected={selected}` on the very element the rules key off + * (`useCarouselNavButton.ts` / `useCarouselNavImageButton.ts` `defaultProps`), so the + * native/ARIA half of `selected` already expresses the state where it is needed and a + * mirror would only widen invalidation. `aria-selected="false"` correctly fails + * `[aria-selected='true']` and therefore matches this complement. + */ +@custom-variant not-selected (&:where(:not([data-selected], [aria-selected='true']))); + +@custom-variant current (&:where([data-current], [aria-current]:not([aria-current='false']))); +@custom-variant open (&:where([open], [data-open], :open)); +@custom-variant expanded (&:where([data-expanded], [aria-expanded='true'])); +@custom-variant pressed (&:where([data-pressed], :active)); +@custom-variant invalid (&:where([data-invalid], :invalid, [aria-invalid='true'])); +@custom-variant required (&:where([required], [data-required], :required)); +@custom-variant read-only (&:where([readonly], [data-readonly], :read-only)); +@custom-variant placeholder-shown (&:where([data-placeholder], :placeholder-shown)); +@custom-variant indeterminate (&:where([data-indeterminate], :indeterminate)); + +/* + * Complement of `indeterminate` above. Needed by tri-state controls: Checkbox's `checked` + * prop is `true | false | 'mixed'`, which rides TWO independent presence attributes + * (`data-checked` + `data-indeterminate`), so its "plain unchecked" branch is the + * intersection `not-checked` ∩ `not-indeterminate` — `not-checked` alone would also match + * the mixed state. Radio/Switch, being genuinely boolean, never need this one. + */ +@custom-variant not-indeterminate (&:where(:not([data-indeterminate], :indeterminate))); + +/* + * Avatar's `active` / `activeAppearance` pair — the animated "this person is active" ring and + * shadow. `data-active` is stamped only for `active === 'active' | 'inactive'` (`unset` + * renders no attribute at all, reproducing the JS guard), so its PRESENCE is the "this avatar + * participates in the active/inactive transition" flag while its VALUE distinguishes the two + * halves. + * + * NAME COLLISION, resolved deliberately: the presence entry is `with-active`, NOT `active` — + * this catalog already binds `active` to the native `:active` pseudo-class (interaction + * section below), which is an unrelated state. `with-active` follows the `with-icon` / + * `with-label` shape: "the enum attribute is present at all". There is no `active-active` + * entry because no slice reads that value on its own — the presence entry already carries the + * shared half and `active-inactive` carries the override. + * + * `activeAppearance: 'ring' | 'shadow' | 'ring-shadow'` (Avatar.types.ts) is a three-value + * enum every slice reads as a UNION — "does this appearance include a ring", "does it include + * a shadow" — so the two `-or-` compounds are the entries Avatar actually uses. The three + * singles name the rest of the value space and emit nothing until a module references them. + */ +@custom-variant with-active (&:where([data-active])); +@custom-variant active-inactive (&:where([data-active='inactive'])); +@custom-variant active-appearance-ring (&:where([data-active-appearance='ring'])); +@custom-variant active-appearance-shadow (&:where([data-active-appearance='shadow'])); +@custom-variant active-appearance-ring-shadow (&:where([data-active-appearance='ring-shadow'])); +@custom-variant active-appearance-ring-or-ring-shadow ( + &:where([data-active-appearance='ring'], [data-active-appearance='ring-shadow']) +); +@custom-variant active-appearance-shadow-or-ring-shadow ( + &:where([data-active-appearance='shadow'], [data-active-appearance='ring-shadow']) +); + +/* + * scale (Button, Badge, Avatar, Spinner, …) — `size` is a scale prop, not a look prop, + * so it rides a data-attribute rather than a module class (DECISIONS.md D3). + */ +@custom-variant size-extra-tiny (&:where([data-size='extra-tiny'])); +@custom-variant size-tiny (&:where([data-size='tiny'])); +@custom-variant size-extra-small (&:where([data-size='extra-small'])); +@custom-variant size-small (&:where([data-size='small'])); +@custom-variant size-medium (&:where([data-size='medium'])); +@custom-variant size-large (&:where([data-size='large'])); +@custom-variant size-extra-large (&:where([data-size='extra-large'])); +@custom-variant size-huge (&:where([data-size='huge'])); + +/* + * Threshold form of the scale above — "at or below `small`". Badge's `smallToTiny` + * (`size === 'small' || 'extra-small' || 'tiny'`) gates two slices: the caption2Strong + * typography step and the `rounded` shape's smaller corner radius. Expressed as ONE + * variant rather than three duplicated blocks so the slice keeps its 1:1 mapping back to + * the Griffel source. Generalises safely: a component whose scale stops at `small` still + * matches only `size-small`. + */ +@custom-variant size-small-or-below ( + &:where([data-size='small'], [data-size='extra-small'], [data-size='tiny']) +); + +/* + * Numeric scale — the SAME `data-size` attribute carrying a pixel-valued scale instead of + * a t-shirt one. Introduced by SkeletonItem, whose `SkeletonItemSize` union is exactly + * this set (react-skeleton/library/src/components/Skeleton/Skeleton.types.ts); Avatar and + * Persona use subsets of the same numbers. A component uses only the names it needs — + * an unreferenced variant emits nothing. + */ +@custom-variant size-8 (&:where([data-size='8'])); +@custom-variant size-12 (&:where([data-size='12'])); +@custom-variant size-14 (&:where([data-size='14'])); +@custom-variant size-16 (&:where([data-size='16'])); +@custom-variant size-20 (&:where([data-size='20'])); +@custom-variant size-22 (&:where([data-size='22'])); +@custom-variant size-24 (&:where([data-size='24'])); +@custom-variant size-28 (&:where([data-size='28'])); +@custom-variant size-32 (&:where([data-size='32'])); +@custom-variant size-36 (&:where([data-size='36'])); +@custom-variant size-40 (&:where([data-size='40'])); +@custom-variant size-48 (&:where([data-size='48'])); +@custom-variant size-52 (&:where([data-size='52'])); +@custom-variant size-56 (&:where([data-size='56'])); +@custom-variant size-64 (&:where([data-size='64'])); +@custom-variant size-72 (&:where([data-size='72'])); +@custom-variant size-92 (&:where([data-size='92'])); +@custom-variant size-96 (&:where([data-size='96'])); +@custom-variant size-120 (&:where([data-size='120'])); +@custom-variant size-128 (&:where([data-size='128'])); + +/* + * The Fluent TYPOGRAPHY ramp riding the same `data-size` attribute — Text's + * `size: 100 | 200 | … | 1000` (react-text Text.types.ts), which selects a `--fontSizeBase*` / + * `--lineHeightBase*` pair rather than a pixel dimension. The two numeric spaces cannot + * collide: no ramp step equals any of the twenty pixel sizes above (verified over both lists). + * `size-300` carries no rules — 300 is the base ramp step Text's own reset already sets, so the + * Griffel source had no `300` slice either — and is listed for completeness. + */ +@custom-variant size-100 (&:where([data-size='100'])); +@custom-variant size-200 (&:where([data-size='200'])); +@custom-variant size-300 (&:where([data-size='300'])); +@custom-variant size-400 (&:where([data-size='400'])); +@custom-variant size-500 (&:where([data-size='500'])); +@custom-variant size-600 (&:where([data-size='600'])); +@custom-variant size-700 (&:where([data-size='700'])); +@custom-variant size-800 (&:where([data-size='800'])); +@custom-variant size-900 (&:where([data-size='900'])); +@custom-variant size-1000 (&:where([data-size='1000'])); + +/* + * `full` is a `data-size` value that is not a scale STEP at all: Drawer's + * `size: 'small' | 'medium' | 'large' | 'full'` (react-drawer shared/DrawerBase.types.ts) ends + * in a fill-the-container mode. It rides the same attribute because it is the same public prop + * — a separate attribute would name something Drawer does not have — and it composes with + * `position-bottom` in both drawers' "full height on the bottom edge" slice. The other three + * values are the t-shirt entries above. + */ +@custom-variant size-full (&:where([data-size='full'])); + +/* + * ProgressBar's scale prop is `thickness`, not `size` — its props type explicitly + * `Omit`s `size`, so reusing `data-size` would name an attribute the component does not + * have. No nyt-games or headless-preview precedent exists for it (reports/ + * nyt-games-conventions.md, reports/headless-precedent.md), so the name is invented from + * the public prop. + */ +@custom-variant thickness-medium (&:where([data-thickness='medium'])); +@custom-variant thickness-large (&:where([data-thickness='large'])); + +/* icon slot presence/placement (Button family, …) */ +@custom-variant icon-only (&:where([data-icon-only])); +@custom-variant with-icon (&:where([data-icon-position])); +@custom-variant icon-before (&:where([data-icon-position='before'])); +@custom-variant icon-after (&:where([data-icon-position='after'])); + +/* + * AccordionHeader's own icon vocabulary: `expandIconPosition: 'start' | 'end'` + * (AccordionHeader.types.ts, the complete union) plus a `data-icon` presence flag carrying the + * `!state.icon` half of its no-icon padding slice. Both names are the headless preview's + * (reports/headless-precedent.md) — its own AccordionHeader stamps + * `data-expand-icon-position` on exactly this element. + * + * `icon` is the presence of the header's OWN icon slot and is a different question from + * `with-icon` above, which is the presence of `data-icon-position` on the Button family. The + * complement `not-icon` is the half AccordionHeader actually references, and it is spelled + * `not-` like every other complement here rather than `no-icon`, which would be the only + * name of its shape in the file. + */ +@custom-variant expand-icon-position-start (&:where([data-expand-icon-position='start'])); +@custom-variant expand-icon-position-end (&:where([data-expand-icon-position='end'])); +@custom-variant icon (&:where([data-icon])); +@custom-variant not-icon (&:where(:not([data-icon]))); + +/* + * label slot presence/placement (Switch, Checkbox, Radio, …) — the same shape as the icon + * block above. `data-label-position` is one of the headless preview's 25 attribute names + * (reports/headless-precedent.md). Whether it is stamped conditionally is the CONVERTING + * COMPONENT's call, and the two converted so far differ: + * • react-switch stamps it ONLY when the label slot exists, because every slice that + * reads it is gated on `label && …` — its presence then doubles as the "has a label" + * signal (`with-label` below). A Switch rule that depends on `labelPosition` WITHOUT + * that gate (its root flex-direction) uses `data-orientation` / `@variant vertical` + * instead, which is always stamped. + * • react-checkbox stamps it UNCONDITIONALLY, because `inputStyles[labelPosition]` + * positions the hidden `` with no `label &&` gate. Its label-slot rules read + * the same attribute and are simply inert with no `.label` element to match. + * So `with-label` means "has a label" only for components in the first group; do not read + * it as a general presence signal. + * + * The three values here are react-switch's union (`'above' | 'after' | 'before'`). The + * rest of the family uses subsets plus one more value: Checkbox is `'before' | 'after'` + * (both already covered), Radio is `'after' | 'below'` — `label-below` was added by the + * Radio conversion. Radio belongs to the FIRST group: `labelStyles[labelPosition]` is the + * only slice that reads the attribute and it is gated on `label &&`, while its two + * ungated `labelPosition === 'below'` slices (root flex-direction, hidden-input box) use + * `data-orientation` exactly as Switch does. + */ +@custom-variant with-label (&:where([data-label-position])); +@custom-variant label-above (&:where([data-label-position='above'])); +@custom-variant label-after (&:where([data-label-position='after'])); +@custom-variant label-before (&:where([data-label-position='before'])); +@custom-variant label-below (&:where([data-label-position='below'])); + +/* + * RadioGroup's `layout` prop. `data-layout` is one of the headless preview's 25 attribute + * names (reports/headless-precedent.md) and is deliberately NOT folded into + * `data-orientation` above: the union includes `horizontal-stacked`, which is a layout mode + * rather than an axis, and `layout` is the public prop's own name. + * + * The three values are RadioGroup's complete union. Only `layout-vertical` carries rules + * today (the Griffel source styled `layout === 'vertical'` alone, leaving both horizontal + * modes on the base `flex-direction: row`); the other two name the rest of the attribute's + * value space and emit nothing until a module references them. + */ +@custom-variant layout-vertical (&:where([data-layout='vertical'])); +@custom-variant layout-horizontal (&:where([data-layout='horizontal'])); +@custom-variant layout-horizontal-stacked (&:where([data-layout='horizontal-stacked'])); + +/* + * The SAME `data-layout` attribute, reused by MessageBar for a disjoint value space: + * `'singleline' | 'multiline'`. The value stamped is the COMPUTED layout — `useMessageBar.ts` + * resolves the public `auto` to one of these two before it reaches the DOM — and + * MessageBarActions reads the identical value off its own element via MessageBar's context, + * which is why `layout-multiline` is referenced from two components' modules. + * + * Sharing the attribute with RadioGroup is deliberate, not an accident: both components' public + * prop is literally called `layout`, and neither can ever see the other's element, so the two + * value spaces cannot meet. `layout-singleline` names the other half and carries no rules — + * singleline is MessageBar's base look, exactly as in the Griffel source. + */ +@custom-variant layout-singleline (&:where([data-layout='singleline'])); +@custom-variant layout-multiline (&:where([data-layout='multiline'])); + +/* + * Semantic status of a message surface — MessageBar's `intent` + * (`'info' | 'success' | 'warning' | 'error'`, MessageBar.types.ts, the complete union). It is + * stamped on the ROOT even though it also selects the icon slot's colour, because the icon's + * rules are descendant selectors from the root. + * + * `data-intent` has no headless-preview or nyt-games precedent, so the name comes from the + * public prop — the ProgressBar `data-thickness` rule. `intent-info` carries no rules: `info` + * is the base look and the Griffel source had no `info` slice either. + */ +@custom-variant intent-info (&:where([data-intent='info'])); +@custom-variant intent-success (&:where([data-intent='success'])); +@custom-variant intent-warning (&:where([data-intent='warning'])); +@custom-variant intent-error (&:where([data-intent='error'])); + +/* + * Where a Persona's text sits relative to its avatar/presence coin. Persona's public prop + * is `textPosition: 'after' | 'before' | 'below'` and it drives the root's whole grid + * template, so the attribute is named from the prop (the ProgressBar `data-thickness` + * precedent): neither nyt-games nor the headless preview has a name for it, and the + * preview's generic `data-position` would be ambiguous here because Persona ALSO has a + * `textAlignment` prop. `text-position-after` is currently unreferenced — Persona's `after` + * slice is empty — and is listed for completeness; an unreferenced variant emits nothing. + */ +@custom-variant text-position-after (&:where([data-text-position='after'])); +@custom-variant text-position-before (&:where([data-text-position='before'])); +@custom-variant text-position-below (&:where([data-text-position='below'])); + +/* spacing opt-ins (Divider inset, …) — presence attributes, written as `|| undefined` */ +@custom-variant inset (&:where([data-inset])); + +/* + * "This element responds to pointer/keyboard interaction" — ListItem's + * `selectable || navigable` union, which its Griffel hook spells as the single slice + * `rootClickableOrSelectable` (display + cursor). Both halves gate exactly one slice and + * neither is used alone, so per the cookbook's boolean-pair rule the union rides ONE + * presence attribute instead of two attributes plus a union variant. + * + * The name is invented: neither the nyt-games catalog (reports/nyt-games-conventions.md, + * 95 variants) nor the headless preview's 25 data-attribute names + * (reports/headless-precedent.md) has an entry for it — the closest headless names, + * `data-has-actions` / `data-selected`, mean different things. Generalises to the rest of + * the row/option family (MenuItem, TreeItem, TableRow) whose Griffel sources gate the + * same `cursor: pointer` on the same "is this row actionable" condition. + */ +@custom-variant interactive (&:where([data-interactive])); + +/* + * Link's `inline` boolean — "this link sits inside a run of text", which underlines it at + * rest. No headless-preview precedent exists for the name, so it follows the react-divider + * pilot's `data-inset` shape: a boolean styling opt-in as a presence attribute. + * NOTE for module authors: `inline` is also the name of Tailwind's `display: inline` + * utility. `@variant inline { … }` and `@apply inline` live in different registries and do + * not collide, but Link.module.css writes `display: inline` longhand anyway for clarity. + */ +@custom-variant inline (&:where([data-inline])); + +/* + * MenuSplitGroup's `data-multiline` — "a child menu item has wrapped onto more than one + * line", which re-centres the split trigger. Unlike every other presence attribute in this + * catalog it is written IMPERATIVELY (`useMenuSplitGroup.ts` calls `toggleAttribute` from a + * ref callback), because the child that knows the answer can mount before the parent that + * renders it; the constant lives in the styles file as `menuSplitGroupMultilineAttr`. The + * attribute name predates this migration and is unchanged. + * + * No headless-preview precedent exists for the name, so it follows the react-divider pilot's + * `data-inset` shape: a boolean styling opt-in as a presence attribute. It generalises to + * any wrap-aware row, which is why it is catalogued rather than written as a hand-rolled + * attribute selector in the module. + */ +@custom-variant multiline (&:where([data-multiline])); + +/* + * Adornment-slot presence (Input contentBefore/contentAfter, and the same pair on + * Textarea/Select/SpinButton/Combobox). Presence attributes on the ROOT, written as + * `|| undefined`: the styles hook branches on `state.contentBefore && …`, and both the + * root's own padding and the inner input's padding key off it, so the flag has to be + * readable from the root for descendant selectors. + */ +@custom-variant content-before (&:where([data-content-before])); +@custom-variant content-after (&:where([data-content-after])); + +/* + * MessageBarActions' `data-has-actions` — "this actions slot holds at least one action". + * `data-has-actions` is one of the headless preview's 25 attribute names + * (reports/headless-precedent.md). Written `hasActions || undefined` in the hook: a literal + * `false` would render `data-has-actions="false"`, which still MATCHES a presence selector and + * would therefore invert the complement. + * + * Boolean-ish pair per the cookbook, and the complement is the half the module uses — an + * actions element with nothing in it is hidden rather than left as an empty grid cell. + */ +@custom-variant has-actions (&:where([data-has-actions])); +@custom-variant not-has-actions (&:where(:not([data-has-actions]))); + +/* + * SpinButton's `data-spin-active` — "this is the increment/decrement button currently + * repeating". Derived from `spinState === 'up'` / `=== 'down'` and stamped on the matching + * button only (`useSpinButtonStyles.styles.ts`), presence-only, with the same `|| undefined` + * discipline as `has-actions` above. + * + * No headless-preview or nyt-games precedent exists for the name, so it follows the + * react-divider pilot's `data-inset` shape: a boolean styling opt-in as a presence attribute. + * It is NOT folded into `pressed` / `active`: the spin repeat continues while the pointer is + * held OR the arrow key is held, and it stays set across the repeat interval, which neither + * pseudo-class tracks. + */ +@custom-variant spin-active (&:where([data-spin-active])); + +/* interaction */ +@custom-variant hover (&:where(:hover)); +@custom-variant focus (&:where(:focus)); +@custom-variant focus-within (&:where(:focus-within)); + +/* + * SearchBox's `focused` — a React state flag, NOT a pseudo-class, and deliberately not a + * union with one. `useSearchBoxBase_unstable` sets it from `onFocus`/`onBlur` handlers on + * the root plus a `relatedTarget` containment check, so it stays true while focus moves + * between the `` and the dismiss button, and it is what the Griffel hook branched on + * (`!focused && …`). `:focus-within` is a near-neighbour, not an equal: it is live where the + * React flag is a render behind, and it would fire on elements whose hook never set the + * state. Folding either into the other is the "half-applying dual-source variant" trap + * reports/nyt-games-conventions.md flags. + * + * Name from the headless preview's `data-focused` (one of its 25 attribute names — + * reports/headless-precedent.md; `react-headless-components-preview`'s own SearchBox stamps + * exactly this on the root). nyt-games spells the same attribute `focus`, a name this + * catalog already binds to the native `:focus` pseudo-class. + * + * Boolean-ish pair per the cookbook: ONE presence attribute plus a `:not()` complement — + * every v9 slice that reads it reads the complement. + */ +@custom-variant focused (&:where([data-focused])); +@custom-variant not-focused (&:where(:not([data-focused]))); + +/* + * UNION compounds — the `-or-` in the name is load-bearing. `hover-active` below is an + * INTERSECTION (`:hover:active`); these are selector lists. The Griffel sources spell + * them `':hover,:focus-within'` / `':active,:focus-within'` with the comment "DO NOT add + * a space between the selectors! It changes the behavior of make-styles" — i.e. one slice + * whose declarations apply in either state. They recur across the whole input family + * (Input, Textarea, Select, SpinButton, Combobox root borders). + */ +@custom-variant hover-or-focus-within (&:where(:hover, :focus-within)); +@custom-variant active-or-focus-within (&:where(:active, :focus-within)); + +/* + * `:not(:focus-within)` complement — the "field is not focused" guard v9 field controls + * use to hold their invalid/rest border while the focus indicator owns the focused look. + * + * ⚠ CONVERSION TRAP (found by react-input's A/B probe; applies to Textarea/Select too). + * Griffel authors that slice as `':not(:focus-within),:hover:not(:focus-within)'`. The + * second term IS a strict subset of the first, so it adds nothing to MATCHING — but it is + * not redundant, it is a SPECIFICITY HACK. Griffel's atomics are not `:where()`-wrapped: + * `.x:hover:not(:focus-within)` scores (0,3,0) and therefore outranks the hover-bucket + * atomics at (0,2,0), letting a `d`-bucket rule beat the `h` bucket. `.x:not(:focus-within)` + * scores only (0,2,0) and correctly LOSES to `:active` at (0,2,0) on bucket order. + * + * `:where()` flattens both to (0,1,0) and the asymmetry disappears, so a converted module + * must SPLIT the slice: the plain branch goes before the module's hover/active blocks, and + * a `@variant hover { @variant not-focus-within { … } }` restatement goes after them. + * Collapsing them into one block silently changes the invalid-plus-hover border. + * Worked example: Input.module.css, "useRootStyles.invalid, SECOND branch". + */ +@custom-variant not-focus-within (&:where(:not(:focus-within))); + +/* + * Bare `:active`, distinct from `pressed` above: several v9 styles files (Textarea's + * `outlineInteractive`, Input, Select, …) author a plain `:active` rule, and a converted + * component must reproduce the COMPILED selector — `pressed` additionally matches + * `[data-pressed]`, which Griffel never emitted for these. + * + * Also distinct from Avatar's `data-active` attribute, which is catalogued as `with-active` / + * `active-inactive` precisely because this name was already taken. See that block above. + */ +@custom-variant active (&:where(:active)); + +/* + * Griffel's authored "pressed" compound — `:hover:active,:active:focus-visible` — used + * verbatim by Button/ToggleButton/MenuButton/SplitButton and many other v9 styles files. + * Kept distinct from `pressed` above, which is the headless-preview vocabulary + * (`[data-pressed], :active`); converting a styled component must reproduce the compiled + * selector, not the headless one. Note the `:focus-visible` here is the NATIVE + * pseudo-class, not keyborg's attribute — that is what Griffel emitted. + */ +@custom-variant hover-active (&:where(:hover:active, :active:focus-visible)); + +/* + * Complement of the `hover` + `hover-active` PAIR above — "the element is at rest". + * `:hover:active` is a strict subset of `:hover`, so the union of the two variants is + * `:hover, :active:focus-visible` and this is its exact negation. + * + * Why it exists (react-toolbar's ToolbarToggleButton / ToolbarRadioButton): Griffel + * resolved a flat `d`-bucket atomic against a LATER slice's `:hover` atomic by + * SPECIFICITY (0-1-0 vs 0-2-0), so a wrapping component's flat override could beat the + * wrapped component's flat rules while still losing to its hover/pressed ones. Cascade + * layers cannot express that interleaving — a higher altitude wins in every state — and + * `:where()` flattens the specificity that used to arbitrate it. Restricting the wrapper's + * flat rule to the rest state reproduces the winner exactly, in every state, without + * either component having to know the other's values. + * + * Use it only for that shape. A component styling its OWN element never needs it: it + * writes its rest rule first and its `hover` / `hover-active` rules after, and file order + * decides. + */ +@custom-variant at-rest (&:where(:not(:hover, :active:focus-visible))); + +/* + * `disabled || disabledFocusable` — the condition v9 components actually branch on. + * A disabled-but-focusable control renders NO `disabled` attribute (react-aria's + * `useARIAButtonProps` sets `disabled: disabled && !disabledFocusable` and + * `aria-disabled: true`), and an ``/`
`-rendered button drops `disabled` + * entirely — so neither `:disabled` nor `[disabled]` alone reproduces the JS condition. + */ +@custom-variant disabled-focusable (&:where([data-disabled-focusable])); +@custom-variant disabled-any (&:where([disabled], [data-disabled], :disabled, [data-disabled-focusable])); + +/* + * The form-CONTROL disabled compound: `':disabled, &[aria-disabled=\"true\"]'` and its + * complement `':enabled:not([aria-disabled=\"true\"])'`, authored verbatim by + * react-switch's hidden-`` reset. As of that conversion this is the ONLY styles + * file in the repo that spells the selector this way (verified by grep over every + * `packages/react-components/*​/library/src/components/*​/*.styles.ts`), but the shape + * generalises to the rest of the hidden-input family (Checkbox, Radio) when they convert: + * the control renders `aria-disabled` instead of `disabled` when it must stay focusable, + * so neither `:disabled` nor `[disabled]` alone reproduces the compiled selector. + * + * This is a SEPARATE entry rather than a widening of `disabled`/`enabled` above, and that + * separation is load-bearing: react-link renders `aria-disabled` for + * `disabled || disabledFocusable` (useLinkState.ts) while its Griffel hook gates the + * disabled slice on `disabled` alone (`disabled && styles.disabled`). Folding + * `[aria-disabled='true']` into the generic `disabled` variant would newly apply disabled + * styling to every `disabledFocusable` Link. Likewise `disabled-any` stays untouched: + * Button's condition is `disabled || disabledFocusable`, already carried by + * `[data-disabled-focusable]`. + */ +@custom-variant disabled-control (&:where([disabled], [data-disabled], :disabled, [aria-disabled='true'])); +@custom-variant enabled-control (&:where(:not([disabled], [data-disabled], :disabled, [aria-disabled='true']))); + +/* + * Focus visibility — Fluent uses keyborg (react-tabster), which sets + * data-fui-focus-visible / data-fui-focus-within. These attributes are the visual + * contract (VR stories script them directly); native :focus-visible is intentionally + * NOT part of these variants (DECISIONS.md D6). + * + * The two are NOT symmetric, and the asymmetry is react-tabster's, not ours: + * `createCustomFocusIndicatorStyle`'s `createBaseSelector` returns `&[data-fui-focus-visible]` + * for selector 'focus' but `&[data-fui-focus-within]:focus-within` for selector + * 'focus-within' (react-tabster/src/focus/createCustomFocusIndicatorStyle.ts:69-76). + * Confirmed in compiled AOT output — react-switch's root reset emits + * `.r2i81i2[data-fui-focus-within]:focus-within::after{…}`. The native pseudo-class must + * stay in the variant: keyborg leaves `data-fui-focus-within` on the element after focus + * moves away, so the attribute alone would keep the ring painted. + */ +@custom-variant focus-visible-fui (&:where([data-fui-focus-visible])); +@custom-variant focus-within-fui (&:where([data-fui-focus-within]:focus-within)); + +/* + * The NATIVE `:focus-visible` pseudo-class, deliberately separate from the keyborg pair + * above. Several v9 styles files author a plain `':focus-visible'` rule alongside their + * keyborg indicator — Link's base slice does exactly this + * (`.f2hkw1w:focus-visible{outline-style:none}` in its compiled AOT output) — so the two + * selectors must stay independently addressable. Overrides Tailwind's built-in variant of + * the same name only to add the `:where()` wrapper every catalog entry carries. + */ +@custom-variant focus-visible (&:where(:focus-visible)); + +/* + * Content presence — named for alignment with the CSS-native :empty selector + * (nyt-games catalog; user-settled 2026-07-27, replacing the pilot's 'childless'). + * data-empty mirrors 'no JSX children' from the styles hook — the DOM may still + * contain slot elements (an icon-only Button root is data-empty but not :empty), + * which is why the attribute leads and :empty is the unstamped-element fallback. + */ +@custom-variant empty (&:where([data-empty], :empty)); +@custom-variant not-empty (&:where(:not([data-empty], :empty))); + +/* forced colors / high contrast */ +@custom-variant forced-colors { + @media (forced-colors: active) { + @slot; + } +} diff --git a/packages/react-components/react-tailwind-theme-preview/package.json b/packages/react-components/react-tailwind-theme-preview/package.json new file mode 100644 index 00000000000000..22310bf161bb91 --- /dev/null +++ b/packages/react-components/react-tailwind-theme-preview/package.json @@ -0,0 +1,61 @@ +{ + "name": "@fluentui/react-tailwind-theme-preview", + "version": "9.0.0-alpha.0", + "description": "Fluent UI themes and design tokens as a Tailwind v4 CSS layer: cascade layer order, custom variants, the base-scale spacing bridge, the generated Fluent token registration (css/tokens.css, `@theme inline` utility names over kebab-case custom properties), and the 7 shipped themes as static CSS classes (css/themes.css — apply `fui-theme-web-dark` etc. to any element to theme its subtree; web-light values are the `:root, :host` defaults). Spacing registers the 22 named tokens as aliases of the numeric axis (one density knob, --spacing); stroke widths keep literal base-scale values under --stroke-width-* on purpose (borders must not thin with layout density). The kebab-case variable namespace is self-contained by design — it does not interoperate with classic FluentProvider runtime theming (camelCase variables). Pairs with @fluentui/react-windmod-preview, and is equally usable for styling @fluentui/react-headless-components-preview directly.", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/microsoft/fluentui" + }, + "beachball": { + "disallowedChangeTypes": [ + "major", + "minor", + "patch" + ] + }, + "scripts": { + "build": "node build.js", + "generate-tokens-css": "node scripts/generate-tokens-css.js", + "verify-tokens-css": "node scripts/generate-tokens-css.js --check", + "generate-theme-values": "node scripts/generate-theme-values.js", + "verify-theme-values": "node scripts/generate-theme-values.js --check", + "test-spacing-behavior": "node scripts/probe-spacing-behavior.mjs", + "prepack": "node build.js" + }, + "style": "css/index.css", + "exports": { + ".": "./css/index.css", + "./styles.css": "./dist/styles.css", + "./theme-class-names": { + "types": "./theme-class-names.d.mts", + "default": "./theme-class-names.mjs" + }, + "./css/*": "./css/*", + "./package.json": "./package.json" + }, + "files": [ + "*.md", + "css", + "dist/styles.css", + "theme-class-names.mjs", + "theme-class-names.d.mts" + ], + "sideEffects": [ + "**/*.css" + ], + "nx": { + "name": "react-tailwind-theme-preview", + "targets": { + "build": { + "inputs": [ + "{projectRoot}/css/**", + "{projectRoot}/build.js" + ], + "outputs": [ + "{projectRoot}/dist" + ] + } + } + } +} diff --git a/packages/react-components/react-tailwind-theme-preview/scripts/generate-theme-values.js b/packages/react-components/react-tailwind-theme-preview/scripts/generate-theme-values.js new file mode 100644 index 00000000000000..a31c1c4b2ea9a9 --- /dev/null +++ b/packages/react-components/react-tailwind-theme-preview/scripts/generate-theme-values.js @@ -0,0 +1,119 @@ +// @ts-check +/** + * Emits `theme-values.json` — a committed snapshot of the 7 shipped theme objects' + * token values (theming Phase 2b). + * + * WHY A SNAPSHOT: `@fluentui/react-tailwind-theme`'s generator + * (`scripts/generate-tokens-css.js`) deliberately has no dependency edge on this + * package's BUILD output — a fresh clone must be able to regenerate/verify its CSS + * without building anything. The theme VALUES, however, are computed + * (`createLightTheme(brandWeb)`, …), so they cannot be text-scraped from source the way + * `tokens.ts` is. This committed JSON is the bridge: the CSS generator reads it, and + * `src/themes/themeValues.test.ts` asserts on every jest run that it deep-equals the + * computed themes — drift fails CI from this package's side. + * + * Regenerating (only needed when a theme value intentionally changes): + * yarn nx run tokens:build # the script reads lib-commonjs + * yarn workspace @fluentui/tokens generate-theme-values + * + * `--check` exits non-zero when the committed file is missing or stale. + */ + +const fs = require('fs'); +const path = require('path'); + +const PACKAGE_ROOT = path.resolve(__dirname, '..'); +const REPO_ROOT = path.resolve(PACKAGE_ROOT, '..', '..', '..'); +const OUTPUT = path.join(PACKAGE_ROOT, 'theme-values.json'); + +/** The shipped themes. Keep in sync with `src/themes/index.ts` and `src/themes/themeClassNames.ts`. */ +const THEME_NAMES = [ + 'webLightTheme', + 'webDarkTheme', + 'teamsLightTheme', + 'teamsDarkTheme', + 'teamsHighContrastTheme', + 'teamsLightV21Theme', + 'teamsDarkV21Theme', +]; + +function loadThemes() { + const libEntry = path.join(REPO_ROOT, 'packages', 'tokens', 'lib-commonjs', 'index.cjs'); + + if (!fs.existsSync(libEntry)) { + throw new Error( + `Cannot find built output at ${libEntry}. Run \`yarn nx run tokens:build\` first — ` + + 'theme values are computed (createLightTheme etc.), so the snapshot is generated from the built package.', + ); + } + + // eslint-disable-next-line @typescript-eslint/no-require-imports + const tokensPackage = require(libEntry); + /** @type {Record>} */ + const themes = {}; + + for (const name of THEME_NAMES) { + const theme = tokensPackage[name]; + + if (!theme || typeof theme !== 'object') { + throw new Error(`Theme \`${name}\` is missing from the built package — shipped-theme set drifted.`); + } + + themes[name] = theme; + } + + return themes; +} + +function render() { + const themes = loadThemes(); + const packageJson = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')); + + const document = { + $schema: 'fluent-theme-values', + description: + 'Committed snapshot of the shipped Fluent theme objects (theme key -> value). ' + + 'Consumed by @fluentui/react-tailwind-theme/scripts/generate-tokens-css.js to emit the static theme ' + + 'CSS classes; drift against the computed themes fails src/themes/themeValues.test.ts. ' + + 'Regenerate with `yarn workspace @fluentui/tokens generate-theme-values` after `yarn nx run tokens:build`.', + generatedBy: 'packages/tokens/scripts/generate-theme-values.js', + source: `${packageJson.name}@${packageJson.version}`, + themes, + }; + + return `${JSON.stringify(document, null, 2)}\n`; +} + +function main() { + const args = process.argv.slice(2); + const check = args.includes('--check'); + const contents = render(); + + if (check) { + if (!fs.existsSync(OUTPUT)) { + console.error(`MISSING: ${OUTPUT} — run \`yarn workspace @fluentui/tokens generate-theme-values\`.`); + process.exitCode = 1; + return; + } + + const existing = fs.readFileSync(OUTPUT, 'utf8').replace(/\r\n/g, '\n'); + + if (existing !== contents) { + console.error(`STALE: ${OUTPUT} — run \`yarn workspace @fluentui/tokens generate-theme-values\`.`); + process.exitCode = 1; + return; + } + + console.log(`OK: ${OUTPUT} is current.`); + return; + } + + fs.writeFileSync(OUTPUT, contents); + console.log(`Wrote ${OUTPUT}.`); +} + +if (require.main === module) { + main(); +} + +module.exports = { THEME_NAMES, loadThemes, render, OUTPUT }; diff --git a/packages/react-components/react-tailwind-theme-preview/scripts/generate-tokens-css.js b/packages/react-components/react-tailwind-theme-preview/scripts/generate-tokens-css.js new file mode 100644 index 00000000000000..fa8ccdfc0cb52f --- /dev/null +++ b/packages/react-components/react-tailwind-theme-preview/scripts/generate-tokens-css.js @@ -0,0 +1,1182 @@ +// @ts-check +/** + * Generates `css/tokens.css` — the `@theme inline` registration that gives every Fluent + * design token a Tailwind utility name (`bg-neutral-background-1`, `text-base-300`, + * `shadow-8`, `rounded-medium`, …). + * + * WHY `inline` IS MANDATORY + * ------------------------- + * A naive `@theme { --color-x: }` emits the value into `:root` and compiles + * `bg-x` to `background-color: var(--color-x)` resolved ONCE, at `:root`, where Fluent + * token VALUES do not exist — FluentProvider writes them on `.fui-FluentProviderN` + * elements. Every provider (and every nested provider) would render the same frozen + * value. FORBIDDEN. + * + * `inline` substitutes the theme value into the utility itself, so `bg-x` compiles to + * `background-color: var(--color-neutral-background-1)` and resolves per-element against + * the nearest FluentProvider — nested themes keep working (DECISIONS.md D4's theming + * guarantee is preserved; only the authoring surface changes). + * + * THEMING PHASE 2a — canonical kebab names; `reference` ADOPTED (supersedes the earlier + * rejection) + * ----------------------------------------------------------------------------------- + * Since theming Phase 2a the runtime CSS variable of every token IS its Tailwind theme + * key (`--color-neutral-background-1`, `--text-base-300`, `--shadow-2`, …; durations are + * the one exception, see NAMESPACES). Each registration is therefore a SELF-NAMED + * reference (`--color-x: var(--color-x)`), which plain `inline` treats as a by-name use + * and answers by emitting a self-referential alias at `:root, :host` — 400+ cyclic + * (guaranteed-invalid at `:root`) declarations that define nothing and cost ~19KB + * (probe: .scratch/phase2a-theming/probe/). `reference` suppresses exactly that + * emission and nothing else — probed byte-identical utility output. + * + * `reference` was REJECTED pre-Phase-2a ("suppresses the alias even when a by-name + * reference exists, emitting CSS against a variable nothing defines"). That reason is + * retired: FluentProvider's runtime theme tag now writes the canonical kebab names on + * every provider element (alongside the legacy camelCase names, until Phase 2b removes + * the tag), so `var(--color-neutral-background-1)` IS defined wherever tokens were ever + * defined. Resolution semantics are unchanged from the camelCase era: per-element, + * against the nearest provider; undefined outside any provider. + * + * SPACING IS DIFFERENT — see SPACING_SCALE / STROKE_WIDTH_SCALE below. The 22 + * spacingHorizontal / spacingVertical tokens register under `--spacing-*` as ALIASES OF + * THE NUMERIC AXIS — `calc(var(--spacing) * )`, where the multiplier is the + * token's canonical px divided by the `--spacing` base px (theming Phase 1, settled with + * user 2026-07-29; supersedes the literal `calc( * var(--base-scale))` form). Probe- + * verified: `@theme inline` carries the `var(--spacing)` reference VERBATIM into each + * utility, so `p-horizontal-m` compiles to `padding: calc(var(--spacing) * 12)` — the + * same shape as `p-12` — and BOTH respond identically to a subtree `--spacing` override. + * `--spacing` (css/index.css: `calc(1px * var(--base-scale))`) is the single density knob. + * + * THE 4 strokeWidth TOKENS ARE THE DELIBERATE EXCEPTION: their PUBLIC variables are + * `--stroke-width-thin/thick/thicker/thickest` with LITERAL base-scale values + * (`calc( * var(--base-scale))`), NOT coupled to `--spacing` — borders must not thin + * when layout density changes. The `--spacing-thin/…` names still exist, but ONLY as + * PRIVATE internal hooks (`--spacing-thin: var(--stroke-width-thin)`) that feed Tailwind + * utility generation (`w-thin`, `p-thick`, …) and the sanctioned direct-var authoring in + * component modules; the public set-contract is `--stroke-width-*`. See STROKE_WIDTH_SCALE. + * + * ALL ENTRIES ARE ALSO EMITTED AS REAL CUSTOM PROPERTIES (`emit: true`): the 22 spacing + * canonicals, the 4 `--stroke-width-*` canonicals, and the 4 private hooks. `@theme inline` + * registers a utility name but emits NO variable, and border/outline/ring/decoration widths + * do NOT consume the spacing namespace (probe-measured — see + * `.scratch/layer-probe/check-stroke-namespace.mjs`), so those properties must be authored + * as a direct `var(--spacing-thin)` reference, which needs a real variable. + * + * THE OLD camelCase NAMES (`--colorNeutralBackground1`, `--fontSizeBase300`, + * `--spacingHorizontalM`, `--strokeWidthThin`, …) NO LONGER EXIST in emitted CSS or in + * any shipped read path (theming Phase 2a extends Phase 1's option B to the FULL token + * set: single vocabulary; a documented major break for hand-written consumer CSS against + * the old names). The `tokens.*` JS constants in @fluentui/tokens are repointed to the + * canonical kebab names — this generator ASSERTS that lockstep for EVERY token on every + * run. (FluentProvider's runtime theme tag writes token values under BOTH vocabularies + * on provider elements until theming Phase 2b removes it — the camelCase half feeds + * nothing shipped and exists only as unbroken-interim insurance for hand-written + * consumer reads.) + * + * The emission rides the once-per-document artifact (css/emit.css -> dist/styles.css) + * and is suppressed in component modules by `@reference`, exactly like `--base-scale`. + * + * Run: node packages/react-components/react-tailwind-theme-preview/scripts/generate-tokens-css.js + * Verify: node packages/react-components/react-tailwind-theme-preview/scripts/generate-tokens-css.js --check + */ + +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +const PACKAGE_ROOT = path.resolve(__dirname, '..'); +const REPO_ROOT = path.resolve(PACKAGE_ROOT, '..', '..', '..'); +const TOKENS_PACKAGE = path.join(REPO_ROOT, 'packages', 'tokens'); +const TOKENS_SOURCE = path.join(TOKENS_PACKAGE, 'src', 'tokens.ts'); +const SPACINGS_SOURCE = path.join(TOKENS_PACKAGE, 'src', 'global', 'spacings.ts'); +const STROKE_WIDTHS_SOURCE = path.join(TOKENS_PACKAGE, 'src', 'global', 'strokeWidths.ts'); +const THEME_VALUES_SOURCE = path.join(PACKAGE_ROOT, 'theme-values.json'); +const THEME_CLASSNAMES_SOURCE = path.join(PACKAGE_ROOT, 'theme-class-names.mjs'); +const DEFAULT_OUTPUT = path.join(PACKAGE_ROOT, 'css', 'tokens.css'); +const THEMES_OUTPUT = path.join(PACKAGE_ROOT, 'css', 'themes.css'); + +/** The theme whose values are emitted as the `:root, :host` defaults (theming Phase 2b). */ +const DEFAULT_THEME = 'webLightTheme'; + +const GENERATOR_ID = 'packages/react-components/react-tailwind-theme-preview/scripts/generate-tokens-css.js'; + +/** The `@theme` modifiers the emitted block carries. See the module header for why. */ +const THEME_MODIFIERS = 'inline reference'; + +/** + * camelCase → kebab-case, with digit runs and acronym runs as their own segments. + * + * colorNeutralBackground1 → color-neutral-background-1 + * fontSizeHero1000 → font-size-hero-1000 + * borderRadius2XLarge → border-radius-2-x-large + * shadow2Brand → shadow-2-brand + * + * NOT used for the spacing namespace — those names come from SPACING_SCALE's pinned table. + * + * @param {string} name + * @returns {string} + */ +function kebabCase(name) { + return name + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') // lower|digit → Upper + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2') // ACRONYM → Word + .replace(/([A-Za-z])([0-9])/g, '$1-$2') // letter → digit + .replace(/([0-9])([A-Za-z])/g, '$1-$2') // digit → letter + .toLowerCase(); +} + +/** + * Fluent's spacing scale — the ONE namespace whose registered values are NOT + * `var(--fluentToken)` references (dual spacing support, settled with user 2026-07-27; + * numeric-axis alias restructure settled with user 2026-07-29 — theming Phase 1). + * + * WHY NUMERIC-AXIS ALIASES AND NOT `var(--spacingHorizontalM)` OR PX LITERALS + * --------------------------------------------------------------------------- + * The numeric spacing utilities (`p-12`, `gap-8`) compile to + * `calc(var(--spacing) * N)` — they compute px→rem through the `--spacing` base + * (`calc(1px * var(--base-scale))`, css/index.css). Registering the semantic tokens as + * runtime token `var()` references would create two spacing systems that scale + * differently: `p-12` would follow the user's root font-size while `p-horizontal-m` + * stayed frozen at the provider's literal px. Registering each token as + * `calc(var(--spacing) * )` — multiplier = canonical px / SPACING_BASE_PX — + * makes BOTH scales aliases of ONE axis: `p-horizontal-m` and `p-12` emit the same + * calc shape, compute the same length at default, and respond identically to a + * `--spacing` override on any subtree (`--spacing` is the single density knob). + * The earlier literal form (`calc(12px * var(--base-scale))`) was arithmetically + * identical at default but bypassed `--spacing`, so a subtree density override reached + * numeric utilities and missed named ones. Probe-verified that `@theme inline` carries + * the `var(--spacing)` reference verbatim into utilities (.scratch/phase1-theming/). + * + * COST, ACCEPTED: a FluentProvider `theme` override of `spacingHorizontalM` no longer + * reaches utility-sourced spacing (a literal `var(--spacingHorizontalM)` still honours it, + * but those are now FORBIDDEN in component modules — see CONVERSION_GUIDE). All 7 shipped + * themes carry byte-identical spacing values (asserted: every theme spreads the same + * `horizontalSpacings`/`verticalSpacings` objects), so no shipped theme changes behavior. + * + * ALSO ACCEPTED: `--spacing-*` is axis-agnostic, so `py-horizontal-m` compiles. The axis in + * the name is a convention, not a constraint — the same is already true of a literal + * `var(--spacingHorizontalM)` in a `padding-block` declaration. + * + * `utility` is the EXPLICIT suffix table (user-specified). It deliberately does NOT go + * through `kebabCase` — that would turn `SNudge` into `s-nudge` correctly but `XXS` into + * `xxs` only by accident of the acronym rule; pinning the table keeps these names stable + * and independent of the generic algorithm. + * + * `global` is the key in `packages/tokens/src/global/spacings.ts`; `px` is asserted against + * it at generation time so a scale change upstream trips the build instead of silently + * desyncing the utilities from the tokens. + * + * @type {{ suffix: string, utility: string, global: string, px: number }[]} + */ +const SPACING_SCALE = [ + { suffix: 'None', utility: 'none', global: 'none', px: 0 }, + { suffix: 'XXS', utility: 'xxs', global: 'xxs', px: 2 }, + { suffix: 'XS', utility: 'xs', global: 'xs', px: 4 }, + { suffix: 'SNudge', utility: 's-nudge', global: 'sNudge', px: 6 }, + { suffix: 'S', utility: 's', global: 's', px: 8 }, + { suffix: 'MNudge', utility: 'm-nudge', global: 'mNudge', px: 10 }, + { suffix: 'M', utility: 'm', global: 'm', px: 12 }, + { suffix: 'L', utility: 'l', global: 'l', px: 16 }, + { suffix: 'XL', utility: 'xl', global: 'xl', px: 20 }, + { suffix: 'XXL', utility: 'xxl', global: 'xxl', px: 24 }, + { suffix: 'XXXL', utility: 'xxxl', global: 'xxxl', px: 32 }, +]; + +/** + * Fluent's stroke widths — border/outline/divider thickness — joining the SAME `--spacing-*` + * namespace as SPACING_SCALE, on the same literal-value terms (settled with user 2026-07-27). + * + * WHY THE SPACING NAMESPACE AND NOT A `--border-width-*` NAMESPACE + * --------------------------------------------------------------- + * There is no `--border-width-*` namespace in Tailwind v4 — border widths are a fixed bare- + * number progression (`border-2` → `border-width: 2px`, a hardcoded px literal, NOT a theme + * lookup). The only width namespace that exists, `--stroke-width-*`, drives SVG `stroke-width` + * (`stroke-2` → `stroke-width: 2`), which is the wrong property. So there is nothing to + * register these against that would give `border-thin` a meaning. + * + * PROBE-MEASURED, not reasoned (`.scratch/layer-probe/check-stroke-namespace.mjs` registers a + * `--spacing-thin` on the real css/index.css and compiles a candidate list): + * CONSUMES `--spacing-*` p m gap space-x w h min-w max-w size inset top start basis + * translate scroll-m scroll-p indent leading (+ every axis/side form) + * DOES NOT border border-t/b/s/x divide-x outline outline-offset ring + * ring-offset inset-ring underline-offset decoration stroke + * + * So registration buys real utilities (`w-thin`, `h-thick`, `p-thin`, `gap-thin` — dimensional + * uses of a stroke width, which Fluent components do have) but CANNOT give `border-thin` a + * meaning. Border-ish properties are authored as a direct `var(--spacing-thin)` — which is why + * these four, alone in the namespace, are ALSO emitted as real custom properties (`emit: true` + * on the NAMESPACES entry). `@theme inline` emits no variables. + * + * CANONICAL `--stroke-width-*` VALUES STAY LITERAL `calc( * var(--base-scale))` — + * DELIBERATELY NOT `--spacing`-COUPLED + * ----------------------------------------------------------------------------------- + * Theming Phase 1 (settled with user 2026-07-29) rebased the 22 spacing tokens onto the + * `--spacing` numeric axis; these four are the intentional exception. `--spacing` is the + * layout DENSITY knob — a subtree that halves it should compress padding and gaps, but + * borders must NOT thin with it: a 1px hairline is a 1px hairline at any density. Stroke + * widths therefore keep the raw `--base-scale` form and ignore `--spacing` overrides. + * + * NAME SPLIT (user amendment): the PUBLIC set-contract variable is + * `--stroke-width-`, emitted with the literal value; the spacing-namespace + * `--spacing-` names are PRIVATE internal hooks registered/emitted as + * `var(--stroke-width-)` so Tailwind's spacing-consuming utility families + * (`w-thin`, `p-thick`, `gap-thicker`, …) and module-authored `var(--spacing-thin)` + * border/outline widths keep working. `--stroke-width-*` is deliberately NOT registered + * in `@theme` — Tailwind's `--stroke-width-*` namespace drives SVG `stroke-width` + * utilities, which would be the wrong property. + * + * All 7 shipped themes carry byte-identical strokeWidth values — asserted before shipping in + * `.scratch/layer-probe/assert-theme-stroke-width.js` (1/2/3/4px, zero divergence). + * + * `global` is the key in `packages/tokens/src/global/strokeWidths.ts`; `px` is asserted against + * it at generation time, exactly like SPACING_SCALE. + * + * @type {{ suffix: string, utility: string, global: string, px: number }[]} + */ +const STROKE_WIDTH_SCALE = [ + { suffix: 'Thin', utility: 'thin', global: 'strokeWidthThin', px: 1 }, + { suffix: 'Thick', utility: 'thick', global: 'strokeWidthThick', px: 2 }, + { suffix: 'Thicker', utility: 'thicker', global: 'strokeWidthThicker', px: 3 }, + { suffix: 'Thickest', utility: 'thickest', global: 'strokeWidthThickest', px: 4 }, +]; + +/** + * The px value of the `--spacing` numeric-axis base, as registered in css/index.css + * (`--spacing: calc(1px * var(--base-scale))`). Spacing-token multipliers are derived + * against it, so if the base ever changes, every multiplier recomputes — and the exactness + * guard in `spacingTokenValue` throws on any px value the new base cannot express exactly. + */ +const SPACING_BASE_PX = 1; + +/** + * The numeric-axis alias for a spacing step: `calc(var(--spacing) * )`, the + * same compiled shape as Tailwind's numeric utilities (`p-12` → `calc(var(--spacing) * 12)`), + * so named and numeric spacing respond identically to a `--spacing` override anywhere in + * the tree. Zero stays a plain `0` — a `calc(… * 0)` would be valid but noisy, and `0` is + * unitless-safe everywhere. + * + * The multiplier MUST reproduce the canonical px exactly at the default base; anything + * else is a silent visual change, so it throws. + * + * @param {{ px: number }} step + * @returns {string} + */ +function spacingTokenValue(step) { + const { px } = step; + if (px === 0) { + return '0'; + } + const multiplier = px / SPACING_BASE_PX; + if (multiplier * SPACING_BASE_PX !== px) { + throw new Error( + `Spacing step ${px}px is not exactly expressible as a multiple of the ${SPACING_BASE_PX}px --spacing base.`, + ); + } + return `calc(var(--spacing) * ${multiplier})`; +} + +/** + * The canonical (public set-contract) variable name for a stroke width step. + * + * @param {{ utility: string }} step + * @returns {string} + */ +function strokeWidthCanonicalName(step) { + return `--stroke-width-${step.utility}`; +} + +/** + * The canonical `--stroke-width-*` value: literal base-scale, deliberately NOT + * `--spacing`-coupled — borders must not thin when layout density changes (see the + * STROKE_WIDTH_SCALE doc block). + * + * @param {{ px: number }} step + * @returns {string} + */ +function strokeWidthCanonicalValue(step) { + return step.px === 0 ? '0' : `calc(${step.px}px * var(--base-scale))`; +} + +/** + * The spacing-namespace PRIVATE hook value for a stroke width step: an alias of the + * canonical variable, so utility families and module-authored `var(--spacing-thin)` + * resolve through the one public definition. + * + * @param {{ utility: string, px: number }} step + * @returns {string} + */ +function strokeWidthValue(step) { + return `var(${strokeWidthCanonicalName(step)})`; +} + +/** + * Reads an object literal out of a `packages/tokens/src/global/*.ts` module and asserts the + * pinned table above still describes it — so an upstream scale change trips the build instead + * of silently desyncing the utilities from the tokens. Text extraction for the same reason as + * `readTokens`: no build step, no dependency edge on @fluentui/tokens. + * + * @param {object} options + * @param {string} options.source absolute path of the module to read + * @param {RegExp} options.declaration must capture the object body in group 1 + * @param {string} options.declarationText human-readable form of `declaration`, for errors + * @param {{ global: string, px: number }[]} options.table the pinned table to assert + * @param {string} options.tableName the table's identifier, for errors + * @returns {Record} + */ +function readGlobalScale({ source, declaration, declarationText, table, tableName }) { + const text = fs.readFileSync(source, 'utf8'); + + const match = declaration.exec(text); + if (!match) { + throw new Error(`${source}: expected a \`${declarationText}\` declaration.`); + } + + /** @type {Record} */ + const scale = {}; + const entry = /^[ \t]*([A-Za-z][A-Za-z0-9_]*):[ \t]*'([^']*)',[ \t]*$/gm; + let entryMatch; + while ((entryMatch = entry.exec(match[1])) !== null) { + scale[entryMatch[1]] = entryMatch[2]; + } + + const keys = Object.keys(scale); + if (keys.length !== table.length) { + throw new Error( + `${source}: parsed ${keys.length} steps but ${tableName} has ${table.length}. ` + + `Update ${tableName} in ${GENERATOR_ID}.`, + ); + } + + for (const { global: key, px } of table) { + const expected = px === 0 ? '0' : `${px}px`; + if (scale[key] !== expected) { + throw new Error( + `${source}: step \`${key}\` is \`${scale[key]}\` but ${tableName} says \`${expected}\`. ` + + `Update ${tableName} in ${GENERATOR_ID}.`, + ); + } + } + + return scale; +} + +/** + * Asserts SPACING_SCALE still matches the private `spacings` literal in packages/tokens. + * + * @returns {Record} + */ +function readSpacingScale() { + return readGlobalScale({ + source: SPACINGS_SOURCE, + declaration: /const spacings: SpacingTokens = \{([^}]*)\}/, + declarationText: 'const spacings: SpacingTokens = {', + table: SPACING_SCALE, + tableName: 'SPACING_SCALE', + }); +} + +/** + * Asserts STROKE_WIDTH_SCALE still matches the `strokeWidths` literal in packages/tokens. + * Same guarantee as `readSpacingScale`: these four values are hardcoded into the generated CSS, + * so an upstream change to 1/2/3/4px MUST throw rather than ship a silent divergence. + * + * @returns {Record} + */ +function readStrokeWidthScale() { + return readGlobalScale({ + source: STROKE_WIDTHS_SOURCE, + declaration: /export const strokeWidths: StrokeWidthTokens = \{([^}]*)\}/, + declarationText: 'export const strokeWidths: StrokeWidthTokens = {', + table: STROKE_WIDTH_SCALE, + tableName: 'STROKE_WIDTH_SCALE', + }); +} + +/** + * Reads `packages/tokens/src/themes/themeClassNames.ts` as text and extracts the shipped + * theme → class-name constants, asserting each class name equals its derivation from the + * theme export name (`fui-theme-` + kebab of the name minus `Theme`; digits attach to the + * preceding segment, so `teamsDarkV21Theme` → `fui-theme-teams-dark-v21` — deliberately + * NOT this file's `kebabCase`, whose letter→digit rule would produce `v-21`). + * + * Text extraction for the same reason as `readTokens`: no build step, no dependency edge + * on @fluentui/tokens. `themeClassNames.test.ts` in packages/tokens asserts the same + * derivation from the jest side. + * + * @returns {Record} theme export name → class name + */ +function readThemeClassNames() { + const text = fs.readFileSync(THEME_CLASSNAMES_SOURCE, 'utf8'); + + /** @type {Record} */ + const classNames = {}; + const entry = /^export const ([A-Za-z][A-Za-z0-9]*Theme)ClassName = '([^']+)';$/gm; + let match; + while ((match = entry.exec(text)) !== null) { + classNames[match[1]] = match[2]; + } + + if (Object.keys(classNames).length === 0) { + throw new Error(`${THEME_CLASSNAMES_SOURCE}: parsed zero theme class-name constants — the file shape changed.`); + } + + for (const [themeName, className] of Object.entries(classNames)) { + const base = themeName.replace(/Theme$/, ''); + const derived = `fui-theme-${base.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase()}`; + if (className !== derived) { + throw new Error( + `${THEME_CLASSNAMES_SOURCE}: \`${themeName}ClassName\` is \`${className}\` but the derivation from the ` + + `export name is \`${derived}\`. The class names are derived, not free-form — fix one side.`, + ); + } + } + + return classNames; +} + +/** + * Reads the committed `packages/tokens/theme-values.json` snapshot — the VALUE source for + * the theme emission (theming Phase 2b). The snapshot exists because theme values are + * computed (`createLightTheme(brandWeb)`, …) and so cannot be text-scraped the way + * `tokens.ts` is; `packages/tokens/src/themes/themeValues.test.ts` asserts on every jest + * run that it deep-equals the computed themes. + * + * @returns {Record>} theme export name → (token → value) + */ +function readThemeValues() { + if (!fs.existsSync(THEME_VALUES_SOURCE)) { + throw new Error( + `${THEME_VALUES_SOURCE} is missing. Run \`yarn workspace @fluentui/tokens generate-theme-values\` ` + + '(after building packages/tokens) and commit the result.', + ); + } + + const document = JSON.parse(fs.readFileSync(THEME_VALUES_SOURCE, 'utf8')); + + if (!document.themes || typeof document.themes !== 'object') { + throw new Error(`${THEME_VALUES_SOURCE}: expected a top-level \`themes\` object.`); + } + + return document.themes; +} + +/** + * Cross-asserts the three inputs of the theme emission and splits the token set: + * + * - the snapshot's theme set must equal the class-name constants' theme set; + * - every theme must carry exactly the same keys: every non-zIndex token, no extras + * (zIndex tokens are theme-absent by design — their defaults ride the `tokens.*` + * var() fallback); + * - every theme's spacing/stroke values must equal the pinned scales — those 26 tokens + * are THEME-INVARIANT and already emitted at `:root, :host` in their density-knob + * calc form, which a literal per-theme re-emission would silently break, so they are + * asserted identical and EXCLUDED from the per-theme classes. + * + * @param {{ name: string, value: string }[]} tokens parsed tokens.ts entries + * @returns {{ + * variantTokens: { name: string, canonical: string }[], + * themes: Record>, + * classNames: Record, + * }} + */ +function analyzeThemeEmission(tokens) { + const classNames = readThemeClassNames(); + const themes = readThemeValues(); + + const snapshotThemes = Object.keys(themes).sort(); + const constantThemes = Object.keys(classNames).sort(); + if (JSON.stringify(snapshotThemes) !== JSON.stringify(constantThemes)) { + throw new Error( + `Shipped-theme sets disagree: theme-values.json has [${snapshotThemes}] but themeClassNames.ts has ` + + `[${constantThemes}]. Regenerate the snapshot and/or update the constants.`, + ); + } + if (!themes[DEFAULT_THEME]) { + throw new Error(`theme-values.json is missing the default theme \`${DEFAULT_THEME}\`.`); + } + + /** @type {{ name: string, canonical: string }[]} */ + const variantTokens = []; + /** @type {{ name: string, expected: string }[]} */ + const invariantTokens = []; + /** @type {string[]} */ + const themeAbsentTokens = []; + + for (const { name } of tokens) { + const classification = classify(name); + if (classification.kind !== 'register') { + continue; + } + if (classification.group.scale) { + const px = classification.step.px; + invariantTokens.push({ name, expected: px === 0 ? '0' : `${px}px` }); + } else if (classification.group.prefix === 'zIndex') { + themeAbsentTokens.push(name); + } else { + variantTokens.push({ name, canonical: /** @type {string} */ (classification.canonical) }); + } + } + + const expectedKeys = [...variantTokens.map(token => token.name), ...invariantTokens.map(token => token.name)].sort(); + + for (const [themeName, theme] of Object.entries(themes)) { + const themeKeys = Object.keys(theme).sort(); + if (JSON.stringify(themeKeys) !== JSON.stringify(expectedKeys)) { + const missing = expectedKeys.filter(key => !(key in theme)); + const extra = themeKeys.filter(key => !expectedKeys.includes(key)); + throw new Error( + `Theme \`${themeName}\` keys drifted from tokens.ts (zIndex excluded): ` + + `missing [${missing}], extra [${extra}]. Regenerate theme-values.json.`, + ); + } + + for (const { name, expected } of invariantTokens) { + if (theme[name] !== expected) { + throw new Error( + `Theme \`${themeName}\` has \`${name}: ${theme[name]}\` but spacing/stroke tokens are theme-invariant ` + + `(pinned ${expected}). The density-knob emission at :root depends on this — a genuinely divergent ` + + 'theme needs a design decision, not a silent literal emission.', + ); + } + } + + for (const name of themeAbsentTokens) { + if (name in theme) { + throw new Error(`Theme \`${themeName}\` unexpectedly carries the theme-absent token \`${name}\`.`); + } + } + } + + return { variantTokens, themes, classNames }; +} + +/** + * Token-name prefix → Tailwind theme namespace. Order matters: the FIRST matching entry + * wins, so longer prefixes that share a stem (fontFamily/fontSize/fontWeight) come first. + * + * `namespace` is the Tailwind theme key prefix; `utility` documents the utilities it feeds + * and is only used for the generated section comments. + * + * Every namespace below was read out of the installed Tailwind v4.3.3 utility registry + * (`node_modules/tailwindcss/dist/lib.mjs`), not guessed: + * duration → themeKeys ["--transition-duration"] (NOT "--duration") + * ease → themeKeys ["--ease"] + * z → themeKeys ["--z-index"] + * font → ["--font"] then ["--font-weight"] + * rounded → ["--radius"], text → ["--text"], leading → ["--leading"], shadow → ["--shadow"] + */ +const NAMESPACES = [ + { + prefix: 'color', + namespace: 'color', + utility: 'bg-* text-* border-* fill-* stroke-* outline-* ring-* decoration-*', + heading: 'Colors', + }, + { + prefix: 'fontFamily', + namespace: 'font', + utility: 'font-*', + heading: 'Font families', + }, + { + prefix: 'fontSize', + namespace: 'text', + utility: 'text-*', + heading: 'Font sizes', + }, + { + prefix: 'fontWeight', + namespace: 'font-weight', + utility: 'font-*', + heading: 'Font weights', + }, + { + prefix: 'lineHeight', + namespace: 'leading', + utility: 'leading-*', + heading: 'Line heights', + }, + { + prefix: 'spacingHorizontal', + namespace: 'spacing-horizontal', + utility: 'px-* ps-* pe-* mx-* gap-x-* …', + heading: 'Horizontal spacing, inline axis — numeric-axis ALIASES (calc(var(--spacing) * N))', + scale: SPACING_SCALE, + value: spacingTokenValue, + emit: true, + }, + { + prefix: 'spacingVertical', + namespace: 'spacing-vertical', + utility: 'py-* pt-* pb-* my-* gap-y-* …', + heading: 'Vertical spacing, block axis — numeric-axis ALIASES (calc(var(--spacing) * N))', + scale: SPACING_SCALE, + value: spacingTokenValue, + emit: true, + }, + { + prefix: 'strokeWidth', + namespace: 'spacing', + utility: 'w-thin h-thick p-thin gap-thin … AND var(--spacing-thin) for border/outline widths', + heading: 'Stroke widths — PRIVATE hooks aliasing the canonical --stroke-width-* variables', + scale: STROKE_WIDTH_SCALE, + value: strokeWidthValue, + emit: true, + }, + { + prefix: 'borderRadius', + namespace: 'radius', + utility: 'rounded-* rounded-s-* rounded-e-* …', + heading: 'Border radii', + }, + { + prefix: 'shadow', + namespace: 'shadow', + utility: 'shadow-*', + heading: 'Shadows', + }, + { + prefix: 'curve', + namespace: 'ease', + utility: 'ease-*', + heading: 'Easing curves', + }, + { + prefix: 'duration', + namespace: 'transition-duration', + // Durations are the ONE Phase-2a family whose canonical RUNTIME variable + // (`--duration-fast`) differs from its Tailwind theme key + // (`--transition-duration-fast`): the family keeps the shorter custom namespace + // (user decision — consistent with the token vocabulary, no first-class variable + // namespace worth occupying), while the theme key must be what the installed + // utility registry reads. The registered value `var(--duration-fast)` carries the + // canonical reference into `duration-*` utilities verbatim. + canonicalNamespace: 'duration', + utility: 'duration-*', + heading: 'Transition durations', + }, + { + prefix: 'zIndex', + namespace: 'z-index', + utility: 'z-*', + heading: 'z-index (theme-absent tokens — fallbacks carried verbatim)', + }, +]; + +/** + * Prefixes deliberately NOT registered — currently EMPTY: every Fluent token has a Tailwind + * theme name. The mechanism stays because `classify` throws on an unclassified token, and a + * future token family may genuinely have no namespace; this is where its reason would live. + * + * HISTORY: `strokeWidth*` was the last exclusion, on the grounds that no namespace fits (still + * true — see STROKE_WIDTH_SCALE). It was removed 2026-07-27 when the four joined `--spacing-*`: + * a stroke width IS a length on the same base-scale system, the spacing-powered families give + * it real utilities, and the accompanying `emit: true` variable covers the border/outline/ring/ + * decoration properties that provably do NOT consume the namespace. + * + * @type {{ prefix: string, reason: string }[]} + */ +const EXCLUSIONS = []; + +/* + * COLLISIONS WITH TAILWIND STATIC UTILITIES — probe-verified, no action needed. + * + * Two generated names collide with a Tailwind static utility of the same name. In both + * cases the registered theme value WINS and the utility compiles to the Fluent token + * (verified by compiling, not read from docs), and in both cases the token is + * value-equivalent to the static it displaces, so nothing changes visually: + * + * rounded-none borderRadiusNone wins over static `border-radius: 0` (token is 0) + * ease-linear curveLinear wins over static `linear` (token is cubic-bezier(0,0,1,1)) + * + * Recorded here so nobody debugs it twice. + */ + +/** + * Reads `packages/tokens/src/tokens.ts` as text and extracts its flat `key: 'value',` entries + * in source order. + * + * Text extraction rather than `require('@fluentui/tokens')` on purpose: the theme package has + * no build step and no dependency edge on @fluentui/tokens, so a fresh clone must be able to + * regenerate/verify without building anything. The parse is guarded — a shape change in + * tokens.ts (spreads, computed keys, non-literal values) trips the assertions below instead + * of silently emitting a short file. + * + * @returns {{ name: string, value: string }[]} + */ +function readTokens() { + const source = fs.readFileSync(TOKENS_SOURCE, 'utf8'); + + const declaration = /export const tokens: Record = \{/; + if (!declaration.test(source)) { + throw new Error( + `${TOKENS_SOURCE}: expected a \`export const tokens: Record = {\` declaration.`, + ); + } + + /** @type {{ name: string, value: string }[]} */ + const tokens = []; + const entry = /^[ \t]*([A-Za-z][A-Za-z0-9_]*):[ \t]*'([^']*)',[ \t]*$/gm; + let match; + while ((match = entry.exec(source)) !== null) { + tokens.push({ name: match[1], value: match[2] }); + } + + if (tokens.length === 0) { + throw new Error(`${TOKENS_SOURCE}: parsed zero tokens — the file shape changed.`); + } + + // Every token value must be a var() reference, optionally with a fallback (zIndex*). + // Hyphens allowed: the 26 spacing/stroke tokens reference canonical kebab-case names + // (theming Phase 1 option B). + for (const { name, value } of tokens) { + if (!/^var\(--[A-Za-z][A-Za-z0-9_-]*(?:, ?[^)]+)?\)$/.test(value)) { + throw new Error(`${TOKENS_SOURCE}: token \`${name}\` has an unexpected value \`${value}\`.`); + } + } + + // Guard against the regex skipping entries: no line inside the literal may look like a + // key/value pair yet fail to parse. + const body = source.slice(source.search(declaration)); + const looseCount = (body.match(/^[ \t]*[A-Za-z][A-Za-z0-9_]*:/gm) || []).length; + if (looseCount !== tokens.length) { + throw new Error(`${TOKENS_SOURCE}: parsed ${tokens.length} tokens but found ${looseCount} key-like lines.`); + } + + return tokens; +} + +/** + * @param {string} name + * @returns {{ kind: 'register', group: typeof NAMESPACES[number], themeKey: string, value?: string } | { kind: 'exclude', prefix: string, reason: string }} + */ +function classify(name) { + for (const group of NAMESPACES) { + if (!name.startsWith(group.prefix)) { + continue; + } + const remainder = name.slice(group.prefix.length); + if (remainder.length === 0) { + throw new Error(`Token \`${name}\` is exactly its namespace prefix — no key left to name a utility.`); + } + + // Spacing (incl. stroke widths) is the one namespace with a pinned suffix table AND a + // substituted value. `group.scale` IS that table. + if (group.scale) { + const step = group.scale.find(entry => entry.suffix === remainder); + if (!step) { + throw new Error( + `Token \`${name}\` has step \`${remainder}\`, which is not in the pinned table for ` + + `\`${group.prefix}\`. Add it (with its px value) in ${GENERATOR_ID}.`, + ); + } + return { + kind: 'register', + group, + themeKey: `--${group.namespace}-${step.utility}`, + value: group.value(step), + step, + }; + } + + const kebab = kebabCase(remainder); + return { + kind: 'register', + group, + themeKey: `--${group.namespace}-${kebab}`, + // The canonical RUNTIME variable name (theming Phase 2a): identical to the theme + // key for every namespace except duration (see NAMESPACES). + canonical: `--${group.canonicalNamespace || group.namespace}-${kebab}`, + }; + } + + for (const exclusion of EXCLUSIONS) { + if (name.startsWith(exclusion.prefix)) { + return { kind: 'exclude', prefix: exclusion.prefix, reason: exclusion.reason }; + } + } + + throw new Error( + `Token \`${name}\` matches no namespace and no exclusion. Add it to NAMESPACES or EXCLUSIONS in ${GENERATOR_ID}.`, + ); +} + +/** + * @param {{ modifiers?: string }} [options] + * @returns {string} + */ +function render(options = {}) { + const modifiers = options.modifiers === undefined ? THEME_MODIFIERS : options.modifiers; + const tokensPackage = JSON.parse(fs.readFileSync(path.join(TOKENS_PACKAGE, 'package.json'), 'utf8')); + const tokens = readTokens(); + // Both pinned tables are asserted against packages/tokens before anything is rendered; an + // upstream scale change throws here instead of silently shipping a stale hardcoded value. + readSpacingScale(); + readStrokeWidthScale(); + // Theming Phase 2b: the default (web light) theme values are emitted at `:root, :host` + // right below the spacing/stroke block — this artifact is now the SOLE value source for + // the canonical token variables (FluentProvider's runtime theme style tag is gone). + const { variantTokens, themes } = analyzeThemeEmission(tokens); + const defaultTheme = themes[DEFAULT_THEME]; + + /** @type {Map} */ + const sections = new Map(); + /** Spacing-canonical keys that must ALSO be emitted as real custom properties. @type {string[]} */ + const emittedVariables = []; + /** Canonical `--stroke-width-*` declarations (public set-contract). @type {string[]} */ + const canonicalStrokes = []; + /** Private spacing-namespace hooks for stroke widths. @type {string[]} */ + const strokeHooks = []; + /** Excluded token names, keyed by the EXCLUSIONS prefix that matched. @type {Map} */ + const excluded = new Map(); + /** @type {Map} */ + const seenThemeKeys = new Map(); + + for (const { name, value } of tokens) { + const classification = classify(name); + + if (classification.kind === 'exclude') { + const bucket = excluded.get(classification.prefix) || []; + bucket.push(name); + excluded.set(classification.prefix, bucket); + continue; + } + + const { group, themeKey } = classification; + const collision = seenThemeKeys.get(themeKey); + if (collision) { + throw new Error(`Theme key \`${themeKey}\` is produced by both \`${collision}\` and \`${name}\`.`); + } + seenThemeKeys.set(themeKey, name); + + // tokens.ts is consumed as the token-name INVENTORY only. The classic (Griffel-era) + // `tokens.*` JS constants keep referencing the historical camelCase variables; this + // package's kebab-case namespace is deliberately self-contained (see README — no + // interoperability with FluentProvider theming), so the JS read path is neither + // asserted nor emitted here. Only a fallback embedded in a tokens.ts value (zIndex + // carries its default as a var() fallback) is preserved, re-targeted at the + // canonical variable. + const canonical = group.scale + ? group.prefix === 'strokeWidth' + ? strokeWidthCanonicalName(classification.step) + : themeKey + : classification.canonical; + const fallback = /^var\(--[A-Za-z0-9-]+, ([^)]+)\)$/.exec(value); + const resolved = + classification.value !== undefined + ? classification.value + : `var(${canonical}${fallback ? `, ${fallback[1]}` : ''})`; + const section = sections.get(group.prefix) || { group, lines: [] }; + section.lines.push(` ${themeKey}: ${resolved};`); + sections.set(group.prefix, section); + + if (group.emit) { + if (group.prefix === 'strokeWidth') { + canonicalStrokes.push( + ` ${strokeWidthCanonicalName(classification.step)}: ${strokeWidthCanonicalValue(classification.step)};`, + ); + strokeHooks.push(` ${themeKey}: ${resolved};`); + } else { + emittedVariables.push(` ${themeKey}: ${resolved};`); + } + } + } + + const registered = seenThemeKeys.size; + const excludedCount = tokens.length - registered; + + const excludedSummary = [...excluded].map(([prefix, names]) => `${names.length}× ${prefix}*`).join(', '); + const excludedNote = excludedCount === 0 ? 'none excluded' : `${excludedCount} excluded (${excludedSummary})`; + + // NOTE: a plain `/*` comment, NOT `/*!`. This file is inlined into the theme package's + // emitted root artifact (css/emit.css -> dist/styles.css, shipped once per document), and + // a bang comment is preserved by minifiers by contract. An earlier 45-line version of this + // header measured 4,390 raw / 1,639 gzip bytes in that artifact — against a 1,515-byte + // baseline. Keep the shipped header to provenance + the two load-bearing warnings; the + // full rationale, namespace mapping and exclusion reasons live in this generator's source. + const out = []; + out.push('/*'); + out.push(' * DO NOT EDIT — generated file.'); + out.push(' *'); + out.push(` * Generator: ${GENERATOR_ID}`); + out.push(` * Source: ${tokensPackage.name}@${tokensPackage.version} (packages/tokens/src/tokens.ts)`); + out.push(` * Regenerate: node ${GENERATOR_ID}`); + out.push(` * Verify: node ${GENERATOR_ID} --check`); + out.push(' *'); + out.push(` * ${tokens.length} Fluent tokens: ${registered} registered, ${excludedNote}.`); + out.push(' *'); + out.push(' * `inline` is MANDATORY: it substitutes the canonical var(--token-name) into each'); + out.push(' * utility, so values resolve per-element — the `:root, :host` defaults below,'); + out.push(' * overridden per subtree by the theme classes (css/themes.css, theming Phase 2b).'); + out.push(' * A plain `@theme` alias would freeze resolution at `:root`, breaking scoped'); + out.push(' * theming. `reference` suppresses the self-referential aliases that plain'); + out.push(' * `inline` would emit now that each runtime variable IS its theme key (theming'); + out.push(' * Phase 2a) — the values are emitted in the fui.theme block below, not by JS.'); + out.push(' *'); + out.push(' * SPACING IS THE ONE EXCEPTION: --spacing-horizontal-* / --spacing-vertical-* are'); + out.push(' * ALIASES OF THE NUMERIC AXIS — calc(var(--spacing) * N), the same shape p-12'); + out.push(' * compiles to — so named and numeric spacing are ONE system with ONE density knob'); + out.push(' * (--spacing). Stroke widths are the deliberate exception: their PUBLIC variables'); + out.push(' * are --stroke-width-thin/thick/thicker/thickest with literal'); + out.push(' * calc( * var(--base-scale)) values (borders must not thin when layout density'); + out.push(' * changes); --spacing-thin/… are PRIVATE hooks aliasing them for utility generation.'); + out.push(' * Provider spacing/strokeWidth overrides do not reach these; all 7 shipped themes'); + out.push(' * carry identical values.'); + out.push(' *'); + out.push(' * ORDER MATTERS: index.css imports this AFTER its `@theme static` block, whose'); + out.push(' * `--color-*: initial` / `--spacing-*: initial` clear only what precedes them.'); + out.push(' *'); + out.push(' * Rationale, namespace mapping, emission reasons: see the generator source.'); + out.push(' */'); + out.push(''); + out.push(`@theme ${modifiers} {`.replace(/\s+\{$/, ' {').replace('@theme {', '@theme {')); + + let first = true; + for (const { group, lines } of sections.values()) { + if (!first) { + out.push(''); + } + first = false; + out.push(` /* ${group.heading} — ${lines.length} tokens → --${group.namespace}-* (${group.utility}) */`); + out.push(...lines); + } + + out.push('}'); + + if (emittedVariables.length + canonicalStrokes.length + strokeHooks.length > 0) { + out.push(''); + out.push('/*'); + out.push(' * REAL CUSTOM PROPERTIES — the one block in this file that emits declarations.'); + out.push(' *'); + out.push(' * `@theme inline` above registers utility NAMES and emits no variables, which is'); + out.push(' * correct for every other namespace. The spacing namespace needs real variables:'); + out.push(' * border-width, outline-width, ring-width, divide-*, underline-offset and'); + out.push(' * text-decoration-thickness do NOT consume the --spacing-* namespace (v4 border'); + out.push(' * widths are a fixed bare-number px progression), so modules author those as a'); + out.push(' * direct var(--spacing-thin); and the `tokens.*` JS constants in @fluentui/tokens'); + out.push(' * are var() reference strings against the canonical names emitted here (charts'); + out.push(' * inline styles etc.).'); + out.push(' *'); + out.push(' * THE OLD camelCase NAMES (--colorNeutralBackground1, --spacingHorizontalM, …)'); + out.push(' * ARE GONE for the ENTIRE token set (option B, theming Phase 2a): single'); + out.push(' * vocabulary, documented major break for hand-written consumer CSS. Theming'); + out.push(" * Phase 2b removed FluentProvider's runtime theme style tag, so this block (plus"); + out.push(' * the theme classes in css/themes.css) is the ONLY writer of token values.'); + out.push(' *'); + out.push(' * Emitted ONCE PER DOCUMENT (D13): `@reference` drops this block, so component'); + out.push(' * `*.module.css` output stays free of theme declarations; css/emit.css compiles it'); + out.push(' * into dist/styles.css alongside --base-scale and --spacing, which utility-sourced'); + out.push(' * spacing already depends on identically.'); + out.push(' *'); + out.push(' * `:root, :host` — not bare `:root` — matches the selector Tailwind emits its own'); + out.push(' * `@theme` block on, so a shadow-DOM consumer that sees --base-scale/--spacing sees'); + out.push(' * these too (verified in the compiled dist/styles.css).'); + out.push(' */'); + out.push('@layer fui.theme {'); + // Selector split across two lines: prettier formats `:root, :host {` that way, and this + // file is prettier-checked in CI — a single-line form makes the generator's `--check` + // and the formatter disagree forever. + out.push(' :root,'); + out.push(' :host {'); + out.push(' /*'); + out.push(' * Stroke widths — PUBLIC set-contract. Literal base-scale values, deliberately'); + out.push(' * NOT coupled to the --spacing density knob: borders must not thin when layout'); + out.push(' * density changes.'); + out.push(' */'); + out.push(...canonicalStrokes); + out.push(''); + out.push(' /*'); + out.push(' * PRIVATE internal hooks — Tailwind utility generation only (w-thin, p-thick, …)'); + out.push(' * and module-authored var(--spacing-thin) border/outline widths. Do NOT set or'); + out.push(' * read these from consumer code; the public contract is --stroke-width-*.'); + out.push(' */'); + out.push(...strokeHooks); + out.push(''); + out.push(' /* Spacing — numeric-axis aliases; --spacing (see css/index.css) is the density knob. */'); + out.push(...emittedVariables); + out.push(''); + out.push(' /*'); + out.push(' * DEFAULT THEME VALUES (web light) — theming Phase 2b. Since the removal of'); + out.push(" * FluentProvider's runtime theme style tag, these declarations are the sole"); + out.push(' * default value source for the theme-variant canonical variables. Non-default'); + out.push(' * themes are shipped as classes in css/themes.css (same layer); a theme class'); + out.push(' * on any DOM node overrides these for that subtree.'); + out.push(' */'); + for (const { name, canonical } of variantTokens) { + out.push(` ${canonical}: ${defaultTheme[name]};`); + } + out.push(' }'); + out.push('}'); + } + + out.push(''); + + const contents = out.join('\n'); + assertCommentsAreWellFormed(contents); + return contents; +} + +/** + * Renders `css/themes.css` — one CSS class per shipped theme (theming Phase 2b). + * + * THE THEMING CONTRACT: a theme class on ANY DOM node themes that node's subtree (custom + * properties cascade); FluentProvider's `themeClassName` prop applies one to its root and + * propagates it to portals. Theme classes contain ONLY custom-property declarations — + * never styling rules — and live in `@layer fui.theme`, same as the `:root, :host` + * default emission they override. + * + * Each class carries the theme's 433 THEME-VARIANT canonical variables. The 26 + * spacing/stroke tokens are EXCLUDED on purpose: they are theme-invariant (asserted in + * `analyzeThemeEmission`) and already emitted at `:root, :host` in their density-knob + * calc form — a literal per-theme re-emission would freeze `--spacing` overrides inside + * themed subtrees. zIndex tokens are theme-absent; their defaults ride the `tokens.*` + * var() fallback. + * + * @returns {string} + */ +function renderThemes() { + const tokensPackage = JSON.parse(fs.readFileSync(path.join(TOKENS_PACKAGE, 'package.json'), 'utf8')); + const tokens = readTokens(); + readSpacingScale(); + readStrokeWidthScale(); + const { variantTokens, themes, classNames } = analyzeThemeEmission(tokens); + + const out = []; + out.push('/*'); + out.push(' * DO NOT EDIT — generated file.'); + out.push(' *'); + out.push(` * Generator: ${GENERATOR_ID}`); + out.push(` * Source: ${tokensPackage.name}@${tokensPackage.version} (packages/tokens/theme-values.json)`); + out.push(` * Regenerate: node ${GENERATOR_ID}`); + out.push(` * Verify: node ${GENERATOR_ID} --check`); + out.push(' *'); + out.push(' * SHIPPED THEME CLASSES (theming Phase 2b). Applying one of these classes to any'); + out.push(' * DOM node themes that subtree — custom properties cascade. They contain ONLY'); + out.push(' * custom-property declarations, in the same fui.theme layer as the web-light'); + out.push(' * defaults at :root/:host (css/tokens.css), which they override element-locally.'); + out.push(' * The JS constants for these class names ship from @fluentui/tokens /'); + out.push(' * @fluentui/react-theme (webLightThemeClassName, …) — the generator asserts the'); + out.push(' * lockstep on every run.'); + out.push(' *'); + out.push(' * The 26 spacing/strokeWidth tokens are deliberately absent: theme-invariant,'); + out.push(' * emitted once at :root/:host in density-knob form (see css/tokens.css).'); + out.push(' */'); + + for (const [themeName, className] of Object.entries(classNames)) { + const theme = themes[themeName]; + out.push(''); + out.push(`/* ${themeName} — ${variantTokens.length} tokens */`); + out.push('@layer fui.theme {'); + out.push(` .${className} {`); + for (const { name, canonical } of variantTokens) { + out.push(` ${canonical}: ${theme[name]};`); + } + out.push(' }'); + out.push('}'); + } + + out.push(''); + + const contents = out.join('\n'); + assertCommentsAreWellFormed(contents); + return contents; +} + +/** + * Guards against a comment-terminator sequence sneaking into generated comment text. A + * utility list written as `shadow-color` silently ends the comment and turns + * the rest of the header into garbage declarations — this caught exactly that during + * development. Verifies every opener is closed and that openers and closers balance. + * + * @param {string} css + */ +function assertCommentsAreWellFormed(css) { + let index = 0; + while (index < css.length) { + const open = css.indexOf('/*', index); + if (open < 0) { + break; + } + const close = css.indexOf('*/', open + 2); + if (close < 0) { + throw new Error(`Generated CSS has an unterminated comment starting at offset ${open}.`); + } + index = close + 2; + } + const opens = (css.match(/\/\*/g) || []).length; + const closes = (css.match(/\*\//g) || []).length; + if (opens !== closes) { + throw new Error(`Generated CSS has ${opens} comment openers but ${closes} closers — a comment body contains "*/".`); + } +} + +function main() { + const argv = process.argv.slice(2); + const check = argv.includes('--check'); + + const outIndex = argv.indexOf('--out'); + const outPath = outIndex >= 0 ? path.resolve(argv[outIndex + 1]) : DEFAULT_OUTPUT; + + const modifiersIndex = argv.indexOf('--modifiers'); + const modifiers = modifiersIndex >= 0 ? argv[modifiersIndex + 1] : undefined; + + const files = [ + { path: outPath, contents: render({ modifiers }) }, + // themes.css is only written/checked alongside the canonical tokens.css — `--out` + // runs are probe runs of the registration block. + ...(outIndex >= 0 ? [] : [{ path: THEMES_OUTPUT, contents: renderThemes() }]), + ]; + + for (const file of files) { + const relative = path.relative(REPO_ROOT, file.path); + + if (check) { + const existing = fs.existsSync(file.path) ? fs.readFileSync(file.path, 'utf8') : null; + if (existing === null) { + console.error(`[generate-tokens-css] MISSING: ${relative}`); + console.error(`[generate-tokens-css] Run: node ${GENERATOR_ID}`); + process.exitCode = 1; + continue; + } + if (existing.replace(/\r\n/g, '\n') !== file.contents) { + console.error(`[generate-tokens-css] STALE: ${relative} differs from generator output.`); + console.error(`[generate-tokens-css] Run: node ${GENERATOR_ID}`); + process.exitCode = 1; + continue; + } + console.log(`[generate-tokens-css] OK: ${relative} is up to date.`); + continue; + } + + fs.mkdirSync(path.dirname(file.path), { recursive: true }); + fs.writeFileSync(file.path, file.contents); + console.log(`[generate-tokens-css] wrote ${relative} (${Buffer.byteLength(file.contents)} bytes)`); + } +} + +if (require.main === module) { + main(); +} + +module.exports = { + kebabCase, + classify, + readTokens, + readSpacingScale, + readStrokeWidthScale, + readThemeClassNames, + readThemeValues, + analyzeThemeEmission, + render, + renderThemes, + spacingTokenValue, + strokeWidthValue, + strokeWidthCanonicalName, + strokeWidthCanonicalValue, + SPACING_BASE_PX, + NAMESPACES, + EXCLUSIONS, + SPACING_SCALE, + STROKE_WIDTH_SCALE, +}; diff --git a/packages/react-components/react-tailwind-theme-preview/scripts/probe-spacing-behavior.mjs b/packages/react-components/react-tailwind-theme-preview/scripts/probe-spacing-behavior.mjs new file mode 100644 index 00000000000000..b3ce33b8d2d8f4 --- /dev/null +++ b/packages/react-components/react-tailwind-theme-preview/scripts/probe-spacing-behavior.mjs @@ -0,0 +1,165 @@ +// @ts-check +/** + * Behavior probes for the spacing token structure (theming Phase 1, option B — settled + * with user 2026-07-29). Loads the REAL compiled theme artifact (dist/styles.css) plus + * utilities compiled from the real css/index.css into headless Chromium and asserts + * computed styles — jsdom cannot resolve var()/calc() chains, so this is a browser test + * by necessity. + * + * A. DENSITY KNOB — overriding `--spacing` on a subtree rescales a numeric utility + * (p-12) and a named-token utility (p-horizontal-m) IDENTICALLY. This is the property + * the numeric-axis alias structure buys; it was RED under the previous literal + * `calc(12px * var(--base-scale))` registration (named utilities ignored --spacing). + * B. CANONICAL READS — the strings the `tokens.*` JS constants now carry + * (`var(--spacing-horizontal-m)`, `var(--stroke-width-thin)`) resolve outside any + * FluentProvider; the private hook `var(--spacing-thin)` (sanctioned module authoring + * for border/outline widths) resolves identically to the canonical. + * C. STROKE DECOUPLING — `var(--stroke-width-thin)` / `var(--spacing-thin)` do NOT + * respond to a `--spacing` override: borders must not thin when layout density + * changes (deliberate design exception). + * D. OLD NAMES ARE GONE (option B) — `var(--spacingHorizontalM)` / + * `var(--strokeWidthThin)` do NOT resolve anywhere in the emitted CSS. + * E. DEFAULT ARITHMETIC — at a 16px root everything computes to the canonical px. + * + * Run: node packages/react-components/react-tailwind-theme-preview/scripts/probe-spacing-behavior.mjs + * Requires dist/styles.css (run `node build.js` in this package first). + */ +import { createRequire } from 'node:module'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const REPO_ROOT = path.resolve(PACKAGE_ROOT, '..', '..', '..'); +const require_ = createRequire(path.join(REPO_ROOT, 'package.json')); + +const DIST = path.join(PACKAGE_ROOT, 'dist', 'styles.css'); +if (!fs.existsSync(DIST)) { + console.error('[probe-spacing-behavior] dist/styles.css missing — run `node build.js` first.'); + process.exit(2); +} + +async function compileUtilities() { + const postcss = require_('postcss'); + const tailwind = require_('@tailwindcss/postcss'); + const html = '
'; + const probeHtml = path.join(PACKAGE_ROOT, 'dist', 'probe-spacing-behavior.html'); + fs.writeFileSync(probeHtml, html); + const entryPath = path.join(PACKAGE_ROOT, 'dist', 'probe-spacing-behavior.entry.css'); + const entry = `@import '${path + .join(PACKAGE_ROOT, 'css', 'index.css') + .replace(/\\/g, '/')}' source(none);\n@source './probe-spacing-behavior.html';\n`; + fs.writeFileSync(entryPath, entry); + const result = await postcss([tailwind()]).process(entry, { from: entryPath }); + fs.rmSync(probeHtml); + fs.rmSync(entryPath); + return result.css; +} + +const utilitiesCss = await compileUtilities(); +const themeCss = fs.readFileSync(DIST, 'utf8'); + +// Option B removal — hard assertion at the artifact level before the browser even opens. +const OLD_NAME = /--spacingHorizontal|--spacingVertical|--strokeWidth(Thin|Thick|Thicker|Thickest)/; +if (OLD_NAME.test(themeCss) || OLD_NAME.test(utilitiesCss)) { + console.error('[probe-spacing-behavior] FAIL: old camelCase token names found in emitted CSS.'); + process.exit(1); +} + +const PAGE = ` + + + + +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+`; + +const { chromium } = require_('playwright'); +const browser = await chromium.launch(); +const page = await browser.newPage(); +await page.setContent(PAGE); + +/** @param {string} id @param {string} prop */ +const computed = (id, prop) => + page.evaluate( + ([i, p]) => getComputedStyle(/** @type {Element} */ (document.getElementById(i))).getPropertyValue(p), + [id, prop], + ); + +let failures = 0; +/** @param {string} label @param {unknown} actual @param {unknown} expected */ +function assertEq(label, actual, expected) { + const ok = actual === expected; + console.log(`${ok ? 'PASS' : 'FAIL'}: ${label} — got \`${actual}\`${ok ? '' : ` expected \`${expected}\``}`); + if (!ok) failures++; +} + +// E. Default arithmetic — canonical px at a 16px root. +assertEq('default: .p-12 padding-top', await computed('numeric', 'padding-top'), '12px'); +assertEq('default: .p-horizontal-m padding-top', await computed('named', 'padding-top'), '12px'); + +// B. Canonical reads at :root scope (no FluentProvider on this page) — the exact strings +// the repointed tokens.* constants carry, plus the private hook and the utility form. +assertEq('tokens.*: var(--spacing-horizontal-m) padding-top', await computed('tokens-spacing', 'padding-top'), '12px'); +assertEq( + 'tokens.*: var(--stroke-width-thin) border-top-width', + await computed('tokens-stroke', 'border-top-width'), + '1px', +); +assertEq('hook: var(--spacing-thin) border-top-width', await computed('hook-stroke', 'border-top-width'), '1px'); +assertEq('utility: .w-thin width', await computed('util-stroke', 'width'), '1px'); + +// D. Old names are gone — unresolved var() → padding falls to its 0 initial; the +// old-name border must NOT compute to the canonical 1px. +assertEq('removed: var(--spacingHorizontalM) padding-top', await computed('old-spacing', 'padding-top'), '0px'); +const oldStroke = await computed('old-stroke', 'border-top-width'); +{ + const ok = oldStroke !== '1px'; + console.log( + `${ok ? 'PASS' : 'FAIL'}: removed: var(--strokeWidthThin) border-top-width does not resolve — got \`${oldStroke}\``, + ); + if (!ok) failures++; +} + +// A. Density knob — subtree --spacing override doubles BOTH forms identically. +assertEq('dense: .p-12 padding-top', await computed('dense-numeric', 'padding-top'), '24px'); +assertEq('dense: .p-horizontal-m padding-top', await computed('dense-named', 'padding-top'), '24px'); +assertEq( + 'dense: numeric === named response', + await computed('dense-numeric', 'padding-top'), + await computed('dense-named', 'padding-top'), +); +assertEq('dense: .p-24 padding-top', await computed('dense-numeric-24', 'padding-top'), '48px'); +assertEq('dense: .p-horizontal-xxl padding-top', await computed('dense-named-xxl', 'padding-top'), '48px'); + +// C. Stroke decoupling — the SAME subtree override must NOT thin/thicken borders. +assertEq( + 'dense: var(--stroke-width-thin) border-top-width unchanged', + await computed('dense-stroke', 'border-top-width'), + '1px', +); +assertEq( + 'dense: var(--spacing-thin) border-top-width unchanged', + await computed('dense-hook', 'border-top-width'), + '1px', +); + +await browser.close(); +console.log(failures === 0 ? '[probe-spacing-behavior] ALL PASS' : `[probe-spacing-behavior] ${failures} FAILURES`); +process.exit(failures === 0 ? 0 : 1); diff --git a/packages/react-components/react-tailwind-theme-preview/theme-class-names.d.mts b/packages/react-components/react-tailwind-theme-preview/theme-class-names.d.mts new file mode 100644 index 00000000000000..7dd5d6af9a0119 --- /dev/null +++ b/packages/react-components/react-tailwind-theme-preview/theme-class-names.d.mts @@ -0,0 +1,28 @@ +export declare const webLightThemeClassName: 'fui-theme-web-light'; +export declare const webDarkThemeClassName: 'fui-theme-web-dark'; +export declare const teamsLightThemeClassName: 'fui-theme-teams-light'; +export declare const teamsDarkThemeClassName: 'fui-theme-teams-dark'; +export declare const teamsHighContrastThemeClassName: 'fui-theme-teams-high-contrast'; +export declare const teamsLightV21ThemeClassName: 'fui-theme-teams-light-v21'; +export declare const teamsDarkV21ThemeClassName: 'fui-theme-teams-dark-v21'; + +/** Union of the shipped theme class names. */ +export type ThemeClassName = + | typeof webLightThemeClassName + | typeof webDarkThemeClassName + | typeof teamsLightThemeClassName + | typeof teamsDarkThemeClassName + | typeof teamsHighContrastThemeClassName + | typeof teamsLightV21ThemeClassName + | typeof teamsDarkV21ThemeClassName; + +/** Map from the historical theme export name (`webLightTheme`, …) to its CSS class name. */ +export declare const themeClassNames: { + readonly webLightTheme: typeof webLightThemeClassName; + readonly webDarkTheme: typeof webDarkThemeClassName; + readonly teamsLightTheme: typeof teamsLightThemeClassName; + readonly teamsDarkTheme: typeof teamsDarkThemeClassName; + readonly teamsHighContrastTheme: typeof teamsHighContrastThemeClassName; + readonly teamsLightV21Theme: typeof teamsLightV21ThemeClassName; + readonly teamsDarkV21Theme: typeof teamsDarkV21ThemeClassName; +}; diff --git a/packages/react-components/react-tailwind-theme-preview/theme-class-names.mjs b/packages/react-components/react-tailwind-theme-preview/theme-class-names.mjs new file mode 100644 index 00000000000000..e4021f3d8b0709 --- /dev/null +++ b/packages/react-components/react-tailwind-theme-preview/theme-class-names.mjs @@ -0,0 +1,41 @@ +/** + * CSS class names for the shipped Fluent themes. + * + * Each class is emitted, at build time, by `@fluentui/react-tailwind-theme-preview` + * inside `@layer fui.theme` and contains ONLY custom-property declarations — the + * canonical kebab-case token variables (`--color-neutral-background-1: …`). + * Applying one of these classes to any DOM node themes that node's subtree; the + * default (web light) values are emitted at `:root, :host`, so a theme class is + * only needed for non-default themes or scoped theming. + * + * These constants are the JS read path for the class names — pass one to + * react-windmod-preview's `ThemeProvider` or put it on any element directly. + * + * LOCKSTEP CONTRACT: `scripts/generate-tokens-css.js` derives the emitted class + * names from the theme export names in `theme-values.json` and asserts they equal + * the constants in this file (it text-reads this file). Renaming a theme or a + * class requires updating both in the same change. + */ + +export const webLightThemeClassName = 'fui-theme-web-light'; +export const webDarkThemeClassName = 'fui-theme-web-dark'; +export const teamsLightThemeClassName = 'fui-theme-teams-light'; +export const teamsDarkThemeClassName = 'fui-theme-teams-dark'; +export const teamsHighContrastThemeClassName = 'fui-theme-teams-high-contrast'; +export const teamsLightV21ThemeClassName = 'fui-theme-teams-light-v21'; +export const teamsDarkV21ThemeClassName = 'fui-theme-teams-dark-v21'; + +/** + * Map from the historical theme export name (`webLightTheme`, …) to its CSS class + * name. Handy for harnesses that enumerate themes; the per-theme constants above + * are the primary read path. + */ +export const themeClassNames = { + webLightTheme: webLightThemeClassName, + webDarkTheme: webDarkThemeClassName, + teamsLightTheme: teamsLightThemeClassName, + teamsDarkTheme: teamsDarkThemeClassName, + teamsHighContrastTheme: teamsHighContrastThemeClassName, + teamsLightV21Theme: teamsLightV21ThemeClassName, + teamsDarkV21Theme: teamsDarkV21ThemeClassName, +}; diff --git a/packages/react-components/react-tailwind-theme-preview/theme-values.json b/packages/react-components/react-tailwind-theme-preview/theme-values.json new file mode 100644 index 00000000000000..30eb7f57f13da2 --- /dev/null +++ b/packages/react-components/react-tailwind-theme-preview/theme-values.json @@ -0,0 +1,3235 @@ +{ + "$schema": "fluent-theme-values", + "description": "Committed snapshot of the shipped Fluent theme objects (theme key -> value). Consumed by @fluentui/react-tailwind-theme/scripts/generate-tokens-css.js to emit the static theme CSS classes; drift against the computed themes fails src/themes/themeValues.test.ts. Regenerate with `yarn workspace @fluentui/tokens generate-theme-values` after `yarn nx run tokens:build`.", + "generatedBy": "packages/tokens/scripts/generate-theme-values.js", + "source": "@fluentui/react-tailwind-theme-preview@9.0.0-alpha.0", + "themes": { + "webLightTheme": { + "borderRadiusNone": "0", + "borderRadiusSmall": "2px", + "borderRadiusMedium": "4px", + "borderRadiusLarge": "6px", + "borderRadiusXLarge": "8px", + "borderRadius2XLarge": "12px", + "borderRadius3XLarge": "16px", + "borderRadius4XLarge": "24px", + "borderRadius5XLarge": "32px", + "borderRadius6XLarge": "40px", + "borderRadiusCircular": "10000px", + "fontSizeBase100": "10px", + "fontSizeBase200": "12px", + "fontSizeBase300": "14px", + "fontSizeBase400": "16px", + "fontSizeBase500": "20px", + "fontSizeBase600": "24px", + "fontSizeHero700": "28px", + "fontSizeHero800": "32px", + "fontSizeHero900": "40px", + "fontSizeHero1000": "68px", + "lineHeightBase100": "14px", + "lineHeightBase200": "16px", + "lineHeightBase300": "20px", + "lineHeightBase400": "22px", + "lineHeightBase500": "28px", + "lineHeightBase600": "32px", + "lineHeightHero700": "36px", + "lineHeightHero800": "40px", + "lineHeightHero900": "52px", + "lineHeightHero1000": "92px", + "fontFamilyBase": "'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', sans-serif", + "fontFamilyMonospace": "Consolas, 'Courier New', Courier, monospace", + "fontFamilyNumeric": "Bahnschrift, 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', sans-serif", + "fontWeightRegular": 400, + "fontWeightMedium": 500, + "fontWeightSemibold": 600, + "fontWeightBold": 700, + "strokeWidthThin": "1px", + "strokeWidthThick": "2px", + "strokeWidthThicker": "3px", + "strokeWidthThickest": "4px", + "spacingHorizontalNone": "0", + "spacingHorizontalXXS": "2px", + "spacingHorizontalXS": "4px", + "spacingHorizontalSNudge": "6px", + "spacingHorizontalS": "8px", + "spacingHorizontalMNudge": "10px", + "spacingHorizontalM": "12px", + "spacingHorizontalL": "16px", + "spacingHorizontalXL": "20px", + "spacingHorizontalXXL": "24px", + "spacingHorizontalXXXL": "32px", + "spacingVerticalNone": "0", + "spacingVerticalXXS": "2px", + "spacingVerticalXS": "4px", + "spacingVerticalSNudge": "6px", + "spacingVerticalS": "8px", + "spacingVerticalMNudge": "10px", + "spacingVerticalM": "12px", + "spacingVerticalL": "16px", + "spacingVerticalXL": "20px", + "spacingVerticalXXL": "24px", + "spacingVerticalXXXL": "32px", + "durationUltraFast": "50ms", + "durationFaster": "100ms", + "durationFast": "150ms", + "durationNormal": "200ms", + "durationGentle": "250ms", + "durationSlow": "300ms", + "durationSlower": "400ms", + "durationUltraSlow": "500ms", + "curveAccelerateMax": "cubic-bezier(0.9,0.1,1,0.2)", + "curveAccelerateMid": "cubic-bezier(1,0,1,1)", + "curveAccelerateMin": "cubic-bezier(0.8,0,0.78,1)", + "curveDecelerateMax": "cubic-bezier(0.1,0.9,0.2,1)", + "curveDecelerateMid": "cubic-bezier(0,0,0,1)", + "curveDecelerateMin": "cubic-bezier(0.33,0,0.1,1)", + "curveEasyEaseMax": "cubic-bezier(0.8,0,0.2,1)", + "curveEasyEase": "cubic-bezier(0.33,0,0.67,1)", + "curveLinear": "cubic-bezier(0,0,1,1)", + "colorNeutralForeground1": "#242424", + "colorNeutralForeground1Hover": "#242424", + "colorNeutralForeground1Pressed": "#242424", + "colorNeutralForeground1Selected": "#242424", + "colorNeutralForeground2": "#424242", + "colorNeutralForeground2Hover": "#242424", + "colorNeutralForeground2Pressed": "#242424", + "colorNeutralForeground2Selected": "#242424", + "colorNeutralForeground2BrandHover": "#0f6cbd", + "colorNeutralForeground2BrandPressed": "#115ea3", + "colorNeutralForeground2BrandSelected": "#0f6cbd", + "colorNeutralForeground3": "#616161", + "colorNeutralForeground3Hover": "#424242", + "colorNeutralForeground3Pressed": "#424242", + "colorNeutralForeground3Selected": "#424242", + "colorNeutralForeground3BrandHover": "#0f6cbd", + "colorNeutralForeground3BrandPressed": "#115ea3", + "colorNeutralForeground3BrandSelected": "#0f6cbd", + "colorNeutralForeground4": "#707070", + "colorNeutralForeground5": "#616161", + "colorNeutralForeground5Hover": "#242424", + "colorNeutralForeground5Pressed": "#242424", + "colorNeutralForeground5Selected": "#242424", + "colorNeutralForegroundDisabled": "#bdbdbd", + "colorNeutralForegroundInvertedDisabled": "rgba(255, 255, 255, 0.4)", + "colorBrandForegroundLink": "#115ea3", + "colorBrandForegroundLinkHover": "#0f548c", + "colorBrandForegroundLinkPressed": "#0c3b5e", + "colorBrandForegroundLinkSelected": "#115ea3", + "colorNeutralForeground2Link": "#424242", + "colorNeutralForeground2LinkHover": "#242424", + "colorNeutralForeground2LinkPressed": "#242424", + "colorNeutralForeground2LinkSelected": "#242424", + "colorCompoundBrandForeground1": "#0f6cbd", + "colorCompoundBrandForeground1Hover": "#115ea3", + "colorCompoundBrandForeground1Pressed": "#0f548c", + "colorBrandForeground1": "#0f6cbd", + "colorBrandForeground2": "#115ea3", + "colorBrandForeground2Hover": "#0f548c", + "colorBrandForeground2Pressed": "#0a2e4a", + "colorNeutralForeground1Static": "#242424", + "colorNeutralForegroundStaticInverted": "#ffffff", + "colorNeutralForegroundInverted": "#ffffff", + "colorNeutralForegroundInvertedHover": "#ffffff", + "colorNeutralForegroundInvertedPressed": "#ffffff", + "colorNeutralForegroundInvertedSelected": "#ffffff", + "colorNeutralForegroundInverted2": "#ffffff", + "colorNeutralForegroundOnBrand": "#ffffff", + "colorNeutralForegroundInvertedLink": "#ffffff", + "colorNeutralForegroundInvertedLinkHover": "#ffffff", + "colorNeutralForegroundInvertedLinkPressed": "#ffffff", + "colorNeutralForegroundInvertedLinkSelected": "#ffffff", + "colorBrandForegroundInverted": "#479ef5", + "colorBrandForegroundInvertedHover": "#62abf5", + "colorBrandForegroundInvertedPressed": "#479ef5", + "colorBrandForegroundOnLight": "#0f6cbd", + "colorBrandForegroundOnLightHover": "#115ea3", + "colorBrandForegroundOnLightPressed": "#0e4775", + "colorBrandForegroundOnLightSelected": "#0f548c", + "colorNeutralBackground1": "#ffffff", + "colorNeutralBackground1Hover": "#f5f5f5", + "colorNeutralBackground1Pressed": "#e0e0e0", + "colorNeutralBackground1Selected": "#ebebeb", + "colorNeutralBackground2": "#fafafa", + "colorNeutralBackground2Hover": "#f0f0f0", + "colorNeutralBackground2Pressed": "#dbdbdb", + "colorNeutralBackground2Selected": "#e6e6e6", + "colorNeutralBackground3": "#f5f5f5", + "colorNeutralBackground3Hover": "#ebebeb", + "colorNeutralBackground3Pressed": "#d6d6d6", + "colorNeutralBackground3Selected": "#e0e0e0", + "colorNeutralBackground4": "#f0f0f0", + "colorNeutralBackground4Hover": "#fafafa", + "colorNeutralBackground4Pressed": "#f5f5f5", + "colorNeutralBackground4Selected": "#ffffff", + "colorNeutralBackground5": "#ebebeb", + "colorNeutralBackground5Hover": "#f5f5f5", + "colorNeutralBackground5Pressed": "#f0f0f0", + "colorNeutralBackground5Selected": "#fafafa", + "colorNeutralBackground6": "#e6e6e6", + "colorNeutralBackground7": "#00000000", + "colorNeutralBackground7Hover": "#ebebeb", + "colorNeutralBackground7Pressed": "#d6d6d6", + "colorNeutralBackground7Selected": "#00000000", + "colorNeutralBackground8": "#fcfcfc", + "colorNeutralBackgroundInverted": "#292929", + "colorNeutralBackgroundInvertedHover": "#3d3d3d", + "colorNeutralBackgroundInvertedPressed": "#1f1f1f", + "colorNeutralBackgroundInvertedSelected": "#383838", + "colorNeutralBackgroundStatic": "#333333", + "colorNeutralBackgroundAlpha": "rgba(255, 255, 255, 0.5)", + "colorNeutralBackgroundAlpha2": "rgba(255, 255, 255, 0.8)", + "colorSubtleBackground": "transparent", + "colorSubtleBackgroundHover": "#f5f5f5", + "colorSubtleBackgroundPressed": "#e0e0e0", + "colorSubtleBackgroundSelected": "#ebebeb", + "colorSubtleBackgroundLightAlphaHover": "rgba(255, 255, 255, 0.7)", + "colorSubtleBackgroundLightAlphaPressed": "rgba(255, 255, 255, 0.5)", + "colorSubtleBackgroundLightAlphaSelected": "transparent", + "colorSubtleBackgroundInverted": "transparent", + "colorSubtleBackgroundInvertedHover": "rgba(0, 0, 0, 0.1)", + "colorSubtleBackgroundInvertedPressed": "rgba(0, 0, 0, 0.3)", + "colorSubtleBackgroundInvertedSelected": "rgba(0, 0, 0, 0.2)", + "colorTransparentBackground": "transparent", + "colorTransparentBackgroundHover": "transparent", + "colorTransparentBackgroundPressed": "transparent", + "colorTransparentBackgroundSelected": "transparent", + "colorNeutralBackgroundDisabled": "#f0f0f0", + "colorNeutralBackgroundDisabled2": "#ffffff", + "colorNeutralBackgroundInvertedDisabled": "rgba(255, 255, 255, 0.1)", + "colorNeutralStencil1": "#e6e6e6", + "colorNeutralStencil2": "#fafafa", + "colorNeutralStencil1Alpha": "rgba(0, 0, 0, 0.1)", + "colorNeutralStencil2Alpha": "rgba(0, 0, 0, 0.05)", + "colorBackgroundOverlay": "rgba(0, 0, 0, 0.4)", + "colorScrollbarOverlay": "rgba(0, 0, 0, 0.5)", + "colorBrandBackground": "#0f6cbd", + "colorBrandBackgroundHover": "#115ea3", + "colorBrandBackgroundPressed": "#0c3b5e", + "colorBrandBackgroundSelected": "#0f548c", + "colorCompoundBrandBackground": "#0f6cbd", + "colorCompoundBrandBackgroundHover": "#115ea3", + "colorCompoundBrandBackgroundPressed": "#0f548c", + "colorBrandBackgroundStatic": "#0f6cbd", + "colorBrandBackground2": "#ebf3fc", + "colorBrandBackground2Hover": "#cfe4fa", + "colorBrandBackground2Pressed": "#96c6fa", + "colorBrandBackground3Static": "#0f548c", + "colorBrandBackground4Static": "#0c3b5e", + "colorBrandBackgroundInverted": "#ffffff", + "colorBrandBackgroundInvertedHover": "#ebf3fc", + "colorBrandBackgroundInvertedPressed": "#b4d6fa", + "colorBrandBackgroundInvertedSelected": "#cfe4fa", + "colorNeutralCardBackground": "#fafafa", + "colorNeutralCardBackgroundHover": "#ffffff", + "colorNeutralCardBackgroundPressed": "#f5f5f5", + "colorNeutralCardBackgroundSelected": "#ebebeb", + "colorNeutralCardBackgroundDisabled": "#f0f0f0", + "colorNeutralStrokeAccessible": "#616161", + "colorNeutralStrokeAccessibleHover": "#575757", + "colorNeutralStrokeAccessiblePressed": "#4d4d4d", + "colorNeutralStrokeAccessibleSelected": "#0f6cbd", + "colorNeutralStroke1": "#d1d1d1", + "colorNeutralStroke1Hover": "#c7c7c7", + "colorNeutralStroke1Pressed": "#b3b3b3", + "colorNeutralStroke1Selected": "#bdbdbd", + "colorNeutralStroke2": "#e0e0e0", + "colorNeutralStroke3": "#f0f0f0", + "colorNeutralStroke4": "#ebebeb", + "colorNeutralStroke4Hover": "#e0e0e0", + "colorNeutralStroke4Pressed": "#d6d6d6", + "colorNeutralStroke4Selected": "#ebebeb", + "colorNeutralStrokeSubtle": "#e0e0e0", + "colorNeutralStrokeOnBrand": "#ffffff", + "colorNeutralStrokeOnBrand2": "#ffffff", + "colorNeutralStrokeOnBrand2Hover": "#ffffff", + "colorNeutralStrokeOnBrand2Pressed": "#ffffff", + "colorNeutralStrokeOnBrand2Selected": "#ffffff", + "colorBrandStroke1": "#0f6cbd", + "colorBrandStroke2": "#b4d6fa", + "colorBrandStroke2Hover": "#77b7f7", + "colorBrandStroke2Pressed": "#0f6cbd", + "colorBrandStroke2Contrast": "#b4d6fa", + "colorCompoundBrandStroke": "#0f6cbd", + "colorCompoundBrandStrokeHover": "#115ea3", + "colorCompoundBrandStrokePressed": "#0f548c", + "colorNeutralStrokeDisabled": "#e0e0e0", + "colorNeutralStrokeDisabled2": "#ebebeb", + "colorNeutralStrokeInvertedDisabled": "rgba(255, 255, 255, 0.4)", + "colorTransparentStroke": "transparent", + "colorTransparentStrokeInteractive": "transparent", + "colorTransparentStrokeDisabled": "transparent", + "colorNeutralStrokeAlpha": "rgba(0, 0, 0, 0.05)", + "colorNeutralStrokeAlpha2": "rgba(255, 255, 255, 0.2)", + "colorStrokeFocus1": "#ffffff", + "colorStrokeFocus2": "#000000", + "colorNeutralShadowAmbient": "rgba(0,0,0,0.12)", + "colorNeutralShadowKey": "rgba(0,0,0,0.14)", + "colorNeutralShadowAmbientLighter": "rgba(0,0,0,0.06)", + "colorNeutralShadowKeyLighter": "rgba(0,0,0,0.07)", + "colorNeutralShadowAmbientDarker": "rgba(0,0,0,0.20)", + "colorNeutralShadowKeyDarker": "rgba(0,0,0,0.24)", + "colorBrandShadowAmbient": "rgba(0,0,0,0.30)", + "colorBrandShadowKey": "rgba(0,0,0,0.25)", + "colorPaletteRedBackground1": "#fdf6f6", + "colorPaletteRedBackground2": "#f1bbbc", + "colorPaletteRedBackground3": "#d13438", + "colorPaletteRedForeground1": "#bc2f32", + "colorPaletteRedForeground2": "#751d1f", + "colorPaletteRedForeground3": "#d13438", + "colorPaletteRedBorderActive": "#d13438", + "colorPaletteRedBorder1": "#f1bbbc", + "colorPaletteRedBorder2": "#d13438", + "colorPaletteGreenBackground1": "#f1faf1", + "colorPaletteGreenBackground2": "#9fd89f", + "colorPaletteGreenBackground3": "#107c10", + "colorPaletteGreenForeground1": "#0e700e", + "colorPaletteGreenForeground2": "#094509", + "colorPaletteGreenForeground3": "#107c10", + "colorPaletteGreenBorderActive": "#107c10", + "colorPaletteGreenBorder1": "#9fd89f", + "colorPaletteGreenBorder2": "#107c10", + "colorPaletteDarkOrangeBackground1": "#fdf6f3", + "colorPaletteDarkOrangeBackground2": "#f4bfab", + "colorPaletteDarkOrangeBackground3": "#da3b01", + "colorPaletteDarkOrangeForeground1": "#c43501", + "colorPaletteDarkOrangeForeground2": "#7a2101", + "colorPaletteDarkOrangeForeground3": "#da3b01", + "colorPaletteDarkOrangeBorderActive": "#da3b01", + "colorPaletteDarkOrangeBorder1": "#f4bfab", + "colorPaletteDarkOrangeBorder2": "#da3b01", + "colorPaletteYellowBackground1": "#fffef5", + "colorPaletteYellowBackground2": "#fef7b2", + "colorPaletteYellowBackground3": "#fde300", + "colorPaletteYellowForeground1": "#817400", + "colorPaletteYellowForeground2": "#817400", + "colorPaletteYellowForeground3": "#fde300", + "colorPaletteYellowBorderActive": "#fde300", + "colorPaletteYellowBorder1": "#fef7b2", + "colorPaletteYellowBorder2": "#fde300", + "colorPaletteBerryBackground1": "#fdf5fc", + "colorPaletteBerryBackground2": "#edbbe7", + "colorPaletteBerryBackground3": "#c239b3", + "colorPaletteBerryForeground1": "#af33a1", + "colorPaletteBerryForeground2": "#6d2064", + "colorPaletteBerryForeground3": "#c239b3", + "colorPaletteBerryBorderActive": "#c239b3", + "colorPaletteBerryBorder1": "#edbbe7", + "colorPaletteBerryBorder2": "#c239b3", + "colorPaletteLightGreenBackground1": "#f2fbf2", + "colorPaletteLightGreenBackground2": "#a7e3a5", + "colorPaletteLightGreenBackground3": "#13a10e", + "colorPaletteLightGreenForeground1": "#11910d", + "colorPaletteLightGreenForeground2": "#0b5a08", + "colorPaletteLightGreenForeground3": "#13a10e", + "colorPaletteLightGreenBorderActive": "#13a10e", + "colorPaletteLightGreenBorder1": "#a7e3a5", + "colorPaletteLightGreenBorder2": "#13a10e", + "colorPaletteMarigoldBackground1": "#fefbf4", + "colorPaletteMarigoldBackground2": "#f9e2ae", + "colorPaletteMarigoldBackground3": "#eaa300", + "colorPaletteMarigoldForeground1": "#d39300", + "colorPaletteMarigoldForeground2": "#835b00", + "colorPaletteMarigoldForeground3": "#eaa300", + "colorPaletteMarigoldBorderActive": "#eaa300", + "colorPaletteMarigoldBorder1": "#f9e2ae", + "colorPaletteMarigoldBorder2": "#eaa300", + "colorPaletteRedForegroundInverted": "#dc5e62", + "colorPaletteGreenForegroundInverted": "#359b35", + "colorPaletteYellowForegroundInverted": "#fef7b2", + "colorPaletteDarkRedBackground2": "#d69ca5", + "colorPaletteDarkRedForeground2": "#420610", + "colorPaletteDarkRedBorderActive": "#750b1c", + "colorPaletteCranberryBackground2": "#eeacb2", + "colorPaletteCranberryForeground2": "#6e0811", + "colorPaletteCranberryBorderActive": "#c50f1f", + "colorPalettePumpkinBackground2": "#efc4ad", + "colorPalettePumpkinForeground2": "#712d09", + "colorPalettePumpkinBorderActive": "#ca5010", + "colorPalettePeachBackground2": "#ffddb3", + "colorPalettePeachForeground2": "#8f4e00", + "colorPalettePeachBorderActive": "#ff8c00", + "colorPaletteGoldBackground2": "#ecdfa5", + "colorPaletteGoldForeground2": "#6c5700", + "colorPaletteGoldBorderActive": "#c19c00", + "colorPaletteBrassBackground2": "#e0cea2", + "colorPaletteBrassForeground2": "#553e06", + "colorPaletteBrassBorderActive": "#986f0b", + "colorPaletteBrownBackground2": "#ddc3b0", + "colorPaletteBrownForeground2": "#50301a", + "colorPaletteBrownBorderActive": "#8e562e", + "colorPaletteForestBackground2": "#bdd99b", + "colorPaletteForestForeground2": "#294903", + "colorPaletteForestBorderActive": "#498205", + "colorPaletteSeafoamBackground2": "#a8f0cd", + "colorPaletteSeafoamForeground2": "#00723b", + "colorPaletteSeafoamBorderActive": "#00cc6a", + "colorPaletteDarkGreenBackground2": "#9ad29a", + "colorPaletteDarkGreenForeground2": "#063b06", + "colorPaletteDarkGreenBorderActive": "#0b6a0b", + "colorPaletteLightTealBackground2": "#a6e9ed", + "colorPaletteLightTealForeground2": "#00666d", + "colorPaletteLightTealBorderActive": "#00b7c3", + "colorPaletteTealBackground2": "#9bd9db", + "colorPaletteTealForeground2": "#02494c", + "colorPaletteTealBorderActive": "#038387", + "colorPaletteSteelBackground2": "#94c8d4", + "colorPaletteSteelForeground2": "#00333f", + "colorPaletteSteelBorderActive": "#005b70", + "colorPaletteBlueBackground2": "#a9d3f2", + "colorPaletteBlueForeground2": "#004377", + "colorPaletteBlueBorderActive": "#0078d4", + "colorPaletteRoyalBlueBackground2": "#9abfdc", + "colorPaletteRoyalBlueForeground2": "#002c4e", + "colorPaletteRoyalBlueBorderActive": "#004e8c", + "colorPaletteCornflowerBackground2": "#c8d1fa", + "colorPaletteCornflowerForeground2": "#2c3c85", + "colorPaletteCornflowerBorderActive": "#4f6bed", + "colorPaletteNavyBackground2": "#a3b2e8", + "colorPaletteNavyForeground2": "#001665", + "colorPaletteNavyBorderActive": "#0027b4", + "colorPaletteLavenderBackground2": "#d2ccf8", + "colorPaletteLavenderForeground2": "#3f3682", + "colorPaletteLavenderBorderActive": "#7160e8", + "colorPalettePurpleBackground2": "#c6b1de", + "colorPalettePurpleForeground2": "#341a51", + "colorPalettePurpleBorderActive": "#5c2e91", + "colorPaletteGrapeBackground2": "#d9a7e0", + "colorPaletteGrapeForeground2": "#4c0d55", + "colorPaletteGrapeBorderActive": "#881798", + "colorPaletteLilacBackground2": "#e6bfed", + "colorPaletteLilacForeground2": "#63276d", + "colorPaletteLilacBorderActive": "#b146c2", + "colorPalettePinkBackground2": "#f7c0e3", + "colorPalettePinkForeground2": "#80215d", + "colorPalettePinkBorderActive": "#e43ba6", + "colorPaletteMagentaBackground2": "#eca5d1", + "colorPaletteMagentaForeground2": "#6b0043", + "colorPaletteMagentaBorderActive": "#bf0077", + "colorPalettePlumBackground2": "#d696c0", + "colorPalettePlumForeground2": "#43002b", + "colorPalettePlumBorderActive": "#77004d", + "colorPaletteBeigeBackground2": "#d7d4d4", + "colorPaletteBeigeForeground2": "#444241", + "colorPaletteBeigeBorderActive": "#7a7574", + "colorPaletteMinkBackground2": "#cecccb", + "colorPaletteMinkForeground2": "#343231", + "colorPaletteMinkBorderActive": "#5d5a58", + "colorPalettePlatinumBackground2": "#cdd6d8", + "colorPalettePlatinumForeground2": "#3b4447", + "colorPalettePlatinumBorderActive": "#69797e", + "colorPaletteAnchorBackground2": "#bcc3c7", + "colorPaletteAnchorForeground2": "#202427", + "colorPaletteAnchorBorderActive": "#394146", + "colorStatusSuccessBackground1": "#f1faf1", + "colorStatusSuccessBackground2": "#9fd89f", + "colorStatusSuccessBackground3": "#107c10", + "colorStatusSuccessForeground1": "#0e700e", + "colorStatusSuccessForeground2": "#094509", + "colorStatusSuccessForeground3": "#107c10", + "colorStatusSuccessForegroundInverted": "#54b054", + "colorStatusSuccessBorderActive": "#107c10", + "colorStatusSuccessBorder1": "#9fd89f", + "colorStatusSuccessBorder2": "#107c10", + "colorStatusWarningBackground1": "#fff9f5", + "colorStatusWarningBackground2": "#fdcfb4", + "colorStatusWarningBackground3": "#f7630c", + "colorStatusWarningForeground1": "#bc4b09", + "colorStatusWarningForeground2": "#8a3707", + "colorStatusWarningForeground3": "#bc4b09", + "colorStatusWarningForegroundInverted": "#faa06b", + "colorStatusWarningBorderActive": "#f7630c", + "colorStatusWarningBorder1": "#fdcfb4", + "colorStatusWarningBorder2": "#bc4b09", + "colorStatusDangerBackground1": "#fdf3f4", + "colorStatusDangerBackground2": "#eeacb2", + "colorStatusDangerBackground3": "#c50f1f", + "colorStatusDangerForeground1": "#b10e1c", + "colorStatusDangerForeground2": "#6e0811", + "colorStatusDangerForeground3": "#c50f1f", + "colorStatusDangerForegroundInverted": "#dc626d", + "colorStatusDangerBorderActive": "#c50f1f", + "colorStatusDangerBorder1": "#eeacb2", + "colorStatusDangerBorder2": "#c50f1f", + "colorStatusDangerBackground3Hover": "#b10e1c", + "colorStatusDangerBackground3Pressed": "#960b18", + "shadow2": "0 0 2px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.14)", + "shadow4": "0 0 2px rgba(0,0,0,0.12), 0 2px 4px rgba(0,0,0,0.14)", + "shadow8": "0 0 2px rgba(0,0,0,0.12), 0 4px 8px rgba(0,0,0,0.14)", + "shadow16": "0 0 2px rgba(0,0,0,0.12), 0 8px 16px rgba(0,0,0,0.14)", + "shadow28": "0 0 8px rgba(0,0,0,0.12), 0 14px 28px rgba(0,0,0,0.14)", + "shadow64": "0 0 8px rgba(0,0,0,0.12), 0 32px 64px rgba(0,0,0,0.14)", + "shadow2Brand": "0 0 2px rgba(0,0,0,0.30), 0 1px 2px rgba(0,0,0,0.25)", + "shadow4Brand": "0 0 2px rgba(0,0,0,0.30), 0 2px 4px rgba(0,0,0,0.25)", + "shadow8Brand": "0 0 2px rgba(0,0,0,0.30), 0 4px 8px rgba(0,0,0,0.25)", + "shadow16Brand": "0 0 2px rgba(0,0,0,0.30), 0 8px 16px rgba(0,0,0,0.25)", + "shadow28Brand": "0 0 8px rgba(0,0,0,0.30), 0 14px 28px rgba(0,0,0,0.25)", + "shadow64Brand": "0 0 8px rgba(0,0,0,0.30), 0 32px 64px rgba(0,0,0,0.25)" + }, + "webDarkTheme": { + "borderRadiusNone": "0", + "borderRadiusSmall": "2px", + "borderRadiusMedium": "4px", + "borderRadiusLarge": "6px", + "borderRadiusXLarge": "8px", + "borderRadius2XLarge": "12px", + "borderRadius3XLarge": "16px", + "borderRadius4XLarge": "24px", + "borderRadius5XLarge": "32px", + "borderRadius6XLarge": "40px", + "borderRadiusCircular": "10000px", + "fontSizeBase100": "10px", + "fontSizeBase200": "12px", + "fontSizeBase300": "14px", + "fontSizeBase400": "16px", + "fontSizeBase500": "20px", + "fontSizeBase600": "24px", + "fontSizeHero700": "28px", + "fontSizeHero800": "32px", + "fontSizeHero900": "40px", + "fontSizeHero1000": "68px", + "lineHeightBase100": "14px", + "lineHeightBase200": "16px", + "lineHeightBase300": "20px", + "lineHeightBase400": "22px", + "lineHeightBase500": "28px", + "lineHeightBase600": "32px", + "lineHeightHero700": "36px", + "lineHeightHero800": "40px", + "lineHeightHero900": "52px", + "lineHeightHero1000": "92px", + "fontFamilyBase": "'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', sans-serif", + "fontFamilyMonospace": "Consolas, 'Courier New', Courier, monospace", + "fontFamilyNumeric": "Bahnschrift, 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', sans-serif", + "fontWeightRegular": 400, + "fontWeightMedium": 500, + "fontWeightSemibold": 600, + "fontWeightBold": 700, + "strokeWidthThin": "1px", + "strokeWidthThick": "2px", + "strokeWidthThicker": "3px", + "strokeWidthThickest": "4px", + "spacingHorizontalNone": "0", + "spacingHorizontalXXS": "2px", + "spacingHorizontalXS": "4px", + "spacingHorizontalSNudge": "6px", + "spacingHorizontalS": "8px", + "spacingHorizontalMNudge": "10px", + "spacingHorizontalM": "12px", + "spacingHorizontalL": "16px", + "spacingHorizontalXL": "20px", + "spacingHorizontalXXL": "24px", + "spacingHorizontalXXXL": "32px", + "spacingVerticalNone": "0", + "spacingVerticalXXS": "2px", + "spacingVerticalXS": "4px", + "spacingVerticalSNudge": "6px", + "spacingVerticalS": "8px", + "spacingVerticalMNudge": "10px", + "spacingVerticalM": "12px", + "spacingVerticalL": "16px", + "spacingVerticalXL": "20px", + "spacingVerticalXXL": "24px", + "spacingVerticalXXXL": "32px", + "durationUltraFast": "50ms", + "durationFaster": "100ms", + "durationFast": "150ms", + "durationNormal": "200ms", + "durationGentle": "250ms", + "durationSlow": "300ms", + "durationSlower": "400ms", + "durationUltraSlow": "500ms", + "curveAccelerateMax": "cubic-bezier(0.9,0.1,1,0.2)", + "curveAccelerateMid": "cubic-bezier(1,0,1,1)", + "curveAccelerateMin": "cubic-bezier(0.8,0,0.78,1)", + "curveDecelerateMax": "cubic-bezier(0.1,0.9,0.2,1)", + "curveDecelerateMid": "cubic-bezier(0,0,0,1)", + "curveDecelerateMin": "cubic-bezier(0.33,0,0.1,1)", + "curveEasyEaseMax": "cubic-bezier(0.8,0,0.2,1)", + "curveEasyEase": "cubic-bezier(0.33,0,0.67,1)", + "curveLinear": "cubic-bezier(0,0,1,1)", + "colorNeutralForeground1": "#ffffff", + "colorNeutralForeground1Hover": "#ffffff", + "colorNeutralForeground1Pressed": "#ffffff", + "colorNeutralForeground1Selected": "#ffffff", + "colorNeutralForeground2": "#d6d6d6", + "colorNeutralForeground2Hover": "#ffffff", + "colorNeutralForeground2Pressed": "#ffffff", + "colorNeutralForeground2Selected": "#ffffff", + "colorNeutralForeground2BrandHover": "#479ef5", + "colorNeutralForeground2BrandPressed": "#2886de", + "colorNeutralForeground2BrandSelected": "#479ef5", + "colorNeutralForeground3": "#adadad", + "colorNeutralForeground3Hover": "#d6d6d6", + "colorNeutralForeground3Pressed": "#d6d6d6", + "colorNeutralForeground3Selected": "#d6d6d6", + "colorNeutralForeground3BrandHover": "#479ef5", + "colorNeutralForeground3BrandPressed": "#2886de", + "colorNeutralForeground3BrandSelected": "#479ef5", + "colorNeutralForeground4": "#999999", + "colorNeutralForeground5": "#adadad", + "colorNeutralForeground5Hover": "#ffffff", + "colorNeutralForeground5Pressed": "#ffffff", + "colorNeutralForeground5Selected": "#ffffff", + "colorNeutralForegroundDisabled": "#5c5c5c", + "colorNeutralForegroundInvertedDisabled": "rgba(255, 255, 255, 0.4)", + "colorBrandForegroundLink": "#479ef5", + "colorBrandForegroundLinkHover": "#62abf5", + "colorBrandForegroundLinkPressed": "#2886de", + "colorBrandForegroundLinkSelected": "#479ef5", + "colorNeutralForeground2Link": "#d6d6d6", + "colorNeutralForeground2LinkHover": "#ffffff", + "colorNeutralForeground2LinkPressed": "#ffffff", + "colorNeutralForeground2LinkSelected": "#ffffff", + "colorCompoundBrandForeground1": "#479ef5", + "colorCompoundBrandForeground1Hover": "#62abf5", + "colorCompoundBrandForeground1Pressed": "#2886de", + "colorBrandForeground1": "#479ef5", + "colorBrandForeground2": "#62abf5", + "colorBrandForeground2Hover": "#96c6fa", + "colorBrandForeground2Pressed": "#ebf3fc", + "colorNeutralForeground1Static": "#242424", + "colorNeutralForegroundStaticInverted": "#ffffff", + "colorNeutralForegroundInverted": "#242424", + "colorNeutralForegroundInvertedHover": "#242424", + "colorNeutralForegroundInvertedPressed": "#242424", + "colorNeutralForegroundInvertedSelected": "#242424", + "colorNeutralForegroundInverted2": "#242424", + "colorNeutralForegroundOnBrand": "#ffffff", + "colorNeutralForegroundInvertedLink": "#ffffff", + "colorNeutralForegroundInvertedLinkHover": "#ffffff", + "colorNeutralForegroundInvertedLinkPressed": "#ffffff", + "colorNeutralForegroundInvertedLinkSelected": "#ffffff", + "colorBrandForegroundInverted": "#0f6cbd", + "colorBrandForegroundInvertedHover": "#115ea3", + "colorBrandForegroundInvertedPressed": "#0f548c", + "colorBrandForegroundOnLight": "#0f6cbd", + "colorBrandForegroundOnLightHover": "#115ea3", + "colorBrandForegroundOnLightPressed": "#0e4775", + "colorBrandForegroundOnLightSelected": "#0f548c", + "colorNeutralBackground1": "#292929", + "colorNeutralBackground1Hover": "#3d3d3d", + "colorNeutralBackground1Pressed": "#1f1f1f", + "colorNeutralBackground1Selected": "#383838", + "colorNeutralBackground2": "#1f1f1f", + "colorNeutralBackground2Hover": "#333333", + "colorNeutralBackground2Pressed": "#141414", + "colorNeutralBackground2Selected": "#2e2e2e", + "colorNeutralBackground3": "#141414", + "colorNeutralBackground3Hover": "#292929", + "colorNeutralBackground3Pressed": "#0a0a0a", + "colorNeutralBackground3Selected": "#242424", + "colorNeutralBackground4": "#0a0a0a", + "colorNeutralBackground4Hover": "#1f1f1f", + "colorNeutralBackground4Pressed": "#000000", + "colorNeutralBackground4Selected": "#1a1a1a", + "colorNeutralBackground5": "#000000", + "colorNeutralBackground5Hover": "#141414", + "colorNeutralBackground5Pressed": "#050505", + "colorNeutralBackground5Selected": "#0f0f0f", + "colorNeutralBackground6": "#333333", + "colorNeutralBackground7": "#00000000", + "colorNeutralBackground7Hover": "#1a1a1a", + "colorNeutralBackground7Pressed": "#0a0a0a", + "colorNeutralBackground7Selected": "#00000000", + "colorNeutralBackground8": "#292929", + "colorNeutralBackgroundInverted": "#ffffff", + "colorNeutralBackgroundInvertedHover": "#f5f5f5", + "colorNeutralBackgroundInvertedPressed": "#e0e0e0", + "colorNeutralBackgroundInvertedSelected": "#ebebeb", + "colorNeutralBackgroundStatic": "#3d3d3d", + "colorNeutralBackgroundAlpha": "rgba(26, 26, 26, 0.5)", + "colorNeutralBackgroundAlpha2": "rgba(31, 31, 31, 0.7)", + "colorSubtleBackground": "transparent", + "colorSubtleBackgroundHover": "#383838", + "colorSubtleBackgroundPressed": "#2e2e2e", + "colorSubtleBackgroundSelected": "#333333", + "colorSubtleBackgroundLightAlphaHover": "rgba(36, 36, 36, 0.8)", + "colorSubtleBackgroundLightAlphaPressed": "rgba(36, 36, 36, 0.5)", + "colorSubtleBackgroundLightAlphaSelected": "transparent", + "colorSubtleBackgroundInverted": "transparent", + "colorSubtleBackgroundInvertedHover": "rgba(0, 0, 0, 0.1)", + "colorSubtleBackgroundInvertedPressed": "rgba(0, 0, 0, 0.3)", + "colorSubtleBackgroundInvertedSelected": "rgba(0, 0, 0, 0.2)", + "colorTransparentBackground": "transparent", + "colorTransparentBackgroundHover": "transparent", + "colorTransparentBackgroundPressed": "transparent", + "colorTransparentBackgroundSelected": "transparent", + "colorNeutralBackgroundDisabled": "#141414", + "colorNeutralBackgroundDisabled2": "#292929", + "colorNeutralBackgroundInvertedDisabled": "rgba(255, 255, 255, 0.1)", + "colorNeutralStencil1": "#575757", + "colorNeutralStencil2": "#333333", + "colorNeutralStencil1Alpha": "rgba(255, 255, 255, 0.1)", + "colorNeutralStencil2Alpha": "rgba(255, 255, 255, 0.05)", + "colorBackgroundOverlay": "rgba(0, 0, 0, 0.5)", + "colorScrollbarOverlay": "rgba(255, 255, 255, 0.6)", + "colorBrandBackground": "#115ea3", + "colorBrandBackgroundHover": "#0f6cbd", + "colorBrandBackgroundPressed": "#0c3b5e", + "colorBrandBackgroundSelected": "#0f548c", + "colorCompoundBrandBackground": "#479ef5", + "colorCompoundBrandBackgroundHover": "#62abf5", + "colorCompoundBrandBackgroundPressed": "#2886de", + "colorBrandBackgroundStatic": "#0f6cbd", + "colorBrandBackground2": "#082338", + "colorBrandBackground2Hover": "#0c3b5e", + "colorBrandBackground2Pressed": "#061724", + "colorBrandBackground3Static": "#0f548c", + "colorBrandBackground4Static": "#0c3b5e", + "colorBrandBackgroundInverted": "#ffffff", + "colorBrandBackgroundInvertedHover": "#ebf3fc", + "colorBrandBackgroundInvertedPressed": "#b4d6fa", + "colorBrandBackgroundInvertedSelected": "#cfe4fa", + "colorNeutralCardBackground": "#333333", + "colorNeutralCardBackgroundHover": "#3d3d3d", + "colorNeutralCardBackgroundPressed": "#2e2e2e", + "colorNeutralCardBackgroundSelected": "#383838", + "colorNeutralCardBackgroundDisabled": "#141414", + "colorNeutralStrokeAccessible": "#adadad", + "colorNeutralStrokeAccessibleHover": "#bdbdbd", + "colorNeutralStrokeAccessiblePressed": "#b3b3b3", + "colorNeutralStrokeAccessibleSelected": "#479ef5", + "colorNeutralStroke1": "#666666", + "colorNeutralStroke1Hover": "#757575", + "colorNeutralStroke1Pressed": "#6b6b6b", + "colorNeutralStroke1Selected": "#707070", + "colorNeutralStroke2": "#525252", + "colorNeutralStroke3": "#3d3d3d", + "colorNeutralStroke4": "#3d3d3d", + "colorNeutralStroke4Hover": "#2e2e2e", + "colorNeutralStroke4Pressed": "#242424", + "colorNeutralStroke4Selected": "#3d3d3d", + "colorNeutralStrokeSubtle": "#0a0a0a", + "colorNeutralStrokeOnBrand": "#292929", + "colorNeutralStrokeOnBrand2": "#ffffff", + "colorNeutralStrokeOnBrand2Hover": "#ffffff", + "colorNeutralStrokeOnBrand2Pressed": "#ffffff", + "colorNeutralStrokeOnBrand2Selected": "#ffffff", + "colorBrandStroke1": "#479ef5", + "colorBrandStroke2": "#0e4775", + "colorBrandStroke2Hover": "#0e4775", + "colorBrandStroke2Pressed": "#0a2e4a", + "colorBrandStroke2Contrast": "#0e4775", + "colorCompoundBrandStroke": "#479ef5", + "colorCompoundBrandStrokeHover": "#62abf5", + "colorCompoundBrandStrokePressed": "#2886de", + "colorNeutralStrokeDisabled": "#424242", + "colorNeutralStrokeDisabled2": "#3d3d3d", + "colorNeutralStrokeInvertedDisabled": "rgba(255, 255, 255, 0.4)", + "colorTransparentStroke": "transparent", + "colorTransparentStrokeInteractive": "transparent", + "colorTransparentStrokeDisabled": "transparent", + "colorNeutralStrokeAlpha": "rgba(255, 255, 255, 0.1)", + "colorNeutralStrokeAlpha2": "rgba(255, 255, 255, 0.2)", + "colorStrokeFocus1": "#000000", + "colorStrokeFocus2": "#ffffff", + "colorNeutralShadowAmbient": "rgba(0,0,0,0.24)", + "colorNeutralShadowKey": "rgba(0,0,0,0.28)", + "colorNeutralShadowAmbientLighter": "rgba(0,0,0,0.12)", + "colorNeutralShadowKeyLighter": "rgba(0,0,0,0.14)", + "colorNeutralShadowAmbientDarker": "rgba(0,0,0,0.40)", + "colorNeutralShadowKeyDarker": "rgba(0,0,0,0.48)", + "colorBrandShadowAmbient": "rgba(0,0,0,0.30)", + "colorBrandShadowKey": "rgba(0,0,0,0.25)", + "colorPaletteRedBackground1": "#3f1011", + "colorPaletteRedBackground2": "#751d1f", + "colorPaletteRedBackground3": "#d13438", + "colorPaletteRedForeground1": "#e37d80", + "colorPaletteRedForeground2": "#f1bbbc", + "colorPaletteRedForeground3": "#e37d80", + "colorPaletteRedBorderActive": "#e37d80", + "colorPaletteRedBorder1": "#d13438", + "colorPaletteRedBorder2": "#e37d80", + "colorPaletteGreenBackground1": "#052505", + "colorPaletteGreenBackground2": "#094509", + "colorPaletteGreenBackground3": "#107c10", + "colorPaletteGreenForeground1": "#54b054", + "colorPaletteGreenForeground2": "#9fd89f", + "colorPaletteGreenForeground3": "#9fd89f", + "colorPaletteGreenBorderActive": "#54b054", + "colorPaletteGreenBorder1": "#107c10", + "colorPaletteGreenBorder2": "#9fd89f", + "colorPaletteDarkOrangeBackground1": "#411200", + "colorPaletteDarkOrangeBackground2": "#7a2101", + "colorPaletteDarkOrangeBackground3": "#da3b01", + "colorPaletteDarkOrangeForeground1": "#e9835e", + "colorPaletteDarkOrangeForeground2": "#f4bfab", + "colorPaletteDarkOrangeForeground3": "#e9835e", + "colorPaletteDarkOrangeBorderActive": "#e9835e", + "colorPaletteDarkOrangeBorder1": "#da3b01", + "colorPaletteDarkOrangeBorder2": "#e9835e", + "colorPaletteYellowBackground1": "#4c4400", + "colorPaletteYellowBackground2": "#817400", + "colorPaletteYellowBackground3": "#fde300", + "colorPaletteYellowForeground1": "#feee66", + "colorPaletteYellowForeground2": "#fef7b2", + "colorPaletteYellowForeground3": "#fdea3d", + "colorPaletteYellowBorderActive": "#feee66", + "colorPaletteYellowBorder1": "#fde300", + "colorPaletteYellowBorder2": "#fdea3d", + "colorPaletteBerryBackground1": "#3a1136", + "colorPaletteBerryBackground2": "#6d2064", + "colorPaletteBerryBackground3": "#c239b3", + "colorPaletteBerryForeground1": "#da7ed0", + "colorPaletteBerryForeground2": "#edbbe7", + "colorPaletteBerryForeground3": "#d161c4", + "colorPaletteBerryBorderActive": "#da7ed0", + "colorPaletteBerryBorder1": "#c239b3", + "colorPaletteBerryBorder2": "#d161c4", + "colorPaletteLightGreenBackground1": "#063004", + "colorPaletteLightGreenBackground2": "#0b5a08", + "colorPaletteLightGreenBackground3": "#13a10e", + "colorPaletteLightGreenForeground1": "#5ec75a", + "colorPaletteLightGreenForeground2": "#a7e3a5", + "colorPaletteLightGreenForeground3": "#3db838", + "colorPaletteLightGreenBorderActive": "#5ec75a", + "colorPaletteLightGreenBorder1": "#13a10e", + "colorPaletteLightGreenBorder2": "#3db838", + "colorPaletteMarigoldBackground1": "#463100", + "colorPaletteMarigoldBackground2": "#835b00", + "colorPaletteMarigoldBackground3": "#eaa300", + "colorPaletteMarigoldForeground1": "#f2c661", + "colorPaletteMarigoldForeground2": "#f9e2ae", + "colorPaletteMarigoldForeground3": "#efb839", + "colorPaletteMarigoldBorderActive": "#f2c661", + "colorPaletteMarigoldBorder1": "#eaa300", + "colorPaletteMarigoldBorder2": "#efb839", + "colorPaletteRedForegroundInverted": "#d13438", + "colorPaletteGreenForegroundInverted": "#107c10", + "colorPaletteYellowForegroundInverted": "#817400", + "colorPaletteDarkRedBackground2": "#590815", + "colorPaletteDarkRedForeground2": "#d69ca5", + "colorPaletteDarkRedBorderActive": "#ac4f5e", + "colorPaletteCranberryBackground2": "#6e0811", + "colorPaletteCranberryForeground2": "#eeacb2", + "colorPaletteCranberryBorderActive": "#dc626d", + "colorPalettePumpkinBackground2": "#712d09", + "colorPalettePumpkinForeground2": "#efc4ad", + "colorPalettePumpkinBorderActive": "#df8e64", + "colorPalettePeachBackground2": "#8f4e00", + "colorPalettePeachForeground2": "#ffddb3", + "colorPalettePeachBorderActive": "#ffba66", + "colorPaletteGoldBackground2": "#6c5700", + "colorPaletteGoldForeground2": "#ecdfa5", + "colorPaletteGoldBorderActive": "#dac157", + "colorPaletteBrassBackground2": "#553e06", + "colorPaletteBrassForeground2": "#e0cea2", + "colorPaletteBrassBorderActive": "#c1a256", + "colorPaletteBrownBackground2": "#50301a", + "colorPaletteBrownForeground2": "#ddc3b0", + "colorPaletteBrownBorderActive": "#bb8f6f", + "colorPaletteForestBackground2": "#294903", + "colorPaletteForestForeground2": "#bdd99b", + "colorPaletteForestBorderActive": "#85b44c", + "colorPaletteSeafoamBackground2": "#00723b", + "colorPaletteSeafoamForeground2": "#a8f0cd", + "colorPaletteSeafoamBorderActive": "#5ae0a0", + "colorPaletteDarkGreenBackground2": "#063b06", + "colorPaletteDarkGreenForeground2": "#9ad29a", + "colorPaletteDarkGreenBorderActive": "#4da64d", + "colorPaletteLightTealBackground2": "#00666d", + "colorPaletteLightTealForeground2": "#a6e9ed", + "colorPaletteLightTealBorderActive": "#58d3db", + "colorPaletteTealBackground2": "#02494c", + "colorPaletteTealForeground2": "#9bd9db", + "colorPaletteTealBorderActive": "#4cb4b7", + "colorPaletteSteelBackground2": "#00333f", + "colorPaletteSteelForeground2": "#94c8d4", + "colorPaletteSteelBorderActive": "#4496a9", + "colorPaletteBlueBackground2": "#004377", + "colorPaletteBlueForeground2": "#a9d3f2", + "colorPaletteBlueBorderActive": "#5caae5", + "colorPaletteRoyalBlueBackground2": "#002c4e", + "colorPaletteRoyalBlueForeground2": "#9abfdc", + "colorPaletteRoyalBlueBorderActive": "#4a89ba", + "colorPaletteCornflowerBackground2": "#2c3c85", + "colorPaletteCornflowerForeground2": "#c8d1fa", + "colorPaletteCornflowerBorderActive": "#93a4f4", + "colorPaletteNavyBackground2": "#001665", + "colorPaletteNavyForeground2": "#a3b2e8", + "colorPaletteNavyBorderActive": "#546fd2", + "colorPaletteLavenderBackground2": "#3f3682", + "colorPaletteLavenderForeground2": "#d2ccf8", + "colorPaletteLavenderBorderActive": "#a79cf1", + "colorPalettePurpleBackground2": "#341a51", + "colorPalettePurpleForeground2": "#c6b1de", + "colorPalettePurpleBorderActive": "#9470bd", + "colorPaletteGrapeBackground2": "#4c0d55", + "colorPaletteGrapeForeground2": "#d9a7e0", + "colorPaletteGrapeBorderActive": "#b55fc1", + "colorPaletteLilacBackground2": "#63276d", + "colorPaletteLilacForeground2": "#e6bfed", + "colorPaletteLilacBorderActive": "#cf87da", + "colorPalettePinkBackground2": "#80215d", + "colorPalettePinkForeground2": "#f7c0e3", + "colorPalettePinkBorderActive": "#ef85c8", + "colorPaletteMagentaBackground2": "#6b0043", + "colorPaletteMagentaForeground2": "#eca5d1", + "colorPaletteMagentaBorderActive": "#d957a8", + "colorPalettePlumBackground2": "#5a003b", + "colorPalettePlumForeground2": "#d696c0", + "colorPalettePlumBorderActive": "#ad4589", + "colorPaletteBeigeBackground2": "#444241", + "colorPaletteBeigeForeground2": "#d7d4d4", + "colorPaletteBeigeBorderActive": "#afabaa", + "colorPaletteMinkBackground2": "#343231", + "colorPaletteMinkForeground2": "#cecccb", + "colorPaletteMinkBorderActive": "#9e9b99", + "colorPalettePlatinumBackground2": "#3b4447", + "colorPalettePlatinumForeground2": "#cdd6d8", + "colorPalettePlatinumBorderActive": "#a0adb2", + "colorPaletteAnchorBackground2": "#202427", + "colorPaletteAnchorForeground2": "#bcc3c7", + "colorPaletteAnchorBorderActive": "#808a90", + "colorStatusSuccessBackground1": "#052505", + "colorStatusSuccessBackground2": "#094509", + "colorStatusSuccessBackground3": "#107c10", + "colorStatusSuccessForeground1": "#54b054", + "colorStatusSuccessForeground2": "#9fd89f", + "colorStatusSuccessForeground3": "#9fd89f", + "colorStatusSuccessBorderActive": "#54b054", + "colorStatusSuccessForegroundInverted": "#0e700e", + "colorStatusSuccessBorder1": "#107c10", + "colorStatusSuccessBorder2": "#9fd89f", + "colorStatusWarningBackground1": "#4a1e04", + "colorStatusWarningBackground2": "#8a3707", + "colorStatusWarningBackground3": "#f7630c", + "colorStatusWarningForeground1": "#faa06b", + "colorStatusWarningForeground2": "#fdcfb4", + "colorStatusWarningForeground3": "#f98845", + "colorStatusWarningBorderActive": "#faa06b", + "colorStatusWarningForegroundInverted": "#bc4b09", + "colorStatusWarningBorder1": "#f7630c", + "colorStatusWarningBorder2": "#f98845", + "colorStatusDangerBackground1": "#3b0509", + "colorStatusDangerBackground2": "#6e0811", + "colorStatusDangerBackground3": "#c50f1f", + "colorStatusDangerForeground1": "#dc626d", + "colorStatusDangerForeground2": "#eeacb2", + "colorStatusDangerForeground3": "#eeacb2", + "colorStatusDangerBorderActive": "#dc626d", + "colorStatusDangerForegroundInverted": "#b10e1c", + "colorStatusDangerBorder1": "#c50f1f", + "colorStatusDangerBorder2": "#dc626d", + "colorStatusDangerBackground3Hover": "#b10e1c", + "colorStatusDangerBackground3Pressed": "#960b18", + "shadow2": "0 0 2px rgba(0,0,0,0.24), 0 1px 2px rgba(0,0,0,0.28)", + "shadow4": "0 0 2px rgba(0,0,0,0.24), 0 2px 4px rgba(0,0,0,0.28)", + "shadow8": "0 0 2px rgba(0,0,0,0.24), 0 4px 8px rgba(0,0,0,0.28)", + "shadow16": "0 0 2px rgba(0,0,0,0.24), 0 8px 16px rgba(0,0,0,0.28)", + "shadow28": "0 0 8px rgba(0,0,0,0.24), 0 14px 28px rgba(0,0,0,0.28)", + "shadow64": "0 0 8px rgba(0,0,0,0.24), 0 32px 64px rgba(0,0,0,0.28)", + "shadow2Brand": "0 0 2px rgba(0,0,0,0.30), 0 1px 2px rgba(0,0,0,0.25)", + "shadow4Brand": "0 0 2px rgba(0,0,0,0.30), 0 2px 4px rgba(0,0,0,0.25)", + "shadow8Brand": "0 0 2px rgba(0,0,0,0.30), 0 4px 8px rgba(0,0,0,0.25)", + "shadow16Brand": "0 0 2px rgba(0,0,0,0.30), 0 8px 16px rgba(0,0,0,0.25)", + "shadow28Brand": "0 0 8px rgba(0,0,0,0.30), 0 14px 28px rgba(0,0,0,0.25)", + "shadow64Brand": "0 0 8px rgba(0,0,0,0.30), 0 32px 64px rgba(0,0,0,0.25)" + }, + "teamsLightTheme": { + "borderRadiusNone": "0", + "borderRadiusSmall": "2px", + "borderRadiusMedium": "4px", + "borderRadiusLarge": "6px", + "borderRadiusXLarge": "8px", + "borderRadius2XLarge": "12px", + "borderRadius3XLarge": "16px", + "borderRadius4XLarge": "24px", + "borderRadius5XLarge": "32px", + "borderRadius6XLarge": "40px", + "borderRadiusCircular": "10000px", + "fontSizeBase100": "10px", + "fontSizeBase200": "12px", + "fontSizeBase300": "14px", + "fontSizeBase400": "16px", + "fontSizeBase500": "20px", + "fontSizeBase600": "24px", + "fontSizeHero700": "28px", + "fontSizeHero800": "32px", + "fontSizeHero900": "40px", + "fontSizeHero1000": "68px", + "lineHeightBase100": "14px", + "lineHeightBase200": "16px", + "lineHeightBase300": "20px", + "lineHeightBase400": "22px", + "lineHeightBase500": "28px", + "lineHeightBase600": "32px", + "lineHeightHero700": "36px", + "lineHeightHero800": "40px", + "lineHeightHero900": "52px", + "lineHeightHero1000": "92px", + "fontFamilyBase": "-apple-system, BlinkMacSystemFont, \"Segoe UI\", system-ui, \"Apple Color Emoji\", \"Segoe UI Emoji\", sans-serif", + "fontFamilyMonospace": "Consolas, 'Courier New', Courier, monospace", + "fontFamilyNumeric": "Bahnschrift, 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', sans-serif", + "fontWeightRegular": 400, + "fontWeightMedium": 500, + "fontWeightSemibold": 600, + "fontWeightBold": 700, + "strokeWidthThin": "1px", + "strokeWidthThick": "2px", + "strokeWidthThicker": "3px", + "strokeWidthThickest": "4px", + "spacingHorizontalNone": "0", + "spacingHorizontalXXS": "2px", + "spacingHorizontalXS": "4px", + "spacingHorizontalSNudge": "6px", + "spacingHorizontalS": "8px", + "spacingHorizontalMNudge": "10px", + "spacingHorizontalM": "12px", + "spacingHorizontalL": "16px", + "spacingHorizontalXL": "20px", + "spacingHorizontalXXL": "24px", + "spacingHorizontalXXXL": "32px", + "spacingVerticalNone": "0", + "spacingVerticalXXS": "2px", + "spacingVerticalXS": "4px", + "spacingVerticalSNudge": "6px", + "spacingVerticalS": "8px", + "spacingVerticalMNudge": "10px", + "spacingVerticalM": "12px", + "spacingVerticalL": "16px", + "spacingVerticalXL": "20px", + "spacingVerticalXXL": "24px", + "spacingVerticalXXXL": "32px", + "durationUltraFast": "50ms", + "durationFaster": "100ms", + "durationFast": "150ms", + "durationNormal": "200ms", + "durationGentle": "250ms", + "durationSlow": "300ms", + "durationSlower": "400ms", + "durationUltraSlow": "500ms", + "curveAccelerateMax": "cubic-bezier(0.9,0.1,1,0.2)", + "curveAccelerateMid": "cubic-bezier(1,0,1,1)", + "curveAccelerateMin": "cubic-bezier(0.8,0,0.78,1)", + "curveDecelerateMax": "cubic-bezier(0.1,0.9,0.2,1)", + "curveDecelerateMid": "cubic-bezier(0,0,0,1)", + "curveDecelerateMin": "cubic-bezier(0.33,0,0.1,1)", + "curveEasyEaseMax": "cubic-bezier(0.8,0,0.2,1)", + "curveEasyEase": "cubic-bezier(0.33,0,0.67,1)", + "curveLinear": "cubic-bezier(0,0,1,1)", + "colorNeutralForeground1": "#242424", + "colorNeutralForeground1Hover": "#242424", + "colorNeutralForeground1Pressed": "#242424", + "colorNeutralForeground1Selected": "#242424", + "colorNeutralForeground2": "#424242", + "colorNeutralForeground2Hover": "#242424", + "colorNeutralForeground2Pressed": "#242424", + "colorNeutralForeground2Selected": "#242424", + "colorNeutralForeground2BrandHover": "#5b5fc7", + "colorNeutralForeground2BrandPressed": "#4f52b2", + "colorNeutralForeground2BrandSelected": "#5b5fc7", + "colorNeutralForeground3": "#616161", + "colorNeutralForeground3Hover": "#424242", + "colorNeutralForeground3Pressed": "#424242", + "colorNeutralForeground3Selected": "#424242", + "colorNeutralForeground3BrandHover": "#5b5fc7", + "colorNeutralForeground3BrandPressed": "#4f52b2", + "colorNeutralForeground3BrandSelected": "#5b5fc7", + "colorNeutralForeground4": "#707070", + "colorNeutralForeground5": "#616161", + "colorNeutralForeground5Hover": "#242424", + "colorNeutralForeground5Pressed": "#242424", + "colorNeutralForeground5Selected": "#242424", + "colorNeutralForegroundDisabled": "#bdbdbd", + "colorNeutralForegroundInvertedDisabled": "rgba(255, 255, 255, 0.4)", + "colorBrandForegroundLink": "#4f52b2", + "colorBrandForegroundLinkHover": "#444791", + "colorBrandForegroundLinkPressed": "#383966", + "colorBrandForegroundLinkSelected": "#4f52b2", + "colorNeutralForeground2Link": "#424242", + "colorNeutralForeground2LinkHover": "#242424", + "colorNeutralForeground2LinkPressed": "#242424", + "colorNeutralForeground2LinkSelected": "#242424", + "colorCompoundBrandForeground1": "#5b5fc7", + "colorCompoundBrandForeground1Hover": "#4f52b2", + "colorCompoundBrandForeground1Pressed": "#444791", + "colorBrandForeground1": "#5b5fc7", + "colorBrandForeground2": "#4f52b2", + "colorBrandForeground2Hover": "#444791", + "colorBrandForeground2Pressed": "#333357", + "colorNeutralForeground1Static": "#242424", + "colorNeutralForegroundStaticInverted": "#ffffff", + "colorNeutralForegroundInverted": "#ffffff", + "colorNeutralForegroundInvertedHover": "#ffffff", + "colorNeutralForegroundInvertedPressed": "#ffffff", + "colorNeutralForegroundInvertedSelected": "#ffffff", + "colorNeutralForegroundInverted2": "#ffffff", + "colorNeutralForegroundOnBrand": "#ffffff", + "colorNeutralForegroundInvertedLink": "#ffffff", + "colorNeutralForegroundInvertedLinkHover": "#ffffff", + "colorNeutralForegroundInvertedLinkPressed": "#ffffff", + "colorNeutralForegroundInvertedLinkSelected": "#ffffff", + "colorBrandForegroundInverted": "#7f85f5", + "colorBrandForegroundInvertedHover": "#9299f7", + "colorBrandForegroundInvertedPressed": "#7f85f5", + "colorBrandForegroundOnLight": "#5b5fc7", + "colorBrandForegroundOnLightHover": "#4f52b2", + "colorBrandForegroundOnLightPressed": "#3d3e78", + "colorBrandForegroundOnLightSelected": "#444791", + "colorNeutralBackground1": "#ffffff", + "colorNeutralBackground1Hover": "#f5f5f5", + "colorNeutralBackground1Pressed": "#e0e0e0", + "colorNeutralBackground1Selected": "#ebebeb", + "colorNeutralBackground2": "#fafafa", + "colorNeutralBackground2Hover": "#f0f0f0", + "colorNeutralBackground2Pressed": "#dbdbdb", + "colorNeutralBackground2Selected": "#e6e6e6", + "colorNeutralBackground3": "#f5f5f5", + "colorNeutralBackground3Hover": "#ebebeb", + "colorNeutralBackground3Pressed": "#d6d6d6", + "colorNeutralBackground3Selected": "#e0e0e0", + "colorNeutralBackground4": "#f0f0f0", + "colorNeutralBackground4Hover": "#fafafa", + "colorNeutralBackground4Pressed": "#f5f5f5", + "colorNeutralBackground4Selected": "#ffffff", + "colorNeutralBackground5": "#ebebeb", + "colorNeutralBackground5Hover": "#f5f5f5", + "colorNeutralBackground5Pressed": "#f0f0f0", + "colorNeutralBackground5Selected": "#fafafa", + "colorNeutralBackground6": "#e6e6e6", + "colorNeutralBackground7": "#00000000", + "colorNeutralBackground7Hover": "#ebebeb", + "colorNeutralBackground7Pressed": "#d6d6d6", + "colorNeutralBackground7Selected": "#00000000", + "colorNeutralBackground8": "#fcfcfc", + "colorNeutralBackgroundInverted": "#292929", + "colorNeutralBackgroundInvertedHover": "#3d3d3d", + "colorNeutralBackgroundInvertedPressed": "#1f1f1f", + "colorNeutralBackgroundInvertedSelected": "#383838", + "colorNeutralBackgroundStatic": "#333333", + "colorNeutralBackgroundAlpha": "rgba(255, 255, 255, 0.5)", + "colorNeutralBackgroundAlpha2": "rgba(255, 255, 255, 0.8)", + "colorSubtleBackground": "transparent", + "colorSubtleBackgroundHover": "#f5f5f5", + "colorSubtleBackgroundPressed": "#e0e0e0", + "colorSubtleBackgroundSelected": "#ebebeb", + "colorSubtleBackgroundLightAlphaHover": "rgba(255, 255, 255, 0.7)", + "colorSubtleBackgroundLightAlphaPressed": "rgba(255, 255, 255, 0.5)", + "colorSubtleBackgroundLightAlphaSelected": "transparent", + "colorSubtleBackgroundInverted": "transparent", + "colorSubtleBackgroundInvertedHover": "rgba(0, 0, 0, 0.1)", + "colorSubtleBackgroundInvertedPressed": "rgba(0, 0, 0, 0.3)", + "colorSubtleBackgroundInvertedSelected": "rgba(0, 0, 0, 0.2)", + "colorTransparentBackground": "transparent", + "colorTransparentBackgroundHover": "transparent", + "colorTransparentBackgroundPressed": "transparent", + "colorTransparentBackgroundSelected": "transparent", + "colorNeutralBackgroundDisabled": "#f0f0f0", + "colorNeutralBackgroundDisabled2": "#ffffff", + "colorNeutralBackgroundInvertedDisabled": "rgba(255, 255, 255, 0.1)", + "colorNeutralStencil1": "#e6e6e6", + "colorNeutralStencil2": "#fafafa", + "colorNeutralStencil1Alpha": "rgba(0, 0, 0, 0.1)", + "colorNeutralStencil2Alpha": "rgba(0, 0, 0, 0.05)", + "colorBackgroundOverlay": "rgba(0, 0, 0, 0.4)", + "colorScrollbarOverlay": "rgba(0, 0, 0, 0.5)", + "colorBrandBackground": "#5b5fc7", + "colorBrandBackgroundHover": "#4f52b2", + "colorBrandBackgroundPressed": "#383966", + "colorBrandBackgroundSelected": "#444791", + "colorCompoundBrandBackground": "#5b5fc7", + "colorCompoundBrandBackgroundHover": "#4f52b2", + "colorCompoundBrandBackgroundPressed": "#444791", + "colorBrandBackgroundStatic": "#5b5fc7", + "colorBrandBackground2": "#e8ebfa", + "colorBrandBackground2Hover": "#dce0fa", + "colorBrandBackground2Pressed": "#b6bcfa", + "colorBrandBackground3Static": "#444791", + "colorBrandBackground4Static": "#383966", + "colorBrandBackgroundInverted": "#ffffff", + "colorBrandBackgroundInvertedHover": "#e8ebfa", + "colorBrandBackgroundInvertedPressed": "#c5cbfa", + "colorBrandBackgroundInvertedSelected": "#dce0fa", + "colorNeutralCardBackground": "#fafafa", + "colorNeutralCardBackgroundHover": "#ffffff", + "colorNeutralCardBackgroundPressed": "#f5f5f5", + "colorNeutralCardBackgroundSelected": "#ebebeb", + "colorNeutralCardBackgroundDisabled": "#f0f0f0", + "colorNeutralStrokeAccessible": "#616161", + "colorNeutralStrokeAccessibleHover": "#575757", + "colorNeutralStrokeAccessiblePressed": "#4d4d4d", + "colorNeutralStrokeAccessibleSelected": "#5b5fc7", + "colorNeutralStroke1": "#d1d1d1", + "colorNeutralStroke1Hover": "#c7c7c7", + "colorNeutralStroke1Pressed": "#b3b3b3", + "colorNeutralStroke1Selected": "#bdbdbd", + "colorNeutralStroke2": "#e0e0e0", + "colorNeutralStroke3": "#f0f0f0", + "colorNeutralStroke4": "#ebebeb", + "colorNeutralStroke4Hover": "#e0e0e0", + "colorNeutralStroke4Pressed": "#d6d6d6", + "colorNeutralStroke4Selected": "#ebebeb", + "colorNeutralStrokeSubtle": "#e0e0e0", + "colorNeutralStrokeOnBrand": "#ffffff", + "colorNeutralStrokeOnBrand2": "#ffffff", + "colorNeutralStrokeOnBrand2Hover": "#ffffff", + "colorNeutralStrokeOnBrand2Pressed": "#ffffff", + "colorNeutralStrokeOnBrand2Selected": "#ffffff", + "colorBrandStroke1": "#5b5fc7", + "colorBrandStroke2": "#c5cbfa", + "colorBrandStroke2Hover": "#aab1fa", + "colorBrandStroke2Pressed": "#5b5fc7", + "colorBrandStroke2Contrast": "#c5cbfa", + "colorCompoundBrandStroke": "#5b5fc7", + "colorCompoundBrandStrokeHover": "#4f52b2", + "colorCompoundBrandStrokePressed": "#444791", + "colorNeutralStrokeDisabled": "#e0e0e0", + "colorNeutralStrokeDisabled2": "#ebebeb", + "colorNeutralStrokeInvertedDisabled": "rgba(255, 255, 255, 0.4)", + "colorTransparentStroke": "transparent", + "colorTransparentStrokeInteractive": "transparent", + "colorTransparentStrokeDisabled": "transparent", + "colorNeutralStrokeAlpha": "rgba(0, 0, 0, 0.05)", + "colorNeutralStrokeAlpha2": "rgba(255, 255, 255, 0.2)", + "colorStrokeFocus1": "#ffffff", + "colorStrokeFocus2": "#000000", + "colorNeutralShadowAmbient": "rgba(0,0,0,0.12)", + "colorNeutralShadowKey": "rgba(0,0,0,0.14)", + "colorNeutralShadowAmbientLighter": "rgba(0,0,0,0.06)", + "colorNeutralShadowKeyLighter": "rgba(0,0,0,0.07)", + "colorNeutralShadowAmbientDarker": "rgba(0,0,0,0.20)", + "colorNeutralShadowKeyDarker": "rgba(0,0,0,0.24)", + "colorBrandShadowAmbient": "rgba(0,0,0,0.30)", + "colorBrandShadowKey": "rgba(0,0,0,0.25)", + "colorPaletteRedBackground1": "#fdf6f6", + "colorPaletteRedBackground2": "#f1bbbc", + "colorPaletteRedBackground3": "#d13438", + "colorPaletteRedForeground1": "#bc2f32", + "colorPaletteRedForeground2": "#751d1f", + "colorPaletteRedForeground3": "#d13438", + "colorPaletteRedBorderActive": "#d13438", + "colorPaletteRedBorder1": "#f1bbbc", + "colorPaletteRedBorder2": "#d13438", + "colorPaletteGreenBackground1": "#f1faf1", + "colorPaletteGreenBackground2": "#9fd89f", + "colorPaletteGreenBackground3": "#107c10", + "colorPaletteGreenForeground1": "#0e700e", + "colorPaletteGreenForeground2": "#094509", + "colorPaletteGreenForeground3": "#107c10", + "colorPaletteGreenBorderActive": "#107c10", + "colorPaletteGreenBorder1": "#9fd89f", + "colorPaletteGreenBorder2": "#107c10", + "colorPaletteDarkOrangeBackground1": "#fdf6f3", + "colorPaletteDarkOrangeBackground2": "#f4bfab", + "colorPaletteDarkOrangeBackground3": "#da3b01", + "colorPaletteDarkOrangeForeground1": "#c43501", + "colorPaletteDarkOrangeForeground2": "#7a2101", + "colorPaletteDarkOrangeForeground3": "#da3b01", + "colorPaletteDarkOrangeBorderActive": "#da3b01", + "colorPaletteDarkOrangeBorder1": "#f4bfab", + "colorPaletteDarkOrangeBorder2": "#da3b01", + "colorPaletteYellowBackground1": "#fffef5", + "colorPaletteYellowBackground2": "#fef7b2", + "colorPaletteYellowBackground3": "#fde300", + "colorPaletteYellowForeground1": "#817400", + "colorPaletteYellowForeground2": "#817400", + "colorPaletteYellowForeground3": "#fde300", + "colorPaletteYellowBorderActive": "#fde300", + "colorPaletteYellowBorder1": "#fef7b2", + "colorPaletteYellowBorder2": "#fde300", + "colorPaletteBerryBackground1": "#fdf5fc", + "colorPaletteBerryBackground2": "#edbbe7", + "colorPaletteBerryBackground3": "#c239b3", + "colorPaletteBerryForeground1": "#af33a1", + "colorPaletteBerryForeground2": "#6d2064", + "colorPaletteBerryForeground3": "#c239b3", + "colorPaletteBerryBorderActive": "#c239b3", + "colorPaletteBerryBorder1": "#edbbe7", + "colorPaletteBerryBorder2": "#c239b3", + "colorPaletteLightGreenBackground1": "#f2fbf2", + "colorPaletteLightGreenBackground2": "#a7e3a5", + "colorPaletteLightGreenBackground3": "#13a10e", + "colorPaletteLightGreenForeground1": "#11910d", + "colorPaletteLightGreenForeground2": "#0b5a08", + "colorPaletteLightGreenForeground3": "#13a10e", + "colorPaletteLightGreenBorderActive": "#13a10e", + "colorPaletteLightGreenBorder1": "#a7e3a5", + "colorPaletteLightGreenBorder2": "#13a10e", + "colorPaletteMarigoldBackground1": "#fefbf4", + "colorPaletteMarigoldBackground2": "#f9e2ae", + "colorPaletteMarigoldBackground3": "#eaa300", + "colorPaletteMarigoldForeground1": "#d39300", + "colorPaletteMarigoldForeground2": "#835b00", + "colorPaletteMarigoldForeground3": "#eaa300", + "colorPaletteMarigoldBorderActive": "#eaa300", + "colorPaletteMarigoldBorder1": "#f9e2ae", + "colorPaletteMarigoldBorder2": "#eaa300", + "colorPaletteRedForegroundInverted": "#dc5e62", + "colorPaletteGreenForegroundInverted": "#359b35", + "colorPaletteYellowForegroundInverted": "#fef7b2", + "colorPaletteDarkRedBackground2": "#d69ca5", + "colorPaletteDarkRedForeground2": "#420610", + "colorPaletteDarkRedBorderActive": "#750b1c", + "colorPaletteCranberryBackground2": "#eeacb2", + "colorPaletteCranberryForeground2": "#6e0811", + "colorPaletteCranberryBorderActive": "#c50f1f", + "colorPalettePumpkinBackground2": "#efc4ad", + "colorPalettePumpkinForeground2": "#712d09", + "colorPalettePumpkinBorderActive": "#ca5010", + "colorPalettePeachBackground2": "#ffddb3", + "colorPalettePeachForeground2": "#8f4e00", + "colorPalettePeachBorderActive": "#ff8c00", + "colorPaletteGoldBackground2": "#ecdfa5", + "colorPaletteGoldForeground2": "#6c5700", + "colorPaletteGoldBorderActive": "#c19c00", + "colorPaletteBrassBackground2": "#e0cea2", + "colorPaletteBrassForeground2": "#553e06", + "colorPaletteBrassBorderActive": "#986f0b", + "colorPaletteBrownBackground2": "#ddc3b0", + "colorPaletteBrownForeground2": "#50301a", + "colorPaletteBrownBorderActive": "#8e562e", + "colorPaletteForestBackground2": "#bdd99b", + "colorPaletteForestForeground2": "#294903", + "colorPaletteForestBorderActive": "#498205", + "colorPaletteSeafoamBackground2": "#a8f0cd", + "colorPaletteSeafoamForeground2": "#00723b", + "colorPaletteSeafoamBorderActive": "#00cc6a", + "colorPaletteDarkGreenBackground2": "#9ad29a", + "colorPaletteDarkGreenForeground2": "#063b06", + "colorPaletteDarkGreenBorderActive": "#0b6a0b", + "colorPaletteLightTealBackground2": "#a6e9ed", + "colorPaletteLightTealForeground2": "#00666d", + "colorPaletteLightTealBorderActive": "#00b7c3", + "colorPaletteTealBackground2": "#9bd9db", + "colorPaletteTealForeground2": "#02494c", + "colorPaletteTealBorderActive": "#038387", + "colorPaletteSteelBackground2": "#94c8d4", + "colorPaletteSteelForeground2": "#00333f", + "colorPaletteSteelBorderActive": "#005b70", + "colorPaletteBlueBackground2": "#a9d3f2", + "colorPaletteBlueForeground2": "#004377", + "colorPaletteBlueBorderActive": "#0078d4", + "colorPaletteRoyalBlueBackground2": "#9abfdc", + "colorPaletteRoyalBlueForeground2": "#002c4e", + "colorPaletteRoyalBlueBorderActive": "#004e8c", + "colorPaletteCornflowerBackground2": "#c8d1fa", + "colorPaletteCornflowerForeground2": "#2c3c85", + "colorPaletteCornflowerBorderActive": "#4f6bed", + "colorPaletteNavyBackground2": "#a3b2e8", + "colorPaletteNavyForeground2": "#001665", + "colorPaletteNavyBorderActive": "#0027b4", + "colorPaletteLavenderBackground2": "#d2ccf8", + "colorPaletteLavenderForeground2": "#3f3682", + "colorPaletteLavenderBorderActive": "#7160e8", + "colorPalettePurpleBackground2": "#c6b1de", + "colorPalettePurpleForeground2": "#341a51", + "colorPalettePurpleBorderActive": "#5c2e91", + "colorPaletteGrapeBackground2": "#d9a7e0", + "colorPaletteGrapeForeground2": "#4c0d55", + "colorPaletteGrapeBorderActive": "#881798", + "colorPaletteLilacBackground2": "#e6bfed", + "colorPaletteLilacForeground2": "#63276d", + "colorPaletteLilacBorderActive": "#b146c2", + "colorPalettePinkBackground2": "#f7c0e3", + "colorPalettePinkForeground2": "#80215d", + "colorPalettePinkBorderActive": "#e43ba6", + "colorPaletteMagentaBackground2": "#eca5d1", + "colorPaletteMagentaForeground2": "#6b0043", + "colorPaletteMagentaBorderActive": "#bf0077", + "colorPalettePlumBackground2": "#d696c0", + "colorPalettePlumForeground2": "#43002b", + "colorPalettePlumBorderActive": "#77004d", + "colorPaletteBeigeBackground2": "#d7d4d4", + "colorPaletteBeigeForeground2": "#444241", + "colorPaletteBeigeBorderActive": "#7a7574", + "colorPaletteMinkBackground2": "#cecccb", + "colorPaletteMinkForeground2": "#343231", + "colorPaletteMinkBorderActive": "#5d5a58", + "colorPalettePlatinumBackground2": "#cdd6d8", + "colorPalettePlatinumForeground2": "#3b4447", + "colorPalettePlatinumBorderActive": "#69797e", + "colorPaletteAnchorBackground2": "#bcc3c7", + "colorPaletteAnchorForeground2": "#202427", + "colorPaletteAnchorBorderActive": "#394146", + "colorStatusSuccessBackground1": "#f1faf1", + "colorStatusSuccessBackground2": "#9fd89f", + "colorStatusSuccessBackground3": "#107c10", + "colorStatusSuccessForeground1": "#0e700e", + "colorStatusSuccessForeground2": "#094509", + "colorStatusSuccessForeground3": "#107c10", + "colorStatusSuccessForegroundInverted": "#54b054", + "colorStatusSuccessBorderActive": "#107c10", + "colorStatusSuccessBorder1": "#9fd89f", + "colorStatusSuccessBorder2": "#107c10", + "colorStatusWarningBackground1": "#fff9f5", + "colorStatusWarningBackground2": "#fdcfb4", + "colorStatusWarningBackground3": "#f7630c", + "colorStatusWarningForeground1": "#bc4b09", + "colorStatusWarningForeground2": "#8a3707", + "colorStatusWarningForeground3": "#bc4b09", + "colorStatusWarningForegroundInverted": "#faa06b", + "colorStatusWarningBorderActive": "#f7630c", + "colorStatusWarningBorder1": "#fdcfb4", + "colorStatusWarningBorder2": "#bc4b09", + "colorStatusDangerBackground1": "#fdf3f4", + "colorStatusDangerBackground2": "#eeacb2", + "colorStatusDangerBackground3": "#c50f1f", + "colorStatusDangerForeground1": "#b10e1c", + "colorStatusDangerForeground2": "#6e0811", + "colorStatusDangerForeground3": "#c50f1f", + "colorStatusDangerForegroundInverted": "#dc626d", + "colorStatusDangerBorderActive": "#c50f1f", + "colorStatusDangerBorder1": "#eeacb2", + "colorStatusDangerBorder2": "#c50f1f", + "colorStatusDangerBackground3Hover": "#b10e1c", + "colorStatusDangerBackground3Pressed": "#960b18", + "shadow2": "0 0 2px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.14)", + "shadow4": "0 0 2px rgba(0,0,0,0.12), 0 2px 4px rgba(0,0,0,0.14)", + "shadow8": "0 0 2px rgba(0,0,0,0.12), 0 4px 8px rgba(0,0,0,0.14)", + "shadow16": "0 0 2px rgba(0,0,0,0.12), 0 8px 16px rgba(0,0,0,0.14)", + "shadow28": "0 0 8px rgba(0,0,0,0.12), 0 14px 28px rgba(0,0,0,0.14)", + "shadow64": "0 0 8px rgba(0,0,0,0.12), 0 32px 64px rgba(0,0,0,0.14)", + "shadow2Brand": "0 0 2px rgba(0,0,0,0.30), 0 1px 2px rgba(0,0,0,0.25)", + "shadow4Brand": "0 0 2px rgba(0,0,0,0.30), 0 2px 4px rgba(0,0,0,0.25)", + "shadow8Brand": "0 0 2px rgba(0,0,0,0.30), 0 4px 8px rgba(0,0,0,0.25)", + "shadow16Brand": "0 0 2px rgba(0,0,0,0.30), 0 8px 16px rgba(0,0,0,0.25)", + "shadow28Brand": "0 0 8px rgba(0,0,0,0.30), 0 14px 28px rgba(0,0,0,0.25)", + "shadow64Brand": "0 0 8px rgba(0,0,0,0.30), 0 32px 64px rgba(0,0,0,0.25)" + }, + "teamsDarkTheme": { + "borderRadiusNone": "0", + "borderRadiusSmall": "2px", + "borderRadiusMedium": "4px", + "borderRadiusLarge": "6px", + "borderRadiusXLarge": "8px", + "borderRadius2XLarge": "12px", + "borderRadius3XLarge": "16px", + "borderRadius4XLarge": "24px", + "borderRadius5XLarge": "32px", + "borderRadius6XLarge": "40px", + "borderRadiusCircular": "10000px", + "fontSizeBase100": "10px", + "fontSizeBase200": "12px", + "fontSizeBase300": "14px", + "fontSizeBase400": "16px", + "fontSizeBase500": "20px", + "fontSizeBase600": "24px", + "fontSizeHero700": "28px", + "fontSizeHero800": "32px", + "fontSizeHero900": "40px", + "fontSizeHero1000": "68px", + "lineHeightBase100": "14px", + "lineHeightBase200": "16px", + "lineHeightBase300": "20px", + "lineHeightBase400": "22px", + "lineHeightBase500": "28px", + "lineHeightBase600": "32px", + "lineHeightHero700": "36px", + "lineHeightHero800": "40px", + "lineHeightHero900": "52px", + "lineHeightHero1000": "92px", + "fontFamilyBase": "-apple-system, BlinkMacSystemFont, \"Segoe UI\", system-ui, \"Apple Color Emoji\", \"Segoe UI Emoji\", sans-serif", + "fontFamilyMonospace": "Consolas, 'Courier New', Courier, monospace", + "fontFamilyNumeric": "Bahnschrift, 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', sans-serif", + "fontWeightRegular": 400, + "fontWeightMedium": 500, + "fontWeightSemibold": 600, + "fontWeightBold": 700, + "strokeWidthThin": "1px", + "strokeWidthThick": "2px", + "strokeWidthThicker": "3px", + "strokeWidthThickest": "4px", + "spacingHorizontalNone": "0", + "spacingHorizontalXXS": "2px", + "spacingHorizontalXS": "4px", + "spacingHorizontalSNudge": "6px", + "spacingHorizontalS": "8px", + "spacingHorizontalMNudge": "10px", + "spacingHorizontalM": "12px", + "spacingHorizontalL": "16px", + "spacingHorizontalXL": "20px", + "spacingHorizontalXXL": "24px", + "spacingHorizontalXXXL": "32px", + "spacingVerticalNone": "0", + "spacingVerticalXXS": "2px", + "spacingVerticalXS": "4px", + "spacingVerticalSNudge": "6px", + "spacingVerticalS": "8px", + "spacingVerticalMNudge": "10px", + "spacingVerticalM": "12px", + "spacingVerticalL": "16px", + "spacingVerticalXL": "20px", + "spacingVerticalXXL": "24px", + "spacingVerticalXXXL": "32px", + "durationUltraFast": "50ms", + "durationFaster": "100ms", + "durationFast": "150ms", + "durationNormal": "200ms", + "durationGentle": "250ms", + "durationSlow": "300ms", + "durationSlower": "400ms", + "durationUltraSlow": "500ms", + "curveAccelerateMax": "cubic-bezier(0.9,0.1,1,0.2)", + "curveAccelerateMid": "cubic-bezier(1,0,1,1)", + "curveAccelerateMin": "cubic-bezier(0.8,0,0.78,1)", + "curveDecelerateMax": "cubic-bezier(0.1,0.9,0.2,1)", + "curveDecelerateMid": "cubic-bezier(0,0,0,1)", + "curveDecelerateMin": "cubic-bezier(0.33,0,0.1,1)", + "curveEasyEaseMax": "cubic-bezier(0.8,0,0.2,1)", + "curveEasyEase": "cubic-bezier(0.33,0,0.67,1)", + "curveLinear": "cubic-bezier(0,0,1,1)", + "colorNeutralForeground1": "#ffffff", + "colorNeutralForeground1Hover": "#ffffff", + "colorNeutralForeground1Pressed": "#ffffff", + "colorNeutralForeground1Selected": "#ffffff", + "colorNeutralForeground2": "#d6d6d6", + "colorNeutralForeground2Hover": "#ffffff", + "colorNeutralForeground2Pressed": "#ffffff", + "colorNeutralForeground2Selected": "#ffffff", + "colorNeutralForeground2BrandHover": "#7f85f5", + "colorNeutralForeground2BrandPressed": "#7579eb", + "colorNeutralForeground2BrandSelected": "#7f85f5", + "colorNeutralForeground3": "#adadad", + "colorNeutralForeground3Hover": "#d6d6d6", + "colorNeutralForeground3Pressed": "#d6d6d6", + "colorNeutralForeground3Selected": "#d6d6d6", + "colorNeutralForeground3BrandHover": "#7f85f5", + "colorNeutralForeground3BrandPressed": "#7579eb", + "colorNeutralForeground3BrandSelected": "#7f85f5", + "colorNeutralForeground4": "#999999", + "colorNeutralForeground5": "#adadad", + "colorNeutralForeground5Hover": "#ffffff", + "colorNeutralForeground5Pressed": "#ffffff", + "colorNeutralForeground5Selected": "#ffffff", + "colorNeutralForegroundDisabled": "#5c5c5c", + "colorNeutralForegroundInvertedDisabled": "rgba(255, 255, 255, 0.4)", + "colorBrandForegroundLink": "#7f85f5", + "colorBrandForegroundLinkHover": "#9299f7", + "colorBrandForegroundLinkPressed": "#7579eb", + "colorBrandForegroundLinkSelected": "#7f85f5", + "colorNeutralForeground2Link": "#d6d6d6", + "colorNeutralForeground2LinkHover": "#ffffff", + "colorNeutralForeground2LinkPressed": "#ffffff", + "colorNeutralForeground2LinkSelected": "#ffffff", + "colorCompoundBrandForeground1": "#7f85f5", + "colorCompoundBrandForeground1Hover": "#9299f7", + "colorCompoundBrandForeground1Pressed": "#7579eb", + "colorBrandForeground1": "#7f85f5", + "colorBrandForeground2": "#aab1fa", + "colorBrandForeground2Hover": "#b6bcfa", + "colorBrandForeground2Pressed": "#e8ebfa", + "colorNeutralForeground1Static": "#242424", + "colorNeutralForegroundStaticInverted": "#ffffff", + "colorNeutralForegroundInverted": "#242424", + "colorNeutralForegroundInvertedHover": "#242424", + "colorNeutralForegroundInvertedPressed": "#242424", + "colorNeutralForegroundInvertedSelected": "#242424", + "colorNeutralForegroundInverted2": "#242424", + "colorNeutralForegroundOnBrand": "#ffffff", + "colorNeutralForegroundInvertedLink": "#ffffff", + "colorNeutralForegroundInvertedLinkHover": "#ffffff", + "colorNeutralForegroundInvertedLinkPressed": "#ffffff", + "colorNeutralForegroundInvertedLinkSelected": "#ffffff", + "colorBrandForegroundInverted": "#5b5fc7", + "colorBrandForegroundInvertedHover": "#4f52b2", + "colorBrandForegroundInvertedPressed": "#444791", + "colorBrandForegroundOnLight": "#5b5fc7", + "colorBrandForegroundOnLightHover": "#4f52b2", + "colorBrandForegroundOnLightPressed": "#3d3e78", + "colorBrandForegroundOnLightSelected": "#444791", + "colorNeutralBackground1": "#292929", + "colorNeutralBackground1Hover": "#3d3d3d", + "colorNeutralBackground1Pressed": "#1f1f1f", + "colorNeutralBackground1Selected": "#383838", + "colorNeutralBackground2": "#242424", + "colorNeutralBackground2Hover": "#383838", + "colorNeutralBackground2Pressed": "#1a1a1a", + "colorNeutralBackground2Selected": "#333333", + "colorNeutralBackground3": "#1f1f1f", + "colorNeutralBackground3Hover": "#333333", + "colorNeutralBackground3Pressed": "#141414", + "colorNeutralBackground3Selected": "#2e2e2e", + "colorNeutralBackground4": "#141414", + "colorNeutralBackground4Hover": "#292929", + "colorNeutralBackground4Pressed": "#0a0a0a", + "colorNeutralBackground4Selected": "#242424", + "colorNeutralBackground5": "#0a0a0a", + "colorNeutralBackground5Hover": "#1f1f1f", + "colorNeutralBackground5Pressed": "#000000", + "colorNeutralBackground5Selected": "#1a1a1a", + "colorNeutralBackground6": "#333333", + "colorNeutralBackground7": "#00000000", + "colorNeutralBackground7Hover": "#1a1a1a", + "colorNeutralBackground7Pressed": "#0a0a0a", + "colorNeutralBackground7Selected": "#00000000", + "colorNeutralBackground8": "#292929", + "colorNeutralBackgroundInverted": "#ffffff", + "colorNeutralBackgroundInvertedHover": "#f5f5f5", + "colorNeutralBackgroundInvertedPressed": "#e0e0e0", + "colorNeutralBackgroundInvertedSelected": "#ebebeb", + "colorNeutralBackgroundStatic": "#3d3d3d", + "colorNeutralBackgroundAlpha": "rgba(26, 26, 26, 0.5)", + "colorNeutralBackgroundAlpha2": "rgba(31, 31, 31, 0.7)", + "colorSubtleBackground": "transparent", + "colorSubtleBackgroundHover": "#383838", + "colorSubtleBackgroundPressed": "#2e2e2e", + "colorSubtleBackgroundSelected": "#333333", + "colorSubtleBackgroundLightAlphaHover": "rgba(36, 36, 36, 0.8)", + "colorSubtleBackgroundLightAlphaPressed": "rgba(36, 36, 36, 0.5)", + "colorSubtleBackgroundLightAlphaSelected": "transparent", + "colorSubtleBackgroundInverted": "transparent", + "colorSubtleBackgroundInvertedHover": "rgba(0, 0, 0, 0.1)", + "colorSubtleBackgroundInvertedPressed": "rgba(0, 0, 0, 0.3)", + "colorSubtleBackgroundInvertedSelected": "rgba(0, 0, 0, 0.2)", + "colorTransparentBackground": "transparent", + "colorTransparentBackgroundHover": "transparent", + "colorTransparentBackgroundPressed": "transparent", + "colorTransparentBackgroundSelected": "transparent", + "colorNeutralBackgroundDisabled": "#141414", + "colorNeutralBackgroundDisabled2": "#292929", + "colorNeutralBackgroundInvertedDisabled": "rgba(255, 255, 255, 0.1)", + "colorNeutralStencil1": "#575757", + "colorNeutralStencil2": "#333333", + "colorNeutralStencil1Alpha": "rgba(255, 255, 255, 0.1)", + "colorNeutralStencil2Alpha": "rgba(255, 255, 255, 0.05)", + "colorBackgroundOverlay": "rgba(0, 0, 0, 0.5)", + "colorScrollbarOverlay": "rgba(255, 255, 255, 0.6)", + "colorBrandBackground": "#4f52b2", + "colorBrandBackgroundHover": "#5b5fc7", + "colorBrandBackgroundPressed": "#383966", + "colorBrandBackgroundSelected": "#444791", + "colorCompoundBrandBackground": "#7f85f5", + "colorCompoundBrandBackgroundHover": "#9299f7", + "colorCompoundBrandBackgroundPressed": "#7579eb", + "colorBrandBackgroundStatic": "#5b5fc7", + "colorBrandBackground2": "#2f2f4a", + "colorBrandBackground2Hover": "#383966", + "colorBrandBackground2Pressed": "#2b2b40", + "colorBrandBackground3Static": "#444791", + "colorBrandBackground4Static": "#383966", + "colorBrandBackgroundInverted": "#ffffff", + "colorBrandBackgroundInvertedHover": "#e8ebfa", + "colorBrandBackgroundInvertedPressed": "#c5cbfa", + "colorBrandBackgroundInvertedSelected": "#dce0fa", + "colorNeutralCardBackground": "#333333", + "colorNeutralCardBackgroundHover": "#3d3d3d", + "colorNeutralCardBackgroundPressed": "#2e2e2e", + "colorNeutralCardBackgroundSelected": "#383838", + "colorNeutralCardBackgroundDisabled": "#141414", + "colorNeutralStrokeAccessible": "#adadad", + "colorNeutralStrokeAccessibleHover": "#bdbdbd", + "colorNeutralStrokeAccessiblePressed": "#b3b3b3", + "colorNeutralStrokeAccessibleSelected": "#7f85f5", + "colorNeutralStroke1": "#666666", + "colorNeutralStroke1Hover": "#757575", + "colorNeutralStroke1Pressed": "#6b6b6b", + "colorNeutralStroke1Selected": "#707070", + "colorNeutralStroke2": "#525252", + "colorNeutralStroke3": "#3d3d3d", + "colorNeutralStroke4": "#3d3d3d", + "colorNeutralStroke4Hover": "#2e2e2e", + "colorNeutralStroke4Pressed": "#242424", + "colorNeutralStroke4Selected": "#3d3d3d", + "colorNeutralStrokeSubtle": "#0a0a0a", + "colorNeutralStrokeOnBrand": "#292929", + "colorNeutralStrokeOnBrand2": "#ffffff", + "colorNeutralStrokeOnBrand2Hover": "#ffffff", + "colorNeutralStrokeOnBrand2Pressed": "#ffffff", + "colorNeutralStrokeOnBrand2Selected": "#ffffff", + "colorBrandStroke1": "#7f85f5", + "colorBrandStroke2": "#3d3e78", + "colorBrandStroke2Hover": "#3d3e78", + "colorBrandStroke2Pressed": "#333357", + "colorBrandStroke2Contrast": "#3d3e78", + "colorCompoundBrandStroke": "#7579eb", + "colorCompoundBrandStrokeHover": "#7f85f5", + "colorCompoundBrandStrokePressed": "#5b5fc7", + "colorNeutralStrokeDisabled": "#424242", + "colorNeutralStrokeDisabled2": "#3d3d3d", + "colorNeutralStrokeInvertedDisabled": "rgba(255, 255, 255, 0.4)", + "colorTransparentStroke": "transparent", + "colorTransparentStrokeInteractive": "transparent", + "colorTransparentStrokeDisabled": "transparent", + "colorNeutralStrokeAlpha": "rgba(255, 255, 255, 0.1)", + "colorNeutralStrokeAlpha2": "rgba(255, 255, 255, 0.2)", + "colorStrokeFocus1": "#000000", + "colorStrokeFocus2": "#ffffff", + "colorNeutralShadowAmbient": "rgba(0,0,0,0.24)", + "colorNeutralShadowKey": "rgba(0,0,0,0.28)", + "colorNeutralShadowAmbientLighter": "rgba(0,0,0,0.12)", + "colorNeutralShadowKeyLighter": "rgba(0,0,0,0.14)", + "colorNeutralShadowAmbientDarker": "rgba(0,0,0,0.40)", + "colorNeutralShadowKeyDarker": "rgba(0,0,0,0.48)", + "colorBrandShadowAmbient": "rgba(0,0,0,0.30)", + "colorBrandShadowKey": "rgba(0,0,0,0.25)", + "colorPaletteRedBackground1": "#3f1011", + "colorPaletteRedBackground2": "#751d1f", + "colorPaletteRedBackground3": "#d13438", + "colorPaletteRedForeground1": "#e37d80", + "colorPaletteRedForeground2": "#f1bbbc", + "colorPaletteRedForeground3": "#e37d80", + "colorPaletteRedBorderActive": "#e37d80", + "colorPaletteRedBorder1": "#d13438", + "colorPaletteRedBorder2": "#e37d80", + "colorPaletteGreenBackground1": "#052505", + "colorPaletteGreenBackground2": "#094509", + "colorPaletteGreenBackground3": "#107c10", + "colorPaletteGreenForeground1": "#54b054", + "colorPaletteGreenForeground2": "#9fd89f", + "colorPaletteGreenForeground3": "#9fd89f", + "colorPaletteGreenBorderActive": "#54b054", + "colorPaletteGreenBorder1": "#107c10", + "colorPaletteGreenBorder2": "#9fd89f", + "colorPaletteDarkOrangeBackground1": "#411200", + "colorPaletteDarkOrangeBackground2": "#7a2101", + "colorPaletteDarkOrangeBackground3": "#da3b01", + "colorPaletteDarkOrangeForeground1": "#e9835e", + "colorPaletteDarkOrangeForeground2": "#f4bfab", + "colorPaletteDarkOrangeForeground3": "#e9835e", + "colorPaletteDarkOrangeBorderActive": "#e9835e", + "colorPaletteDarkOrangeBorder1": "#da3b01", + "colorPaletteDarkOrangeBorder2": "#e9835e", + "colorPaletteYellowBackground1": "#4c4400", + "colorPaletteYellowBackground2": "#817400", + "colorPaletteYellowBackground3": "#fde300", + "colorPaletteYellowForeground1": "#feee66", + "colorPaletteYellowForeground2": "#fef7b2", + "colorPaletteYellowForeground3": "#fdea3d", + "colorPaletteYellowBorderActive": "#feee66", + "colorPaletteYellowBorder1": "#fde300", + "colorPaletteYellowBorder2": "#fdea3d", + "colorPaletteBerryBackground1": "#3a1136", + "colorPaletteBerryBackground2": "#6d2064", + "colorPaletteBerryBackground3": "#c239b3", + "colorPaletteBerryForeground1": "#da7ed0", + "colorPaletteBerryForeground2": "#edbbe7", + "colorPaletteBerryForeground3": "#d161c4", + "colorPaletteBerryBorderActive": "#da7ed0", + "colorPaletteBerryBorder1": "#c239b3", + "colorPaletteBerryBorder2": "#d161c4", + "colorPaletteLightGreenBackground1": "#063004", + "colorPaletteLightGreenBackground2": "#0b5a08", + "colorPaletteLightGreenBackground3": "#13a10e", + "colorPaletteLightGreenForeground1": "#5ec75a", + "colorPaletteLightGreenForeground2": "#a7e3a5", + "colorPaletteLightGreenForeground3": "#3db838", + "colorPaletteLightGreenBorderActive": "#5ec75a", + "colorPaletteLightGreenBorder1": "#13a10e", + "colorPaletteLightGreenBorder2": "#3db838", + "colorPaletteMarigoldBackground1": "#463100", + "colorPaletteMarigoldBackground2": "#835b00", + "colorPaletteMarigoldBackground3": "#eaa300", + "colorPaletteMarigoldForeground1": "#f2c661", + "colorPaletteMarigoldForeground2": "#f9e2ae", + "colorPaletteMarigoldForeground3": "#efb839", + "colorPaletteMarigoldBorderActive": "#f2c661", + "colorPaletteMarigoldBorder1": "#eaa300", + "colorPaletteMarigoldBorder2": "#efb839", + "colorPaletteRedForegroundInverted": "#d13438", + "colorPaletteGreenForegroundInverted": "#107c10", + "colorPaletteYellowForegroundInverted": "#817400", + "colorPaletteDarkRedBackground2": "#590815", + "colorPaletteDarkRedForeground2": "#d69ca5", + "colorPaletteDarkRedBorderActive": "#ac4f5e", + "colorPaletteCranberryBackground2": "#6e0811", + "colorPaletteCranberryForeground2": "#eeacb2", + "colorPaletteCranberryBorderActive": "#dc626d", + "colorPalettePumpkinBackground2": "#712d09", + "colorPalettePumpkinForeground2": "#efc4ad", + "colorPalettePumpkinBorderActive": "#df8e64", + "colorPalettePeachBackground2": "#8f4e00", + "colorPalettePeachForeground2": "#ffddb3", + "colorPalettePeachBorderActive": "#ffba66", + "colorPaletteGoldBackground2": "#6c5700", + "colorPaletteGoldForeground2": "#ecdfa5", + "colorPaletteGoldBorderActive": "#dac157", + "colorPaletteBrassBackground2": "#553e06", + "colorPaletteBrassForeground2": "#e0cea2", + "colorPaletteBrassBorderActive": "#c1a256", + "colorPaletteBrownBackground2": "#50301a", + "colorPaletteBrownForeground2": "#ddc3b0", + "colorPaletteBrownBorderActive": "#bb8f6f", + "colorPaletteForestBackground2": "#294903", + "colorPaletteForestForeground2": "#bdd99b", + "colorPaletteForestBorderActive": "#85b44c", + "colorPaletteSeafoamBackground2": "#00723b", + "colorPaletteSeafoamForeground2": "#a8f0cd", + "colorPaletteSeafoamBorderActive": "#5ae0a0", + "colorPaletteDarkGreenBackground2": "#063b06", + "colorPaletteDarkGreenForeground2": "#9ad29a", + "colorPaletteDarkGreenBorderActive": "#4da64d", + "colorPaletteLightTealBackground2": "#00666d", + "colorPaletteLightTealForeground2": "#a6e9ed", + "colorPaletteLightTealBorderActive": "#58d3db", + "colorPaletteTealBackground2": "#02494c", + "colorPaletteTealForeground2": "#9bd9db", + "colorPaletteTealBorderActive": "#4cb4b7", + "colorPaletteSteelBackground2": "#00333f", + "colorPaletteSteelForeground2": "#94c8d4", + "colorPaletteSteelBorderActive": "#4496a9", + "colorPaletteBlueBackground2": "#004377", + "colorPaletteBlueForeground2": "#a9d3f2", + "colorPaletteBlueBorderActive": "#5caae5", + "colorPaletteRoyalBlueBackground2": "#002c4e", + "colorPaletteRoyalBlueForeground2": "#9abfdc", + "colorPaletteRoyalBlueBorderActive": "#4a89ba", + "colorPaletteCornflowerBackground2": "#2c3c85", + "colorPaletteCornflowerForeground2": "#c8d1fa", + "colorPaletteCornflowerBorderActive": "#93a4f4", + "colorPaletteNavyBackground2": "#001665", + "colorPaletteNavyForeground2": "#a3b2e8", + "colorPaletteNavyBorderActive": "#546fd2", + "colorPaletteLavenderBackground2": "#3f3682", + "colorPaletteLavenderForeground2": "#d2ccf8", + "colorPaletteLavenderBorderActive": "#a79cf1", + "colorPalettePurpleBackground2": "#341a51", + "colorPalettePurpleForeground2": "#c6b1de", + "colorPalettePurpleBorderActive": "#9470bd", + "colorPaletteGrapeBackground2": "#4c0d55", + "colorPaletteGrapeForeground2": "#d9a7e0", + "colorPaletteGrapeBorderActive": "#b55fc1", + "colorPaletteLilacBackground2": "#63276d", + "colorPaletteLilacForeground2": "#e6bfed", + "colorPaletteLilacBorderActive": "#cf87da", + "colorPalettePinkBackground2": "#80215d", + "colorPalettePinkForeground2": "#f7c0e3", + "colorPalettePinkBorderActive": "#ef85c8", + "colorPaletteMagentaBackground2": "#6b0043", + "colorPaletteMagentaForeground2": "#eca5d1", + "colorPaletteMagentaBorderActive": "#d957a8", + "colorPalettePlumBackground2": "#5a003b", + "colorPalettePlumForeground2": "#d696c0", + "colorPalettePlumBorderActive": "#ad4589", + "colorPaletteBeigeBackground2": "#444241", + "colorPaletteBeigeForeground2": "#d7d4d4", + "colorPaletteBeigeBorderActive": "#afabaa", + "colorPaletteMinkBackground2": "#343231", + "colorPaletteMinkForeground2": "#cecccb", + "colorPaletteMinkBorderActive": "#9e9b99", + "colorPalettePlatinumBackground2": "#3b4447", + "colorPalettePlatinumForeground2": "#cdd6d8", + "colorPalettePlatinumBorderActive": "#a0adb2", + "colorPaletteAnchorBackground2": "#202427", + "colorPaletteAnchorForeground2": "#bcc3c7", + "colorPaletteAnchorBorderActive": "#808a90", + "colorStatusSuccessBackground1": "#052505", + "colorStatusSuccessBackground2": "#094509", + "colorStatusSuccessBackground3": "#107c10", + "colorStatusSuccessForeground1": "#54b054", + "colorStatusSuccessForeground2": "#9fd89f", + "colorStatusSuccessForeground3": "#9fd89f", + "colorStatusSuccessBorderActive": "#54b054", + "colorStatusSuccessForegroundInverted": "#0e700e", + "colorStatusSuccessBorder1": "#107c10", + "colorStatusSuccessBorder2": "#9fd89f", + "colorStatusWarningBackground1": "#4a1e04", + "colorStatusWarningBackground2": "#8a3707", + "colorStatusWarningBackground3": "#f7630c", + "colorStatusWarningForeground1": "#faa06b", + "colorStatusWarningForeground2": "#fdcfb4", + "colorStatusWarningForeground3": "#f98845", + "colorStatusWarningBorderActive": "#faa06b", + "colorStatusWarningForegroundInverted": "#bc4b09", + "colorStatusWarningBorder1": "#f7630c", + "colorStatusWarningBorder2": "#f98845", + "colorStatusDangerBackground1": "#3b0509", + "colorStatusDangerBackground2": "#6e0811", + "colorStatusDangerBackground3": "#c50f1f", + "colorStatusDangerForeground1": "#dc626d", + "colorStatusDangerForeground2": "#eeacb2", + "colorStatusDangerForeground3": "#eeacb2", + "colorStatusDangerBorderActive": "#dc626d", + "colorStatusDangerForegroundInverted": "#b10e1c", + "colorStatusDangerBorder1": "#c50f1f", + "colorStatusDangerBorder2": "#dc626d", + "colorStatusDangerBackground3Hover": "#b10e1c", + "colorStatusDangerBackground3Pressed": "#960b18", + "shadow2": "0 0 2px rgba(0,0,0,0.24), 0 1px 2px rgba(0,0,0,0.28)", + "shadow4": "0 0 2px rgba(0,0,0,0.24), 0 2px 4px rgba(0,0,0,0.28)", + "shadow8": "0 0 2px rgba(0,0,0,0.24), 0 4px 8px rgba(0,0,0,0.28)", + "shadow16": "0 0 2px rgba(0,0,0,0.24), 0 8px 16px rgba(0,0,0,0.28)", + "shadow28": "0 0 8px rgba(0,0,0,0.24), 0 14px 28px rgba(0,0,0,0.28)", + "shadow64": "0 0 8px rgba(0,0,0,0.24), 0 32px 64px rgba(0,0,0,0.28)", + "shadow2Brand": "0 0 2px rgba(0,0,0,0.30), 0 1px 2px rgba(0,0,0,0.25)", + "shadow4Brand": "0 0 2px rgba(0,0,0,0.30), 0 2px 4px rgba(0,0,0,0.25)", + "shadow8Brand": "0 0 2px rgba(0,0,0,0.30), 0 4px 8px rgba(0,0,0,0.25)", + "shadow16Brand": "0 0 2px rgba(0,0,0,0.30), 0 8px 16px rgba(0,0,0,0.25)", + "shadow28Brand": "0 0 8px rgba(0,0,0,0.30), 0 14px 28px rgba(0,0,0,0.25)", + "shadow64Brand": "0 0 8px rgba(0,0,0,0.30), 0 32px 64px rgba(0,0,0,0.25)" + }, + "teamsHighContrastTheme": { + "borderRadiusNone": "0", + "borderRadiusSmall": "2px", + "borderRadiusMedium": "4px", + "borderRadiusLarge": "6px", + "borderRadiusXLarge": "8px", + "borderRadius2XLarge": "12px", + "borderRadius3XLarge": "16px", + "borderRadius4XLarge": "24px", + "borderRadius5XLarge": "32px", + "borderRadius6XLarge": "40px", + "borderRadiusCircular": "10000px", + "fontSizeBase100": "10px", + "fontSizeBase200": "12px", + "fontSizeBase300": "14px", + "fontSizeBase400": "16px", + "fontSizeBase500": "20px", + "fontSizeBase600": "24px", + "fontSizeHero700": "28px", + "fontSizeHero800": "32px", + "fontSizeHero900": "40px", + "fontSizeHero1000": "68px", + "lineHeightBase100": "14px", + "lineHeightBase200": "16px", + "lineHeightBase300": "20px", + "lineHeightBase400": "22px", + "lineHeightBase500": "28px", + "lineHeightBase600": "32px", + "lineHeightHero700": "36px", + "lineHeightHero800": "40px", + "lineHeightHero900": "52px", + "lineHeightHero1000": "92px", + "fontFamilyBase": "-apple-system, BlinkMacSystemFont, \"Segoe UI\", system-ui, \"Apple Color Emoji\", \"Segoe UI Emoji\", sans-serif", + "fontFamilyMonospace": "Consolas, 'Courier New', Courier, monospace", + "fontFamilyNumeric": "Bahnschrift, 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', sans-serif", + "fontWeightRegular": 400, + "fontWeightMedium": 500, + "fontWeightSemibold": 600, + "fontWeightBold": 700, + "strokeWidthThin": "1px", + "strokeWidthThick": "2px", + "strokeWidthThicker": "3px", + "strokeWidthThickest": "4px", + "spacingHorizontalNone": "0", + "spacingHorizontalXXS": "2px", + "spacingHorizontalXS": "4px", + "spacingHorizontalSNudge": "6px", + "spacingHorizontalS": "8px", + "spacingHorizontalMNudge": "10px", + "spacingHorizontalM": "12px", + "spacingHorizontalL": "16px", + "spacingHorizontalXL": "20px", + "spacingHorizontalXXL": "24px", + "spacingHorizontalXXXL": "32px", + "spacingVerticalNone": "0", + "spacingVerticalXXS": "2px", + "spacingVerticalXS": "4px", + "spacingVerticalSNudge": "6px", + "spacingVerticalS": "8px", + "spacingVerticalMNudge": "10px", + "spacingVerticalM": "12px", + "spacingVerticalL": "16px", + "spacingVerticalXL": "20px", + "spacingVerticalXXL": "24px", + "spacingVerticalXXXL": "32px", + "durationUltraFast": "50ms", + "durationFaster": "100ms", + "durationFast": "150ms", + "durationNormal": "200ms", + "durationGentle": "250ms", + "durationSlow": "300ms", + "durationSlower": "400ms", + "durationUltraSlow": "500ms", + "curveAccelerateMax": "cubic-bezier(0.9,0.1,1,0.2)", + "curveAccelerateMid": "cubic-bezier(1,0,1,1)", + "curveAccelerateMin": "cubic-bezier(0.8,0,0.78,1)", + "curveDecelerateMax": "cubic-bezier(0.1,0.9,0.2,1)", + "curveDecelerateMid": "cubic-bezier(0,0,0,1)", + "curveDecelerateMin": "cubic-bezier(0.33,0,0.1,1)", + "curveEasyEaseMax": "cubic-bezier(0.8,0,0.2,1)", + "curveEasyEase": "cubic-bezier(0.33,0,0.67,1)", + "curveLinear": "cubic-bezier(0,0,1,1)", + "colorNeutralForeground1": "#ffffff", + "colorNeutralForeground1Hover": "#000000", + "colorNeutralForeground1Pressed": "#000000", + "colorNeutralForeground1Selected": "#000000", + "colorNeutralForeground2": "#ffffff", + "colorNeutralForeground2Hover": "#000000", + "colorNeutralForeground2Pressed": "#000000", + "colorNeutralForeground2Selected": "#000000", + "colorNeutralForeground2BrandHover": "#000000", + "colorNeutralForeground2BrandPressed": "#000000", + "colorNeutralForeground2BrandSelected": "#000000", + "colorNeutralForeground3": "#ffffff", + "colorNeutralForeground3Hover": "#000000", + "colorNeutralForeground3Pressed": "#000000", + "colorNeutralForeground3Selected": "#000000", + "colorNeutralForeground3BrandHover": "#000000", + "colorNeutralForeground3BrandPressed": "#000000", + "colorNeutralForeground3BrandSelected": "#000000", + "colorNeutralForeground4": "#ffffff", + "colorNeutralForeground5": "#ffffff", + "colorNeutralForeground5Hover": "#000000", + "colorNeutralForeground5Pressed": "#000000", + "colorNeutralForeground5Selected": "#000000", + "colorNeutralForegroundDisabled": "#3ff23f", + "colorNeutralForegroundInvertedDisabled": "#3ff23f", + "colorBrandForegroundLink": "#ffff00", + "colorBrandForegroundLinkHover": "#ffff00", + "colorBrandForegroundLinkPressed": "#ffff00", + "colorBrandForegroundLinkSelected": "#ffff00", + "colorNeutralForeground2Link": "#ffff00", + "colorNeutralForeground2LinkHover": "#ffff00", + "colorNeutralForeground2LinkPressed": "#ffff00", + "colorNeutralForeground2LinkSelected": "#ffff00", + "colorCompoundBrandForeground1": "#1aebff", + "colorCompoundBrandForeground1Hover": "#1aebff", + "colorCompoundBrandForeground1Pressed": "#1aebff", + "colorBrandForeground1": "#ffffff", + "colorBrandForeground2": "#ffffff", + "colorBrandForeground2Hover": "#ffffff", + "colorBrandForeground2Pressed": "#ffffff", + "colorNeutralForeground1Static": "#000000", + "colorNeutralForegroundStaticInverted": "#ffffff", + "colorNeutralForegroundInverted": "#000000", + "colorNeutralForegroundInvertedHover": "#000000", + "colorNeutralForegroundInvertedPressed": "#000000", + "colorNeutralForegroundInvertedSelected": "#000000", + "colorNeutralForegroundInverted2": "#ffffff", + "colorNeutralForegroundOnBrand": "#000000", + "colorNeutralForegroundInvertedLink": "#ffff00", + "colorNeutralForegroundInvertedLinkHover": "#ffff00", + "colorNeutralForegroundInvertedLinkPressed": "#ffff00", + "colorNeutralForegroundInvertedLinkSelected": "#ffff00", + "colorBrandForegroundInverted": "#ffffff", + "colorBrandForegroundInvertedHover": "#000000", + "colorBrandForegroundInvertedPressed": "#000000", + "colorBrandForegroundOnLight": "#000000", + "colorBrandForegroundOnLightHover": "#000000", + "colorBrandForegroundOnLightPressed": "#000000", + "colorBrandForegroundOnLightSelected": "#000000", + "colorNeutralBackground1": "#000000", + "colorNeutralBackground1Hover": "#1aebff", + "colorNeutralBackground1Pressed": "#1aebff", + "colorNeutralBackground1Selected": "#1aebff", + "colorNeutralBackground2": "#000000", + "colorNeutralBackground2Hover": "#1aebff", + "colorNeutralBackground2Pressed": "#1aebff", + "colorNeutralBackground2Selected": "#1aebff", + "colorNeutralBackground3": "#000000", + "colorNeutralBackground3Hover": "#1aebff", + "colorNeutralBackground3Pressed": "#1aebff", + "colorNeutralBackground3Selected": "#1aebff", + "colorNeutralBackground4": "#000000", + "colorNeutralBackground4Hover": "#1aebff", + "colorNeutralBackground4Pressed": "#1aebff", + "colorNeutralBackground4Selected": "#1aebff", + "colorNeutralBackground5": "#000000", + "colorNeutralBackground5Hover": "#1aebff", + "colorNeutralBackground5Pressed": "#1aebff", + "colorNeutralBackground5Selected": "#1aebff", + "colorNeutralBackground6": "#000000", + "colorNeutralBackground7": "#000000", + "colorNeutralBackground7Hover": "#1aebff", + "colorNeutralBackground7Pressed": "#1aebff", + "colorNeutralBackground7Selected": "#1aebff", + "colorNeutralBackground8": "#000000", + "colorNeutralBackgroundInverted": "#000000", + "colorNeutralBackgroundInvertedHover": "#1aebff", + "colorNeutralBackgroundInvertedPressed": "#1aebff", + "colorNeutralBackgroundInvertedSelected": "#1aebff", + "colorNeutralBackgroundStatic": "#000000", + "colorNeutralBackgroundAlpha": "#000000", + "colorNeutralBackgroundAlpha2": "#000000", + "colorSubtleBackground": "transparent", + "colorSubtleBackgroundHover": "#1aebff", + "colorSubtleBackgroundPressed": "#1aebff", + "colorSubtleBackgroundSelected": "#1aebff", + "colorSubtleBackgroundLightAlphaHover": "#1aebff", + "colorSubtleBackgroundLightAlphaPressed": "#1aebff", + "colorSubtleBackgroundLightAlphaSelected": "#1aebff", + "colorSubtleBackgroundInverted": "transparent", + "colorSubtleBackgroundInvertedHover": "#1aebff", + "colorSubtleBackgroundInvertedPressed": "#1aebff", + "colorSubtleBackgroundInvertedSelected": "#1aebff", + "colorTransparentBackground": "transparent", + "colorTransparentBackgroundHover": "#1aebff", + "colorTransparentBackgroundPressed": "#1aebff", + "colorTransparentBackgroundSelected": "#1aebff", + "colorNeutralBackgroundDisabled": "#000000", + "colorNeutralBackgroundDisabled2": "#3ff23f", + "colorNeutralBackgroundInvertedDisabled": "#000000", + "colorNeutralStencil1": "#ffffff", + "colorNeutralStencil2": "#ffffff", + "colorNeutralStencil1Alpha": "#ffffff", + "colorNeutralStencil2Alpha": "#ffffff", + "colorBackgroundOverlay": "rgba(0, 0, 0, 0.5)", + "colorScrollbarOverlay": "#ffffff", + "colorBrandBackground": "#ffffff", + "colorBrandBackgroundHover": "#1aebff", + "colorBrandBackgroundPressed": "#1aebff", + "colorBrandBackgroundSelected": "#1aebff", + "colorCompoundBrandBackground": "#1aebff", + "colorCompoundBrandBackgroundHover": "#1aebff", + "colorCompoundBrandBackgroundPressed": "#1aebff", + "colorBrandBackgroundStatic": "#000000", + "colorBrandBackground2": "#000000", + "colorBrandBackground2Hover": "#000000", + "colorBrandBackground2Pressed": "#000000", + "colorBrandBackground3Static": "#000000", + "colorBrandBackground4Static": "#000000", + "colorBrandBackgroundInverted": "#ffffff", + "colorBrandBackgroundInvertedHover": "#1aebff", + "colorBrandBackgroundInvertedPressed": "#1aebff", + "colorBrandBackgroundInvertedSelected": "#1aebff", + "colorNeutralCardBackground": "#000000", + "colorNeutralCardBackgroundHover": "#1aebff", + "colorNeutralCardBackgroundPressed": "#1aebff", + "colorNeutralCardBackgroundSelected": "#1aebff", + "colorNeutralCardBackgroundDisabled": "#000000", + "colorNeutralStrokeAccessible": "#ffffff", + "colorNeutralStrokeAccessibleHover": "#1aebff", + "colorNeutralStrokeAccessiblePressed": "#1aebff", + "colorNeutralStrokeAccessibleSelected": "#1aebff", + "colorNeutralStroke1": "#ffffff", + "colorNeutralStroke1Hover": "#1aebff", + "colorNeutralStroke1Pressed": "#1aebff", + "colorNeutralStroke1Selected": "#1aebff", + "colorNeutralStroke2": "#ffffff", + "colorNeutralStroke3": "#ffffff", + "colorNeutralStroke4": "#ffffff", + "colorNeutralStroke4Hover": "#1aebff", + "colorNeutralStroke4Pressed": "#1aebff", + "colorNeutralStroke4Selected": "#1aebff", + "colorNeutralStrokeSubtle": "#ffffff", + "colorNeutralStrokeOnBrand": "#000000", + "colorNeutralStrokeOnBrand2": "#ffffff", + "colorNeutralStrokeOnBrand2Hover": "#ffffff", + "colorNeutralStrokeOnBrand2Pressed": "#ffffff", + "colorNeutralStrokeOnBrand2Selected": "#ffffff", + "colorBrandStroke1": "#ffffff", + "colorBrandStroke2": "#ffffff", + "colorBrandStroke2Hover": "#1aebff", + "colorBrandStroke2Pressed": "#1aebff", + "colorBrandStroke2Contrast": "#000000", + "colorCompoundBrandStroke": "#1aebff", + "colorCompoundBrandStrokeHover": "#1aebff", + "colorCompoundBrandStrokePressed": "#1aebff", + "colorNeutralStrokeDisabled": "#3ff23f", + "colorNeutralStrokeDisabled2": "#3ff23f", + "colorNeutralStrokeInvertedDisabled": "#3ff23f", + "colorTransparentStroke": "#ffffff", + "colorTransparentStrokeInteractive": "#1aebff", + "colorTransparentStrokeDisabled": "#3ff23f", + "colorNeutralStrokeAlpha": "#ffffff", + "colorNeutralStrokeAlpha2": "#000000", + "colorStrokeFocus1": "#000000", + "colorStrokeFocus2": "#1aebff", + "colorNeutralShadowAmbient": "rgba(0,0,0,0.24)", + "colorNeutralShadowKey": "rgba(0,0,0,0.28)", + "colorNeutralShadowAmbientLighter": "rgba(0,0,0,0.12)", + "colorNeutralShadowKeyLighter": "rgba(0,0,0,0.14)", + "colorNeutralShadowAmbientDarker": "rgba(0,0,0,0.40)", + "colorNeutralShadowKeyDarker": "rgba(0,0,0,0.48)", + "colorBrandShadowAmbient": "rgba(0,0,0,0.30)", + "colorBrandShadowKey": "rgba(0,0,0,0.25)", + "colorPaletteRedBackground1": "#000000", + "colorPaletteRedBackground2": "#000000", + "colorPaletteRedBackground3": "#ffffff", + "colorPaletteRedForeground1": "#ffffff", + "colorPaletteRedForeground2": "#ffffff", + "colorPaletteRedForeground3": "#ffffff", + "colorPaletteRedBorderActive": "#1aebff", + "colorPaletteRedBorder1": "#ffffff", + "colorPaletteRedBorder2": "#ffffff", + "colorPaletteGreenBackground1": "#000000", + "colorPaletteGreenBackground2": "#000000", + "colorPaletteGreenBackground3": "#ffffff", + "colorPaletteGreenForeground1": "#ffffff", + "colorPaletteGreenForeground2": "#ffffff", + "colorPaletteGreenForeground3": "#ffffff", + "colorPaletteGreenBorderActive": "#1aebff", + "colorPaletteGreenBorder1": "#ffffff", + "colorPaletteGreenBorder2": "#ffffff", + "colorPaletteDarkOrangeBackground1": "#000000", + "colorPaletteDarkOrangeBackground2": "#000000", + "colorPaletteDarkOrangeBackground3": "#ffffff", + "colorPaletteDarkOrangeForeground1": "#ffffff", + "colorPaletteDarkOrangeForeground2": "#ffffff", + "colorPaletteDarkOrangeForeground3": "#ffffff", + "colorPaletteDarkOrangeBorderActive": "#1aebff", + "colorPaletteDarkOrangeBorder1": "#ffffff", + "colorPaletteDarkOrangeBorder2": "#ffffff", + "colorPaletteYellowBackground1": "#000000", + "colorPaletteYellowBackground2": "#000000", + "colorPaletteYellowBackground3": "#ffffff", + "colorPaletteYellowForeground1": "#ffffff", + "colorPaletteYellowForeground2": "#ffffff", + "colorPaletteYellowForeground3": "#ffffff", + "colorPaletteYellowBorderActive": "#1aebff", + "colorPaletteYellowBorder1": "#ffffff", + "colorPaletteYellowBorder2": "#ffffff", + "colorPaletteBerryBackground1": "#000000", + "colorPaletteBerryBackground2": "#000000", + "colorPaletteBerryBackground3": "#ffffff", + "colorPaletteBerryForeground1": "#ffffff", + "colorPaletteBerryForeground2": "#ffffff", + "colorPaletteBerryForeground3": "#ffffff", + "colorPaletteBerryBorderActive": "#1aebff", + "colorPaletteBerryBorder1": "#ffffff", + "colorPaletteBerryBorder2": "#ffffff", + "colorPaletteLightGreenBackground1": "#000000", + "colorPaletteLightGreenBackground2": "#000000", + "colorPaletteLightGreenBackground3": "#ffffff", + "colorPaletteLightGreenForeground1": "#ffffff", + "colorPaletteLightGreenForeground2": "#ffffff", + "colorPaletteLightGreenForeground3": "#ffffff", + "colorPaletteLightGreenBorderActive": "#1aebff", + "colorPaletteLightGreenBorder1": "#ffffff", + "colorPaletteLightGreenBorder2": "#ffffff", + "colorPaletteMarigoldBackground1": "#000000", + "colorPaletteMarigoldBackground2": "#000000", + "colorPaletteMarigoldBackground3": "#ffffff", + "colorPaletteMarigoldForeground1": "#ffffff", + "colorPaletteMarigoldForeground2": "#ffffff", + "colorPaletteMarigoldForeground3": "#ffffff", + "colorPaletteMarigoldBorderActive": "#1aebff", + "colorPaletteMarigoldBorder1": "#ffffff", + "colorPaletteMarigoldBorder2": "#ffffff", + "colorPaletteRedForegroundInverted": "#ffffff", + "colorPaletteGreenForegroundInverted": "#ffffff", + "colorPaletteYellowForegroundInverted": "#ffffff", + "colorPaletteDarkRedBackground2": "#000000", + "colorPaletteDarkRedForeground2": "#ffffff", + "colorPaletteDarkRedBorderActive": "#1aebff", + "colorPaletteCranberryBackground2": "#000000", + "colorPaletteCranberryForeground2": "#ffffff", + "colorPaletteCranberryBorderActive": "#1aebff", + "colorPalettePumpkinBackground2": "#000000", + "colorPalettePumpkinForeground2": "#ffffff", + "colorPalettePumpkinBorderActive": "#1aebff", + "colorPalettePeachBackground2": "#000000", + "colorPalettePeachForeground2": "#ffffff", + "colorPalettePeachBorderActive": "#1aebff", + "colorPaletteGoldBackground2": "#000000", + "colorPaletteGoldForeground2": "#ffffff", + "colorPaletteGoldBorderActive": "#1aebff", + "colorPaletteBrassBackground2": "#000000", + "colorPaletteBrassForeground2": "#ffffff", + "colorPaletteBrassBorderActive": "#1aebff", + "colorPaletteBrownBackground2": "#000000", + "colorPaletteBrownForeground2": "#ffffff", + "colorPaletteBrownBorderActive": "#1aebff", + "colorPaletteForestBackground2": "#000000", + "colorPaletteForestForeground2": "#ffffff", + "colorPaletteForestBorderActive": "#1aebff", + "colorPaletteSeafoamBackground2": "#000000", + "colorPaletteSeafoamForeground2": "#ffffff", + "colorPaletteSeafoamBorderActive": "#1aebff", + "colorPaletteDarkGreenBackground2": "#000000", + "colorPaletteDarkGreenForeground2": "#ffffff", + "colorPaletteDarkGreenBorderActive": "#1aebff", + "colorPaletteLightTealBackground2": "#000000", + "colorPaletteLightTealForeground2": "#ffffff", + "colorPaletteLightTealBorderActive": "#1aebff", + "colorPaletteTealBackground2": "#000000", + "colorPaletteTealForeground2": "#ffffff", + "colorPaletteTealBorderActive": "#1aebff", + "colorPaletteSteelBackground2": "#000000", + "colorPaletteSteelForeground2": "#ffffff", + "colorPaletteSteelBorderActive": "#1aebff", + "colorPaletteBlueBackground2": "#000000", + "colorPaletteBlueForeground2": "#ffffff", + "colorPaletteBlueBorderActive": "#1aebff", + "colorPaletteRoyalBlueBackground2": "#000000", + "colorPaletteRoyalBlueForeground2": "#ffffff", + "colorPaletteRoyalBlueBorderActive": "#1aebff", + "colorPaletteCornflowerBackground2": "#000000", + "colorPaletteCornflowerForeground2": "#ffffff", + "colorPaletteCornflowerBorderActive": "#1aebff", + "colorPaletteNavyBackground2": "#000000", + "colorPaletteNavyForeground2": "#ffffff", + "colorPaletteNavyBorderActive": "#1aebff", + "colorPaletteLavenderBackground2": "#000000", + "colorPaletteLavenderForeground2": "#ffffff", + "colorPaletteLavenderBorderActive": "#1aebff", + "colorPalettePurpleBackground2": "#000000", + "colorPalettePurpleForeground2": "#ffffff", + "colorPalettePurpleBorderActive": "#1aebff", + "colorPaletteGrapeBackground2": "#000000", + "colorPaletteGrapeForeground2": "#ffffff", + "colorPaletteGrapeBorderActive": "#1aebff", + "colorPaletteLilacBackground2": "#000000", + "colorPaletteLilacForeground2": "#ffffff", + "colorPaletteLilacBorderActive": "#1aebff", + "colorPalettePinkBackground2": "#000000", + "colorPalettePinkForeground2": "#ffffff", + "colorPalettePinkBorderActive": "#1aebff", + "colorPaletteMagentaBackground2": "#000000", + "colorPaletteMagentaForeground2": "#ffffff", + "colorPaletteMagentaBorderActive": "#1aebff", + "colorPalettePlumBackground2": "#000000", + "colorPalettePlumForeground2": "#ffffff", + "colorPalettePlumBorderActive": "#1aebff", + "colorPaletteBeigeBackground2": "#000000", + "colorPaletteBeigeForeground2": "#ffffff", + "colorPaletteBeigeBorderActive": "#1aebff", + "colorPaletteMinkBackground2": "#000000", + "colorPaletteMinkForeground2": "#ffffff", + "colorPaletteMinkBorderActive": "#1aebff", + "colorPalettePlatinumBackground2": "#000000", + "colorPalettePlatinumForeground2": "#ffffff", + "colorPalettePlatinumBorderActive": "#1aebff", + "colorPaletteAnchorBackground2": "#000000", + "colorPaletteAnchorForeground2": "#ffffff", + "colorPaletteAnchorBorderActive": "#1aebff", + "colorStatusSuccessBackground1": "#000000", + "colorStatusSuccessBackground2": "#000000", + "colorStatusSuccessBackground3": "#ffffff", + "colorStatusSuccessForeground1": "#ffffff", + "colorStatusSuccessForeground2": "#ffffff", + "colorStatusSuccessForeground3": "#ffffff", + "colorStatusSuccessBorderActive": "#1aebff", + "colorStatusSuccessForegroundInverted": "#ffffff", + "colorStatusSuccessBorder1": "#ffffff", + "colorStatusSuccessBorder2": "#ffffff", + "colorStatusWarningBackground1": "#000000", + "colorStatusWarningBackground2": "#000000", + "colorStatusWarningBackground3": "#ffffff", + "colorStatusWarningForeground1": "#ffffff", + "colorStatusWarningForeground2": "#ffffff", + "colorStatusWarningForeground3": "#ffffff", + "colorStatusWarningBorderActive": "#1aebff", + "colorStatusWarningForegroundInverted": "#ffffff", + "colorStatusWarningBorder1": "#ffffff", + "colorStatusWarningBorder2": "#ffffff", + "colorStatusDangerBackground1": "#000000", + "colorStatusDangerBackground2": "#000000", + "colorStatusDangerBackground3": "#ffffff", + "colorStatusDangerForeground1": "#ffffff", + "colorStatusDangerForeground2": "#ffffff", + "colorStatusDangerForeground3": "#ffffff", + "colorStatusDangerBorderActive": "#1aebff", + "colorStatusDangerForegroundInverted": "#ffffff", + "colorStatusDangerBorder1": "#ffffff", + "colorStatusDangerBorder2": "#ffffff", + "colorStatusDangerBackground3Hover": "#1aebff", + "colorStatusDangerBackground3Pressed": "#1aebff", + "shadow2": "0 0 2px rgba(0,0,0,0.24), 0 1px 2px rgba(0,0,0,0.28)", + "shadow4": "0 0 2px rgba(0,0,0,0.24), 0 2px 4px rgba(0,0,0,0.28)", + "shadow8": "0 0 2px rgba(0,0,0,0.24), 0 4px 8px rgba(0,0,0,0.28)", + "shadow16": "0 0 2px rgba(0,0,0,0.24), 0 8px 16px rgba(0,0,0,0.28)", + "shadow28": "0 0 8px rgba(0,0,0,0.24), 0 14px 28px rgba(0,0,0,0.28)", + "shadow64": "0 0 8px rgba(0,0,0,0.24), 0 32px 64px rgba(0,0,0,0.28)", + "shadow2Brand": "0 0 2px rgba(0,0,0,0.30), 0 1px 2px rgba(0,0,0,0.25)", + "shadow4Brand": "0 0 2px rgba(0,0,0,0.30), 0 2px 4px rgba(0,0,0,0.25)", + "shadow8Brand": "0 0 2px rgba(0,0,0,0.30), 0 4px 8px rgba(0,0,0,0.25)", + "shadow16Brand": "0 0 2px rgba(0,0,0,0.30), 0 8px 16px rgba(0,0,0,0.25)", + "shadow28Brand": "0 0 8px rgba(0,0,0,0.30), 0 14px 28px rgba(0,0,0,0.25)", + "shadow64Brand": "0 0 8px rgba(0,0,0,0.30), 0 32px 64px rgba(0,0,0,0.25)" + }, + "teamsLightV21Theme": { + "borderRadiusNone": "0", + "borderRadiusSmall": "2px", + "borderRadiusMedium": "4px", + "borderRadiusLarge": "6px", + "borderRadiusXLarge": "8px", + "borderRadius2XLarge": "12px", + "borderRadius3XLarge": "16px", + "borderRadius4XLarge": "24px", + "borderRadius5XLarge": "32px", + "borderRadius6XLarge": "40px", + "borderRadiusCircular": "10000px", + "fontSizeBase100": "10px", + "fontSizeBase200": "12px", + "fontSizeBase300": "14px", + "fontSizeBase400": "16px", + "fontSizeBase500": "20px", + "fontSizeBase600": "24px", + "fontSizeHero700": "28px", + "fontSizeHero800": "32px", + "fontSizeHero900": "40px", + "fontSizeHero1000": "68px", + "lineHeightBase100": "14px", + "lineHeightBase200": "16px", + "lineHeightBase300": "20px", + "lineHeightBase400": "22px", + "lineHeightBase500": "28px", + "lineHeightBase600": "32px", + "lineHeightHero700": "36px", + "lineHeightHero800": "40px", + "lineHeightHero900": "52px", + "lineHeightHero1000": "92px", + "fontFamilyBase": "-apple-system, BlinkMacSystemFont, \"Segoe UI\", system-ui, \"Apple Color Emoji\", \"Segoe UI Emoji\", sans-serif", + "fontFamilyMonospace": "Consolas, 'Courier New', Courier, monospace", + "fontFamilyNumeric": "Bahnschrift, 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', sans-serif", + "fontWeightRegular": 400, + "fontWeightMedium": 500, + "fontWeightSemibold": 600, + "fontWeightBold": 700, + "strokeWidthThin": "1px", + "strokeWidthThick": "2px", + "strokeWidthThicker": "3px", + "strokeWidthThickest": "4px", + "spacingHorizontalNone": "0", + "spacingHorizontalXXS": "2px", + "spacingHorizontalXS": "4px", + "spacingHorizontalSNudge": "6px", + "spacingHorizontalS": "8px", + "spacingHorizontalMNudge": "10px", + "spacingHorizontalM": "12px", + "spacingHorizontalL": "16px", + "spacingHorizontalXL": "20px", + "spacingHorizontalXXL": "24px", + "spacingHorizontalXXXL": "32px", + "spacingVerticalNone": "0", + "spacingVerticalXXS": "2px", + "spacingVerticalXS": "4px", + "spacingVerticalSNudge": "6px", + "spacingVerticalS": "8px", + "spacingVerticalMNudge": "10px", + "spacingVerticalM": "12px", + "spacingVerticalL": "16px", + "spacingVerticalXL": "20px", + "spacingVerticalXXL": "24px", + "spacingVerticalXXXL": "32px", + "durationUltraFast": "50ms", + "durationFaster": "100ms", + "durationFast": "150ms", + "durationNormal": "200ms", + "durationGentle": "250ms", + "durationSlow": "300ms", + "durationSlower": "400ms", + "durationUltraSlow": "500ms", + "curveAccelerateMax": "cubic-bezier(0.9,0.1,1,0.2)", + "curveAccelerateMid": "cubic-bezier(1,0,1,1)", + "curveAccelerateMin": "cubic-bezier(0.8,0,0.78,1)", + "curveDecelerateMax": "cubic-bezier(0.1,0.9,0.2,1)", + "curveDecelerateMid": "cubic-bezier(0,0,0,1)", + "curveDecelerateMin": "cubic-bezier(0.33,0,0.1,1)", + "curveEasyEaseMax": "cubic-bezier(0.8,0,0.2,1)", + "curveEasyEase": "cubic-bezier(0.33,0,0.67,1)", + "curveLinear": "cubic-bezier(0,0,1,1)", + "colorNeutralForeground1": "#242424", + "colorNeutralForeground1Hover": "#242424", + "colorNeutralForeground1Pressed": "#242424", + "colorNeutralForeground1Selected": "#242424", + "colorNeutralForeground2": "#424242", + "colorNeutralForeground2Hover": "#242424", + "colorNeutralForeground2Pressed": "#242424", + "colorNeutralForeground2Selected": "#242424", + "colorNeutralForeground2BrandHover": "#654cf5", + "colorNeutralForeground2BrandPressed": "#5a40db", + "colorNeutralForeground2BrandSelected": "#654cf5", + "colorNeutralForeground3": "#616161", + "colorNeutralForeground3Hover": "#424242", + "colorNeutralForeground3Pressed": "#424242", + "colorNeutralForeground3Selected": "#424242", + "colorNeutralForeground3BrandHover": "#654cf5", + "colorNeutralForeground3BrandPressed": "#5a40db", + "colorNeutralForeground3BrandSelected": "#654cf5", + "colorNeutralForeground4": "#707070", + "colorNeutralForeground5": "#616161", + "colorNeutralForeground5Hover": "#242424", + "colorNeutralForeground5Pressed": "#242424", + "colorNeutralForeground5Selected": "#242424", + "colorNeutralForegroundDisabled": "#bdbdbd", + "colorNeutralForegroundInvertedDisabled": "rgba(255, 255, 255, 0.4)", + "colorBrandForegroundLink": "#5a40db", + "colorBrandForegroundLinkHover": "#4d3aba", + "colorBrandForegroundLinkPressed": "#3b3185", + "colorBrandForegroundLinkSelected": "#5a40db", + "colorNeutralForeground2Link": "#424242", + "colorNeutralForeground2LinkHover": "#242424", + "colorNeutralForeground2LinkPressed": "#242424", + "colorNeutralForeground2LinkSelected": "#242424", + "colorCompoundBrandForeground1": "#654cf5", + "colorCompoundBrandForeground1Hover": "#5a40db", + "colorCompoundBrandForeground1Pressed": "#4d3aba", + "colorBrandForeground1": "#654cf5", + "colorBrandForeground2": "#5a40db", + "colorBrandForeground2Hover": "#4d3aba", + "colorBrandForeground2Pressed": "#352e70", + "colorNeutralForeground1Static": "#242424", + "colorNeutralForegroundStaticInverted": "#ffffff", + "colorNeutralForegroundInverted": "#ffffff", + "colorNeutralForegroundInvertedHover": "#ffffff", + "colorNeutralForegroundInvertedPressed": "#ffffff", + "colorNeutralForegroundInvertedSelected": "#ffffff", + "colorNeutralForegroundInverted2": "#ffffff", + "colorNeutralForegroundOnBrand": "#ffffff", + "colorNeutralForegroundInvertedLink": "#ffffff", + "colorNeutralForegroundInvertedLinkHover": "#ffffff", + "colorNeutralForegroundInvertedLinkPressed": "#ffffff", + "colorNeutralForegroundInvertedLinkSelected": "#ffffff", + "colorBrandForegroundInverted": "#887dff", + "colorBrandForegroundInvertedHover": "#9791ff", + "colorBrandForegroundInvertedPressed": "#887dff", + "colorBrandForegroundOnLight": "#654cf5", + "colorBrandForegroundOnLightHover": "#5a40db", + "colorBrandForegroundOnLightPressed": "#44359e", + "colorBrandForegroundOnLightSelected": "#4d3aba", + "colorNeutralBackground1": "#ffffff", + "colorNeutralBackground1Hover": "#f5f5f5", + "colorNeutralBackground1Pressed": "#e0e0e0", + "colorNeutralBackground1Selected": "#ebebeb", + "colorNeutralBackground2": "#fafafa", + "colorNeutralBackground2Hover": "#f0f0f0", + "colorNeutralBackground2Pressed": "#dbdbdb", + "colorNeutralBackground2Selected": "#e6e6e6", + "colorNeutralBackground3": "#f5f5f5", + "colorNeutralBackground3Hover": "#ebebeb", + "colorNeutralBackground3Pressed": "#d6d6d6", + "colorNeutralBackground3Selected": "#e0e0e0", + "colorNeutralBackground4": "#f0f0f0", + "colorNeutralBackground4Hover": "#fafafa", + "colorNeutralBackground4Pressed": "#f5f5f5", + "colorNeutralBackground4Selected": "#ffffff", + "colorNeutralBackground5": "#ebebeb", + "colorNeutralBackground5Hover": "#f5f5f5", + "colorNeutralBackground5Pressed": "#f0f0f0", + "colorNeutralBackground5Selected": "#fafafa", + "colorNeutralBackground6": "#e6e6e6", + "colorNeutralBackground7": "#00000000", + "colorNeutralBackground7Hover": "#ebebeb", + "colorNeutralBackground7Pressed": "#d6d6d6", + "colorNeutralBackground7Selected": "#00000000", + "colorNeutralBackground8": "#fcfcfc", + "colorNeutralBackgroundInverted": "#292929", + "colorNeutralBackgroundInvertedHover": "#3d3d3d", + "colorNeutralBackgroundInvertedPressed": "#1f1f1f", + "colorNeutralBackgroundInvertedSelected": "#383838", + "colorNeutralBackgroundStatic": "#333333", + "colorNeutralBackgroundAlpha": "rgba(255, 255, 255, 0.5)", + "colorNeutralBackgroundAlpha2": "rgba(255, 255, 255, 0.8)", + "colorSubtleBackground": "transparent", + "colorSubtleBackgroundHover": "#f5f5f5", + "colorSubtleBackgroundPressed": "#e0e0e0", + "colorSubtleBackgroundSelected": "#ebebeb", + "colorSubtleBackgroundLightAlphaHover": "rgba(255, 255, 255, 0.7)", + "colorSubtleBackgroundLightAlphaPressed": "rgba(255, 255, 255, 0.5)", + "colorSubtleBackgroundLightAlphaSelected": "transparent", + "colorSubtleBackgroundInverted": "transparent", + "colorSubtleBackgroundInvertedHover": "rgba(0, 0, 0, 0.1)", + "colorSubtleBackgroundInvertedPressed": "rgba(0, 0, 0, 0.3)", + "colorSubtleBackgroundInvertedSelected": "rgba(0, 0, 0, 0.2)", + "colorTransparentBackground": "transparent", + "colorTransparentBackgroundHover": "transparent", + "colorTransparentBackgroundPressed": "transparent", + "colorTransparentBackgroundSelected": "transparent", + "colorNeutralBackgroundDisabled": "#f0f0f0", + "colorNeutralBackgroundDisabled2": "#ffffff", + "colorNeutralBackgroundInvertedDisabled": "rgba(255, 255, 255, 0.1)", + "colorNeutralStencil1": "#e6e6e6", + "colorNeutralStencil2": "#fafafa", + "colorNeutralStencil1Alpha": "rgba(0, 0, 0, 0.1)", + "colorNeutralStencil2Alpha": "rgba(0, 0, 0, 0.05)", + "colorBackgroundOverlay": "rgba(0, 0, 0, 0.4)", + "colorScrollbarOverlay": "rgba(0, 0, 0, 0.5)", + "colorBrandBackground": "#654cf5", + "colorBrandBackgroundHover": "#5a40db", + "colorBrandBackgroundPressed": "#3b3185", + "colorBrandBackgroundSelected": "#4d3aba", + "colorCompoundBrandBackground": "#654cf5", + "colorCompoundBrandBackgroundHover": "#5a40db", + "colorCompoundBrandBackgroundPressed": "#4d3aba", + "colorBrandBackgroundStatic": "#654cf5", + "colorBrandBackground2": "#e8e8ff", + "colorBrandBackground2Hover": "#dcdbff", + "colorBrandBackground2Pressed": "#bab8ff", + "colorBrandBackground3Static": "#4d3aba", + "colorBrandBackground4Static": "#3b3185", + "colorBrandBackgroundInverted": "#ffffff", + "colorBrandBackgroundInvertedHover": "#e8e8ff", + "colorBrandBackgroundInvertedPressed": "#c8c7ff", + "colorBrandBackgroundInvertedSelected": "#dcdbff", + "colorNeutralCardBackground": "#fafafa", + "colorNeutralCardBackgroundHover": "#ffffff", + "colorNeutralCardBackgroundPressed": "#f5f5f5", + "colorNeutralCardBackgroundSelected": "#ebebeb", + "colorNeutralCardBackgroundDisabled": "#f0f0f0", + "colorNeutralStrokeAccessible": "#616161", + "colorNeutralStrokeAccessibleHover": "#575757", + "colorNeutralStrokeAccessiblePressed": "#4d4d4d", + "colorNeutralStrokeAccessibleSelected": "#654cf5", + "colorNeutralStroke1": "#d1d1d1", + "colorNeutralStroke1Hover": "#c7c7c7", + "colorNeutralStroke1Pressed": "#b3b3b3", + "colorNeutralStroke1Selected": "#bdbdbd", + "colorNeutralStroke2": "#e0e0e0", + "colorNeutralStroke3": "#f0f0f0", + "colorNeutralStroke4": "#ebebeb", + "colorNeutralStroke4Hover": "#e0e0e0", + "colorNeutralStroke4Pressed": "#d6d6d6", + "colorNeutralStroke4Selected": "#ebebeb", + "colorNeutralStrokeSubtle": "#e0e0e0", + "colorNeutralStrokeOnBrand": "#ffffff", + "colorNeutralStrokeOnBrand2": "#ffffff", + "colorNeutralStrokeOnBrand2Hover": "#ffffff", + "colorNeutralStrokeOnBrand2Pressed": "#ffffff", + "colorNeutralStrokeOnBrand2Selected": "#ffffff", + "colorBrandStroke1": "#654cf5", + "colorBrandStroke2": "#c8c7ff", + "colorBrandStroke2Hover": "#aba8ff", + "colorBrandStroke2Pressed": "#654cf5", + "colorBrandStroke2Contrast": "#c8c7ff", + "colorCompoundBrandStroke": "#654cf5", + "colorCompoundBrandStrokeHover": "#5a40db", + "colorCompoundBrandStrokePressed": "#4d3aba", + "colorNeutralStrokeDisabled": "#e0e0e0", + "colorNeutralStrokeDisabled2": "#ebebeb", + "colorNeutralStrokeInvertedDisabled": "rgba(255, 255, 255, 0.4)", + "colorTransparentStroke": "transparent", + "colorTransparentStrokeInteractive": "transparent", + "colorTransparentStrokeDisabled": "transparent", + "colorNeutralStrokeAlpha": "rgba(0, 0, 0, 0.05)", + "colorNeutralStrokeAlpha2": "rgba(255, 255, 255, 0.2)", + "colorStrokeFocus1": "#ffffff", + "colorStrokeFocus2": "#000000", + "colorNeutralShadowAmbient": "rgba(0,0,0,0.12)", + "colorNeutralShadowKey": "rgba(0,0,0,0.14)", + "colorNeutralShadowAmbientLighter": "rgba(0,0,0,0.06)", + "colorNeutralShadowKeyLighter": "rgba(0,0,0,0.07)", + "colorNeutralShadowAmbientDarker": "rgba(0,0,0,0.20)", + "colorNeutralShadowKeyDarker": "rgba(0,0,0,0.24)", + "colorBrandShadowAmbient": "rgba(0,0,0,0.30)", + "colorBrandShadowKey": "rgba(0,0,0,0.25)", + "colorPaletteRedBackground1": "#fdf6f6", + "colorPaletteRedBackground2": "#f1bbbc", + "colorPaletteRedBackground3": "#d13438", + "colorPaletteRedForeground1": "#bc2f32", + "colorPaletteRedForeground2": "#751d1f", + "colorPaletteRedForeground3": "#d13438", + "colorPaletteRedBorderActive": "#d13438", + "colorPaletteRedBorder1": "#f1bbbc", + "colorPaletteRedBorder2": "#d13438", + "colorPaletteGreenBackground1": "#f1faf1", + "colorPaletteGreenBackground2": "#9fd89f", + "colorPaletteGreenBackground3": "#107c10", + "colorPaletteGreenForeground1": "#0e700e", + "colorPaletteGreenForeground2": "#094509", + "colorPaletteGreenForeground3": "#107c10", + "colorPaletteGreenBorderActive": "#107c10", + "colorPaletteGreenBorder1": "#9fd89f", + "colorPaletteGreenBorder2": "#107c10", + "colorPaletteDarkOrangeBackground1": "#fdf6f3", + "colorPaletteDarkOrangeBackground2": "#f4bfab", + "colorPaletteDarkOrangeBackground3": "#da3b01", + "colorPaletteDarkOrangeForeground1": "#c43501", + "colorPaletteDarkOrangeForeground2": "#7a2101", + "colorPaletteDarkOrangeForeground3": "#da3b01", + "colorPaletteDarkOrangeBorderActive": "#da3b01", + "colorPaletteDarkOrangeBorder1": "#f4bfab", + "colorPaletteDarkOrangeBorder2": "#da3b01", + "colorPaletteYellowBackground1": "#fffef5", + "colorPaletteYellowBackground2": "#fef7b2", + "colorPaletteYellowBackground3": "#fde300", + "colorPaletteYellowForeground1": "#817400", + "colorPaletteYellowForeground2": "#817400", + "colorPaletteYellowForeground3": "#fde300", + "colorPaletteYellowBorderActive": "#fde300", + "colorPaletteYellowBorder1": "#fef7b2", + "colorPaletteYellowBorder2": "#fde300", + "colorPaletteBerryBackground1": "#fdf5fc", + "colorPaletteBerryBackground2": "#edbbe7", + "colorPaletteBerryBackground3": "#c239b3", + "colorPaletteBerryForeground1": "#af33a1", + "colorPaletteBerryForeground2": "#6d2064", + "colorPaletteBerryForeground3": "#c239b3", + "colorPaletteBerryBorderActive": "#c239b3", + "colorPaletteBerryBorder1": "#edbbe7", + "colorPaletteBerryBorder2": "#c239b3", + "colorPaletteLightGreenBackground1": "#f2fbf2", + "colorPaletteLightGreenBackground2": "#a7e3a5", + "colorPaletteLightGreenBackground3": "#13a10e", + "colorPaletteLightGreenForeground1": "#11910d", + "colorPaletteLightGreenForeground2": "#0b5a08", + "colorPaletteLightGreenForeground3": "#13a10e", + "colorPaletteLightGreenBorderActive": "#13a10e", + "colorPaletteLightGreenBorder1": "#a7e3a5", + "colorPaletteLightGreenBorder2": "#13a10e", + "colorPaletteMarigoldBackground1": "#fefbf4", + "colorPaletteMarigoldBackground2": "#f9e2ae", + "colorPaletteMarigoldBackground3": "#eaa300", + "colorPaletteMarigoldForeground1": "#d39300", + "colorPaletteMarigoldForeground2": "#835b00", + "colorPaletteMarigoldForeground3": "#eaa300", + "colorPaletteMarigoldBorderActive": "#eaa300", + "colorPaletteMarigoldBorder1": "#f9e2ae", + "colorPaletteMarigoldBorder2": "#eaa300", + "colorPaletteRedForegroundInverted": "#dc5e62", + "colorPaletteGreenForegroundInverted": "#359b35", + "colorPaletteYellowForegroundInverted": "#fef7b2", + "colorPaletteDarkRedBackground2": "#d69ca5", + "colorPaletteDarkRedForeground2": "#420610", + "colorPaletteDarkRedBorderActive": "#750b1c", + "colorPaletteCranberryBackground2": "#eeacb2", + "colorPaletteCranberryForeground2": "#6e0811", + "colorPaletteCranberryBorderActive": "#c50f1f", + "colorPalettePumpkinBackground2": "#efc4ad", + "colorPalettePumpkinForeground2": "#712d09", + "colorPalettePumpkinBorderActive": "#ca5010", + "colorPalettePeachBackground2": "#ffddb3", + "colorPalettePeachForeground2": "#8f4e00", + "colorPalettePeachBorderActive": "#ff8c00", + "colorPaletteGoldBackground2": "#ecdfa5", + "colorPaletteGoldForeground2": "#6c5700", + "colorPaletteGoldBorderActive": "#c19c00", + "colorPaletteBrassBackground2": "#e0cea2", + "colorPaletteBrassForeground2": "#553e06", + "colorPaletteBrassBorderActive": "#986f0b", + "colorPaletteBrownBackground2": "#ddc3b0", + "colorPaletteBrownForeground2": "#50301a", + "colorPaletteBrownBorderActive": "#8e562e", + "colorPaletteForestBackground2": "#bdd99b", + "colorPaletteForestForeground2": "#294903", + "colorPaletteForestBorderActive": "#498205", + "colorPaletteSeafoamBackground2": "#a8f0cd", + "colorPaletteSeafoamForeground2": "#00723b", + "colorPaletteSeafoamBorderActive": "#00cc6a", + "colorPaletteDarkGreenBackground2": "#9ad29a", + "colorPaletteDarkGreenForeground2": "#063b06", + "colorPaletteDarkGreenBorderActive": "#0b6a0b", + "colorPaletteLightTealBackground2": "#a6e9ed", + "colorPaletteLightTealForeground2": "#00666d", + "colorPaletteLightTealBorderActive": "#00b7c3", + "colorPaletteTealBackground2": "#9bd9db", + "colorPaletteTealForeground2": "#02494c", + "colorPaletteTealBorderActive": "#038387", + "colorPaletteSteelBackground2": "#94c8d4", + "colorPaletteSteelForeground2": "#00333f", + "colorPaletteSteelBorderActive": "#005b70", + "colorPaletteBlueBackground2": "#a9d3f2", + "colorPaletteBlueForeground2": "#004377", + "colorPaletteBlueBorderActive": "#0078d4", + "colorPaletteRoyalBlueBackground2": "#9abfdc", + "colorPaletteRoyalBlueForeground2": "#002c4e", + "colorPaletteRoyalBlueBorderActive": "#004e8c", + "colorPaletteCornflowerBackground2": "#c8d1fa", + "colorPaletteCornflowerForeground2": "#2c3c85", + "colorPaletteCornflowerBorderActive": "#4f6bed", + "colorPaletteNavyBackground2": "#a3b2e8", + "colorPaletteNavyForeground2": "#001665", + "colorPaletteNavyBorderActive": "#0027b4", + "colorPaletteLavenderBackground2": "#d2ccf8", + "colorPaletteLavenderForeground2": "#3f3682", + "colorPaletteLavenderBorderActive": "#7160e8", + "colorPalettePurpleBackground2": "#c6b1de", + "colorPalettePurpleForeground2": "#341a51", + "colorPalettePurpleBorderActive": "#5c2e91", + "colorPaletteGrapeBackground2": "#d9a7e0", + "colorPaletteGrapeForeground2": "#4c0d55", + "colorPaletteGrapeBorderActive": "#881798", + "colorPaletteLilacBackground2": "#e6bfed", + "colorPaletteLilacForeground2": "#63276d", + "colorPaletteLilacBorderActive": "#b146c2", + "colorPalettePinkBackground2": "#f7c0e3", + "colorPalettePinkForeground2": "#80215d", + "colorPalettePinkBorderActive": "#e43ba6", + "colorPaletteMagentaBackground2": "#eca5d1", + "colorPaletteMagentaForeground2": "#6b0043", + "colorPaletteMagentaBorderActive": "#bf0077", + "colorPalettePlumBackground2": "#d696c0", + "colorPalettePlumForeground2": "#43002b", + "colorPalettePlumBorderActive": "#77004d", + "colorPaletteBeigeBackground2": "#d7d4d4", + "colorPaletteBeigeForeground2": "#444241", + "colorPaletteBeigeBorderActive": "#7a7574", + "colorPaletteMinkBackground2": "#cecccb", + "colorPaletteMinkForeground2": "#343231", + "colorPaletteMinkBorderActive": "#5d5a58", + "colorPalettePlatinumBackground2": "#cdd6d8", + "colorPalettePlatinumForeground2": "#3b4447", + "colorPalettePlatinumBorderActive": "#69797e", + "colorPaletteAnchorBackground2": "#bcc3c7", + "colorPaletteAnchorForeground2": "#202427", + "colorPaletteAnchorBorderActive": "#394146", + "colorStatusSuccessBackground1": "#f1faf1", + "colorStatusSuccessBackground2": "#9fd89f", + "colorStatusSuccessBackground3": "#107c10", + "colorStatusSuccessForeground1": "#0e700e", + "colorStatusSuccessForeground2": "#094509", + "colorStatusSuccessForeground3": "#107c10", + "colorStatusSuccessForegroundInverted": "#54b054", + "colorStatusSuccessBorderActive": "#107c10", + "colorStatusSuccessBorder1": "#9fd89f", + "colorStatusSuccessBorder2": "#107c10", + "colorStatusWarningBackground1": "#fff9f5", + "colorStatusWarningBackground2": "#fdcfb4", + "colorStatusWarningBackground3": "#f7630c", + "colorStatusWarningForeground1": "#bc4b09", + "colorStatusWarningForeground2": "#8a3707", + "colorStatusWarningForeground3": "#bc4b09", + "colorStatusWarningForegroundInverted": "#faa06b", + "colorStatusWarningBorderActive": "#f7630c", + "colorStatusWarningBorder1": "#fdcfb4", + "colorStatusWarningBorder2": "#bc4b09", + "colorStatusDangerBackground1": "#fdf3f4", + "colorStatusDangerBackground2": "#eeacb2", + "colorStatusDangerBackground3": "#c50f1f", + "colorStatusDangerForeground1": "#b10e1c", + "colorStatusDangerForeground2": "#6e0811", + "colorStatusDangerForeground3": "#c50f1f", + "colorStatusDangerForegroundInverted": "#dc626d", + "colorStatusDangerBorderActive": "#c50f1f", + "colorStatusDangerBorder1": "#eeacb2", + "colorStatusDangerBorder2": "#c50f1f", + "colorStatusDangerBackground3Hover": "#b10e1c", + "colorStatusDangerBackground3Pressed": "#960b18", + "shadow2": "0 0 2px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.14)", + "shadow4": "0 0 2px rgba(0,0,0,0.12), 0 2px 4px rgba(0,0,0,0.14)", + "shadow8": "0 0 2px rgba(0,0,0,0.12), 0 4px 8px rgba(0,0,0,0.14)", + "shadow16": "0 0 2px rgba(0,0,0,0.12), 0 8px 16px rgba(0,0,0,0.14)", + "shadow28": "0 0 8px rgba(0,0,0,0.12), 0 14px 28px rgba(0,0,0,0.14)", + "shadow64": "0 0 8px rgba(0,0,0,0.12), 0 32px 64px rgba(0,0,0,0.14)", + "shadow2Brand": "0 0 2px rgba(0,0,0,0.30), 0 1px 2px rgba(0,0,0,0.25)", + "shadow4Brand": "0 0 2px rgba(0,0,0,0.30), 0 2px 4px rgba(0,0,0,0.25)", + "shadow8Brand": "0 0 2px rgba(0,0,0,0.30), 0 4px 8px rgba(0,0,0,0.25)", + "shadow16Brand": "0 0 2px rgba(0,0,0,0.30), 0 8px 16px rgba(0,0,0,0.25)", + "shadow28Brand": "0 0 8px rgba(0,0,0,0.30), 0 14px 28px rgba(0,0,0,0.25)", + "shadow64Brand": "0 0 8px rgba(0,0,0,0.30), 0 32px 64px rgba(0,0,0,0.25)" + }, + "teamsDarkV21Theme": { + "borderRadiusNone": "0", + "borderRadiusSmall": "2px", + "borderRadiusMedium": "4px", + "borderRadiusLarge": "6px", + "borderRadiusXLarge": "8px", + "borderRadius2XLarge": "12px", + "borderRadius3XLarge": "16px", + "borderRadius4XLarge": "24px", + "borderRadius5XLarge": "32px", + "borderRadius6XLarge": "40px", + "borderRadiusCircular": "10000px", + "fontSizeBase100": "10px", + "fontSizeBase200": "12px", + "fontSizeBase300": "14px", + "fontSizeBase400": "16px", + "fontSizeBase500": "20px", + "fontSizeBase600": "24px", + "fontSizeHero700": "28px", + "fontSizeHero800": "32px", + "fontSizeHero900": "40px", + "fontSizeHero1000": "68px", + "lineHeightBase100": "14px", + "lineHeightBase200": "16px", + "lineHeightBase300": "20px", + "lineHeightBase400": "22px", + "lineHeightBase500": "28px", + "lineHeightBase600": "32px", + "lineHeightHero700": "36px", + "lineHeightHero800": "40px", + "lineHeightHero900": "52px", + "lineHeightHero1000": "92px", + "fontFamilyBase": "-apple-system, BlinkMacSystemFont, \"Segoe UI\", system-ui, \"Apple Color Emoji\", \"Segoe UI Emoji\", sans-serif", + "fontFamilyMonospace": "Consolas, 'Courier New', Courier, monospace", + "fontFamilyNumeric": "Bahnschrift, 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', sans-serif", + "fontWeightRegular": 400, + "fontWeightMedium": 500, + "fontWeightSemibold": 600, + "fontWeightBold": 700, + "strokeWidthThin": "1px", + "strokeWidthThick": "2px", + "strokeWidthThicker": "3px", + "strokeWidthThickest": "4px", + "spacingHorizontalNone": "0", + "spacingHorizontalXXS": "2px", + "spacingHorizontalXS": "4px", + "spacingHorizontalSNudge": "6px", + "spacingHorizontalS": "8px", + "spacingHorizontalMNudge": "10px", + "spacingHorizontalM": "12px", + "spacingHorizontalL": "16px", + "spacingHorizontalXL": "20px", + "spacingHorizontalXXL": "24px", + "spacingHorizontalXXXL": "32px", + "spacingVerticalNone": "0", + "spacingVerticalXXS": "2px", + "spacingVerticalXS": "4px", + "spacingVerticalSNudge": "6px", + "spacingVerticalS": "8px", + "spacingVerticalMNudge": "10px", + "spacingVerticalM": "12px", + "spacingVerticalL": "16px", + "spacingVerticalXL": "20px", + "spacingVerticalXXL": "24px", + "spacingVerticalXXXL": "32px", + "durationUltraFast": "50ms", + "durationFaster": "100ms", + "durationFast": "150ms", + "durationNormal": "200ms", + "durationGentle": "250ms", + "durationSlow": "300ms", + "durationSlower": "400ms", + "durationUltraSlow": "500ms", + "curveAccelerateMax": "cubic-bezier(0.9,0.1,1,0.2)", + "curveAccelerateMid": "cubic-bezier(1,0,1,1)", + "curveAccelerateMin": "cubic-bezier(0.8,0,0.78,1)", + "curveDecelerateMax": "cubic-bezier(0.1,0.9,0.2,1)", + "curveDecelerateMid": "cubic-bezier(0,0,0,1)", + "curveDecelerateMin": "cubic-bezier(0.33,0,0.1,1)", + "curveEasyEaseMax": "cubic-bezier(0.8,0,0.2,1)", + "curveEasyEase": "cubic-bezier(0.33,0,0.67,1)", + "curveLinear": "cubic-bezier(0,0,1,1)", + "colorNeutralForeground1": "#ffffff", + "colorNeutralForeground1Hover": "#ffffff", + "colorNeutralForeground1Pressed": "#ffffff", + "colorNeutralForeground1Selected": "#ffffff", + "colorNeutralForeground2": "#d6d6d6", + "colorNeutralForeground2Hover": "#ffffff", + "colorNeutralForeground2Pressed": "#ffffff", + "colorNeutralForeground2Selected": "#ffffff", + "colorNeutralForeground2BrandHover": "#887dff", + "colorNeutralForeground2BrandPressed": "#7769fa", + "colorNeutralForeground2BrandSelected": "#887dff", + "colorNeutralForeground3": "#adadad", + "colorNeutralForeground3Hover": "#d6d6d6", + "colorNeutralForeground3Pressed": "#d6d6d6", + "colorNeutralForeground3Selected": "#d6d6d6", + "colorNeutralForeground3BrandHover": "#887dff", + "colorNeutralForeground3BrandPressed": "#7769fa", + "colorNeutralForeground3BrandSelected": "#887dff", + "colorNeutralForeground4": "#999999", + "colorNeutralForeground5": "#adadad", + "colorNeutralForeground5Hover": "#ffffff", + "colorNeutralForeground5Pressed": "#ffffff", + "colorNeutralForeground5Selected": "#ffffff", + "colorNeutralForegroundDisabled": "#5c5c5c", + "colorNeutralForegroundInvertedDisabled": "rgba(255, 255, 255, 0.4)", + "colorBrandForegroundLink": "#887dff", + "colorBrandForegroundLinkHover": "#9791ff", + "colorBrandForegroundLinkPressed": "#7769fa", + "colorBrandForegroundLinkSelected": "#887dff", + "colorNeutralForeground2Link": "#d6d6d6", + "colorNeutralForeground2LinkHover": "#ffffff", + "colorNeutralForeground2LinkPressed": "#ffffff", + "colorNeutralForeground2LinkSelected": "#ffffff", + "colorCompoundBrandForeground1": "#887dff", + "colorCompoundBrandForeground1Hover": "#9791ff", + "colorCompoundBrandForeground1Pressed": "#7769fa", + "colorBrandForeground1": "#887dff", + "colorBrandForeground2": "#aba8ff", + "colorBrandForeground2Hover": "#bab8ff", + "colorBrandForeground2Pressed": "#e8e8ff", + "colorNeutralForeground1Static": "#242424", + "colorNeutralForegroundStaticInverted": "#ffffff", + "colorNeutralForegroundInverted": "#242424", + "colorNeutralForegroundInvertedHover": "#242424", + "colorNeutralForegroundInvertedPressed": "#242424", + "colorNeutralForegroundInvertedSelected": "#242424", + "colorNeutralForegroundInverted2": "#242424", + "colorNeutralForegroundOnBrand": "#ffffff", + "colorNeutralForegroundInvertedLink": "#ffffff", + "colorNeutralForegroundInvertedLinkHover": "#ffffff", + "colorNeutralForegroundInvertedLinkPressed": "#ffffff", + "colorNeutralForegroundInvertedLinkSelected": "#ffffff", + "colorBrandForegroundInverted": "#654cf5", + "colorBrandForegroundInvertedHover": "#5a40db", + "colorBrandForegroundInvertedPressed": "#4d3aba", + "colorBrandForegroundOnLight": "#654cf5", + "colorBrandForegroundOnLightHover": "#5a40db", + "colorBrandForegroundOnLightPressed": "#44359e", + "colorBrandForegroundOnLightSelected": "#4d3aba", + "colorNeutralBackground1": "#292929", + "colorNeutralBackground1Hover": "#3d3d3d", + "colorNeutralBackground1Pressed": "#1f1f1f", + "colorNeutralBackground1Selected": "#383838", + "colorNeutralBackground2": "#242424", + "colorNeutralBackground2Hover": "#383838", + "colorNeutralBackground2Pressed": "#1a1a1a", + "colorNeutralBackground2Selected": "#333333", + "colorNeutralBackground3": "#1f1f1f", + "colorNeutralBackground3Hover": "#333333", + "colorNeutralBackground3Pressed": "#141414", + "colorNeutralBackground3Selected": "#2e2e2e", + "colorNeutralBackground4": "#141414", + "colorNeutralBackground4Hover": "#292929", + "colorNeutralBackground4Pressed": "#0a0a0a", + "colorNeutralBackground4Selected": "#242424", + "colorNeutralBackground5": "#0a0a0a", + "colorNeutralBackground5Hover": "#1f1f1f", + "colorNeutralBackground5Pressed": "#000000", + "colorNeutralBackground5Selected": "#1a1a1a", + "colorNeutralBackground6": "#333333", + "colorNeutralBackground7": "#00000000", + "colorNeutralBackground7Hover": "#1a1a1a", + "colorNeutralBackground7Pressed": "#0a0a0a", + "colorNeutralBackground7Selected": "#00000000", + "colorNeutralBackground8": "#292929", + "colorNeutralBackgroundInverted": "#ffffff", + "colorNeutralBackgroundInvertedHover": "#f5f5f5", + "colorNeutralBackgroundInvertedPressed": "#e0e0e0", + "colorNeutralBackgroundInvertedSelected": "#ebebeb", + "colorNeutralBackgroundStatic": "#3d3d3d", + "colorNeutralBackgroundAlpha": "rgba(26, 26, 26, 0.5)", + "colorNeutralBackgroundAlpha2": "rgba(31, 31, 31, 0.7)", + "colorSubtleBackground": "transparent", + "colorSubtleBackgroundHover": "#383838", + "colorSubtleBackgroundPressed": "#2e2e2e", + "colorSubtleBackgroundSelected": "#333333", + "colorSubtleBackgroundLightAlphaHover": "rgba(36, 36, 36, 0.8)", + "colorSubtleBackgroundLightAlphaPressed": "rgba(36, 36, 36, 0.5)", + "colorSubtleBackgroundLightAlphaSelected": "transparent", + "colorSubtleBackgroundInverted": "transparent", + "colorSubtleBackgroundInvertedHover": "rgba(0, 0, 0, 0.1)", + "colorSubtleBackgroundInvertedPressed": "rgba(0, 0, 0, 0.3)", + "colorSubtleBackgroundInvertedSelected": "rgba(0, 0, 0, 0.2)", + "colorTransparentBackground": "transparent", + "colorTransparentBackgroundHover": "transparent", + "colorTransparentBackgroundPressed": "transparent", + "colorTransparentBackgroundSelected": "transparent", + "colorNeutralBackgroundDisabled": "#141414", + "colorNeutralBackgroundDisabled2": "#292929", + "colorNeutralBackgroundInvertedDisabled": "rgba(255, 255, 255, 0.1)", + "colorNeutralStencil1": "#575757", + "colorNeutralStencil2": "#333333", + "colorNeutralStencil1Alpha": "rgba(255, 255, 255, 0.1)", + "colorNeutralStencil2Alpha": "rgba(255, 255, 255, 0.05)", + "colorBackgroundOverlay": "rgba(0, 0, 0, 0.5)", + "colorScrollbarOverlay": "rgba(255, 255, 255, 0.6)", + "colorBrandBackground": "#5a40db", + "colorBrandBackgroundHover": "#654cf5", + "colorBrandBackgroundPressed": "#3b3185", + "colorBrandBackgroundSelected": "#4d3aba", + "colorCompoundBrandBackground": "#887dff", + "colorCompoundBrandBackgroundHover": "#9791ff", + "colorCompoundBrandBackgroundPressed": "#7769fa", + "colorBrandBackgroundStatic": "#654cf5", + "colorBrandBackground2": "#2f2a5e", + "colorBrandBackground2Hover": "#3b3185", + "colorBrandBackground2Pressed": "#29274f", + "colorBrandBackground3Static": "#4d3aba", + "colorBrandBackground4Static": "#3b3185", + "colorBrandBackgroundInverted": "#ffffff", + "colorBrandBackgroundInvertedHover": "#e8e8ff", + "colorBrandBackgroundInvertedPressed": "#c8c7ff", + "colorBrandBackgroundInvertedSelected": "#dcdbff", + "colorNeutralCardBackground": "#333333", + "colorNeutralCardBackgroundHover": "#3d3d3d", + "colorNeutralCardBackgroundPressed": "#2e2e2e", + "colorNeutralCardBackgroundSelected": "#383838", + "colorNeutralCardBackgroundDisabled": "#141414", + "colorNeutralStrokeAccessible": "#adadad", + "colorNeutralStrokeAccessibleHover": "#bdbdbd", + "colorNeutralStrokeAccessiblePressed": "#b3b3b3", + "colorNeutralStrokeAccessibleSelected": "#887dff", + "colorNeutralStroke1": "#666666", + "colorNeutralStroke1Hover": "#757575", + "colorNeutralStroke1Pressed": "#6b6b6b", + "colorNeutralStroke1Selected": "#707070", + "colorNeutralStroke2": "#525252", + "colorNeutralStroke3": "#3d3d3d", + "colorNeutralStroke4": "#3d3d3d", + "colorNeutralStroke4Hover": "#2e2e2e", + "colorNeutralStroke4Pressed": "#242424", + "colorNeutralStroke4Selected": "#3d3d3d", + "colorNeutralStrokeSubtle": "#0a0a0a", + "colorNeutralStrokeOnBrand": "#292929", + "colorNeutralStrokeOnBrand2": "#ffffff", + "colorNeutralStrokeOnBrand2Hover": "#ffffff", + "colorNeutralStrokeOnBrand2Pressed": "#ffffff", + "colorNeutralStrokeOnBrand2Selected": "#ffffff", + "colorBrandStroke1": "#887dff", + "colorBrandStroke2": "#44359e", + "colorBrandStroke2Hover": "#44359e", + "colorBrandStroke2Pressed": "#352e70", + "colorBrandStroke2Contrast": "#44359e", + "colorCompoundBrandStroke": "#7769fa", + "colorCompoundBrandStrokeHover": "#887dff", + "colorCompoundBrandStrokePressed": "#654cf5", + "colorNeutralStrokeDisabled": "#424242", + "colorNeutralStrokeDisabled2": "#3d3d3d", + "colorNeutralStrokeInvertedDisabled": "rgba(255, 255, 255, 0.4)", + "colorTransparentStroke": "transparent", + "colorTransparentStrokeInteractive": "transparent", + "colorTransparentStrokeDisabled": "transparent", + "colorNeutralStrokeAlpha": "rgba(255, 255, 255, 0.1)", + "colorNeutralStrokeAlpha2": "rgba(255, 255, 255, 0.2)", + "colorStrokeFocus1": "#000000", + "colorStrokeFocus2": "#ffffff", + "colorNeutralShadowAmbient": "rgba(0,0,0,0.24)", + "colorNeutralShadowKey": "rgba(0,0,0,0.28)", + "colorNeutralShadowAmbientLighter": "rgba(0,0,0,0.12)", + "colorNeutralShadowKeyLighter": "rgba(0,0,0,0.14)", + "colorNeutralShadowAmbientDarker": "rgba(0,0,0,0.40)", + "colorNeutralShadowKeyDarker": "rgba(0,0,0,0.48)", + "colorBrandShadowAmbient": "rgba(0,0,0,0.30)", + "colorBrandShadowKey": "rgba(0,0,0,0.25)", + "colorPaletteRedBackground1": "#3f1011", + "colorPaletteRedBackground2": "#751d1f", + "colorPaletteRedBackground3": "#d13438", + "colorPaletteRedForeground1": "#e37d80", + "colorPaletteRedForeground2": "#f1bbbc", + "colorPaletteRedForeground3": "#e37d80", + "colorPaletteRedBorderActive": "#e37d80", + "colorPaletteRedBorder1": "#d13438", + "colorPaletteRedBorder2": "#e37d80", + "colorPaletteGreenBackground1": "#052505", + "colorPaletteGreenBackground2": "#094509", + "colorPaletteGreenBackground3": "#107c10", + "colorPaletteGreenForeground1": "#54b054", + "colorPaletteGreenForeground2": "#9fd89f", + "colorPaletteGreenForeground3": "#9fd89f", + "colorPaletteGreenBorderActive": "#54b054", + "colorPaletteGreenBorder1": "#107c10", + "colorPaletteGreenBorder2": "#9fd89f", + "colorPaletteDarkOrangeBackground1": "#411200", + "colorPaletteDarkOrangeBackground2": "#7a2101", + "colorPaletteDarkOrangeBackground3": "#da3b01", + "colorPaletteDarkOrangeForeground1": "#e9835e", + "colorPaletteDarkOrangeForeground2": "#f4bfab", + "colorPaletteDarkOrangeForeground3": "#e9835e", + "colorPaletteDarkOrangeBorderActive": "#e9835e", + "colorPaletteDarkOrangeBorder1": "#da3b01", + "colorPaletteDarkOrangeBorder2": "#e9835e", + "colorPaletteYellowBackground1": "#4c4400", + "colorPaletteYellowBackground2": "#817400", + "colorPaletteYellowBackground3": "#fde300", + "colorPaletteYellowForeground1": "#feee66", + "colorPaletteYellowForeground2": "#fef7b2", + "colorPaletteYellowForeground3": "#fdea3d", + "colorPaletteYellowBorderActive": "#feee66", + "colorPaletteYellowBorder1": "#fde300", + "colorPaletteYellowBorder2": "#fdea3d", + "colorPaletteBerryBackground1": "#3a1136", + "colorPaletteBerryBackground2": "#6d2064", + "colorPaletteBerryBackground3": "#c239b3", + "colorPaletteBerryForeground1": "#da7ed0", + "colorPaletteBerryForeground2": "#edbbe7", + "colorPaletteBerryForeground3": "#d161c4", + "colorPaletteBerryBorderActive": "#da7ed0", + "colorPaletteBerryBorder1": "#c239b3", + "colorPaletteBerryBorder2": "#d161c4", + "colorPaletteLightGreenBackground1": "#063004", + "colorPaletteLightGreenBackground2": "#0b5a08", + "colorPaletteLightGreenBackground3": "#13a10e", + "colorPaletteLightGreenForeground1": "#5ec75a", + "colorPaletteLightGreenForeground2": "#a7e3a5", + "colorPaletteLightGreenForeground3": "#3db838", + "colorPaletteLightGreenBorderActive": "#5ec75a", + "colorPaletteLightGreenBorder1": "#13a10e", + "colorPaletteLightGreenBorder2": "#3db838", + "colorPaletteMarigoldBackground1": "#463100", + "colorPaletteMarigoldBackground2": "#835b00", + "colorPaletteMarigoldBackground3": "#eaa300", + "colorPaletteMarigoldForeground1": "#f2c661", + "colorPaletteMarigoldForeground2": "#f9e2ae", + "colorPaletteMarigoldForeground3": "#efb839", + "colorPaletteMarigoldBorderActive": "#f2c661", + "colorPaletteMarigoldBorder1": "#eaa300", + "colorPaletteMarigoldBorder2": "#efb839", + "colorPaletteRedForegroundInverted": "#d13438", + "colorPaletteGreenForegroundInverted": "#107c10", + "colorPaletteYellowForegroundInverted": "#817400", + "colorPaletteDarkRedBackground2": "#590815", + "colorPaletteDarkRedForeground2": "#d69ca5", + "colorPaletteDarkRedBorderActive": "#ac4f5e", + "colorPaletteCranberryBackground2": "#6e0811", + "colorPaletteCranberryForeground2": "#eeacb2", + "colorPaletteCranberryBorderActive": "#dc626d", + "colorPalettePumpkinBackground2": "#712d09", + "colorPalettePumpkinForeground2": "#efc4ad", + "colorPalettePumpkinBorderActive": "#df8e64", + "colorPalettePeachBackground2": "#8f4e00", + "colorPalettePeachForeground2": "#ffddb3", + "colorPalettePeachBorderActive": "#ffba66", + "colorPaletteGoldBackground2": "#6c5700", + "colorPaletteGoldForeground2": "#ecdfa5", + "colorPaletteGoldBorderActive": "#dac157", + "colorPaletteBrassBackground2": "#553e06", + "colorPaletteBrassForeground2": "#e0cea2", + "colorPaletteBrassBorderActive": "#c1a256", + "colorPaletteBrownBackground2": "#50301a", + "colorPaletteBrownForeground2": "#ddc3b0", + "colorPaletteBrownBorderActive": "#bb8f6f", + "colorPaletteForestBackground2": "#294903", + "colorPaletteForestForeground2": "#bdd99b", + "colorPaletteForestBorderActive": "#85b44c", + "colorPaletteSeafoamBackground2": "#00723b", + "colorPaletteSeafoamForeground2": "#a8f0cd", + "colorPaletteSeafoamBorderActive": "#5ae0a0", + "colorPaletteDarkGreenBackground2": "#063b06", + "colorPaletteDarkGreenForeground2": "#9ad29a", + "colorPaletteDarkGreenBorderActive": "#4da64d", + "colorPaletteLightTealBackground2": "#00666d", + "colorPaletteLightTealForeground2": "#a6e9ed", + "colorPaletteLightTealBorderActive": "#58d3db", + "colorPaletteTealBackground2": "#02494c", + "colorPaletteTealForeground2": "#9bd9db", + "colorPaletteTealBorderActive": "#4cb4b7", + "colorPaletteSteelBackground2": "#00333f", + "colorPaletteSteelForeground2": "#94c8d4", + "colorPaletteSteelBorderActive": "#4496a9", + "colorPaletteBlueBackground2": "#004377", + "colorPaletteBlueForeground2": "#a9d3f2", + "colorPaletteBlueBorderActive": "#5caae5", + "colorPaletteRoyalBlueBackground2": "#002c4e", + "colorPaletteRoyalBlueForeground2": "#9abfdc", + "colorPaletteRoyalBlueBorderActive": "#4a89ba", + "colorPaletteCornflowerBackground2": "#2c3c85", + "colorPaletteCornflowerForeground2": "#c8d1fa", + "colorPaletteCornflowerBorderActive": "#93a4f4", + "colorPaletteNavyBackground2": "#001665", + "colorPaletteNavyForeground2": "#a3b2e8", + "colorPaletteNavyBorderActive": "#546fd2", + "colorPaletteLavenderBackground2": "#3f3682", + "colorPaletteLavenderForeground2": "#d2ccf8", + "colorPaletteLavenderBorderActive": "#a79cf1", + "colorPalettePurpleBackground2": "#341a51", + "colorPalettePurpleForeground2": "#c6b1de", + "colorPalettePurpleBorderActive": "#9470bd", + "colorPaletteGrapeBackground2": "#4c0d55", + "colorPaletteGrapeForeground2": "#d9a7e0", + "colorPaletteGrapeBorderActive": "#b55fc1", + "colorPaletteLilacBackground2": "#63276d", + "colorPaletteLilacForeground2": "#e6bfed", + "colorPaletteLilacBorderActive": "#cf87da", + "colorPalettePinkBackground2": "#80215d", + "colorPalettePinkForeground2": "#f7c0e3", + "colorPalettePinkBorderActive": "#ef85c8", + "colorPaletteMagentaBackground2": "#6b0043", + "colorPaletteMagentaForeground2": "#eca5d1", + "colorPaletteMagentaBorderActive": "#d957a8", + "colorPalettePlumBackground2": "#5a003b", + "colorPalettePlumForeground2": "#d696c0", + "colorPalettePlumBorderActive": "#ad4589", + "colorPaletteBeigeBackground2": "#444241", + "colorPaletteBeigeForeground2": "#d7d4d4", + "colorPaletteBeigeBorderActive": "#afabaa", + "colorPaletteMinkBackground2": "#343231", + "colorPaletteMinkForeground2": "#cecccb", + "colorPaletteMinkBorderActive": "#9e9b99", + "colorPalettePlatinumBackground2": "#3b4447", + "colorPalettePlatinumForeground2": "#cdd6d8", + "colorPalettePlatinumBorderActive": "#a0adb2", + "colorPaletteAnchorBackground2": "#202427", + "colorPaletteAnchorForeground2": "#bcc3c7", + "colorPaletteAnchorBorderActive": "#808a90", + "colorStatusSuccessBackground1": "#052505", + "colorStatusSuccessBackground2": "#094509", + "colorStatusSuccessBackground3": "#107c10", + "colorStatusSuccessForeground1": "#54b054", + "colorStatusSuccessForeground2": "#9fd89f", + "colorStatusSuccessForeground3": "#9fd89f", + "colorStatusSuccessBorderActive": "#54b054", + "colorStatusSuccessForegroundInverted": "#0e700e", + "colorStatusSuccessBorder1": "#107c10", + "colorStatusSuccessBorder2": "#9fd89f", + "colorStatusWarningBackground1": "#4a1e04", + "colorStatusWarningBackground2": "#8a3707", + "colorStatusWarningBackground3": "#f7630c", + "colorStatusWarningForeground1": "#faa06b", + "colorStatusWarningForeground2": "#fdcfb4", + "colorStatusWarningForeground3": "#f98845", + "colorStatusWarningBorderActive": "#faa06b", + "colorStatusWarningForegroundInverted": "#bc4b09", + "colorStatusWarningBorder1": "#f7630c", + "colorStatusWarningBorder2": "#f98845", + "colorStatusDangerBackground1": "#3b0509", + "colorStatusDangerBackground2": "#6e0811", + "colorStatusDangerBackground3": "#c50f1f", + "colorStatusDangerForeground1": "#dc626d", + "colorStatusDangerForeground2": "#eeacb2", + "colorStatusDangerForeground3": "#eeacb2", + "colorStatusDangerBorderActive": "#dc626d", + "colorStatusDangerForegroundInverted": "#b10e1c", + "colorStatusDangerBorder1": "#c50f1f", + "colorStatusDangerBorder2": "#dc626d", + "colorStatusDangerBackground3Hover": "#b10e1c", + "colorStatusDangerBackground3Pressed": "#960b18", + "shadow2": "0 0 2px rgba(0,0,0,0.24), 0 1px 2px rgba(0,0,0,0.28)", + "shadow4": "0 0 2px rgba(0,0,0,0.24), 0 2px 4px rgba(0,0,0,0.28)", + "shadow8": "0 0 2px rgba(0,0,0,0.24), 0 4px 8px rgba(0,0,0,0.28)", + "shadow16": "0 0 2px rgba(0,0,0,0.24), 0 8px 16px rgba(0,0,0,0.28)", + "shadow28": "0 0 8px rgba(0,0,0,0.24), 0 14px 28px rgba(0,0,0,0.28)", + "shadow64": "0 0 8px rgba(0,0,0,0.24), 0 32px 64px rgba(0,0,0,0.28)", + "shadow2Brand": "0 0 2px rgba(0,0,0,0.30), 0 1px 2px rgba(0,0,0,0.25)", + "shadow4Brand": "0 0 2px rgba(0,0,0,0.30), 0 2px 4px rgba(0,0,0,0.25)", + "shadow8Brand": "0 0 2px rgba(0,0,0,0.30), 0 4px 8px rgba(0,0,0,0.25)", + "shadow16Brand": "0 0 2px rgba(0,0,0,0.30), 0 8px 16px rgba(0,0,0,0.25)", + "shadow28Brand": "0 0 8px rgba(0,0,0,0.30), 0 14px 28px rgba(0,0,0,0.25)", + "shadow64Brand": "0 0 8px rgba(0,0,0,0.30), 0 32px 64px rgba(0,0,0,0.25)" + } + } +} diff --git a/scripts/css-modules/globalize-group-markers.js b/scripts/css-modules/globalize-group-markers.js new file mode 100644 index 00000000000000..7c995af059c083 --- /dev/null +++ b/scripts/css-modules/globalize-group-markers.js @@ -0,0 +1,117 @@ +// @ts-check +/** + * PostCSS plugin — keep Tailwind named-group / named-peer markers OUT of CSS-Modules hashing. + * + * ── The failure it fixes (DECISIONS.md D15, reports/named-groups-design.md §2.4) ────────── + * + * A named group is written in JSX as a literal, unhashed string: + * + * clsx('group/fui-switch', switchClassNames.root, styles.root, state.root.className) + * + * and read from another module — possibly in another package — as + * + * @variant group-checked/fui-switch { … } + * + * which Tailwind compiles to a selector containing `.group\/fui-switch`. + * + * Both `postcss-modules` and `css-loader` scope EVERY class selector in a `*.module.css` + * file, and they cannot tell this one apart from a real local. Without this plugin the + * compiled selector becomes `.fuicm-switch-group-fui-switch-xxxxxx`, which nothing in the + * DOM ever matches. There is no error and no warning: the rules simply never apply, VR + * passes because nothing changed visually, and the feature silently does not exist. That is + * why this is a blocking prerequisite for the marker rollout rather than a nicety. + * + * ── The fix ───────────────────────────────────────────────────────────────────────────── + * + * Wrap the marker segment in `:global(…)`, which BOTH CSS-Modules implementations honour and + * strip, leaving the class unscoped and out of the exported class map: + * + * .fuicm-switch-thumb-xxxxxx:is(:where(.group\/fui-switch):where([data-checked]) *) + * ↑ still scoped ↑ untouched, matches the JSX literal + * + * ── Where it runs ─────────────────────────────────────────────────────────────────────── + * + * - package build: BETWEEN `tailwindcss()` and `postcssModules()` + * (tools/workspace-plugin/src/executors/build/lib/css-modules.ts) — Tailwind must have + * emitted the selector before this can rewrite it, and CSS Modules must see the + * `:global()` before it scopes. + * - VR storybook: appended to `postcssOptions.plugins` + * (apps/vr-tests-react-components/.storybook/main.js). webpack runs loaders + * right-to-left, so postcss-loader already runs before css-loader. + * + * Prior art: nyt-games uses `@accelint/postcss-tailwind-css-modules@1.1.0` for exactly this. + * A local plugin is preferred over adding a third-party runtime dependency to the build of a + * Microsoft-shipped library. + */ + +/** + * `.group\/` or `.peer\/` — the ESCAPED SLASH is what Tailwind emits, because + * `/` is not legal in a bare class selector. `\\\/` in this literal is one backslash + * followed by one forward slash in the selector text. + * + * `peer/…` (the sibling analogue) has no component using it today; it is covered here so the + * infrastructure does not need revisiting when one does. + */ +const GROUP_OR_PEER_MARKER = /\.((?:group|peer)\\\/[a-zA-Z0-9_-]+)/g; + +/** What an already-wrapped marker looks like, so the pass is idempotent. */ +const GLOBAL_WRAPPER_OPEN = ':global('; + +const postcssPlugin = 'fui-globalize-group-markers'; + +/** + * @param {string} selector + * @returns {{ selector: string, rewrites: number }} + */ +function globalizeSelector(selector) { + let rewrites = 0; + + GROUP_OR_PEER_MARKER.lastIndex = 0; + + const next = selector.replace(GROUP_OR_PEER_MARKER, (match, marker, offset) => { + // Idempotence: a marker already sitting directly inside `:global(` is left alone, so + // running the plugin twice (or over hand-authored `:global(.group\/x)`) is a no-op. + if (selector.slice(Math.max(0, offset - GLOBAL_WRAPPER_OPEN.length), offset) === GLOBAL_WRAPPER_OPEN) { + return match; + } + + rewrites++; + return `${GLOBAL_WRAPPER_OPEN}.${marker})`; + }); + + return { selector: next, rewrites }; +} + +/** + * @param {{ onRewrite?: (info: { from: string, to: string, count: number }) => void }} [options] + */ +function globalizeGroupMarkers(options = {}) { + return { + postcssPlugin, + /** + * @param {import('postcss').Rule} rule + */ + Rule(rule) { + if (!rule.selector.includes('\\/')) { + return; + } + + const { selector, rewrites } = globalizeSelector(rule.selector); + + if (rewrites === 0) { + return; + } + + options.onRewrite?.({ from: rule.selector, to: selector, count: rewrites }); + rule.selector = selector; + }, + }; +} + +globalizeGroupMarkers.postcss = true; + +module.exports = globalizeGroupMarkers; +module.exports.globalizeGroupMarkers = globalizeGroupMarkers; +module.exports.globalizeSelector = globalizeSelector; +module.exports.GROUP_OR_PEER_MARKER = GROUP_OR_PEER_MARKER; +module.exports.postcssPlugin = postcssPlugin; diff --git a/scripts/css-modules/ident.js b/scripts/css-modules/ident.js new file mode 100644 index 00000000000000..e5e774d23aa14d --- /dev/null +++ b/scripts/css-modules/ident.js @@ -0,0 +1,247 @@ +// @ts-check +/** + * THE single source of truth for generated CSS-Modules class names ("idents"). + * + * Three pipelines turn `*.module.css` into class names and all three must agree on the + * SHAPE of what they produce; only then can one snapshot serializer strip them all and one + * conformance rule describe them: + * + * 1. package build — `postcss-modules` `generateScopedName` + * (tools/workspace-plugin/src/executors/build/lib/css-modules.ts) + * 2. VR storybook — `css-loader` `modules.getLocalIdent` + * (apps/vr-tests-react-components/.storybook/main.js) + * 3. jest — the `moduleNameMapper` Proxy + * (scripts/jest/src/css-modules/proxy.js) + * + * This file is required by RELATIVE PATH from all three, deliberately: it must stay free of + * workspace requires (jest.preset.js documents why — `@fluentui/scripts-jest` pulls in + * `@fluentui/scripts-monorepo`, which walks the repo on load) and it must be requirable from + * `tools/workspace-plugin`, which does not depend on any `scripts/*` package. + * + * ── The scheme ────────────────────────────────────────────────────────────────────────── + * + * fuicm--- + * fuicm-switch-root-a3f2c1 + * fuicm-message-bar-actions-container-action-7d10be + * + * ALL LOWERCASE, `-` separated, nothing else. Rationale, point by point: + * + * - `fuicm-` prefix — a HARD CONTRACT. scripts/jest/src/css-modules/serializer.js strips + * every `fuicm-…` token from snapshots the way Griffel's jest serializer stripped atomics + * (DECISIONS.md D9). Changing the prefix silently reintroduces generated class names into + * every committed snapshot. + * - No `-module__` infix and no `--` separator. The previous shape + * (`fuicm-[name]__[local]--[hash:base64:4]`) mixed three separator styles and carried a + * redundant `module` token; a single `-` reads better in a DOM inspector and in a diff. + * - No base64. base64 is case-SENSITIVE, so it put uppercase letters into class names that + * are otherwise lowercase, and its alphabet includes `+` and `/` which have to be + * scrubbed anyway. Hex is lowercase by construction. 6 hex chars = 24 bits. + * - Lowercase everywhere, including the component and the local, so the whole generated + * surface is case-insensitively unique. Module-local class names are authored + * lowercase-kebab for the same reason; `toKebabCase` is the safety net that keeps the + * invariant true even where a module still declares a camelCase local. + * + * ── Why the hash is over the NAME and not the CSS ──────────────────────────────────────── + * The digest seeds on ` ` (never the file's bytes, + * which is what css-loader's default does), so shipped class names are stable across content + * edits: a cosmetic CSS change does not churn every identifier in `dist/styles.css`. What the + * digest buys is UNIQUENESS — two packages that both declare `.root` in a same-named file + * still differ, because the package name is in the seed. + * + * The kebab-casing happens AFTER hashing, on the display half only. Two locals that kebab to + * the same string (`fooBar` / `foo-bar`) therefore still receive different hashes and cannot + * collide. + */ + +const { createHash } = require('node:crypto'); +const { existsSync, readFileSync } = require('node:fs'); +const { basename, dirname, join, relative, sep } = require('node:path'); + +/** + * Hard contract with scripts/jest/src/css-modules/serializer.js — see the header. + */ +const GENERATED_CLASS_PREFIX = 'fuicm-'; + +/** 24 bits of sha256, lowercase hex. Enough for a per-package-per-file-per-local seed. */ +const IDENT_HASH_LENGTH = 6; + +/** Characters that are not legal in the lowercase-kebab alphabet this scheme uses. */ +const UNSAFE_IDENT_CHARS = /[^a-z0-9-]/g; + +/** Collapse runs of `-` and trim them off the ends, so the ident never doubles a separator. */ +const SEPARATOR_RUN = /-{2,}/g; +const LEADING_OR_TRAILING_SEPARATOR = /^-+|-+$/g; + +/** + * `camelCase` / `PascalCase` / `snake_case` / `dot.case` → `kebab-case`, lowercased. + * + * The two `replace`s are the standard pair and they are both needed: + * 1. `([a-z0-9])([A-Z])` — `squareXLarge` → `square-XLarge` (lower/digit → upper) + * 2. `([A-Z]+)([A-Z][a-z])` — `square-XLarge` → `square-X-Large` (acronym → word) + * Without #2, `XLarge` and `RTLSlices` keep their acronym glued to the following word. + * + * @param {string} value + * @returns {string} + */ +function toKebabCase(value) { + return String(value) + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2') + .replace(/[_.\s]+/g, '-') + .toLowerCase() + .replace(UNSAFE_IDENT_CHARS, '-') + .replace(SEPARATOR_RUN, '-') + .replace(LEADING_OR_TRAILING_SEPARATOR, ''); +} + +/** + * The component half of the ident: the module file's own name, kebab-cased. + * `components/MessageBar/MessageBar.module.css` → `message-bar`. + * + * @param {string} relativePath source-root-relative path, POSIX or native separators + * @returns {string} + */ +function toComponentToken(relativePath) { + return toKebabCase( + basename(String(relativePath)) + .replace(/\.module\.css$/i, '') + .replace(/\.css$/i, ''), + ); +} + +/** + * @param {string} seed + * @returns {string} lowercase hex, {@link IDENT_HASH_LENGTH} characters + */ +function hashIdent(seed) { + return createHash('sha256').update(seed).digest('hex').slice(0, IDENT_HASH_LENGTH); +} + +/** + * @param {string} value + * @returns {string} + */ +function toPosix(value) { + return String(value).split(sep).join('/'); +} + +/** + * Build one generated class name. + * + * @param {object} args + * @param {string} args.packageName e.g. `@fluentui/react-switch` + * @param {string} args.relativePath source-root-relative, e.g. `components/Switch/Switch.module.css` + * @param {string} args.localName the class name as authored in the module + * @returns {string} e.g. `fuicm-switch-root-a3f2c1` + */ +function generateIdent({ packageName, relativePath, localName }) { + const posixPath = toPosix(relativePath); + const hash = hashIdent(`${packageName} ${posixPath} ${localName}`); + + return `${GENERATED_CLASS_PREFIX}${toComponentToken(posixPath)}-${toKebabCase(localName)}-${hash}`; +} + +/** + * Curried form for `postcss-modules`' `generateScopedName`, which is called once per local + * with the file already fixed. + * + * @param {object} args + * @param {string} args.packageName + * @param {string} args.relativePath + * @returns {(localName: string) => string} + */ +function createGenerateScopedName({ packageName, relativePath }) { + return localName => generateIdent({ packageName, relativePath, localName }); +} + +/** + * Recover `{ packageName, relativePath }` from an absolute module path, for the pipelines + * that only get a file path (webpack's `getLocalIdent` receives `loaderContext.resourcePath`). + * + * Walks up to the nearest `package.json` that declares a `name` — for a v9 component that is + * `packages/react-components//library/package.json` — and makes the path relative to + * that package's `src`, which is exactly the `absoluteSourceRoot` the build executor hashes + * against. That equality is the whole point: the storybook and the shipped stylesheet + * generate byte-identical idents for the same module. + * + * @param {string} absoluteFilePath + * @returns {{ packageName: string, relativePath: string }} + */ +function resolveIdentContext(absoluteFilePath) { + let directory = dirname(absoluteFilePath); + let previous = ''; + + while (directory !== previous) { + const manifestPath = join(directory, 'package.json'); + + if (existsSync(manifestPath)) { + let packageName = ''; + + try { + packageName = JSON.parse(readFileSync(manifestPath, 'utf8')).name || ''; + } catch { + packageName = ''; + } + + if (packageName) { + const sourceRoot = join(directory, 'src'); + const base = absoluteFilePath.startsWith(sourceRoot + sep) ? sourceRoot : directory; + + return { packageName, relativePath: toPosix(relative(base, absoluteFilePath)) }; + } + } + + previous = directory; + directory = dirname(directory); + } + + // Unreachable inside this repo; keeps the function total rather than throwing during a + // webpack build, where an exception in getLocalIdent surfaces as an unrelated loader error. + return { packageName: '', relativePath: toPosix(absoluteFilePath) }; +} + +/** + * `css-loader`'s `modules.getLocalIdent` hook. Signature per css-loader's docs: + * `(loaderContext, localIdentName, localName, options) => string`. `localIdentName` (the + * template string) is intentionally ignored — this scheme is not expressible as a css-loader + * template, which is why the option exists. + * + * @param {{ resourcePath: string }} loaderContext + * @param {string} _localIdentName + * @param {string} localName + * @returns {string} + */ +function getLocalIdent(loaderContext, _localIdentName, localName) { + const { packageName, relativePath } = resolveIdentContext(loaderContext.resourcePath); + + return generateIdent({ packageName, relativePath, localName }); +} + +/** + * The jest half of the scheme. + * + * Jest maps EVERY `*.module.css` import to one module (`moduleNameMapper` cannot pass the + * importer's path), so there is no file context here and therefore no component token and no + * hash to compute — those two segments are dropped rather than faked. What must match, and + * does, is the part the rest of the toolchain actually depends on: the `fuicm-` prefix that + * the snapshot serializer strips, and the lowercase-kebab alphabet. + * + * @param {string} localName + * @returns {string} e.g. `fuicm-root`, `fuicm-non-zero-determinate` + */ +function generateTestIdent(localName) { + return `${GENERATED_CLASS_PREFIX}${toKebabCase(localName)}`; +} + +module.exports = { + GENERATED_CLASS_PREFIX, + IDENT_HASH_LENGTH, + toKebabCase, + toComponentToken, + hashIdent, + generateIdent, + createGenerateScopedName, + resolveIdentContext, + getLocalIdent, + generateTestIdent, +}; diff --git a/scripts/jest/src/css-modules/proxy.js b/scripts/jest/src/css-modules/proxy.js new file mode 100644 index 00000000000000..b6657529f0ec8c --- /dev/null +++ b/scripts/jest/src/css-modules/proxy.js @@ -0,0 +1,43 @@ +/** + * Jest `moduleNameMapper` target for `*.module.css` imports. + * + * Jest never runs webpack, so `import styles from './X.module.css'` has to resolve to + * something. This returns a deterministic name for any property read, in the same + * lowercase-kebab, `fuicm-`-prefixed shape the two real pipelines emit + * (`fuicm---` — see scripts/css-modules/ident.js). The snapshot + * serializer in ./serializer.js strips every `fuicm-…` token, so component snapshots stay + * free of generated class names — the role Griffel's jest serializer played for Griffel + * atomics (migration/griffel-to-tailwind/reports/DECISIONS.md D9). + * + * WHY THE NAME IS SHORTER HERE: `moduleNameMapper` maps every `*.module.css` in the repo to + * this ONE module and gives it no way to learn which file was imported, so the component and + * hash segments cannot be computed. They are dropped rather than faked — `fuicm-root` is + * honestly "the `root` local of some module", and everything the toolchain actually keys on + * (the `fuicm-` prefix, the lowercase-kebab alphabet) is identical. `generateTestIdent` owns + * that decision so the shape stays defined in one place. + * + * `__esModule: false` makes SWC/babel interop wrap this as the default export, which is + * what `import styles from ...` reads. + */ + +const { generateTestIdent } = require('../../../css-modules/ident'); + +module.exports = new Proxy( + {}, + { + get(_target, key) { + if (typeof key !== 'string') { + return undefined; + } + + // Keep module-system and promise probing intact. NOTE: `default` is deliberately + // NOT excluded — it is a legitimate CSS class name (Divider's default appearance) + // and CJS interop only ever reads `__esModule` off this object. + if (key === '__esModule' || key === 'then') { + return undefined; + } + + return generateTestIdent(key); + }, + }, +); diff --git a/scripts/jest/src/css-modules/serializer.js b/scripts/jest/src/css-modules/serializer.js new file mode 100644 index 00000000000000..fd68b0f2872b21 --- /dev/null +++ b/scripts/jest/src/css-modules/serializer.js @@ -0,0 +1,70 @@ +/** + * Jest snapshot serializer that strips generated CSS-Modules class names. + * + * Counterpart to Griffel's retired jest serializer: converted components render + * `class="group/fui-divider fui-Divider fuicm-divider-root-a3f2c1"`, and only the stable, + * public tokens belong in a snapshot. Generated names carry a digest over the package name, + * the source-relative path and the local (webpack and the package build: + * `fuicm---`; jest: `fuicm-` from ./proxy.js — see + * scripts/css-modules/ident.js), so snapshots that kept them would churn whenever a module + * is renamed or moved. + * + * NOT stripped, deliberately: the `group/fui-*` named-group marker. It is unhashed by + * construction (that is the entire point — DECISIONS.md D15) and it is public DOM surface, + * so it belongs in the snapshot exactly the way `fui-Divider` does. Extending the match to + * hide it would hide a public contract. + * + * pretty-format hands each attribute value to the registered plugins as a string, which + * is why `test`/`print` operate on strings rather than DOM nodes — same contract as + * Griffel's jest serializer (removed from the repo in the S-J closing batch). + * + * IMPORTANT — why `print` edits in place instead of split/join: + * pretty-format offers a plugin every string in the tree, not just class attributes. A + * test that snapshots a whole HTML *document string* (react-provider's + * `FluentProvider-node.test.tsx` / `-hydrate.test.tsx` do exactly this) hands the entire + * multi-line markup to this plugin because one class inside it is generated. Rebuilding + * that string with `split(/\s+/).join(' ')` silently collapsed every newline and indent. + * The algorithm below is therefore the same shape as Griffel's: a targeted + * `String.replace` for the generated tokens, one whole-string `trim()`, then a trim of + * the *inside* of any `class="…"` / `className="…"` value. All other whitespace — and + * therefore the snapshot's line structure — is preserved byte for byte. + * + * KNOWN LIMITATION: pretty-format uses the first plugin whose `test()` passes. In a + * whole-HTML-string snapshot containing BOTH generated CSS-Modules classes and Griffel + * atomics, this plugin wins and the Griffel atomics are left in place. DOM-node snapshots + * are unaffected (each attribute value is offered to the plugins separately), which is + * what every converted package uses. + * + * See migration/griffel-to-tailwind/reports/DECISIONS.md D9. + */ + +const GENERATED_CLASS_PREFIX = 'fuicm-'; + +/** + * A generated class token: the prefix plus every following character that can legally be + * part of a class name in this position. Stops at whitespace and at either quote so the + * match can never run past the end of an attribute value. + */ +const GENERATED_CLASS_TOKEN = new RegExp(`${GENERATED_CLASS_PREFIX}[^\\s"']*\\s?`, 'g'); + +/** `class="…"` / `className="…"` — `[^"]*` keeps the match inside a single attribute. */ +const CLASS_ATTRIBUTE = /(?:class|className)="([^"]*)"/g; + +/** + * @param {unknown} val + */ +function test(val) { + return typeof val === 'string' && val.includes(GENERATED_CLASS_PREFIX); +} + +/** + * @param {string} val + */ +function print(val) { + const stripped = val.replace(GENERATED_CLASS_TOKEN, '').trim(); + const tidied = stripped.replace(CLASS_ATTRIBUTE, (match, value) => match.replace(value, value.trim())); + + return `"${tidied}"`; +} + +module.exports = { test, print }; diff --git a/scripts/storybook/src/rules.js b/scripts/storybook/src/rules.js index cca1cb3c906644..de8393a860d186 100644 --- a/scripts/storybook/src/rules.js +++ b/scripts/storybook/src/rules.js @@ -1,3 +1,13 @@ +const path = require('path'); + +/** + * Shared with the package build (`postcss-modules` `generateScopedName`) and with jest — one + * scheme, three pipelines. Required by relative path for the reasons in that file's header + * (it must stay free of workspace requires). + */ +const globalizeGroupMarkers = require('../../css-modules/globalize-group-markers'); +const { getLocalIdent } = require('../../css-modules/ident'); + /** * @type {import("webpack").RuleSetRule} */ @@ -34,6 +44,152 @@ const cssRule = { use: ['style-loader', 'css-loader'], }; +/** + * The one non-module stylesheet that must go through Tailwind: it emits the shared theme layer + * once per storybook document (see the file's own header). Every storybook config in the repo + * imports it from its `preview.js`, so it is a single shared file rather than one copy per app. + * + * Matched by SHAPE rather than by absolute path: `scripts/storybook` is consumed through a + * workspace symlink (`node_modules/@fluentui/scripts-storybook`), and webpack's `resolve.symlinks` + * vs. node's `__dirname` can disagree about which of the two spellings a module identity carries. + * Exactly one file in the repo is named `tailwind-theme.css`, so the regexp is unambiguous, and it + * is written to match both `/` and `\` so it holds on Windows. + */ +const TAILWIND_THEME_ENTRY = /[\\/]tailwind-theme\.css$/; + +/** Canonical absolute path of that entry, for configs that would rather import it by path. */ +const tailwindThemeEntry = path.resolve(__dirname, 'tailwind-theme.css'); + +/** + * `@tailwindcss/postcss` is a *plugin creator*: it has to be invoked to produce the + * PostCSS plugin object. postcss-loader@4's string/`[name, options]` plugin forms call + * `require(name)` and return it UNINVOKED when options are empty (dist/utils.js + * `loadPlugin`), so the instantiated object is passed directly instead. + * + * The `require` is wrapped because `@tailwindcss/postcss` publishes its types only through + * `exports` (`dist/index.d.mts`), which this project's `moduleResolution` cannot follow — a + * type-lookup failure only; the runtime resolution is fine. Keeping the suppression on its own + * line confines it to the specifier instead of the whole plugin list. + * + * @type {import("webpack").RuleSetUseItem} + */ +const tailwindPostcssLoader = { + loader: 'postcss-loader', + options: { + postcssOptions: { + // no postcss.config.* exists in this repo — skip cosmiconfig's upward search + config: false, + plugins: [ + // @ts-ignore -- types are behind `exports`; see the note above + require('@tailwindcss/postcss')(), + /** + * MUST come after Tailwind and before css-loader's CSS-Modules pass. webpack runs + * loaders right-to-left, so postcss-loader is already ahead of css-loader; within + * this list Tailwind has to have emitted `.group\/fui-switch` first. + * + * Without it css-loader hashes the marker and every named-group rule compiles to a + * selector the DOM never matches — silently, with VR still green. See + * scripts/css-modules/globalize-group-markers.js and DECISIONS.md D15. + */ + globalizeGroupMarkers(), + ], + }, + }, +}; + +/** + * Tailwind-flavoured CSS Modules for converted (Griffel-free) packages. + * + * Class names come from `getLocalIdent` rather than a `localIdentName` template, because the + * scheme (`fuicm---`, all lowercase) is not expressible as one: it + * kebab-cases both the file token and the local, and it hashes the package name + the + * source-relative path + the local rather than the file's contents. The identical function + * drives `postcss-modules` in the package build, so every storybook and `dist/styles.css` + * produce byte-identical class names for the same module — see scripts/css-modules/ident.js. + * + * The `fuicm-` prefix is a hard contract with the jest snapshot serializer, which strips + * these exactly like Griffel's jest serializer stripped atomics + * (migration/griffel-to-tailwind/reports/DECISIONS.md D9). + * + * @type {import("webpack").RuleSetRule} + */ +const cssModulesRule = { + test: /\.module\.css$/, + sideEffects: true, + use: [ + 'style-loader', + { + loader: 'css-loader', + options: { + importLoaders: 1, + modules: { + getLocalIdent, + namedExport: false, + }, + }, + }, + tailwindPostcssLoader, + ], +}; + +/** + * @type {import("webpack").RuleSetRule} + */ +const tailwindThemeRule = { + test: /\.css$/, + include: [TAILWIND_THEME_ENTRY], + sideEffects: true, + use: ['style-loader', { loader: 'css-loader', options: { importLoaders: 1 } }, tailwindPostcssLoader], +}; + +/** + * Storybook's builder appends an implicit `{ test: /\.css$/, use: [style-loader, + * css-loader] }` rule (builder-webpack5 `createDefaultWebpackConfig`) with no `modules` + * option — it would swallow `*.module.css` as global CSS and hand back an empty class + * map, and it would emit the theme entry's `@import … source(none)` verbatim. Narrow it + * instead of replacing it: unconverted packages still need plain CSS. + * + * Call this BEFORE `registerRules` — webpack applies every matching rule, so the builder's + * rule has to stop matching before {@link cssModulesRule} / {@link tailwindThemeRule} are added. + * + * @param {import("webpack").Configuration} config + */ +function excludeTailwindCssFromDefaultCssRule(config) { + const cssRuleTest = String(/\.css$/); + const moduleRules = /** @type {import("webpack").RuleSetRule[]} */ (config.module?.rules ?? []); + + for (const rule of moduleRules) { + if (!rule || typeof rule !== 'object' || String(rule.test) !== cssRuleTest) { + continue; + } + + // An `include` marks a rule that deliberately scopes itself to specific files — that is how + // `tailwindThemeRule` guards itself, and it shares `test: /\.css$/`. Never narrow those. + const isScopedRule = rule.include !== null && rule.include !== undefined; + + if (isScopedRule) { + continue; + } + + /** + * The cast is what makes the spread below typecheck: webpack's `exclude` is a union whose + * array member is `RuleSetConditionAbsolute[]`, and wrapping a possibly-`undefined` scalar + * in `[rule.exclude]` widens the element type to include `undefined`. The `hasExcludes` + * guard already rules that out. + * + * @type {import("webpack").RuleSetConditionAbsolute[]} + */ + const existingExcludes = + rule.exclude === null || rule.exclude === undefined + ? [] + : /** @type {import("webpack").RuleSetConditionAbsolute[]} */ ( + Array.isArray(rule.exclude) ? rule.exclude : [rule.exclude] + ); + + rule.exclude = [...existingExcludes, /\.module\.css$/, TAILWIND_THEME_ENTRY]; + } +} + /** * v8 uses SCSS/CSS modules * @type {import("webpack").RuleSetRule} @@ -147,6 +303,10 @@ const reactCompilerRule = [ exports.tsRule = tsRule; exports.scssRule = scssRule; exports.cssRule = cssRule; +exports.cssModulesRule = cssModulesRule; +exports.tailwindThemeRule = tailwindThemeRule; +exports.tailwindThemeEntry = tailwindThemeEntry; +exports.excludeTailwindCssFromDefaultCssRule = excludeTailwindCssFromDefaultCssRule; exports.griffelRule = griffelRule; exports.swcRule = swcRule; exports.reactCompilerRule = reactCompilerRule; diff --git a/scripts/storybook/src/tailwind-theme.css b/scripts/storybook/src/tailwind-theme.css new file mode 100644 index 00000000000000..5795928b7f05f4 --- /dev/null +++ b/scripts/storybook/src/tailwind-theme.css @@ -0,0 +1,55 @@ +/* + * Emits the shared Tailwind theme layer ONCE per storybook document. + * + * This is THE single copy for the whole repo. Every storybook `preview.js` reaches it — the + * root `.storybook/preview.js` imports it, and the ~76 per-package / per-app previews import + * the root preview as a module, so the side effect propagates to all of them. The VR app + * imports it directly for the same reason. One file, one emission point, one theme. + * + * Component `*.module.css` files open with `@reference '#theme'`, which makes the + * theme's variables/variants/utilities available for `@apply` + `@variant` but emits + * NOTHING. The compiled utilities therefore reference `var(--spacing, calc(1px * + * var(--base-scale)))`, and `--base-scale` has to exist at `:root` or every numeric + * spacing utility is invalid at computed-value time (verified: min-width resolves to + * 0px without it, 8px with it). + * + * `source(none)` disables Tailwind's automatic content detection, so this emits only + * the theme custom properties + focus-knob `@property` registrations (1,770 bytes total, + * of which 377 are the two `@layer fui.theme` variable blocks) — no preflight, no utility + * classes, and therefore zero rendering impact on unconverted packages' VR baselines. That + * `source(none)` discipline is what makes it safe to load this into EVERY storybook, + * including the ones whose packages are still on Griffel. + * + * The SECOND variable block holds `--spacing-thin/thick/thicker/thickest` (the Fluent + * stroke widths). Those four are the one part of the token registration that must exist as + * real variables rather than `@theme inline` names: border/outline/ring/decoration widths do + * not consume the `--spacing-*` namespace, so modules reference `var(--spacing-thin)` + * directly. Byte-identical to the theme package's own `dist/styles.css` emission — verified + * by compiling both (.scratch/layer-probe/check-stroke-emission.mjs). + * + * Routed through Tailwind by `rules.tailwindThemeRule`, which matches this file by name; + * `rules.excludeTailwindCssFromDefaultCssRule` keeps storybook's implicit plain-CSS rule off it. + * + */ +@import '@fluentui/react-tailwind-theme-preview' source(none); + +/* + * Headless icons stylesheet (@fluentui/react-icons/headless, 2.0.337+ — the Griffel-free + * icon factories windmod uses, which express styling as attributes: `data-fui-icon`, + * `data-fui-icon-rtl`, `data-fui-icon-hidden`, `data-fui-icon-font`). REQUIRED: without it + * headless icons lose `display`, the RTL flip, high-contrast handling, and bundled pairs + * render BOTH variants at once. Classic (Griffel) icons don't read this file. + * + * The icons package ships these rules UNLAYERED by default (its own back-compat posture). + * Cascade layers are compared before specificity, so unlayered icon rules would beat every + * layered `fui.*` rule no matter how specific — adhering to this repo's layering system + * therefore requires assigning them a layer at import time. `fui.components.l1` (the + * lowest component layer) is the chosen altitude: windmod base components live in l1, so + * icon state rules arbitrate with them by in-file source order, and everything above + * (l2+, utilities, unlayered consumer CSS) still wins. + * + * Mirrors the same import in @fluentui/react-tailwind-theme-preview's css/emit.css (the + * published root artifact); this copy serves storybook documents, which import this entry + * rather than dist/styles.css. + */ +@import '@fluentui/react-icons/headless/styles.css' layer(fui.components.l1); diff --git a/tools/workspace-plugin/src/executors/build/executor.ts b/tools/workspace-plugin/src/executors/build/executor.ts index 55aba5910de1e2..a583affa1dfe16 100644 --- a/tools/workspace-plugin/src/executors/build/executor.ts +++ b/tools/workspace-plugin/src/executors/build/executor.ts @@ -2,6 +2,7 @@ import { type ExecutorContext, type PromiseExecutor } from '@nx/devkit'; import { compileSwc } from './lib/swc'; import { compileWithGriffelStylesAOT, compileWithReactCompiler, hasStylesFilesToProcess } from './lib/babel'; +import { compileCssModules } from './lib/css-modules'; import { assetGlobsToFiles, copyAssets } from './lib/assets'; import { cleanOutput } from './lib/clean'; import { cjsRenameTransforms, copyCjsTypes } from './lib/cjs-extension'; @@ -33,6 +34,10 @@ const runExecutor: PromiseExecutor = async (schema, context return generateApiExecutor(generateApiSchema, context).then(res => res.success); }, ), + // Serial, and after the parallel leg on purpose: this reads `lib*/` (written by + // runBuild) and writes into `dist/` (written by generate-api) — running it inside the + // parallel block would race both. + () => compileCssModules(options), () => copyAssets(assetFiles), () => copyCjsTypes(options), ); diff --git a/tools/workspace-plugin/src/executors/build/lib/css-modules.spec.ts b/tools/workspace-plugin/src/executors/build/lib/css-modules.spec.ts new file mode 100644 index 00000000000000..097218c8de0be0 --- /dev/null +++ b/tools/workspace-plugin/src/executors/build/lib/css-modules.spec.ts @@ -0,0 +1,330 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import postcss, { type AtRule, type Rule } from 'postcss'; + +import { + CANONICAL_LAYER_STATEMENT, + compileCssModules, + compileCssModuleSource, + GENERATED_CLASS_PREFIX, +} from './css-modules'; +import { type NormalizedOptions } from './shared'; + +/** + * Guardrail for the two halves of the generated-class-name contract + * (migration/griffel-to-tailwind/reports/DECISIONS.md D9 + D15). + * + * Both failures these cover are SILENT — the CSS stays well-formed, nothing warns, and the + * visual-regression suite passes because nothing changed visually: + * + * - a hashed `group/…` marker produces selectors the DOM never matches, so every + * cross-component rule quietly stops applying; + * - a generated name that drifts off `fuicm-…` stops being stripped by the jest snapshot + * serializer and floods every committed snapshot instead. + */ +const PACKAGE_NAME = '@fluentui/react-switch'; +const RELATIVE_PATH = 'components/Switch/Switch.module.css'; +const ABSOLUTE_PATH = `/repo/packages/react-components/react-switch/library/src/${RELATIVE_PATH}`; + +const SOURCE = ` +@custom-variant checked (&:where([data-checked], :checked)); + +.root { + color: blue; +} + +.thumb { + @variant group-checked/fui-switch { + color: red; + } +} + +.nonZeroDeterminate { + width: 50%; +} +`; + +function compile() { + return compileCssModuleSource({ + source: SOURCE, + absolutePath: ABSOLUTE_PATH, + packageName: PACKAGE_NAME, + relativePath: RELATIVE_PATH, + }); +} + +describe('compileCssModuleSource', () => { + describe('named-group markers (D15)', () => { + it('leaves the compiled marker unhashed and globally addressable', async () => { + const { css } = await compile(); + + // The literal the JSX writes — `clsx(switchClassNames.root, 'group/fui-switch', …)` — + // escaped the way Tailwind emits it in a selector. + expect(css).toContain('.group\\/fui-switch'); + }); + + it('does not scope the marker into a generated class name', async () => { + const { css } = await compile(); + + expect(css).not.toMatch(new RegExp(`${GENERATED_CLASS_PREFIX}[a-z0-9-]*group`)); + }); + + it('keeps the marker out of the exported class map', async () => { + const { classMap } = await compile(); + + expect(Object.keys(classMap).sort()).toEqual(['nonZeroDeterminate', 'root', 'thumb']); + }); + + it('still scopes the module locals around it', async () => { + const { css, classMap } = await compile(); + + expect(css).toContain(`.${classMap.thumb}:is(:where(.group\\/fui-switch)`); + }); + }); + + describe('generated class names', () => { + it('emits fuicm---', async () => { + const { classMap } = await compile(); + + expect(classMap.root).toMatch(/^fuicm-switch-root-[0-9a-f]{6}$/); + }); + + it('kebab-cases the local so the whole name stays lowercase', async () => { + const { classMap } = await compile(); + + expect(classMap.nonZeroDeterminate).toMatch(/^fuicm-switch-non-zero-determinate-[0-9a-f]{6}$/); + }); + + it('never emits an uppercase character', async () => { + const { classMap } = await compile(); + + for (const generated of Object.values(classMap)) { + expect(generated).toEqual(generated.toLowerCase()); + } + }); + + it('is stable across runs and unique per package', async () => { + const first = await compile(); + const second = await compile(); + const otherPackage = await compileCssModuleSource({ + source: SOURCE, + absolutePath: ABSOLUTE_PATH, + packageName: '@fluentui/react-radio', + relativePath: RELATIVE_PATH, + }); + + expect(first.classMap.root).toEqual(second.classMap.root); + expect(first.classMap.root).not.toEqual(otherPackage.classMap.root); + }); + }); +}); + +/** + * StackShim's permanently UNLAYERED descendant rules + * (migration/griffel-to-tailwind/reports/s4-v8-layering-decision.md §2.1, §3.4). + * + * `@fluentui/react-migration-v8-v9` exists to render hybrid v8/v9 trees, so a StackShim's + * children are routinely v8 components styled by merge-styles — which injects UNLAYERED at + * runtime. Four of StackShim's rules have those children as their subject, and an unlayered + * declaration beats every `@layer` before specificity is even consulted. Moving them into + * `fui.components.l1`, adding `:where()`, or dropping the `:global()` around `ms-StackItem` + * each silently inverts a `flex-shrink` / `text-overflow` tie the shim wins today at 0-2-0. + * + * Every one of those failures is invisible: the CSS stays well-formed, nothing warns, no test + * renders differently, and this package has NO visual-regression baselines at all (a grep of + * apps/vr-tests-react-components/src for `Shim` returns zero story files). These assertions are + * the only mechanism that can see them, which is why they read the SHIPPED file rather than a + * fixture copy of it. + */ +describe('compileCssModuleSource — StackShim unlayered descendant rules (S4)', () => { + const REPO_ROOT = join(__dirname, '../../../../../..'); + const STACK_SHIM_PACKAGE = '@fluentui/react-migration-v8-v9'; + const STACK_SHIM_RELATIVE_PATH = 'components/Stack/StackShim.module.css'; + const STACK_SHIM_ABSOLUTE_PATH = join( + REPO_ROOT, + 'packages/react-components/react-migration-v8-v9/library/src', + STACK_SHIM_RELATIVE_PATH, + ); + + /** The literal StackItemShim.tsx renders; the exclusions match on it verbatim. */ + const STACK_ITEM_STATIC = '.ms-StackItem'; + + function compileStackShim() { + return compileCssModuleSource({ + source: readFileSync(STACK_SHIM_ABSOLUTE_PATH).toString(), + absolutePath: STACK_SHIM_ABSOLUTE_PATH, + packageName: STACK_SHIM_PACKAGE, + relativePath: STACK_SHIM_RELATIVE_PATH, + }); + } + + /** Every rule in the compiled output that has a consumer child as its subject. */ + function descendantRules(css: string): Rule[] { + const found: Rule[] = []; + + postcss.parse(css).walkRules(rule => { + if (rule.selector.includes('> *')) { + found.push(rule); + } + }); + + return found; + } + + function isInsideLayer(rule: Rule): boolean { + for (let node = rule.parent; node; node = (node as AtRule).parent) { + if (node.type === 'atrule' && (node as AtRule).name === 'layer') { + return true; + } + } + + return false; + } + + it('keeps `ms-StackItem` unhashed, so the exclusions still match', async () => { + const { css, classMap } = await compileStackShim(); + + // `:global()` survived: the class is emitted verbatim, not scoped to `fuicm-…`. Left bare + // in the source, postcss-modules hashes it and the exclusion stops matching — silently + // (the same failure class D15.4 documented for group markers). + expect(css).toContain(`:not(${STACK_ITEM_STATIC})`); + expect(css).not.toMatch(new RegExp(`${GENERATED_CLASS_PREFIX}[a-z0-9-]*ms-stack-item`, 'i')); + + // postcss-modules ignores anything inside `:global()`, so a correctly handled name is + // absent from the exported map entirely. + expect(Object.keys(classMap)).not.toContain('ms-StackItem'); + }); + + it('emits the four descendant rules OUTSIDE any @layer', async () => { + const { css } = await compileStackShim(); + const rules = descendantRules(css); + + expect(rules).toHaveLength(4); + + for (const rule of rules) { + expect({ selector: rule.selector, layered: isInsideLayer(rule) }).toEqual({ + selector: rule.selector, + layered: false, + }); + } + }); + + it('never wraps them in :where(), which would zero the specificity they depend on', async () => { + const { css } = await compileStackShim(); + + for (const rule of descendantRules(css)) { + expect(rule.selector).not.toContain(':where('); + } + }); + + it('writes `.disable-shrink` LAST, so `flex-shrink: 0` still beats `.root`/`.inner`', async () => { + const { css, classMap } = await compileStackShim(); + const selectors = descendantRules(css).map(rule => rule.selector); + + // Unlayered rules of equal specificity are ordered by file position and nothing else. + // StackShim.tsx pushes `styles.root` at :70 and `styles.disableShrink` at :129, so the + // disableShrink rule has to come after both flex-shrink:1 rules or `disableShrink` silently + // stops disabling shrink. + const lastIndexOf = (generated: string) => + selectors.reduce((last, selector, index) => (selector.startsWith(`.${generated}`) ? index : last), -1); + + expect(lastIndexOf(classMap['disable-shrink'])).toBe(selectors.length - 1); + expect(lastIndexOf(classMap['disable-shrink'])).toBeGreaterThan(lastIndexOf(classMap.root)); + expect(lastIndexOf(classMap['disable-shrink'])).toBeGreaterThan(lastIndexOf(classMap.inner)); + }); +}); + +/** + * `cleanOutput` does not clear `dist/` (api-extractor owns it), so deleting a package's last + * `*.module.css` used to leave `dist/styles.css` behind forever on incremental local builds — + * a stale stylesheet that nothing imports but that `"./styles.css"` still resolves for + * consumers (reports/phase15-css-emission.md open question 4). + */ +describe('compileCssModules — orphaned dist/styles.css', () => { + let projectRoot: string; + + function options(): NormalizedOptions { + // compileCssModules' zero-source-file path reads only these two fields. + return { + absoluteProjectRoot: projectRoot, + absoluteSourceRoot: join(projectRoot, 'src'), + } as unknown as NormalizedOptions; + } + + beforeEach(async () => { + projectRoot = await mkdtemp(join(tmpdir(), 'fui-css-modules-')); + mkdirSync(join(projectRoot, 'src'), { recursive: true }); + mkdirSync(join(projectRoot, 'dist'), { recursive: true }); + }); + + afterEach(async () => { + await rm(projectRoot, { recursive: true, force: true }); + }); + + /** Shape of a stylesheet a previous run of this executor wrote — carries the provenance banner. */ + const GENERATED = [ + CANONICAL_LAYER_STATEMENT, + '', + '/*', + ' * @fluentui/react-thing — compiled component styles.', + ' *', + ' * Generated by @fluentui/workspace-plugin:build from this package\'s "src" CSS Modules.', + ' */', + '', + '.fuicm-thing-root-abc123 { color: red; }', + ].join('\n'); + + it('deletes the stylesheet when the package no longer owns any *.module.css', async () => { + writeFileSync(join(projectRoot, 'dist', 'styles.css'), GENERATED); + + await expect(compileCssModules(options())).resolves.toBe(true); + + expect(existsSync(join(projectRoot, 'dist', 'styles.css'))).toBe(false); + }); + + it('leaves the rest of dist/ alone — api-extractor owns that directory', async () => { + writeFileSync(join(projectRoot, 'dist', 'styles.css'), GENERATED); + writeFileSync(join(projectRoot, 'dist', 'index.d.ts'), 'export {};'); + + await compileCssModules(options()); + + expect(existsSync(join(projectRoot, 'dist', 'index.d.ts'))).toBe(true); + expect(existsSync(join(projectRoot, 'dist'))).toBe(true); + }); + + /** + * `react-storybook-addon-export-to-sandbox` copies a hand-authored `src/styles.css` to + * `dist/styles.css` via the build's `assets` config, declares it in `exports["./styles.css"]`, + * `files` and `sideEffects` — and owns zero `*.module.css`, so it takes this exact path on every + * build. Deleting by filename alone would silently unpublish it. + */ + it('never deletes a stylesheet this executor did not generate', async () => { + const authored = '/* Remove z-index from "show code" button container */\n.docs-story > div { z-index: auto; }'; + writeFileSync(join(projectRoot, 'dist', 'styles.css'), authored); + + await expect(compileCssModules(options())).resolves.toBe(true); + + expect(existsSync(join(projectRoot, 'dist', 'styles.css'))).toBe(true); + expect(readFileSync(join(projectRoot, 'dist', 'styles.css'), 'utf8')).toBe(authored); + }); + + it('is a no-op for a package that never had a stylesheet', async () => { + await expect(compileCssModules(options())).resolves.toBe(true); + + expect(existsSync(join(projectRoot, 'dist', 'styles.css'))).toBe(false); + }); + + it('keeps the stylesheet when the package still owns a *.module.css', async () => { + mkdirSync(join(projectRoot, 'src', 'components'), { recursive: true }); + writeFileSync(join(projectRoot, 'src', 'components', 'Thing.module.css'), '.root { color: blue; }'); + writeFileSync(join(projectRoot, 'dist', 'styles.css'), '.fuicm-existing-abc123 { color: red; }'); + + // Not asserting on the compile result here — only that the early-return cleanup did not fire. + await compileCssModules(options()).catch(() => undefined); + + expect(existsSync(join(projectRoot, 'dist', 'styles.css'))).toBe(true); + }); +}); diff --git a/tools/workspace-plugin/src/executors/build/lib/css-modules.ts b/tools/workspace-plugin/src/executors/build/lib/css-modules.ts new file mode 100644 index 00000000000000..8ba45b22307ce4 --- /dev/null +++ b/tools/workspace-plugin/src/executors/build/lib/css-modules.ts @@ -0,0 +1,505 @@ +/** + * Package-build CSS emission for Tailwind-flavoured CSS Modules. + * + * Implements DECISIONS.md D1 + D13 (migration/griffel-to-tailwind/reports/DECISIONS.md): + * source of truth is `src/**\/*.module.css`; the PACKAGE BUILD compiles it so that + * consumers never run Tailwind and never see CSS-Modules syntax. + * + * Per package that owns at least one `*.module.css` this emits: + * + * 1. `dist/styles.css` — one aggregated, plain-CSS, layered stylesheet. The canonical + * `@layer` order statement is PREPENDED verbatim: Tailwind v4 is free to rewrite or + * trim a multi-name `@layer` statement while compiling a module, so its output is not + * trusted to carry it (D13). Re-declaring an identical order later in the file is a + * no-op (CSS Cascade 5), so whatever Tailwind emitted stays harmless. + * The stylesheet contains component rules ONLY — the theme emission + * (`--base-scale`, `--spacing`, …) is a standalone root artifact imported once per + * document and is NEVER embedded per package (D13). Modules only `@reference '#theme'`, + * which emits nothing, so the compiled rules merely *reference* those custom + * properties (`calc(var(--spacing, calc(1px * var(--base-scale))) * 8)`). + * + * 2. A generated class-map JS module per `*.module.css`, written next to the compiled + * component code in every module output (`lib/`, `lib-commonjs/`, …) as + * `.module.css.js`, plus a post-transform pass that repoints the emitted + * `'./.module.css'` specifiers (SWC emits them verbatim in both the ESM `import` + * and the CJS `require`) at it. Without this the shipped JS carries dangling imports + * of files that do not exist in the package. + * + * ── Stylesheet auto-loading (D1) ──────────────────────────────────────────────────── + * The side-effect `import '<…>/dist/styles.css'` is emitted into the ESM class map ONLY. + * + * - Bundler consumers resolve `@fluentui/` through `exports.import` / the `module` + * field → `lib/` → they import the class map → they get the stylesheet automatically. + * `"sideEffects": ["**\/*.css"]` keeps that import from being tree-shaken (without the + * allowlist the import is dropped and the component renders unstyled). + * - `require()` in plain node resolves through `exports.node` / `exports.require` → + * `lib-commonjs/` → a class map with NO stylesheet require, because node cannot load a + * raw `.css` file and would throw `SyntaxError: Unexpected token '.'`. SSR consumers + * therefore ship the stylesheet themselves via `` / a bundled client entry, + * reading it from the `"./styles.css"` export subpath. + * + * Non-ESM outputs other than commonjs (`lib-amd`, tag `ships-amd`) get the commonjs shape + * and a warning: no converted package ships AMD today. + */ + +import { existsSync } from 'node:fs'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { dirname, join, relative, sep } from 'node:path'; + +import { logger, readJsonFile } from '@nx/devkit'; +import { globSync } from 'fast-glob'; +import postcss, { type AcceptedPlugin } from 'postcss'; +import postcssModules from 'postcss-modules'; + +/** + * `@tailwindcss/postcss` ships its types only as `.d.mts`, which this package's + * `moduleResolution: 'node'` cannot resolve, so it is required rather than imported. It is a + * plugin CREATOR: it has to be invoked to produce the PostCSS plugin — handing PostCSS the + * uninvoked creator fails cryptically (the same trap the VR storybook's postcss-loader + * config documents, apps/vr-tests-react-components/.storybook/main.js). + */ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const tailwindcss: () => AcceptedPlugin = require('@tailwindcss/postcss'); + +/** + * Shared with the VR storybook's css-loader and with jest — see that file's header. It is + * required by RELATIVE PATH rather than imported: it lives outside this project's `rootDir` + * (it has to, so `apps/` and `scripts/` can reach it too) and this package declares no + * dependency on any `scripts/*` workspace. Nx loads this executor from source, so `__dirname` + * is this directory and the traversal is stable. + */ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const cssModulesIdent: { + GENERATED_CLASS_PREFIX: string; + createGenerateScopedName: (args: { packageName: string; relativePath: string }) => (localName: string) => string; +} = require('../../../../../../scripts/css-modules/ident.js'); + +/** + * `:global()`-wraps Tailwind's `group/…` / `peer/…` markers so CSS Modules cannot hash them. + * BLOCKING prerequisite for named groups — see that file's header and DECISIONS.md D15. + */ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const globalizeGroupMarkers: (options?: { + onRewrite?: (info: { from: string; to: string; count: number }) => void; +}) => AcceptedPlugin = require('../../../../../../scripts/css-modules/globalize-group-markers.js'); + +import { type NormalizedOptions } from './shared'; + +/** + * D2 amendment 4 — the final `fui.*` layer family. Kept byte-identical to + * `packages/react-components/react-tailwind-theme/css/index.css` and to the statement every + * component module repeats after its `@reference`. + */ +export const CANONICAL_LAYER_STATEMENT = + '@layer fui.theme, fui.base, fui.components, fui.components.l1, fui.components.l2, fui.components.l3, fui.components.l4, fui.components.l5, fui.utilities;'; + +const CSS_MODULE_GLOB = '**/*.module.css'; +/** + * One aggregated stylesheet per package. Deliberate, and re-confirmed in Phase 3 (worklist + * item 11): splitting this per component was evaluated and DROPPED for the migration PR, + * recorded as an independent follow-up. + * + * The case for splitting: every fixture in a package pays the whole package sheet. + * `metrics/batch3/monosize-react-badge.json` — all three badge fixtures charge an identical + * `css` gz of 2517, which is why `PresenceBadge` measures +26.3% while `Badge` and + * `CounterBadge` measure −22.7%/−22.8%. + * + * The case against, which won: the regression only appears in single-component micro-bundles + * and washes out in aggregate (`Suite ENTIRE LIBRARY` −2.1%), while turning `./styles.css` + * into N subpaths is a public export-surface break for consumers who import that subpath + * directly (SSR/CJS setups). The work is contained to three functions here + * (`writeAggregatedStylesheet()`, `renderEsmClassMap()`, and the `stylesheetSpecifier` + * computation) plus each converted package's `exports`/`files`, with NO authoring changes — + * so it can land independently at any time without redoing anything. + */ +const STYLESHEET_RELATIVE_PATH = 'dist/styles.css'; + +/** + * Re-exported for tests and for the `dist/styles.css` probe; the value itself is owned by + * scripts/css-modules/ident.js, which every pipeline shares. + */ +export const GENERATED_CLASS_PREFIX = cssModulesIdent.GENERATED_CLASS_PREFIX; + +/** + * Provenance marker written into every generated `dist/styles.css` banner and the ONLY thing + * that authorises `removeOrphanedStylesheet()` to delete that path. + * + * `dist/styles.css` is not exclusively ours: `react-storybook-addon-export-to-sandbox` ships a + * hand-authored `src/styles.css` copied there by the build's `assets` config, declares it in + * `exports["./styles.css"]`, `files` and `sideEffects`, and owns zero `*.module.css` — so it + * takes the orphan-cleanup path on every single build. Keying deletion on this sentinel instead + * of on the filename is what keeps that package's stylesheet (and any future authored one) + * intact. Shared between the writer and the remover so the two cannot drift apart. + */ +const GENERATED_STYLESHEET_SENTINEL = 'Generated by @fluentui/workspace-plugin:build'; + +/** + * A relative specifier ending in `.module.css`. Deliberately anchored on the quotes so it + * cannot match an already-rewritten `'./X.module.css.js'`, which makes the pass idempotent, + * and cannot match a bare package specifier (`@fluentui/x/y.module.css`) — cross-package + * CSS-module imports are not a supported shape. + */ +const CSS_MODULE_SPECIFIER = /(['"])(\.{1,2}\/[^'"\n]*?\.module\.css)\1/g; + +interface CompiledCssModule { + /** Source-root-relative POSIX path, e.g. `components/Divider/Divider.module.css`. */ + relativePath: string; + /** Compiled, scoped, plain CSS. */ + css: string; + /** `{ root: 'fuicm-divider-root-a3f2c1', … }` */ + classMap: Record; +} + +/** + * No-ops for every package that owns no `*.module.css` — which is all of them but three + * today. Unconverted packages pay one glob and their build output is byte-identical. + */ +export async function compileCssModules(normalizedOptions: NormalizedOptions): Promise { + const sourceFiles = globSync(CSS_MODULE_GLOB, { cwd: normalizedOptions.absoluteSourceRoot }).sort(); + + if (sourceFiles.length === 0) { + await removeOrphanedStylesheet(normalizedOptions); + return true; + } + + logger.log(`🎨 Compiling CSS Modules: ${sourceFiles.length} files`); + + const packageName = readPackageName(normalizedOptions); + const compiled: CompiledCssModule[] = []; + + for (const fileName of sourceFiles) { + compiled.push(await compileOne(fileName, packageName, normalizedOptions)); + } + + await writeAggregatedStylesheet(compiled, packageName, normalizedOptions); + await writeClassMaps(compiled, normalizedOptions); + + return true; +} + +// =========== + +/** + * Deletes an orphaned `dist/styles.css` left behind after a package's last `*.module.css` is + * removed (or renamed). + * + * `cleanOutput` deliberately does not clear `dist/` — api-extractor owns that directory — and + * nx's `{projectRoot}/dist` output entry only makes CACHE RESTORES correct. A local incremental + * build therefore used to keep publishing a stale stylesheet forever: the package no longer + * emits any class map, so nothing imports the file, yet `package.json`'s `"./styles.css"` export + * still resolves and consumers would load dead rules for components that had moved on. + * + * Deletion requires BOTH conditions, because this path is reached on every build of every + * package that owns no CSS Modules — i.e. most of the repo: + * + * 1. the file exists, and + * 2. it carries `GENERATED_STYLESHEET_SENTINEL`, proving a previous run of THIS executor wrote + * it. Without check 2 the pass would delete `react-storybook-addon-export-to-sandbox`'s + * hand-authored, `exports`-declared stylesheet on every build (see the sentinel's docs). + * + * Scoped to exactly that one file — never the directory — so api-extractor's `dist/*.d.ts` and + * everything else under `dist/` is untouched. + */ +async function removeOrphanedStylesheet(normalizedOptions: NormalizedOptions): Promise { + const stylesheetPath = join(normalizedOptions.absoluteProjectRoot, STYLESHEET_RELATIVE_PATH); + + if (!existsSync(stylesheetPath)) { + return; + } + + const existing = await readFile(stylesheetPath, 'utf8'); + + if (!existing.includes(GENERATED_STYLESHEET_SENTINEL)) { + // Authored or copied by some other build step — not ours to delete. + return; + } + + await rm(stylesheetPath, { force: true }); + logger.log(`🎨 Removed orphaned ${STYLESHEET_RELATIVE_PATH} (package owns no *.module.css)`); +} + +function readPackageName(normalizedOptions: NormalizedOptions): string { + const packageJson = readJsonFile<{ name?: string }>(join(normalizedOptions.absoluteProjectRoot, 'package.json')); + return packageJson.name ?? normalizedOptions.project.root; +} + +function toPosix(value: string): string { + return value.split(sep).join('/'); +} + +/** + * Deterministic, workspace-unique generated class name. Both halves of the scheme — the + * readable `-` display text and the 6-hex-char digest over + * ` ` — live in scripts/css-modules/ident.js, which + * the VR storybook and jest share. See that file for why the digest seeds on the NAME and + * not on the CSS bytes. + */ +function createScopedNameGenerator(packageName: string, relativePath: string) { + return cssModulesIdent.createGenerateScopedName({ packageName, relativePath }); +} + +async function compileOne( + fileName: string, + packageName: string, + normalizedOptions: NormalizedOptions, +): Promise { + const relativePath = toPosix(fileName); + const absolutePath = join(normalizedOptions.absoluteSourceRoot, fileName); + const source = (await readFile(absolutePath)).toString(); + + return compileCssModuleSource({ source, absolutePath, packageName, relativePath }); +} + +/** + * The PostCSS chain itself, separated from disk access so the guardrail test + * ({@link 'file://./css-modules.spec.ts'}) exercises the real plugin order rather than a + * copy of it. A copy is exactly what would rot: the failure this guards against is silent. + */ +export async function compileCssModuleSource({ + source, + absolutePath, + packageName, + relativePath, +}: { + source: string; + absolutePath: string; + packageName: string; + relativePath: string; +}): Promise { + let classMap: Record = {}; + let globalizedMarkers = 0; + const generateScopedName = createScopedNameGenerator(packageName, relativePath); + + const result = await postcss([ + // `@tailwindcss/postcss` is a plugin CREATOR — it must be invoked (same trap the VR + // storybook documents for postcss-loader). It resolves `@reference '#theme'` through the + // package's `imports` field; `@reference` emits nothing, so no theme CSS lands here. + tailwindcss(), + // ORDER IS LOAD-BEARING and this is the only position that works: Tailwind has to have + // emitted `.group\/fui-switch` before it can be rewritten, and postcssModules has to see + // the `:global()` wrapper before it scopes. Drop this plugin and every named-group rule + // compiles to a selector the DOM never matches — with no error (DECISIONS.md D15). + globalizeGroupMarkers({ + onRewrite: ({ count }) => { + globalizedMarkers += count; + }, + }), + postcssModules({ + generateScopedName: (localName: string) => generateScopedName(localName), + // Supplying `getJSON` also suppresses postcss-modules' default behaviour of writing a + // `.json` sidecar next to the SOURCE file. + getJSON: (_fileName: string, json: Record) => { + classMap = json; + }, + }), + ]).process(source, { from: absolutePath, to: absolutePath, map: false }); + + assertGroupMarkersSurvived(relativePath, classMap); + + if (globalizedMarkers > 0) { + logger.verbose(`css-modules: kept ${globalizedMarkers} group/peer marker(s) global in ${relativePath}`); + } + + return { relativePath, css: result.css, classMap }; +} + +/** + * A `group/…` marker that reached CSS Modules unwrapped gets SCOPED, and the only visible + * trace is that it appears as a key in the exported class map — postcss-modules ignores + * anything inside `:global()`, so a correctly handled marker is absent from the map entirely + * (verified, reports/named-groups-design.md §2.4). + * + * The failure is otherwise completely silent: the compiled selector is well-formed, nothing + * warns, and VR passes because the rules simply never match. Fail the build instead. + */ +function assertGroupMarkersSurvived(relativePath: string, classMap: Record): void { + const leaked = Object.keys(classMap).filter(key => /^(?:group|peer)\\?\//.test(key)); + + if (leaked.length > 0) { + throw new Error( + `${relativePath}: Tailwind group/peer markers were scoped by CSS Modules (${leaked.join(', ')}). ` + + 'The fui-globalize-group-markers PostCSS plugin must run between tailwindcss() and postcssModules() — ' + + 'see scripts/css-modules/globalize-group-markers.js and DECISIONS.md D15.', + ); + } +} + +async function writeAggregatedStylesheet( + compiled: CompiledCssModule[], + packageName: string, + normalizedOptions: NormalizedOptions, +): Promise { + const stylesheetPath = join(normalizedOptions.absoluteProjectRoot, STYLESHEET_RELATIVE_PATH); + + // CSS block comments do not nest and have no escape: the first `*/` ends them. A glob + // like `src//.module.css` written with an asterisk wildcard would close this + // banner early and spill the remaining prose into the stylesheet as garbage rules + // (esbuild's CSS parser reports it as `Expected identifier but found whitespace`). + // Keep every asterisk out of the text below. + const banner = [ + '/*', + ` * ${packageName} — compiled component styles.`, + ' *', + ` * ${GENERATED_STYLESHEET_SENTINEL} from this package's "src" CSS Modules.`, + ' * Plain CSS: no Tailwind syntax, no CSS-Modules syntax, no theme emission. Every custom', + ' * property referenced below is DEFINED elsewhere — Fluent design tokens by', + ' * FluentProvider at runtime, Tailwind theme variables by the shared theme stylesheet the', + ' * document imports exactly once (DECISIONS.md D13). This file declares none of them.', + ' */', + ].join('\n'); + + const body = compiled.map(module => `/* ${module.relativePath} */\n${module.css.trim()}`).join('\n\n'); + + // The canonical order statement goes FIRST and verbatim — see the module header. + const contents = `${CANONICAL_LAYER_STATEMENT}\n\n${banner}\n\n${body}\n`; + + assertNoPrematureCommentEnd(banner); + + await mkdir(dirname(stylesheetPath), { recursive: true }); + await writeFile(stylesheetPath, contents); + + logger.log(`🎨 Emitted ${STYLESHEET_RELATIVE_PATH} (${Buffer.byteLength(contents)} bytes)`); +} + +/** + * Guards the banner against the one way a comment can corrupt the stylesheet: a comment + * terminator anywhere before the intended one. The package name is interpolated into the + * banner, so this stays a runtime check rather than a review convention. + */ +function assertNoPrematureCommentEnd(banner: string): void { + const terminator = banner.indexOf('*/'); + + if (terminator !== banner.length - 2) { + throw new Error( + `Generated dist/styles.css banner closes its comment early (at index ${terminator}) — the rest would be parsed as CSS.`, + ); + } +} + +async function writeClassMaps(compiled: CompiledCssModule[], normalizedOptions: NormalizedOptions): Promise { + const stylesheetPath = join(normalizedOptions.absoluteProjectRoot, STYLESHEET_RELATIVE_PATH); + + for (const outputConfig of normalizedOptions.moduleOutput) { + const outputRoot = join(normalizedOptions.absoluteProjectRoot, outputConfig.outputPath); + const isEsm = outputConfig.module === 'es6'; + + if (outputConfig.module === 'amd') { + logger.warn( + `CSS Modules + AMD output ('${outputConfig.outputPath}') is not supported: the class map is emitted in commonjs form and the stylesheet is not auto-loaded there.`, + ); + } + + for (const module of compiled) { + const classMapPath = join(outputRoot, `${module.relativePath}.js`); + const stylesheetSpecifier = toRelativeSpecifier(dirname(classMapPath), stylesheetPath); + + await mkdir(dirname(classMapPath), { recursive: true }); + await writeFile( + classMapPath, + isEsm + ? renderEsmClassMap(module, stylesheetSpecifier) + : renderCommonJsClassMap(module, outputConfig.outputPath), + ); + } + + await rewriteCssModuleSpecifiers(outputRoot); + } +} + +function toRelativeSpecifier(fromDir: string, toFile: string): string { + const specifier = toPosix(relative(fromDir, toFile)); + return specifier.startsWith('.') ? specifier : `./${specifier}`; +} + +function serializeClassMap(classMap: Record): string { + const entries = Object.keys(classMap) + .sort() + .map(key => ` ${JSON.stringify(key)}: ${JSON.stringify(classMap[key])},`) + .join('\n'); + + return entries.length > 0 ? `{\n${entries}\n}` : '{}'; +} + +function renderEsmClassMap(module: CompiledCssModule, stylesheetSpecifier: string): string { + return `/** + * GENERATED by @fluentui/workspace-plugin:build — do not edit. + * + * Class map for src/${module.relativePath}, compiled into ${STYLESHEET_RELATIVE_PATH}. + * + * The side-effect import below is what makes the package self-styling for bundler + * consumers; it is present in the ESM output only, because node cannot require a raw + * stylesheet. It survives tree-shaking through the package's "sideEffects": ["**\\/*.css"] + * allowlist. SSR/commonjs consumers load the "./styles.css" export subpath themselves. + */ +import ${JSON.stringify(stylesheetSpecifier)}; + +const classes = ${serializeClassMap(module.classMap)}; + +export default classes; +`; +} + +function renderCommonJsClassMap(module: CompiledCssModule, outputPath: string): string { + return `"use strict"; +/** + * GENERATED by @fluentui/workspace-plugin:build — do not edit. + * + * Class map for src/${module.relativePath}, compiled into ${STYLESHEET_RELATIVE_PATH}. + * + * No stylesheet require: \`require('/${outputPath}/index.js')\` has to work in plain + * node, which cannot parse CSS. Consumers of this output load the "./styles.css" export + * subpath (a , or their bundler's client entry). + */ +Object.defineProperty(exports, "__esModule", { + value: true +}); + +const classes = ${serializeClassMap(module.classMap)}; + +exports.default = classes; +`; +} + +/** + * Repoints emitted `'./X.module.css'` specifiers at the generated `'./X.module.css.js'` + * class map, in both the ESM `import` and the CJS `require` form. + * + * Precedent: the Griffel AOT pass ({@link 'file://./babel.ts'}) already post-processes the + * SWC output in place. Only the specifier string changes, so `.map` files are left alone — + * the sourcemap for that one import line is off by the three characters of `.js`. + */ +async function rewriteCssModuleSpecifiers(outputRoot: string): Promise { + const files = globSync('**/*.js', { cwd: outputRoot }); + let rewritten = 0; + + for (const fileName of files) { + const filePath = join(outputRoot, fileName); + const code = (await readFile(filePath)).toString(); + + if (!code.includes('.module.css')) { + continue; + } + + let changed = false; + const next = code.replace(CSS_MODULE_SPECIFIER, (match, quote: string, specifier: string) => { + const resolved = join(dirname(filePath), `${specifier}.js`); + + if (!existsSync(resolved)) { + logger.warn(`No generated class map for ${specifier} imported by ${filePath} — leaving the import dangling.`); + return match; + } + + changed = true; + return `${quote}${specifier}.js${quote}`; + }); + + if (changed) { + await writeFile(filePath, next); + rewritten++; + } + } + + if (rewritten > 0) { + logger.verbose(`css-modules: repointed .module.css specifiers in ${rewritten} files under ${outputRoot}`); + } +} diff --git a/yarn.lock b/yarn.lock index 29a79676267e69..3aaf12f7a3dfc1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -43,6 +43,13 @@ __metadata: languageName: node linkType: hard +"@alloc/quick-lru@npm:^5.2.0": + version: 5.2.0 + resolution: "@alloc/quick-lru@npm:5.2.0" + checksum: 10c0/7b878c48b9d25277d0e1a9b8b2f2312a314af806b4129dc902f2bc29ab09b58236e53964689feec187b28c80d2203aff03829754773a707a8a5987f1b7682d92 + languageName: node + linkType: hard + "@andrewbranch/untar.js@npm:^1.0.3": version: 1.0.3 resolution: "@andrewbranch/untar.js@npm:1.0.3" @@ -2163,6 +2170,16 @@ __metadata: languageName: node linkType: hard +"@emnapi/core@npm:^1.11.1": + version: 1.11.3 + resolution: "@emnapi/core@npm:1.11.3" + dependencies: + "@emnapi/wasi-threads": "npm:1.2.3" + tslib: "npm:^2.4.0" + checksum: 10c0/4ca08d349a82d5d2887ccc9e12df630877b0412ddcd59b9faee61e3c3947ccead27a18257a18bfe17abdf2b0709857808ad75d423ac49edd50c32fb140a7ed6e + languageName: node + linkType: hard + "@emnapi/runtime@npm:^1.1.0, @emnapi/runtime@npm:^1.4.3": version: 1.6.0 resolution: "@emnapi/runtime@npm:1.6.0" @@ -2172,6 +2189,15 @@ __metadata: languageName: node linkType: hard +"@emnapi/runtime@npm:^1.11.1": + version: 1.11.3 + resolution: "@emnapi/runtime@npm:1.11.3" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/a00f1020fefb9d4145c367f93a9fddb383a00da8ffd7871e20b19659890379b83aeecb9f84d7d0eda5456343f4a09eb05b0acb5153b0d3d889539dedb1ed87c3 + languageName: node + linkType: hard + "@emnapi/wasi-threads@npm:1.1.0": version: 1.1.0 resolution: "@emnapi/wasi-threads@npm:1.1.0" @@ -2181,6 +2207,15 @@ __metadata: languageName: node linkType: hard +"@emnapi/wasi-threads@npm:1.2.3, @emnapi/wasi-threads@npm:^1.2.2": + version: 1.2.3 + resolution: "@emnapi/wasi-threads@npm:1.2.3" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/5aed84bc5dbe867973b20406ca1ed95661a4892ecaef80378fdf2d37424b3f7b9c428df8a3e1d82b783a3345a530032bf049e699e1f113ea52203c3e9b4e6ce5 + languageName: node + linkType: hard + "@emotion/hash@npm:^0.8.0": version: 0.8.0 resolution: "@emotion/hash@npm:0.8.0" @@ -2864,6 +2899,7 @@ __metadata: "@swc/core": "npm:1.11.24" "@swc/helpers": "npm:0.5.1" "@swc/jest": "npm:0.2.38" + "@tailwindcss/postcss": "npm:^4.3.2" "@testing-library/dom": "npm:10.4.0" "@testing-library/jest-dom": "npm:5.16.5" "@testing-library/react": "npm:16.3.0" @@ -2939,6 +2975,7 @@ __metadata: circular-dependency-plugin: "npm:5.2.2" clean-css: "npm:4.2.3" clean-webpack-plugin: "npm:4.0.0" + clsx: "npm:2.1.1" connect-history-api-fallback: "npm:1.6.0" copy-to-clipboard: "npm:3.3.1" copy-webpack-plugin: "npm:12.0.2" @@ -3062,6 +3099,7 @@ __metadata: swc-loader: "npm:0.2.6" swc-plugin-de-indent-template-literal: "npm:1.5.0" syncpack: "npm:10.6.1" + tailwindcss: "npm:^4.3.2" terser: "npm:5.39.1" terser-webpack-plugin: "npm:5.3.10" through2: "npm:4.0.2" @@ -4477,14 +4515,14 @@ __metadata: linkType: hard "@fluentui/react-icons@npm:^2.0.237, @fluentui/react-icons@npm:^2.0.239, @fluentui/react-icons@npm:^2.0.245, @fluentui/react-icons@npm:^2.0.306": - version: 2.0.311 - resolution: "@fluentui/react-icons@npm:2.0.311" + version: 2.0.337 + resolution: "@fluentui/react-icons@npm:2.0.337" dependencies: - "@griffel/react": "npm:^1.0.0" + "@griffel/react": "npm:^1.6.1" tslib: "npm:^2.1.0" peerDependencies: react: ">=16.8.0 <20.0.0" - checksum: 10c0/92374c68a5271afb4f8ed7e42e012bf422b3569aa0209216b65a2ec0d828bf011c1a6eccc438ff5fea7f49bf114df919ce2da861f3dcc21091025c1a4e430563 + checksum: 10c0/ac8e1756e9fb8654fd5a6b53afe714c79f1be7dbcf9244f889f8f6db26ebdefbf1e2a7ab50fd30bddbb90f8136557c9bf05ef329f8bfe3b5b7301534a1b06076 languageName: node linkType: hard @@ -5699,6 +5737,12 @@ __metadata: languageName: unknown linkType: soft +"@fluentui/react-tailwind-theme-preview@workspace:packages/react-components/react-tailwind-theme-preview": + version: 0.0.0-use.local + resolution: "@fluentui/react-tailwind-theme-preview@workspace:packages/react-components/react-tailwind-theme-preview" + languageName: unknown + linkType: soft + "@fluentui/react-teaching-popover-stories@workspace:packages/react-components/react-teaching-popover/stories": version: 0.0.0-use.local resolution: "@fluentui/react-teaching-popover-stories@workspace:packages/react-components/react-teaching-popover/stories" @@ -6729,6 +6773,20 @@ __metadata: languageName: node linkType: hard +"@griffel/core@npm:^1.21.3": + version: 1.21.3 + resolution: "@griffel/core@npm:1.21.3" + dependencies: + "@emotion/hash": "npm:^0.9.0" + "@griffel/style-types": "npm:^1.4.2" + csstype: "npm:^3.2.3" + rtl-css-js: "npm:^1.16.1" + stylis: "npm:^4.4.0" + tslib: "npm:^2.1.0" + checksum: 10c0/9bfa315a90b2351e3292798cf7aeaa64709aa6acf29c6dd8b9c1fc3e4805dae4c81e1c0d1b4767a3a4772c66dd55a10788e36ac472f2e7ca08db43ec14f15c11 + languageName: node + linkType: hard + "@griffel/eslint-plugin@npm:^2.0.0": version: 2.0.0 resolution: "@griffel/eslint-plugin@npm:2.0.0" @@ -6752,7 +6810,7 @@ __metadata: languageName: node linkType: hard -"@griffel/react@npm:^1.0.0, @griffel/react@npm:^1.5.32": +"@griffel/react@npm:^1.5.32": version: 1.5.32 resolution: "@griffel/react@npm:1.5.32" dependencies: @@ -6764,6 +6822,18 @@ __metadata: languageName: node linkType: hard +"@griffel/react@npm:^1.6.1": + version: 1.7.7 + resolution: "@griffel/react@npm:1.7.7" + dependencies: + "@griffel/core": "npm:^1.21.3" + tslib: "npm:^2.1.0" + peerDependencies: + react: ">=16.14.0 <20.0.0" + checksum: 10c0/6b2a81b3965efb4850efefbbb141dbe5a25331c22a2c0a567b909388e31f8f859148951d92010e36eac414ebc494969b0d2d6e2820b40413022ec6a3f5e9a276 + languageName: node + linkType: hard + "@griffel/shadow-dom@npm:0.2.2": version: 0.2.2 resolution: "@griffel/shadow-dom@npm:0.2.2" @@ -6783,6 +6853,15 @@ __metadata: languageName: node linkType: hard +"@griffel/style-types@npm:^1.4.2": + version: 1.4.2 + resolution: "@griffel/style-types@npm:1.4.2" + dependencies: + csstype: "npm:^3.2.3" + checksum: 10c0/5049b59d5f978c7d34774e49da6824c9f44178bc0261f38f5359512661a5cb6da827ecb855cc6237bc9ffdf2e584e6cbe8b622f2bcac347eb1307920ce583652 + languageName: node + linkType: hard + "@griffel/webpack-loader@npm:2.2.10": version: 2.2.10 resolution: "@griffel/webpack-loader@npm:2.2.10" @@ -7954,6 +8033,18 @@ __metadata: languageName: node linkType: hard +"@napi-rs/wasm-runtime@npm:^1.1.4": + version: 1.2.3 + resolution: "@napi-rs/wasm-runtime@npm:1.2.3" + dependencies: + "@tybys/wasm-util": "npm:^0.10.3" + peerDependencies: + "@emnapi/core": ^1.7.1 || ^2.0.0-alpha.4 + "@emnapi/runtime": ^1.7.1 || ^2.0.0-alpha.4 + checksum: 10c0/6da0a4bf9df79e9abfb3e3d462f6a5d42889be39426040038c9dd5925865585d7dbd06dd439c2ffc20f49ef2751412da5bd748cfb3c5b049142d72c0550f6f79 + languageName: node + linkType: hard + "@nevware21/ts-async@npm:>= 0.5.5 < 0.6.0": version: 0.5.5 resolution: "@nevware21/ts-async@npm:0.5.5" @@ -9696,6 +9787,170 @@ __metadata: languageName: node linkType: hard +"@tailwindcss/node@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/node@npm:4.3.3" + dependencies: + "@jridgewell/remapping": "npm:^2.3.5" + enhanced-resolve: "npm:^5.24.1" + jiti: "npm:^2.7.0" + lightningcss: "npm:1.32.0" + magic-string: "npm:^0.30.21" + source-map-js: "npm:^1.2.1" + tailwindcss: "npm:4.3.3" + checksum: 10c0/0123669be5b3e78d42b0e10cb34d547fb8574c8e7dc7a97a141c8a4ab4215852b5b1b99b97763e898e1da1493ee8424cb72e28225cc4876828c95156f94d7f0e + languageName: node + linkType: hard + +"@tailwindcss/oxide-android-arm64@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide-android-arm64@npm:4.3.3" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@tailwindcss/oxide-darwin-arm64@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide-darwin-arm64@npm:4.3.3" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@tailwindcss/oxide-darwin-x64@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide-darwin-x64@npm:4.3.3" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@tailwindcss/oxide-freebsd-x64@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide-freebsd-x64@npm:4.3.3" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@tailwindcss/oxide-linux-arm-gnueabihf@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide-linux-arm-gnueabihf@npm:4.3.3" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@tailwindcss/oxide-linux-arm64-gnu@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide-linux-arm64-gnu@npm:4.3.3" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@tailwindcss/oxide-linux-arm64-musl@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide-linux-arm64-musl@npm:4.3.3" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@tailwindcss/oxide-linux-x64-gnu@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide-linux-x64-gnu@npm:4.3.3" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@tailwindcss/oxide-linux-x64-musl@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide-linux-x64-musl@npm:4.3.3" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@tailwindcss/oxide-wasm32-wasi@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide-wasm32-wasi@npm:4.3.3" + dependencies: + "@emnapi/core": "npm:^1.11.1" + "@emnapi/runtime": "npm:^1.11.1" + "@emnapi/wasi-threads": "npm:^1.2.2" + "@napi-rs/wasm-runtime": "npm:^1.1.4" + "@tybys/wasm-util": "npm:^0.10.2" + tslib: "npm:^2.8.1" + conditions: cpu=wasm32 + languageName: node + linkType: hard + +"@tailwindcss/oxide-win32-arm64-msvc@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide-win32-arm64-msvc@npm:4.3.3" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@tailwindcss/oxide-win32-x64-msvc@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide-win32-x64-msvc@npm:4.3.3" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@tailwindcss/oxide@npm:4.3.3": + version: 4.3.3 + resolution: "@tailwindcss/oxide@npm:4.3.3" + dependencies: + "@tailwindcss/oxide-android-arm64": "npm:4.3.3" + "@tailwindcss/oxide-darwin-arm64": "npm:4.3.3" + "@tailwindcss/oxide-darwin-x64": "npm:4.3.3" + "@tailwindcss/oxide-freebsd-x64": "npm:4.3.3" + "@tailwindcss/oxide-linux-arm-gnueabihf": "npm:4.3.3" + "@tailwindcss/oxide-linux-arm64-gnu": "npm:4.3.3" + "@tailwindcss/oxide-linux-arm64-musl": "npm:4.3.3" + "@tailwindcss/oxide-linux-x64-gnu": "npm:4.3.3" + "@tailwindcss/oxide-linux-x64-musl": "npm:4.3.3" + "@tailwindcss/oxide-wasm32-wasi": "npm:4.3.3" + "@tailwindcss/oxide-win32-arm64-msvc": "npm:4.3.3" + "@tailwindcss/oxide-win32-x64-msvc": "npm:4.3.3" + dependenciesMeta: + "@tailwindcss/oxide-android-arm64": + optional: true + "@tailwindcss/oxide-darwin-arm64": + optional: true + "@tailwindcss/oxide-darwin-x64": + optional: true + "@tailwindcss/oxide-freebsd-x64": + optional: true + "@tailwindcss/oxide-linux-arm-gnueabihf": + optional: true + "@tailwindcss/oxide-linux-arm64-gnu": + optional: true + "@tailwindcss/oxide-linux-arm64-musl": + optional: true + "@tailwindcss/oxide-linux-x64-gnu": + optional: true + "@tailwindcss/oxide-linux-x64-musl": + optional: true + "@tailwindcss/oxide-wasm32-wasi": + optional: true + "@tailwindcss/oxide-win32-arm64-msvc": + optional: true + "@tailwindcss/oxide-win32-x64-msvc": + optional: true + checksum: 10c0/6c38b25226dad252347451ed8953a998432b51f1e068b25e021215b886ccf8bcf3af58faf5f4ab50368091e657ca2fa341df1deca6101651eddb567122f2acb5 + languageName: node + linkType: hard + +"@tailwindcss/postcss@npm:^4.3.2": + version: 4.3.3 + resolution: "@tailwindcss/postcss@npm:4.3.3" + dependencies: + "@alloc/quick-lru": "npm:^5.2.0" + "@tailwindcss/node": "npm:4.3.3" + "@tailwindcss/oxide": "npm:4.3.3" + postcss: "npm:^8.5.16" + tailwindcss: "npm:4.3.3" + checksum: 10c0/50b33643a4ba50c0d2ea0fd769d16072b0b3d2f31180df667fe5ad4ef60464b5f00931cfd5f2de2116a91219edb7a2dbd4c073b5c2f23393eab6ef1163db8dbf + languageName: node + linkType: hard + "@tensile-perf/runner@npm:0.5.0": version: 0.5.0 resolution: "@tensile-perf/runner@npm:0.5.0" @@ -9959,6 +10214,15 @@ __metadata: languageName: node linkType: hard +"@tybys/wasm-util@npm:^0.10.2, @tybys/wasm-util@npm:^0.10.3": + version: 0.10.3 + resolution: "@tybys/wasm-util@npm:0.10.3" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10c0/fd2bd2a79c6cd8c79ed1cf7a0fa375c64589264c88a27acaf9756d556b453ea222b62a4f68dd2fbb8b3a78b6bab3b1f4fb2431b6afc6aeda8344b53a521a1cd3 + languageName: node + linkType: hard + "@tybys/wasm-util@npm:^0.9.0": version: 0.9.0 resolution: "@tybys/wasm-util@npm:0.9.0" @@ -14631,6 +14895,13 @@ __metadata: languageName: node linkType: hard +"clsx@npm:2.1.1": + version: 2.1.1 + resolution: "clsx@npm:2.1.1" + checksum: 10c0/c4c8eb865f8c82baab07e71bfa8897c73454881c4f99d6bc81585aecd7c441746c1399d08363dc096c550cceaf97bd4ce1e8854e1771e9998d9f94c4fe075839 + languageName: node + linkType: hard + "co-body@npm:^6.0.0": version: 6.1.0 resolution: "co-body@npm:6.1.0" @@ -15457,6 +15728,13 @@ __metadata: languageName: node linkType: hard +"csstype@npm:^3.2.3": + version: 3.2.3 + resolution: "csstype@npm:3.2.3" + checksum: 10c0/cd29c51e70fa822f1cecd8641a1445bed7063697469d35633b516e60fe8c1bde04b08f6c5b6022136bb669b64c63d4173af54864510fbb4ee23281801841a3ce + languageName: node + linkType: hard + "csv-stringify@npm:^6.2.0": version: 6.4.6 resolution: "csv-stringify@npm:6.4.6" @@ -16178,6 +16456,13 @@ __metadata: languageName: node linkType: hard +"detect-libc@npm:^2.0.3": + version: 2.1.2 + resolution: "detect-libc@npm:2.1.2" + checksum: 10c0/acc675c29a5649fa1fb6e255f993b8ee829e510b6b56b0910666949c80c364738833417d0edb5f90e4e46be17228b0f2b66a010513984e18b15deeeac49369c4 + languageName: node + linkType: hard + "detect-newline@npm:^3.1.0": version: 3.1.0 resolution: "detect-newline@npm:3.1.0" @@ -16784,6 +17069,16 @@ __metadata: languageName: node linkType: hard +"enhanced-resolve@npm:^5.24.1": + version: 5.24.5 + resolution: "enhanced-resolve@npm:5.24.5" + dependencies: + graceful-fs: "npm:^4.2.4" + tapable: "npm:^2.3.3" + checksum: 10c0/76c32ce5485ecea0ef808c9289bc6f7c49ce85142943598d89a23a846f2909556d3dc6db97a007442a48c185baad5514c1c76d20a2656497f9511d13ccfab73b + languageName: node + linkType: hard + "enquirer@npm:2.3.6, enquirer@npm:~2.3.6": version: 2.3.6 resolution: "enquirer@npm:2.3.6" @@ -22177,6 +22472,15 @@ __metadata: languageName: node linkType: hard +"jiti@npm:^2.7.0": + version: 2.7.0 + resolution: "jiti@npm:2.7.0" + bin: + jiti: lib/jiti-cli.mjs + checksum: 10c0/1b1e2310a490dce1aeea3da5f5dfe18273516c20ce48be2e98eb8ea452d5f3dcc8fd0cfd6d28b4052a24c5dbab6e3089b2d7e79f0bce7915b10d750929563c42 + languageName: node + linkType: hard + "jju@npm:1.4.0, jju@npm:^1.4.0, jju@npm:~1.4.0": version: 1.4.0 resolution: "jju@npm:1.4.0" @@ -22874,6 +23178,126 @@ __metadata: languageName: node linkType: hard +"lightningcss-android-arm64@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-android-arm64@npm:1.32.0" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-darwin-arm64@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-darwin-arm64@npm:1.32.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-darwin-x64@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-darwin-x64@npm:1.32.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"lightningcss-freebsd-x64@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-freebsd-x64@npm:1.32.0" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"lightningcss-linux-arm-gnueabihf@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-arm-gnueabihf@npm:1.32.0" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"lightningcss-linux-arm64-gnu@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-arm64-gnu@npm:1.32.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"lightningcss-linux-arm64-musl@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-arm64-musl@npm:1.32.0" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"lightningcss-linux-x64-gnu@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-x64-gnu@npm:1.32.0" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"lightningcss-linux-x64-musl@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-linux-x64-musl@npm:1.32.0" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"lightningcss-win32-arm64-msvc@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-win32-arm64-msvc@npm:1.32.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-win32-x64-msvc@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss-win32-x64-msvc@npm:1.32.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"lightningcss@npm:1.32.0": + version: 1.32.0 + resolution: "lightningcss@npm:1.32.0" + dependencies: + detect-libc: "npm:^2.0.3" + lightningcss-android-arm64: "npm:1.32.0" + lightningcss-darwin-arm64: "npm:1.32.0" + lightningcss-darwin-x64: "npm:1.32.0" + lightningcss-freebsd-x64: "npm:1.32.0" + lightningcss-linux-arm-gnueabihf: "npm:1.32.0" + lightningcss-linux-arm64-gnu: "npm:1.32.0" + lightningcss-linux-arm64-musl: "npm:1.32.0" + lightningcss-linux-x64-gnu: "npm:1.32.0" + lightningcss-linux-x64-musl: "npm:1.32.0" + lightningcss-win32-arm64-msvc: "npm:1.32.0" + lightningcss-win32-x64-msvc: "npm:1.32.0" + dependenciesMeta: + lightningcss-android-arm64: + optional: true + lightningcss-darwin-arm64: + optional: true + lightningcss-darwin-x64: + optional: true + lightningcss-freebsd-x64: + optional: true + lightningcss-linux-arm-gnueabihf: + optional: true + lightningcss-linux-arm64-gnu: + optional: true + lightningcss-linux-arm64-musl: + optional: true + lightningcss-linux-x64-gnu: + optional: true + lightningcss-linux-x64-musl: + optional: true + lightningcss-win32-arm64-msvc: + optional: true + lightningcss-win32-x64-msvc: + optional: true + checksum: 10c0/70945bd55097af46fc9fab7f5ed09cd5869d85940a2acab7ee06d0117004a1d68155708a2d462531cea2fc3c67aefc9333a7068c80b0b78dd404c16838809e03 + languageName: node + linkType: hard + "lines-and-columns@npm:2.0.3": version: 2.0.3 resolution: "lines-and-columns@npm:2.0.3" @@ -23359,7 +23783,7 @@ __metadata: languageName: node linkType: hard -"magic-string@npm:^0.30.12, magic-string@npm:^0.30.17, magic-string@npm:^0.30.5": +"magic-string@npm:^0.30.12, magic-string@npm:^0.30.17, magic-string@npm:^0.30.21, magic-string@npm:^0.30.5": version: 0.30.21 resolution: "magic-string@npm:0.30.21" dependencies: @@ -25086,6 +25510,15 @@ __metadata: languageName: node linkType: hard +"nanoid@npm:^3.3.17": + version: 3.3.18 + resolution: "nanoid@npm:3.3.18" + bin: + nanoid: bin/nanoid.cjs + checksum: 10c0/b994b4e396730f8be2520923284e2040d61eaee55cc6d4935ef6d38d34bafdc46133eda4d3faea5073bda545aa6079d82b886caeac5c731cf9ac18bcc1301425 + languageName: node + linkType: hard + "nanoid@npm:^5.0.8": version: 5.0.9 resolution: "nanoid@npm:5.0.9" @@ -26850,6 +27283,17 @@ __metadata: languageName: node linkType: hard +"postcss@npm:^8.5.16": + version: 8.5.26 + resolution: "postcss@npm:8.5.26" + dependencies: + nanoid: "npm:^3.3.17" + picocolors: "npm:^1.1.1" + source-map-js: "npm:^1.2.1" + checksum: 10c0/2bdafc00d96bd57b6649a52e458864a4bf58ee56cfdbe4aea1472b5cccc127e6c1ad653bd0bec50d211e650eb0b9270c80e1e72aff2e2fa40d9e7363234d6e43 + languageName: node + linkType: hard + "prefix-style@npm:2.0.1": version: 2.0.1 resolution: "prefix-style@npm:2.0.1" @@ -30025,6 +30469,13 @@ __metadata: languageName: node linkType: hard +"stylis@npm:^4.4.0": + version: 4.4.0 + resolution: "stylis@npm:4.4.0" + checksum: 10c0/259be096d90dfbfe903c8656dcb7591e52a421e577e950ef42ebd9ca02f387623a1165dd08761492fb6e92a7a562d62a53a694a10b0a2f6dcd7a0db107b4bf55 + languageName: node + linkType: hard + "supports-color@npm:^2.0.0": version: 2.0.0 resolution: "supports-color@npm:2.0.0" @@ -30264,6 +30715,13 @@ __metadata: languageName: node linkType: hard +"tailwindcss@npm:4.3.3, tailwindcss@npm:^4.3.2": + version: 4.3.3 + resolution: "tailwindcss@npm:4.3.3" + checksum: 10c0/7e9cd7553cd890ffa5350efb0ca8971ba5435257d0f98bf0714994ac8f1fee5c7cdf9a9da47fe83e8ac995d728410837ac49d3ef5ea66b5afe1db312086ffae2 + languageName: node + linkType: hard + "tapable@npm:^2.0.0, tapable@npm:^2.2.0, tapable@npm:^2.2.1, tapable@npm:^2.3.0, tapable@npm:^2.3.3": version: 2.3.3 resolution: "tapable@npm:2.3.3" From d826380ae57f387d4d58de553443f15e683a4103 Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Thu, 20 Aug 2026 19:26:14 -0700 Subject: [PATCH 003/246] feat(windmod): scaffold react-windmod-preview with pilot Button, Tooltip and ThemeProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package composes headless hooks with the Fluent visual contract: useX (headless, data-* state attrs) → useXStyles (module class map, ported from the Griffel→Tailwind campaign; JS variant branching for the look props the headless surface omits — Button appearance/shape/size, Tooltip appearance) → renderX (headless re-export). ThemeProvider renders a display:contents div carrying a theme class + the headless Provider context. Tooltip.module.css is a real top-layer adaptation, not a port: popover='hint' owns display; placement rides data-placement on the content (headless logical vocabulary re-keyed from data-popper-placement); the arrow is the [data-arrow] child positioned by pure CSS with an rtl swap for the logical before/after placements; JS side compensates the arrow offset (4+6) that mergeArrowOffset used to add. fix(workspace-plugin): generate-api's export-subpath rollups silently skipped on Windows — path.resolve backslashes failed the '/index.d.ts' suffix check. Normalized to posix; 56 headless rollups now emit locally (CI was unaffected). Library builds green: SWC dual ESM/CJS, per-subpath d.ts rollups + api.md, compiled dist/styles.css with fuicm-* idents and ESM class maps. Type-check clean under moduleResolution: bundler (subpath exports resolve against built deps; the type-check executor's --baseUrl override defeats repo-relative paths mappings). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- .github/CODEOWNERS | 2 + .../react-tailwind-theme-preview/package.json | 2 + .../theme-class-names.cjs | 35 + .../library/.babelrc.json | 4 + .../react-windmod-preview/library/.swcrc | 31 + .../react-windmod-preview/library/LICENSE | 15 + .../react-windmod-preview/library/README.md | 5 + .../library/config/api-extractor.json | 5 + .../library/config/tests.cjs | 1 + .../library/docs/Spec.md | 63 ++ .../library/eslint.config.cjs | 5 + .../library/etc/button.api.md | 52 ++ .../library/etc/provider.api.md | 61 ++ .../library/etc/react-windmod-preview.api.md | 148 ++++ .../library/etc/tooltip.api.md | 55 ++ .../library/jest.config.cjs | 34 + .../library/package.json | 93 +++ .../library/project.json | 15 + .../library/src/button.ts | 12 + .../src/components/Button/Button.module.css | 656 ++++++++++++++++++ .../library/src/components/Button/Button.tsx | 32 + .../src/components/Button/Button.types.ts | 31 + .../library/src/components/Button/index.ts | 3 + .../src/components/Button/useButtonStyles.ts | 54 ++ .../Provider/ThemeProvider.module.css | 14 + .../src/components/Provider/ThemeProvider.tsx | 33 + .../Provider/ThemeProvider.types.ts | 22 + .../library/src/components/Provider/index.ts | 2 + .../src/components/Tooltip/Tooltip.module.css | 163 +++++ .../src/components/Tooltip/Tooltip.tsx | 52 ++ .../src/components/Tooltip/Tooltip.types.ts | 27 + .../library/src/components/Tooltip/index.ts | 10 + .../components/Tooltip/useTooltipStyles.ts | 37 + .../library/src/index.ts | 28 + .../library/src/provider.ts | 20 + .../library/src/testing/isConformant.ts | 62 ++ .../library/src/tooltip.ts | 12 + .../library/tsconfig.json | 31 + .../library/tsconfig.lib.json | 22 + .../library/tsconfig.spec.json | 17 + .../stories/.storybook/main.cjs | 14 + .../stories/.storybook/preview.js | 9 + .../stories/.storybook/tsconfig.json | 10 + .../react-windmod-preview/stories/README.md | 17 + .../stories/eslint.config.js | 10 + .../stories/package.json | 5 + .../stories/project.json | 8 + .../stories/src/.gitkeep | 0 .../stories/src/index.ts | 1 + .../stories/tsconfig.json | 22 + .../stories/tsconfig.lib.json | 10 + .../src/executors/generate-api/lib/utils.ts | 5 +- tsconfig.base.all.json | 12 +- tsconfig.base.json | 4 + yarn.lock | 30 +- 55 files changed, 2115 insertions(+), 8 deletions(-) create mode 100644 packages/react-components/react-tailwind-theme-preview/theme-class-names.cjs create mode 100644 packages/react-components/react-windmod-preview/library/.babelrc.json create mode 100644 packages/react-components/react-windmod-preview/library/.swcrc create mode 100644 packages/react-components/react-windmod-preview/library/LICENSE create mode 100644 packages/react-components/react-windmod-preview/library/README.md create mode 100644 packages/react-components/react-windmod-preview/library/config/api-extractor.json create mode 100644 packages/react-components/react-windmod-preview/library/config/tests.cjs create mode 100644 packages/react-components/react-windmod-preview/library/docs/Spec.md create mode 100644 packages/react-components/react-windmod-preview/library/eslint.config.cjs create mode 100644 packages/react-components/react-windmod-preview/library/etc/button.api.md create mode 100644 packages/react-components/react-windmod-preview/library/etc/provider.api.md create mode 100644 packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md create mode 100644 packages/react-components/react-windmod-preview/library/etc/tooltip.api.md create mode 100644 packages/react-components/react-windmod-preview/library/jest.config.cjs create mode 100644 packages/react-components/react-windmod-preview/library/package.json create mode 100644 packages/react-components/react-windmod-preview/library/project.json create mode 100644 packages/react-components/react-windmod-preview/library/src/button.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Button/Button.tsx create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Button/Button.types.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Button/index.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Button/useButtonStyles.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Provider/ThemeProvider.module.css create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Provider/ThemeProvider.tsx create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Provider/ThemeProvider.types.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Provider/index.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Tooltip/Tooltip.module.css create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Tooltip/Tooltip.tsx create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Tooltip/Tooltip.types.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Tooltip/index.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Tooltip/useTooltipStyles.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/index.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/provider.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/testing/isConformant.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/tooltip.ts create mode 100644 packages/react-components/react-windmod-preview/library/tsconfig.json create mode 100644 packages/react-components/react-windmod-preview/library/tsconfig.lib.json create mode 100644 packages/react-components/react-windmod-preview/library/tsconfig.spec.json create mode 100644 packages/react-components/react-windmod-preview/stories/.storybook/main.cjs create mode 100644 packages/react-components/react-windmod-preview/stories/.storybook/preview.js create mode 100644 packages/react-components/react-windmod-preview/stories/.storybook/tsconfig.json create mode 100644 packages/react-components/react-windmod-preview/stories/README.md create mode 100644 packages/react-components/react-windmod-preview/stories/eslint.config.js create mode 100644 packages/react-components/react-windmod-preview/stories/package.json create mode 100644 packages/react-components/react-windmod-preview/stories/project.json create mode 100644 packages/react-components/react-windmod-preview/stories/src/.gitkeep create mode 100644 packages/react-components/react-windmod-preview/stories/src/index.ts create mode 100644 packages/react-components/react-windmod-preview/stories/tsconfig.json create mode 100644 packages/react-components/react-windmod-preview/stories/tsconfig.lib.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ca6ae6b05d5750..39ce39d588f5d1 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -334,6 +334,8 @@ packages/react-components/react-menu-grid-preview/library @microsoft/teams-prg packages/react-components/react-menu-grid-preview/stories @microsoft/teams-prg packages/react-components/react-headless-components-preview/library @microsoft/cxe-prg packages/react-components/react-headless-components-preview/stories @microsoft/cxe-prg +packages/react-components/react-windmod-preview/library @microsoft/cxe-prg +packages/react-components/react-windmod-preview/stories @microsoft/cxe-prg # <%= NX-CODEOWNER-PLACEHOLDER %> # Deprecated v9 packages - exposed as part of `/unstable` api diff --git a/packages/react-components/react-tailwind-theme-preview/package.json b/packages/react-components/react-tailwind-theme-preview/package.json index 22310bf161bb91..71ad216f2c0a3c 100644 --- a/packages/react-components/react-tailwind-theme-preview/package.json +++ b/packages/react-components/react-tailwind-theme-preview/package.json @@ -29,6 +29,7 @@ "./styles.css": "./dist/styles.css", "./theme-class-names": { "types": "./theme-class-names.d.mts", + "require": "./theme-class-names.cjs", "default": "./theme-class-names.mjs" }, "./css/*": "./css/*", @@ -39,6 +40,7 @@ "css", "dist/styles.css", "theme-class-names.mjs", + "theme-class-names.cjs", "theme-class-names.d.mts" ], "sideEffects": [ diff --git a/packages/react-components/react-tailwind-theme-preview/theme-class-names.cjs b/packages/react-components/react-tailwind-theme-preview/theme-class-names.cjs new file mode 100644 index 00000000000000..888e3781129870 --- /dev/null +++ b/packages/react-components/react-tailwind-theme-preview/theme-class-names.cjs @@ -0,0 +1,35 @@ +/** + * CommonJS twin of theme-class-names.mjs — keep the two files' values identical. + * The lockstep generator asserts against the .mjs; this file exists so `require()` + * consumers (e.g. react-windmod-preview's lib-commonjs build) can load the constants. + */ +'use strict'; + +const webLightThemeClassName = 'fui-theme-web-light'; +const webDarkThemeClassName = 'fui-theme-web-dark'; +const teamsLightThemeClassName = 'fui-theme-teams-light'; +const teamsDarkThemeClassName = 'fui-theme-teams-dark'; +const teamsHighContrastThemeClassName = 'fui-theme-teams-high-contrast'; +const teamsLightV21ThemeClassName = 'fui-theme-teams-light-v21'; +const teamsDarkV21ThemeClassName = 'fui-theme-teams-dark-v21'; + +const themeClassNames = { + webLightTheme: webLightThemeClassName, + webDarkTheme: webDarkThemeClassName, + teamsLightTheme: teamsLightThemeClassName, + teamsDarkTheme: teamsDarkThemeClassName, + teamsHighContrastTheme: teamsHighContrastThemeClassName, + teamsLightV21Theme: teamsLightV21ThemeClassName, + teamsDarkV21Theme: teamsDarkV21ThemeClassName, +}; + +module.exports = { + webLightThemeClassName, + webDarkThemeClassName, + teamsLightThemeClassName, + teamsDarkThemeClassName, + teamsHighContrastThemeClassName, + teamsLightV21ThemeClassName, + teamsDarkV21ThemeClassName, + themeClassNames, +}; diff --git a/packages/react-components/react-windmod-preview/library/.babelrc.json b/packages/react-components/react-windmod-preview/library/.babelrc.json new file mode 100644 index 00000000000000..630deaf765c49f --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/.babelrc.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../../.babelrc-v9.json", + "plugins": ["annotate-pure-calls", "@babel/transform-react-pure-annotations"] +} diff --git a/packages/react-components/react-windmod-preview/library/.swcrc b/packages/react-components/react-windmod-preview/library/.swcrc new file mode 100644 index 00000000000000..67f33cb59414a3 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/.swcrc @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/swcrc", + "exclude": [ + "/testing", + "/**/*.cy.ts", + "/**/*.cy.tsx", + "/**/*.spec.ts", + "/**/*.spec.tsx", + "/**/*.test.ts", + "/**/*.test.tsx" + ], + "jsc": { + "baseUrl": ".", + "parser": { + "syntax": "typescript", + "tsx": true, + "decorators": false, + "dynamicImport": false + }, + "externalHelpers": true, + "transform": { + "react": { + "runtime": "classic", + "useSpread": true + } + }, + "target": "es2022" + }, + "minify": false, + "sourceMaps": true +} diff --git a/packages/react-components/react-windmod-preview/library/LICENSE b/packages/react-components/react-windmod-preview/library/LICENSE new file mode 100644 index 00000000000000..edddda279ff671 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/LICENSE @@ -0,0 +1,15 @@ +@fluentui/react-windmod-preview + +Copyright (c) Microsoft Corporation + +All rights reserved. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the ""Software""), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED _AS IS_, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Note: Usage of the fonts and icons referenced in Fluent UI React is subject to the terms listed at https://aka.ms/fluentui-assets-license diff --git a/packages/react-components/react-windmod-preview/library/README.md b/packages/react-components/react-windmod-preview/library/README.md new file mode 100644 index 00000000000000..b3d3f5fea075a1 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/README.md @@ -0,0 +1,5 @@ +# @fluentui/react-windmod-preview + +**React Windmod components for [Fluent UI React](https://react.fluentui.dev/)** + +These are not production-ready components and **should never be used in product**. This space is useful for testing new components whose APIs might change before final release. diff --git a/packages/react-components/react-windmod-preview/library/config/api-extractor.json b/packages/react-components/react-windmod-preview/library/config/api-extractor.json new file mode 100644 index 00000000000000..8d482156d10d53 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/config/api-extractor.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "extends": "@fluentui/scripts-api-extractor/api-extractor.common.v-next.json", + "mainEntryPointFilePath": "/../../../../../../dist/out-tsc/types/packages/react-components//library/src/index.d.ts" +} diff --git a/packages/react-components/react-windmod-preview/library/config/tests.cjs b/packages/react-components/react-windmod-preview/library/config/tests.cjs new file mode 100644 index 00000000000000..2e211ae9e21420 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/config/tests.cjs @@ -0,0 +1 @@ +/** Jest test setup file. */ diff --git a/packages/react-components/react-windmod-preview/library/docs/Spec.md b/packages/react-components/react-windmod-preview/library/docs/Spec.md new file mode 100644 index 00000000000000..72820f4ea8052b --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/docs/Spec.md @@ -0,0 +1,63 @@ +# @fluentui/react-windmod-preview Spec + +## Background + +_Description and use cases of this component_ + +## Prior Art + +_Include background research done for this component_ + +- _Link to Open UI research_ +- _Link to comparison of v7 and v0_ +- _Link to GitHub epic issue for the converged component_ + +## Sample Code + +_Provide some representative example code that uses the proposed API for the component_ + +## Variants + +_Describe visual or functional variants of this control, if applicable. For example, a slider could have a 2D variant._ + +## API + +_List the **Props** and **Slots** proposed for the component. Ideally this would just be a link to the component's `.types.ts` file_ + +## Structure + +- _**Public**_ +- _**Internal**_ +- _**DOM** - how the component will be rendered as HTML elements_ + +## Migration + +_Describe what will need to be done to upgrade from the existing implementations:_ + +- _Migration from v8_ +- _Migration from v0_ + +## Behaviors + +_Explain how the component will behave in use, including:_ + +- _Component States_ +- _Interaction_ + - _Keyboard_ + - _Cursor_ + - _Touch_ + - _Screen readers_ + +## Accessibility + +Base accessibility information is included in the design document. After the spec is filled and review, outcomes from it need to be communicated to design and incorporated in the design document. + +- Decide whether to use **native element** or folow **ARIA** and provide reasons +- Identify the **[ARIA](https://www.w3.org/TR/wai-aria-practices-1.2/) pattern** and, if the component is listed there, follow its specification as possible. +- Identify accessibility **variants**, the `role` ([ARIA roles](https://www.w3.org/TR/wai-aria-1.1/#role_definitions)) of the component, its `slots` and `aria-*` props. +- Describe the **keyboard navigation**: Tab Oder and Arrow Key Navigation. Describe any other keyboard **shortcuts** used +- Specify texts for **state change announcements** - [ARIA live regions + ](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions) (number of available items in dropdown, error messages, confirmations, ...) +- Identify UI parts that appear on **hover or focus** and specify keyboard and screen reader interaction with them +- List cases when **focus** needs to be **trapped** in sections of the UI (for dialogs and popups or for hierarchical navigation) +- List cases when **focus** needs to be **moved programatically** (if parts of the UI are appearing/disappearing or other cases) diff --git a/packages/react-components/react-windmod-preview/library/eslint.config.cjs b/packages/react-components/react-windmod-preview/library/eslint.config.cjs new file mode 100644 index 00000000000000..ec2e7cb1fc479f --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/eslint.config.cjs @@ -0,0 +1,5 @@ +// @ts-check + +const fluentPlugin = require('@fluentui/eslint-plugin'); + +module.exports = [...fluentPlugin.configs['flat/react']]; diff --git a/packages/react-components/react-windmod-preview/library/etc/button.api.md b/packages/react-components/react-windmod-preview/library/etc/button.api.md new file mode 100644 index 00000000000000..58625a8d578e5c --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/etc/button.api.md @@ -0,0 +1,52 @@ +## API Report File for "@fluentui/react-windmod-preview" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { ButtonProps as ButtonProps_2 } from '@fluentui/react-headless-components-preview/button'; +import { ButtonSlots } from '@fluentui/react-headless-components-preview/button'; +import type { ButtonState as ButtonState_2 } from '@fluentui/react-headless-components-preview/button'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { renderButton } from '@fluentui/react-headless-components-preview/button'; +import { useButton } from '@fluentui/react-headless-components-preview/button'; + +// @public +export const Button: ForwardRefComponent; + +// @public +export type ButtonAppearance = 'secondary' | 'primary' | 'outline' | 'subtle' | 'transparent'; + +// @public +export const buttonClassNames: { + root: string; +}; + +// @public +export type ButtonProps = ButtonProps_2 & { + appearance?: ButtonAppearance; + shape?: ButtonShape; + size?: ButtonSize; +}; + +// @public +export type ButtonShape = 'rounded' | 'circular' | 'square'; + +// @public +export type ButtonSize = 'small' | 'medium' | 'large'; + +export { ButtonSlots } + +// @public +export type ButtonState = ButtonState_2 & Required>; + +export { renderButton } + +export { useButton } + +// @public +export const useButtonStyles: (state: ButtonState) => ButtonState; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-windmod-preview/library/etc/provider.api.md b/packages/react-components/react-windmod-preview/library/etc/provider.api.md new file mode 100644 index 00000000000000..777b9d4ac01a2d --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/etc/provider.api.md @@ -0,0 +1,61 @@ +## API Report File for "@fluentui/react-windmod-preview" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { Provider } from '@fluentui/react-headless-components-preview/provider'; +import * as React_2 from 'react'; +import { renderProvider } from '@fluentui/react-headless-components-preview/provider'; +import { teamsDarkThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; +import { teamsDarkV21ThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; +import { teamsHighContrastThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; +import { teamsLightThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; +import { teamsLightV21ThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; +import { ThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; +import { themeClassNames } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; +import { useProvider } from '@fluentui/react-headless-components-preview/provider'; +import { webDarkThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; +import { webLightThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; + +export { Provider } + +export { renderProvider } + +export { teamsDarkThemeClassName } + +export { teamsDarkV21ThemeClassName } + +export { teamsHighContrastThemeClassName } + +export { teamsLightThemeClassName } + +export { teamsLightV21ThemeClassName } + +export { ThemeClassName } + +export { themeClassNames } + +// @public +export const ThemeProvider: React_2.ForwardRefExoticComponent & { + theme?: ThemeClassName | (string & {}); + dir?: "ltr" | "rtl"; + targetDocument?: Document; +} & React_2.RefAttributes>; + +// @public (undocumented) +export type ThemeProviderProps = React_2.HTMLAttributes & { + theme?: ThemeClassName | (string & {}); + dir?: 'ltr' | 'rtl'; + targetDocument?: Document; +}; + +export { useProvider } + +export { webDarkThemeClassName } + +export { webLightThemeClassName } + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md b/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md new file mode 100644 index 00000000000000..7c131ffcf40ee4 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md @@ -0,0 +1,148 @@ +## API Report File for "@fluentui/react-windmod-preview" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { ButtonProps as ButtonProps_2 } from '@fluentui/react-headless-components-preview/button'; +import { ButtonSlots } from '@fluentui/react-headless-components-preview/button'; +import type { ButtonState as ButtonState_2 } from '@fluentui/react-headless-components-preview/button'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { JSXElement } from '@fluentui/react-utilities'; +import { OnVisibleChangeData } from '@fluentui/react-headless-components-preview/tooltip'; +import { Provider } from '@fluentui/react-headless-components-preview/provider'; +import * as React_2 from 'react'; +import { renderButton } from '@fluentui/react-headless-components-preview/button'; +import { renderProvider } from '@fluentui/react-headless-components-preview/provider'; +import { renderTooltip } from '@fluentui/react-headless-components-preview/tooltip'; +import { teamsDarkThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; +import { teamsDarkV21ThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; +import { teamsHighContrastThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; +import { teamsLightThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; +import { teamsLightV21ThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; +import { ThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; +import { themeClassNames } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; +import type { TooltipProps as TooltipProps_2 } from '@fluentui/react-headless-components-preview/tooltip'; +import { TooltipSlots } from '@fluentui/react-headless-components-preview/tooltip'; +import type { TooltipState as TooltipState_2 } from '@fluentui/react-headless-components-preview/tooltip'; +import { TooltipTriggerProps } from '@fluentui/react-headless-components-preview/tooltip'; +import { useButton } from '@fluentui/react-headless-components-preview/button'; +import { useProvider } from '@fluentui/react-headless-components-preview/provider'; +import { useTooltip } from '@fluentui/react-headless-components-preview/tooltip'; +import { webDarkThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; +import { webLightThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; + +// @public +export const Button: ForwardRefComponent; + +// @public +export type ButtonAppearance = 'secondary' | 'primary' | 'outline' | 'subtle' | 'transparent'; + +// @public +export const buttonClassNames: { + root: string; +}; + +// @public +export type ButtonProps = ButtonProps_2 & { + appearance?: ButtonAppearance; + shape?: ButtonShape; + size?: ButtonSize; +}; + +// @public +export type ButtonShape = 'rounded' | 'circular' | 'square'; + +// @public +export type ButtonSize = 'small' | 'medium' | 'large'; + +export { ButtonSlots } + +// @public +export type ButtonState = ButtonState_2 & Required>; + +export { OnVisibleChangeData } + +export { Provider } + +export { renderButton } + +export { renderProvider } + +export { renderTooltip } + +export { teamsDarkThemeClassName } + +export { teamsDarkV21ThemeClassName } + +export { teamsHighContrastThemeClassName } + +export { teamsLightThemeClassName } + +export { teamsLightV21ThemeClassName } + +export { ThemeClassName } + +export { themeClassNames } + +// @public +export const ThemeProvider: React_2.ForwardRefExoticComponent & { + theme?: ThemeClassName | (string & {}); + dir?: "ltr" | "rtl"; + targetDocument?: Document; +} & React_2.RefAttributes>; + +// @public (undocumented) +export type ThemeProviderProps = React_2.HTMLAttributes & { + theme?: ThemeClassName | (string & {}); + dir?: 'ltr' | 'rtl'; + targetDocument?: Document; +}; + +// @public +export const Tooltip: { + (props: TooltipProps): JSXElement; + displayName: string; +}; + +// @public +export type TooltipAppearance = 'normal' | 'inverted'; + +// @public +export const tooltipClassNames: { + root: string; +}; + +// @public +export type TooltipProps = TooltipProps_2 & { + appearance?: TooltipAppearance; +}; + +export { TooltipSlots } + +// @public +export type TooltipState = TooltipState_2 & { + appearance: TooltipAppearance; +}; + +export { TooltipTriggerProps } + +export { useButton } + +// @public +export const useButtonStyles: (state: ButtonState) => ButtonState; + +export { useProvider } + +export { useTooltip } + +// @public +export const useTooltipStyles: (state: TooltipState) => TooltipState; + +export { webDarkThemeClassName } + +export { webLightThemeClassName } + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-windmod-preview/library/etc/tooltip.api.md b/packages/react-components/react-windmod-preview/library/etc/tooltip.api.md new file mode 100644 index 00000000000000..585c00fcdfccea --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/etc/tooltip.api.md @@ -0,0 +1,55 @@ +## API Report File for "@fluentui/react-windmod-preview" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { JSXElement } from '@fluentui/react-utilities'; +import { OnVisibleChangeData } from '@fluentui/react-headless-components-preview/tooltip'; +import { renderTooltip } from '@fluentui/react-headless-components-preview/tooltip'; +import type { TooltipProps as TooltipProps_2 } from '@fluentui/react-headless-components-preview/tooltip'; +import { TooltipSlots } from '@fluentui/react-headless-components-preview/tooltip'; +import type { TooltipState as TooltipState_2 } from '@fluentui/react-headless-components-preview/tooltip'; +import { TooltipTriggerProps } from '@fluentui/react-headless-components-preview/tooltip'; +import { useTooltip } from '@fluentui/react-headless-components-preview/tooltip'; + +export { OnVisibleChangeData } + +export { renderTooltip } + +// @public +export const Tooltip: { + (props: TooltipProps): JSXElement; + displayName: string; +}; + +// @public +export type TooltipAppearance = 'normal' | 'inverted'; + +// @public +export const tooltipClassNames: { + root: string; +}; + +// @public +export type TooltipProps = TooltipProps_2 & { + appearance?: TooltipAppearance; +}; + +export { TooltipSlots } + +// @public +export type TooltipState = TooltipState_2 & { + appearance: TooltipAppearance; +}; + +export { TooltipTriggerProps } + +export { useTooltip } + +// @public +export const useTooltipStyles: (state: TooltipState) => TooltipState; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-windmod-preview/library/jest.config.cjs b/packages/react-components/react-windmod-preview/library/jest.config.cjs new file mode 100644 index 00000000000000..8f38427e186543 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/jest.config.cjs @@ -0,0 +1,34 @@ +// @ts-check +/* eslint-disable */ + +const { readFileSync } = require('node:fs'); +const { join } = require('node:path'); + +// Reading the SWC compilation config and remove the "exclude" +// for the test files to be compiled by SWC +const { exclude: _, ...swcJestConfig } = JSON.parse(readFileSync(join(__dirname, '.swcrc'), 'utf-8')); + +// disable .swcrc look-up by SWC core because we're passing in swcJestConfig ourselves. +// If we do not disable this, SWC Core will read .swcrc and won't transform our test files due to "exclude" +if (swcJestConfig.swcrc === undefined) { + swcJestConfig.swcrc = false; +} + +// Uncomment if using global setup/teardown files being transformed via swc +// https://nx.dev/packages/jest/documents/overview#global-setup/teardown-with-nx-libraries +// jest needs EsModule Interop to find the default exported setup/teardown functions +// swcJestConfig.module.noInterop = false; + +/** + * @type {import('@jest/types').Config.InitialOptions} + */ +module.exports = { + displayName: 'react-windmod-preview', + preset: '../../../../jest.preset.js', + transform: { + '^.+\\.tsx?$': ['@swc/jest', swcJestConfig], + }, + coverageDirectory: './coverage', + setupFilesAfterEnv: ['./config/tests.cjs'], + snapshotSerializers: ['@griffel/jest-serializer'], +}; diff --git a/packages/react-components/react-windmod-preview/library/package.json b/packages/react-components/react-windmod-preview/library/package.json new file mode 100644 index 00000000000000..2333707f16e9f3 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/package.json @@ -0,0 +1,93 @@ +{ + "name": "@fluentui/react-windmod-preview", + "version": "0.0.0", + "private": true, + "description": "Fluent UI React components styled with Tailwind v4 + CSS Modules ('windmod'). Composes @fluentui/react-headless-components-preview hooks (useX → useXStyles → renderX) with build-time-compiled CSS Modules that replicate the Griffel suite's visuals; theming is pure CSS via @fluentui/react-tailwind-theme-preview theme classes applied by ThemeProvider.", + "type": "module", + "main": "lib-commonjs/index.cjs", + "module": "lib/index.js", + "typings": "./dist/index.d.ts", + "sideEffects": [ + "**/*.css" + ], + "files": [ + "*.md", + "dist/*.d.ts", + "dist/*.d.cts", + "dist/styles.css", + "lib", + "lib-commonjs" + ], + "repository": { + "type": "git", + "url": "https://github.com/microsoft/fluentui" + }, + "license": "MIT", + "dependencies": { + "@fluentui/react-headless-components-preview": "^0.2.5", + "@fluentui/react-shared-contexts": "^9.26.3", + "@fluentui/react-tailwind-theme-preview": "^9.0.0-alpha.0", + "@fluentui/react-utilities": "^9.26.6", + "@swc/helpers": "^0.5.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "@types/react": ">=16.14.0 <20.0.0", + "@types/react-dom": ">=16.9.0 <20.0.0", + "react": ">=16.14.0 <20.0.0", + "react-dom": ">=16.14.0 <20.0.0" + }, + "imports": { + "#theme": "@fluentui/react-tailwind-theme-preview/css/index.css" + }, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./lib/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./lib-commonjs/index.cjs" + } + }, + "./button": { + "import": { + "types": "./dist/button.d.ts", + "default": "./lib/button.js" + }, + "require": { + "types": "./dist/button.d.cts", + "default": "./lib-commonjs/button.cjs" + } + }, + "./provider": { + "import": { + "types": "./dist/provider.d.ts", + "default": "./lib/provider.js" + }, + "require": { + "types": "./dist/provider.d.cts", + "default": "./lib-commonjs/provider.cjs" + } + }, + "./tooltip": { + "import": { + "types": "./dist/tooltip.d.ts", + "default": "./lib/tooltip.js" + }, + "require": { + "types": "./dist/tooltip.d.cts", + "default": "./lib-commonjs/tooltip.cjs" + } + }, + "./styles.css": "./dist/styles.css", + "./package.json": "./package.json" + }, + "beachball": { + "disallowedChangeTypes": [ + "major", + "prerelease" + ] + } +} diff --git a/packages/react-components/react-windmod-preview/library/project.json b/packages/react-components/react-windmod-preview/library/project.json new file mode 100644 index 00000000000000..356d899677f01a --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/project.json @@ -0,0 +1,15 @@ +{ + "name": "react-windmod-preview", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "packages/react-components/react-windmod-preview/library/src", + "tags": ["platform:web", "vNext", "react-windmod"], + "implicitDependencies": [], + "targets": { + "generate-api": { + "options": { + "exportSubpaths": true + } + } + } +} diff --git a/packages/react-components/react-windmod-preview/library/src/button.ts b/packages/react-components/react-windmod-preview/library/src/button.ts new file mode 100644 index 00000000000000..24b58550285de2 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/button.ts @@ -0,0 +1,12 @@ +export { Button, buttonClassNames, useButtonStyles } from './components/Button/index'; +export type { + ButtonAppearance, + ButtonProps, + ButtonShape, + ButtonSize, + ButtonSlots, + ButtonState, +} from './components/Button/index'; + +/** Headless building blocks, re-exported for consumers composing their own Button. */ +export { renderButton, useButton } from '@fluentui/react-headless-components-preview/button'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css b/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css new file mode 100644 index 00000000000000..cb980e2e203077 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css @@ -0,0 +1,656 @@ +@reference '#theme'; + +/* Layer order (DECISIONS.md D2) - restated per module so stylesheet load order cannot + decide the ranking; re-declaring an existing layer is a no-op. */ +@layer fui.theme, fui.base, fui.components, fui.components.l1, fui.components.l2, fui.components.l3, fui.components.l4, fui.components.l5, fui.utilities; + +@layer fui.base { + .root { + @apply m-horizontal-none box-border inline-flex min-w-96 items-center justify-center overflow-hidden rounded-medium bg-neutral-background-1 px-horizontal-m py-5 align-middle font-base text-base-300 leading-base-300 font-semibold text-neutral-foreground-1 no-underline; + + border: var(--spacing-thin) solid var(--color-neutral-stroke-1); + + outline-style: none; + + transition-duration: var(--duration-faster); + /* + * Longhands only. The Griffel source declared `background, border, color`, which is not + * 3 properties: per CSS Transitions L1 a shorthand in `transition-property` expands to + * every longhand it covers, so that list is 21 (8 `background-*` + 12 `border-*` + 1). + * Only these 6 ever change — the base `border: solid ` above is the file's ONLY + * border-width/style declaration and no state or appearance slice touches it, and no + * slice touches any non-color `background-*`. `cursor`, `outline-style` and + * `forced-color-adjust` do change on hover/active but were never in the list and are not + * added: the first two are not animatable/are discrete, and Griffel never transitioned + * them. See reports/transition-audit.md §2.2. + * + * Safe to expand here because `transition-duration` and `transition-timing-function` are + * SINGLE-valued, so they apply to every entry — no positional-list realignment (that is + * the Avatar trap, §2.1, which is why Avatar's `margin` deliberately stays a shorthand). + */ + transition-property: background-color, border-top-color, border-right-color, border-bottom-color, border-left-color, + color; + transition-timing-function: var(--ease-easy-ease); + + @variant hover { + @apply cursor-pointer border-neutral-stroke-1-hover bg-neutral-background-1-hover text-neutral-foreground-1-hover; + } + + @variant hover-active { + @apply border-neutral-stroke-1-pressed bg-neutral-background-1-pressed text-neutral-foreground-1-pressed; + + outline-style: none; + } + + @media screen and (prefers-reduced-motion: reduce) { + transition-duration: 0.01ms; + } + + /* High contrast styles */ + @variant forced-colors { + @variant focus { + border-color: ButtonText; + } + + @variant hover { + background-color: HighlightText; + border-color: Highlight; + color: Highlight; + forced-color-adjust: none; + } + + @variant hover-active { + background-color: HighlightText; + border-color: Highlight; + color: Highlight; + forced-color-adjust: none; + } + } + + @variant focus-visible-fui { + @apply fui-focus-ring; + } + + /* BUGFIX: Mozilla specific styles (Mozilla BugID: 1857642) */ + @supports (-moz-appearance: button) { + @variant focus-visible-fui { + box-shadow: 0 0 0 calc(var(--spacing-thin) + 0.25px) var(--color-stroke-focus-2) inset; + } + } + } + + .icon { + @apply inline-flex size-20 items-center justify-center; + + /* `--base-scale` keeps the glyph and its 20px box scaling together (D4); at the + default 16px root this is exactly `font-size: 20px`, the compiled value. */ + font-size: calc(20px * var(--base-scale)); + + --fui-Button__icon--spacing: calc(6px * var(--base-scale)); + } +} + +@layer fui.components.l1 { + .root { + @variant not-empty { + @variant icon-before { + & .icon { + margin-inline-end: var(--fui-Button__icon--spacing); + } + } + + @variant icon-after { + & .icon { + margin-inline-start: var(--fui-Button__icon--spacing); + } + } + } + } +} + +@layer fui.components.l1 { + .outline { + @apply bg-transparent-background; + + @variant hover { + @apply bg-transparent-background-hover; + } + + @variant hover-active { + @apply bg-transparent-background-pressed; + } + } + + .primary { + @apply bg-brand-background text-neutral-foreground-on-brand; + + border-top-color: transparent; + border-right-color: transparent; + border-bottom-color: transparent; + border-left-color: transparent; + + @variant hover { + @apply bg-brand-background-hover text-neutral-foreground-on-brand; + + border-top-color: transparent; + border-right-color: transparent; + border-bottom-color: transparent; + border-left-color: transparent; + } + + @variant hover-active { + @apply bg-brand-background-pressed text-neutral-foreground-on-brand; + + border-top-color: transparent; + border-right-color: transparent; + border-bottom-color: transparent; + border-left-color: transparent; + } + + @variant forced-colors { + background-color: Highlight; + border-top-color: HighlightText; + border-right-color: HighlightText; + border-bottom-color: HighlightText; + border-left-color: HighlightText; + color: HighlightText; + forced-color-adjust: none; + + @variant hover { + background-color: HighlightText; + border-top-color: Highlight; + border-right-color: Highlight; + border-bottom-color: Highlight; + border-left-color: Highlight; + color: Highlight; + } + + @variant hover-active { + background-color: HighlightText; + border-top-color: Highlight; + border-right-color: Highlight; + border-bottom-color: Highlight; + border-left-color: Highlight; + color: Highlight; + } + } + } + + .subtle { + @apply bg-subtle-background text-neutral-foreground-2; + + border-top-color: transparent; + border-right-color: transparent; + border-bottom-color: transparent; + border-left-color: transparent; + + @variant hover { + @apply bg-subtle-background-hover text-neutral-foreground-2-hover; + + border-top-color: transparent; + border-right-color: transparent; + border-bottom-color: transparent; + border-left-color: transparent; + + /* The `.fui-Icon-filled` / `.fui-Icon-regular` swap that belongs here lives in the + l1 icon-swap block at the bottom of this file (D27 / S-J batch 1). `.icon` below is + this module's own local on react-button's icon slot, so its colour rule stays here. */ + + & .icon { + @apply text-neutral-foreground-2-brand-hover; + } + } + + @variant hover-active { + @apply bg-subtle-background-pressed text-neutral-foreground-2-pressed; + + border-top-color: transparent; + border-right-color: transparent; + border-bottom-color: transparent; + border-left-color: transparent; + + /* Icon swap in the l1 block at the bottom of this file (D27 / S-J batch 1). `.icon` + is this module's own local and its colour rule stays here. */ + + & .icon { + @apply text-neutral-foreground-2-brand-pressed; + } + } + + @variant forced-colors { + @variant hover { + color: Highlight; + + & .icon { + color: Highlight; + } + } + + @variant hover-active { + color: Highlight; + + & .icon { + color: Highlight; + } + } + } + } + + .transparent { + @apply bg-transparent-background text-neutral-foreground-2; + + border-top-color: transparent; + border-right-color: transparent; + border-bottom-color: transparent; + border-left-color: transparent; + + @variant hover { + @apply bg-transparent-background-hover text-neutral-foreground-2-brand-hover; + + border-top-color: transparent; + border-right-color: transparent; + border-bottom-color: transparent; + border-left-color: transparent; + + /* Icon swap in the l1 block at the bottom of this file (D27 / S-J batch 1). */ + } + + @variant hover-active { + @apply bg-transparent-background-pressed text-neutral-foreground-2-brand-pressed; + + border-top-color: transparent; + border-right-color: transparent; + border-bottom-color: transparent; + border-left-color: transparent; + + /* Icon swap in the l1 block at the bottom of this file (D27 / S-J batch 1). */ + } + + @variant forced-colors { + @variant hover { + @apply bg-transparent-background; + + color: Highlight; + } + + @variant hover-active { + @apply bg-transparent-background; + + color: Highlight; + } + } + } +} + +@layer fui.components.l1 { + .root { + @variant size-small { + @apply min-w-64 rounded-medium px-horizontal-s py-3 text-base-200 leading-base-200 font-regular; + + /* `padding: buttonSpacingSmall(3px) tokens.spacingHorizontalS`; 3px and 64px match no step */ + } + + @variant size-large { + @apply min-w-96 rounded-medium px-horizontal-l py-vertical-s text-base-400 leading-base-400 font-semibold; + } + + /* from useIconStyles.small — icon slot arg #4, keyed off the root's data-size */ + @variant size-small { + & .icon { + @apply size-20; + + font-size: calc(20px * var(--base-scale)); + + /* tokens.spacingHorizontalXS — custom property, so D4's literal form (see the reset) */ + --fui-Button__icon--spacing: calc(4px * var(--base-scale)); + } + } + + @variant size-large { + & .icon { + @apply size-24; + + font-size: calc(24px * var(--base-scale)); + + /* tokens.spacingHorizontalSNudge — custom property, so D4's literal form (see the reset) */ + --fui-Button__icon--spacing: calc(6px * var(--base-scale)); + } + } + + @variant disabled-any { + @apply cursor-not-allowed border-t-neutral-stroke-disabled border-r-neutral-stroke-disabled border-b-neutral-stroke-disabled border-l-neutral-stroke-disabled bg-neutral-background-disabled text-neutral-foreground-disabled; + + & .icon { + @apply text-neutral-foreground-disabled; + } + + @variant hover { + @apply cursor-not-allowed border-t-neutral-stroke-disabled border-r-neutral-stroke-disabled border-b-neutral-stroke-disabled border-l-neutral-stroke-disabled bg-neutral-background-disabled text-neutral-foreground-disabled; + + /* The icon swap-BACK that belongs here is in the l1 icon-swap block at the bottom of + this file, written LAST there so it still beats the appearance swaps (D27 / S-J + batch 1). */ + + & .icon { + @apply text-neutral-foreground-disabled; + } + } + + @variant hover-active { + @apply cursor-not-allowed border-t-neutral-stroke-disabled border-r-neutral-stroke-disabled border-b-neutral-stroke-disabled border-l-neutral-stroke-disabled bg-neutral-background-disabled text-neutral-foreground-disabled; + + /* The icon swap-BACK that belongs here is in the l1 icon-swap block at the bottom of + this file, written LAST there so it still beats the appearance swaps (D27 / S-J + batch 1). */ + + & .icon { + @apply text-neutral-foreground-disabled; + } + } + + @variant forced-colors { + background-color: ButtonFace; + border-top-color: GrayText; + border-right-color: GrayText; + border-bottom-color: GrayText; + border-left-color: GrayText; + color: GrayText; + + & .icon { + color: GrayText; + } + + @variant focus { + border-top-color: GrayText; + border-right-color: GrayText; + border-bottom-color: GrayText; + border-left-color: GrayText; + } + + @variant hover { + background-color: ButtonFace; + border-top-color: GrayText; + border-right-color: GrayText; + border-bottom-color: GrayText; + border-left-color: GrayText; + color: GrayText; + + & .icon { + color: GrayText; + } + } + + @variant hover-active { + background-color: ButtonFace; + border-top-color: GrayText; + border-right-color: GrayText; + border-bottom-color: GrayText; + border-left-color: GrayText; + color: GrayText; + + & .icon { + color: GrayText; + } + } + } + } + + @variant focus-visible-fui { + @variant size-small { + @apply rounded-small; + } + + @variant size-large { + @apply rounded-large; + } + } + } + + .outline { + @variant disabled-any { + @apply bg-transparent-background; + + @variant hover { + @apply bg-transparent-background; + } + + @variant hover-active { + @apply bg-transparent-background; + } + } + } + + .primary { + @variant disabled-any { + border-top-color: transparent; + border-right-color: transparent; + border-bottom-color: transparent; + border-left-color: transparent; + + @variant hover { + border-top-color: transparent; + border-right-color: transparent; + border-bottom-color: transparent; + border-left-color: transparent; + } + + @variant hover-active { + border-top-color: transparent; + border-right-color: transparent; + border-bottom-color: transparent; + border-left-color: transparent; + } + } + + /* + * from useRootFocusStyles.primary — arg #11. Applied only for + * `appearance === 'primary'`, i.e. exactly when this class is on the element. + */ + @variant focus-visible-fui { + @apply border-t-stroke-focus-2 border-r-stroke-focus-2 border-b-stroke-focus-2 border-l-stroke-focus-2; + + box-shadow: var(--shadow-2), 0 0 0 var(--spacing-thin) var(--color-stroke-focus-2) inset, + 0 0 0 var(--spacing-thick) var(--color-neutral-foreground-on-brand) inset; + + @variant hover { + @apply border-t-stroke-focus-2 border-r-stroke-focus-2 border-b-stroke-focus-2 border-l-stroke-focus-2; + + box-shadow: var(--shadow-2), 0 0 0 var(--spacing-thin) var(--color-stroke-focus-2) inset; + } + } + + /* BUGFIX: Mozilla specific styles (Mozilla BugID: 1857642) */ + @supports (-moz-appearance: button) { + @variant focus-visible-fui { + box-shadow: var(--shadow-2), 0 0 0 calc(var(--spacing-thin) + 0.25px) var(--color-stroke-focus-2) inset, + 0 0 0 var(--spacing-thick) var(--color-neutral-foreground-on-brand) inset; + + @variant hover { + box-shadow: var(--shadow-2), 0 0 0 calc(var(--spacing-thin) + 0.25px) var(--color-stroke-focus-2) inset; + } + } + } + } + + .subtle { + @variant disabled-any { + @apply bg-transparent-background; + + border-top-color: transparent; + border-right-color: transparent; + border-bottom-color: transparent; + border-left-color: transparent; + + @variant hover { + @apply bg-transparent-background; + + border-top-color: transparent; + border-right-color: transparent; + border-bottom-color: transparent; + border-left-color: transparent; + } + + @variant hover-active { + @apply bg-transparent-background; + + border-top-color: transparent; + border-right-color: transparent; + border-bottom-color: transparent; + border-left-color: transparent; + } + } + } + + .transparent { + @variant disabled-any { + @apply bg-transparent-background; + + border-top-color: transparent; + border-right-color: transparent; + border-bottom-color: transparent; + border-left-color: transparent; + + @variant hover { + @apply bg-transparent-background; + + border-top-color: transparent; + border-right-color: transparent; + border-bottom-color: transparent; + border-left-color: transparent; + } + + @variant hover-active { + @apply bg-transparent-background; + + border-top-color: transparent; + border-right-color: transparent; + border-bottom-color: transparent; + border-left-color: transparent; + } + } + } +} + +@layer fui.components.l1 { + .root { + @variant with-icon { + @variant size-small { + @apply py-1; + } + + @variant size-large { + @apply py-7; + } + } + } + + .circular { + @apply rounded-circular; + + @variant focus-visible-fui { + @apply rounded-circular; + } + } + + .square { + @apply rounded-none; + + @variant focus-visible-fui { + @apply rounded-none; + } + } + + .root { + @variant icon-only { + @variant size-small { + @apply max-w-24 min-w-24 p-1; + } + + @variant size-medium { + @apply max-w-32 min-w-32 p-5; + } + + @variant size-large { + @apply max-w-40 min-w-40 p-7; + } + } + } +} + +/* + * - Within each appearance, `hover` is written before `hover-active` — Griffel separated them by specificity (0-2-0 vs 0-3-0), `:where()` does not, so pressed must come second to win. + */ + +@layer fui.components.l1 { + .subtle { + @variant hover { + & :global(.fui-Icon-filled) { + @apply inline; + } + + & :global(.fui-Icon-regular) { + @apply hidden; + } + } + + @variant hover-active { + & :global(.fui-Icon-filled) { + @apply inline; + } + + & :global(.fui-Icon-regular) { + @apply hidden; + } + } + } + + .transparent { + @variant hover { + & :global(.fui-Icon-filled) { + @apply inline; + } + + & :global(.fui-Icon-regular) { + @apply hidden; + } + } + + @variant hover-active { + & :global(.fui-Icon-filled) { + @apply inline; + } + + & :global(.fui-Icon-regular) { + @apply hidden; + } + } + } + + /* + * from useRootDisabledStyles.base — arg #8. LAST in the file because arg #8 outranks the + * appearance args above; this is the swap-BACK that keeps a disabled button's glyph static. + */ + .root { + @variant disabled-any { + @variant hover { + & :global(.fui-Icon-filled) { + @apply hidden; + } + + & :global(.fui-Icon-regular) { + @apply inline; + } + } + + @variant hover-active { + & :global(.fui-Icon-filled) { + @apply hidden; + } + + & :global(.fui-Icon-regular) { + @apply inline; + } + } + } + } +} diff --git a/packages/react-components/react-windmod-preview/library/src/components/Button/Button.tsx b/packages/react-components/react-windmod-preview/library/src/components/Button/Button.tsx new file mode 100644 index 00000000000000..ed856025f66181 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Button/Button.tsx @@ -0,0 +1,32 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { renderButton, useButton } from '@fluentui/react-headless-components-preview/button'; + +import type { ButtonProps, ButtonState } from './Button.types'; +import { useButtonStyles } from './useButtonStyles'; + +/** + * Buttons give people a way to trigger an action. Windmod Button: the headless button + * decorated with the Fluent visual contract (Tailwind v4 + CSS Modules). + */ +export const Button: ForwardRefComponent = React.forwardRef((props, ref) => { + // Look props belong to windmod — the headless hook neither accepts nor resolves them. + // Defaults mirror @fluentui/react-button's styled useButton. + const { appearance = 'secondary', shape = 'rounded', size = 'medium', ...rest } = props; + + const state: ButtonState = { + ...useButton(rest, ref), + appearance, + shape, + size, + }; + + useButtonStyles(state); + + return renderButton(state); + // Casting is required due to lack of distributive union to support union on @types/react +}) as ForwardRefComponent; + +Button.displayName = 'Button'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Button/Button.types.ts b/packages/react-components/react-windmod-preview/library/src/components/Button/Button.types.ts new file mode 100644 index 00000000000000..cce3c022d6e3e4 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Button/Button.types.ts @@ -0,0 +1,31 @@ +import type { + ButtonProps as ButtonHeadlessProps, + ButtonState as ButtonHeadlessState, +} from '@fluentui/react-headless-components-preview/button'; + +export type { ButtonSlots } from '@fluentui/react-headless-components-preview/button'; + +/** Visual style of the Button. `'secondary'` is the base look. */ +export type ButtonAppearance = 'secondary' | 'primary' | 'outline' | 'subtle' | 'transparent'; + +/** Corner treatment of the Button. `'rounded'` is the base look. */ +export type ButtonShape = 'rounded' | 'circular' | 'square'; + +/** Size of the Button. */ +export type ButtonSize = 'small' | 'medium' | 'large'; + +/** + * Windmod Button props: the headless button plus the look props the headless surface + * deliberately omits (they exist purely to select styles). + */ +export type ButtonProps = ButtonHeadlessProps & { + /** @default 'secondary' */ + appearance?: ButtonAppearance; + /** @default 'rounded' */ + shape?: ButtonShape; + /** @default 'medium' */ + size?: ButtonSize; +}; + +/** Windmod Button state: headless state plus the resolved look props. */ +export type ButtonState = ButtonHeadlessState & Required>; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Button/index.ts b/packages/react-components/react-windmod-preview/library/src/components/Button/index.ts new file mode 100644 index 00000000000000..44d3852627b069 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Button/index.ts @@ -0,0 +1,3 @@ +export { Button } from './Button'; +export { buttonClassNames, useButtonStyles } from './useButtonStyles'; +export type { ButtonAppearance, ButtonProps, ButtonShape, ButtonSize, ButtonSlots, ButtonState } from './Button.types'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Button/useButtonStyles.ts b/packages/react-components/react-windmod-preview/library/src/components/Button/useButtonStyles.ts new file mode 100644 index 00000000000000..2df0509564b733 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Button/useButtonStyles.ts @@ -0,0 +1,54 @@ +'use client'; + +import { clsx } from 'clsx'; + +import type { ButtonState } from './Button.types'; + +import styles from './Button.module.css'; + +/** + * Public identity class for Button — the Tailwind named-group marker. It is the ONLY + * public class; every other class name is a hashed CSS Modules ident. Style descendants + * with `group-*\/fui-button` variants (or `[class~='group/fui-button']` in plain CSS) + * rather than targeting internals. + */ +export const buttonClassNames: { root: string } = { + root: 'group/fui-button', +}; + +type ButtonRootDataAttributes = { + 'data-size'?: ButtonState['size']; + 'data-empty'?: true; +}; + +/** + * Applies the Fluent visual contract to the headless Button state. + * + * `data-disabled`, `data-disabled-focusable`, `data-icon-only` and `data-icon-position` + * are already set by the headless `useButton`; the two below are windmod-only styling + * states. + */ +export const useButtonStyles = (state: ButtonState): ButtonState => { + const { appearance, shape } = state; + + const root = state.root as ButtonState['root'] & ButtonRootDataAttributes; + + root['data-size'] = state.size; + root['data-empty'] = !state.root.children || undefined; + + // Module class FIRST (the group marker must never be classList[0] — nwsapi's :scope + // polyfill throws on the `/`), consumer className LAST so consumer overrides win. + state.root.className = clsx( + styles.root, + buttonClassNames.root, + styles[appearance], + styles[shape], + state.root.className, + ); + + if (state.icon) { + state.icon.className = clsx(styles.icon, state.icon.className); + } + + return state; +}; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Provider/ThemeProvider.module.css b/packages/react-components/react-windmod-preview/library/src/components/Provider/ThemeProvider.module.css new file mode 100644 index 00000000000000..3a91474d9ed644 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Provider/ThemeProvider.module.css @@ -0,0 +1,14 @@ +@reference '#theme'; + +/* Layer order — restated per module so stylesheet load order cannot decide the ranking; + re-declaring an existing layer is a no-op. */ +@layer fui.theme, fui.base, fui.components, fui.components.l1, fui.components.l2, fui.components.l3, fui.components.l4, fui.components.l5, fui.utilities; + +@layer fui.base { + /* The provider element must not affect layout: custom properties inherit through the + DOM tree regardless of box generation, so `display: contents` gives the theme class + a carrier without introducing a box (verified to reach open top-layer popovers). */ + .root { + display: contents; + } +} diff --git a/packages/react-components/react-windmod-preview/library/src/components/Provider/ThemeProvider.tsx b/packages/react-components/react-windmod-preview/library/src/components/Provider/ThemeProvider.tsx new file mode 100644 index 00000000000000..0819d6bdb98cce --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Provider/ThemeProvider.tsx @@ -0,0 +1,33 @@ +'use client'; + +import * as React from 'react'; +import { clsx } from 'clsx'; +import { Provider } from '@fluentui/react-headless-components-preview/provider'; + +import type { ThemeProviderProps } from './ThemeProvider.types'; + +import styles from './ThemeProvider.module.css'; + +/** + * Applies a Fluent theme to a subtree by rendering a `display: contents` div carrying + * the theme class — the theme's custom properties cascade from it without introducing a + * layout box, and they reach top-layer surfaces (popover/dialog) opened from inside the + * subtree, since those keep their DOM ancestry. Also composes the headless Provider so + * descendants read `dir`/`targetDocument` from context. + * + * The theme stylesheet itself (`@fluentui/react-tailwind-theme-preview/styles.css`) must + * be imported once per document — this component only selects which theme applies. + */ +export const ThemeProvider = React.forwardRef((props, ref) => { + const { theme, dir, targetDocument, className, children, ...rest } = props; + + return ( + +
+ {children} +
+
+ ); +}); + +ThemeProvider.displayName = 'ThemeProvider'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Provider/ThemeProvider.types.ts b/packages/react-components/react-windmod-preview/library/src/components/Provider/ThemeProvider.types.ts new file mode 100644 index 00000000000000..6540cce5c4322a --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Provider/ThemeProvider.types.ts @@ -0,0 +1,22 @@ +import type * as React from 'react'; +import type { ThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; + +export type { ThemeClassName }; + +export type ThemeProviderProps = React.HTMLAttributes & { + /** + * Theme class to apply to the subtree — one of the constants re-exported from this + * package's `/provider` entry (e.g. `webDarkThemeClassName`). Omit it for the default + * web-light values, which are emitted at `:root, :host` by the theme stylesheet. + * + * Typed as the shipped union but open to any string so consumer-authored theme classes + * (same custom-property contract) work too. + */ + theme?: ThemeClassName | (string & {}); + + /** Text direction for the subtree. Also provided to headless components via context. */ + dir?: 'ltr' | 'rtl'; + + /** The document, for components that need it (portals-free, but e.g. event listeners). */ + targetDocument?: Document; +}; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Provider/index.ts b/packages/react-components/react-windmod-preview/library/src/components/Provider/index.ts new file mode 100644 index 00000000000000..b612d4f6f50b9d --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Provider/index.ts @@ -0,0 +1,2 @@ +export { ThemeProvider } from './ThemeProvider'; +export type { ThemeClassName, ThemeProviderProps } from './ThemeProvider.types'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Tooltip/Tooltip.module.css b/packages/react-components/react-windmod-preview/library/src/components/Tooltip/Tooltip.module.css new file mode 100644 index 00000000000000..d80e8d7e43d3cb --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Tooltip/Tooltip.module.css @@ -0,0 +1,163 @@ +@reference '#theme'; + +/* Layer order — restated per module so stylesheet load order cannot decide the ranking; + re-declaring an existing layer is a no-op. */ +@layer fui.theme, fui.base, fui.components, fui.components.l1, fui.components.l2, fui.components.l3, fui.components.l4, fui.components.l5, fui.utilities; + +/* + * Adapted from the Griffel-suite parity CSS for the headless top-layer model: + * + * - The content is a native `popover="hint"` element. The top layer owns display (no + * `hidden`/`data-open` display dance), but the UA [popover] defaults (border, padding, + * Canvas colors, `overflow: auto`) must all be overridden here — layered author rules + * still beat UA styles, layers only order the author origin. + * - Placement rides `data-placement` ON THE CONTENT (headless vocabulary: + * above|below|before|after with -start/-end/-top/-bottom suffixes, kept in sync with + * the browser-resolved fallback by the headless placement observer) — not + * react-positioning's `data-popper-placement` ancestor attribute. + * - The arrow is the headless-rendered `[data-arrow]` child (no class hook, no arrowRef): + * its cross-axis position is pure CSS (centered via auto margins; -start/-end offsets + * via --fui-positioning-arrow-cross), where react-positioning used to set inline + * coordinates from JS. + * - `before`/`after` are LOGICAL placements, but the arrow's edge offsets and rotation + * are physical geometry (`@noflip` in the Griffel source) — the rtl variant swaps them + * explicitly. + */ + +@layer fui.components.l1 { + .content { + @apply box-border max-w-240 cursor-default overflow-visible rounded-medium bg-neutral-background-1 px-11 pt-vertical-xs pb-vertical-s-nudge font-base text-base-200 leading-base-200 break-words text-neutral-foreground-1; + + border: 1px solid var(--color-transparent-stroke); + + /* TODO carried from the Griffel source: need versions of tokens.alias.shadow.shadow8 + etc. that work with filter. */ + filter: drop-shadow(0 0 2px var(--color-neutral-shadow-ambient)) + drop-shadow(0 4px 8px var(--color-neutral-shadow-key)); + } +} + +@layer fui.components.l1 { + /* `appearance` is a LOOK prop, so it rides a module class */ + .inverted { + @apply bg-neutral-background-static text-neutral-foreground-static-inverted; + } +} + +@layer fui.components.l1 { + .content { + /* Cross-axis inset for -start/-end/-top/-bottom arrow placements. The Griffel arrow + was positioned from JS to align with the anchor; this fixed inset is the pure-CSS + stand-in, overridable per consumer. */ + --fui-positioning-arrow-cross: 8px; + + /* + * The arrow: a rotated square whose visible half is carved by the ::before clip-path. + * Sizes stay literal px on purpose — the JS side (Tooltip.tsx ARROW_HEIGHT) does the + * matching arithmetic in CSS pixels, so they must NOT be rescaled via --base-scale. + * 8.484px = 6px arrow height × 1.414 (diagonal); -4.242px = half, poking out of the + * surface. Rotation and the per-edge offsets are PHYSICAL geometry (`@noflip` in the + * Griffel source) — see the rtl block for the logical placements. + */ + > :where([data-arrow]) { + @apply absolute box-border; + + z-index: -1; + + --fui-positioning-arrow-height: 8.484px; + --fui-positioning-arrow-offset: -4.242px; + + background-color: inherit; + background-clip: content-box; + + border-bottom-left-radius: var(--radius-small); /* @noflip */ + transform: rotate(var(--fui-positioning-arrow-angle)); /* @noflip */ + + height: var(--fui-positioning-arrow-height); + width: var(--fui-positioning-arrow-height); + + &::before { + @apply block h-full w-full; + + content: ''; + background-color: inherit; + + margin: -1px; + border: 1px solid var(--color-transparent-stroke); + + border-bottom-left-radius: var(--radius-small); /* @noflip */ + clip-path: polygon(0% 0%, 100% 100%, 0% 100%); + } + } + + /* Main axis: which edge the arrow pokes out of, and its rotation. */ + &:where([data-placement^='above']) > :where([data-arrow]) { + bottom: var(--fui-positioning-arrow-offset); + + --fui-positioning-arrow-angle: -45deg; + } + + &:where([data-placement^='below']) > :where([data-arrow]) { + top: var(--fui-positioning-arrow-offset); + + --fui-positioning-arrow-angle: 135deg; + } + + /* Logical `before` = physical left of the trigger in LTR → arrow on the surface's + right edge (Griffel's popper-left case); `after` mirrors it. */ + &:where([data-placement^='before']) > :where([data-arrow]) { + right: var(--fui-positioning-arrow-offset); /* @noflip */ + + --fui-positioning-arrow-angle: 225deg; + } + + &:where([data-placement^='after']) > :where([data-arrow]) { + left: var(--fui-positioning-arrow-offset); /* @noflip */ + + --fui-positioning-arrow-angle: 45deg; + } + + @variant rtl { + &:where([data-placement^='before']) > :where([data-arrow]) { + right: auto; + left: var(--fui-positioning-arrow-offset); + + --fui-positioning-arrow-angle: 45deg; + } + + &:where([data-placement^='after']) > :where([data-arrow]) { + left: auto; + right: var(--fui-positioning-arrow-offset); + + --fui-positioning-arrow-angle: 225deg; + } + } + + /* Cross axis: centered for suffix-less placements, near-edge inset for suffixed. */ + &:where([data-placement='above'], [data-placement='below']) > :where([data-arrow]) { + inset-inline: 0; + margin-inline: auto; + } + + &:where([data-placement$='-start']) > :where([data-arrow]) { + inset-inline-start: var(--fui-positioning-arrow-cross); + } + + &:where([data-placement$='-end']) > :where([data-arrow]) { + inset-inline-end: var(--fui-positioning-arrow-cross); + } + + &:where([data-placement='before'], [data-placement='after']) > :where([data-arrow]) { + inset-block: 0; + margin-block: auto; + } + + &:where([data-placement$='-top']) > :where([data-arrow]) { + top: var(--fui-positioning-arrow-cross); + } + + &:where([data-placement$='-bottom']) > :where([data-arrow]) { + bottom: var(--fui-positioning-arrow-cross); + } + } +} diff --git a/packages/react-components/react-windmod-preview/library/src/components/Tooltip/Tooltip.tsx b/packages/react-components/react-windmod-preview/library/src/components/Tooltip/Tooltip.tsx new file mode 100644 index 00000000000000..a3256e6b4e9b75 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Tooltip/Tooltip.tsx @@ -0,0 +1,52 @@ +'use client'; + +import type { FluentTriggerComponent, JSXElement } from '@fluentui/react-utilities'; +import { renderTooltip, useTooltip } from '@fluentui/react-headless-components-preview/tooltip'; +import { resolvePositioningShorthand } from '@fluentui/react-headless-components-preview/positioning'; + +import type { TooltipProps, TooltipState } from './Tooltip.types'; +import { useTooltipStyles } from './useTooltipStyles'; + +/** + * Griffel-parity constants: the classic Tooltip positions at offset 4, and with an arrow + * react-positioning's `mergeArrowOffset` added the 6px arrow height (4 + 6 = 10). The + * headless `usePositioning` applies offsets as margins and does no arrow arithmetic, so + * the same numbers are applied here. The 6px height also drives the CSS constants in + * Tooltip.module.css (8.484px diagonal / -4.242px edge offset) — change them together. + */ +const TOOLTIP_OFFSET = 4; +const ARROW_HEIGHT = 6; + +/** + * Tooltip renders a non-modal floating label or description anchored to a trigger + * element. Windmod Tooltip: the headless tooltip (native `popover="hint"` top layer + + * CSS anchor positioning) decorated with the Fluent visual contract. + */ +export const Tooltip = (props: TooltipProps): JSXElement => { + const { appearance = 'normal', positioning = 'above', withArrow = false, ...rest } = props; + + const resolved = resolvePositioningShorthand(positioning); + const offset = + resolved.offset === undefined + ? TOOLTIP_OFFSET + (withArrow ? ARROW_HEIGHT : 0) + : typeof resolved.offset === 'number' && withArrow + ? resolved.offset + ARROW_HEIGHT + : resolved.offset; + + const state: TooltipState = { + ...useTooltip({ ...rest, withArrow, positioning: { ...resolved, offset } }), + appearance, + }; + + useTooltipStyles(state); + + return renderTooltip(state); +}; + +Tooltip.displayName = 'Tooltip'; + +/** + * Marks the Tooltip component as a FluentTriggerComponent so trigger utilities clone + * props through it. Type-cast to avoid exposing internal types in the public API. + */ +(Tooltip as FluentTriggerComponent).isFluentTriggerComponent = true; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Tooltip/Tooltip.types.ts b/packages/react-components/react-windmod-preview/library/src/components/Tooltip/Tooltip.types.ts new file mode 100644 index 00000000000000..1d80f6e90f5131 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Tooltip/Tooltip.types.ts @@ -0,0 +1,27 @@ +import type { + TooltipProps as TooltipHeadlessProps, + TooltipState as TooltipHeadlessState, +} from '@fluentui/react-headless-components-preview/tooltip'; + +export type { + OnVisibleChangeData, + TooltipSlots, + TooltipTriggerProps, +} from '@fluentui/react-headless-components-preview/tooltip'; + +/** The tooltip's visual appearance. `'inverted'` is the static high-contrast surface. */ +export type TooltipAppearance = 'normal' | 'inverted'; + +/** + * Windmod Tooltip props: the headless tooltip plus the look prop the headless surface + * deliberately omits. + */ +export type TooltipProps = TooltipHeadlessProps & { + /** @default 'normal' */ + appearance?: TooltipAppearance; +}; + +/** Windmod Tooltip state: headless state plus the resolved look prop. */ +export type TooltipState = TooltipHeadlessState & { + appearance: TooltipAppearance; +}; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Tooltip/index.ts b/packages/react-components/react-windmod-preview/library/src/components/Tooltip/index.ts new file mode 100644 index 00000000000000..6eff177b1ee36c --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Tooltip/index.ts @@ -0,0 +1,10 @@ +export { Tooltip } from './Tooltip'; +export { tooltipClassNames, useTooltipStyles } from './useTooltipStyles'; +export type { + OnVisibleChangeData, + TooltipAppearance, + TooltipProps, + TooltipSlots, + TooltipState, + TooltipTriggerProps, +} from './Tooltip.types'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Tooltip/useTooltipStyles.ts b/packages/react-components/react-windmod-preview/library/src/components/Tooltip/useTooltipStyles.ts new file mode 100644 index 00000000000000..22ba04fc0290d1 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Tooltip/useTooltipStyles.ts @@ -0,0 +1,37 @@ +'use client'; + +import { clsx } from 'clsx'; + +import type { TooltipState } from './Tooltip.types'; + +import styles from './Tooltip.module.css'; + +/** + * Public identity class for Tooltip — the Tailwind named-group marker on the content + * surface (Tooltip's only slot). The only public class; everything else is a hashed + * CSS Modules ident. + */ +export const tooltipClassNames: { root: string } = { + root: 'group/fui-tooltip', +}; + +/** + * Applies the Fluent visual contract to the headless Tooltip state. + * + * Visibility needs no class work: the content is a native `popover="hint"` element, so + * the top layer controls display, and the headless hook already stamps `data-open` and + * `data-placement` on the content for state/placement selectors (the arrow is the + * `[data-arrow]` child, styled from the content's module rules). + */ +export const useTooltipStyles = (state: TooltipState): TooltipState => { + // Module class FIRST (the group marker must never be classList[0] — nwsapi's :scope + // polyfill throws on the `/`), consumer className LAST so consumer overrides win. + state.content.className = clsx( + styles.content, + tooltipClassNames.root, + state.appearance === 'inverted' && styles.inverted, + state.content.className, + ); + + return state; +}; diff --git a/packages/react-components/react-windmod-preview/library/src/index.ts b/packages/react-components/react-windmod-preview/library/src/index.ts new file mode 100644 index 00000000000000..cd9d8e51a5f52d --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/index.ts @@ -0,0 +1,28 @@ +export { Button, buttonClassNames, renderButton, useButton, useButtonStyles } from './button'; +export type { ButtonAppearance, ButtonProps, ButtonShape, ButtonSize, ButtonSlots, ButtonState } from './button'; + +export { renderTooltip, Tooltip, tooltipClassNames, useTooltip, useTooltipStyles } from './tooltip'; +export type { + OnVisibleChangeData, + TooltipAppearance, + TooltipProps, + TooltipSlots, + TooltipState, + TooltipTriggerProps, +} from './tooltip'; + +export { + Provider, + renderProvider, + teamsDarkThemeClassName, + teamsDarkV21ThemeClassName, + teamsHighContrastThemeClassName, + teamsLightThemeClassName, + teamsLightV21ThemeClassName, + themeClassNames, + ThemeProvider, + useProvider, + webDarkThemeClassName, + webLightThemeClassName, +} from './provider'; +export type { ThemeClassName, ThemeProviderProps } from './provider'; diff --git a/packages/react-components/react-windmod-preview/library/src/provider.ts b/packages/react-components/react-windmod-preview/library/src/provider.ts new file mode 100644 index 00000000000000..a090ebe346965c --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/provider.ts @@ -0,0 +1,20 @@ +export { ThemeProvider } from './components/Provider/index'; +export type { ThemeClassName, ThemeProviderProps } from './components/Provider/index'; + +/** + * The shipped theme class names, re-exported from the theme package so the provider and + * its themes come from one import. + */ +export { + themeClassNames, + teamsDarkThemeClassName, + teamsDarkV21ThemeClassName, + teamsHighContrastThemeClassName, + teamsLightThemeClassName, + teamsLightV21ThemeClassName, + webDarkThemeClassName, + webLightThemeClassName, +} from '@fluentui/react-tailwind-theme-preview/theme-class-names'; + +/** The headless context Provider, for consumers who bring their own theming. */ +export { Provider, renderProvider, useProvider } from '@fluentui/react-headless-components-preview/provider'; diff --git a/packages/react-components/react-windmod-preview/library/src/testing/isConformant.ts b/packages/react-components/react-windmod-preview/library/src/testing/isConformant.ts new file mode 100644 index 00000000000000..a090d81a91b404 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/testing/isConformant.ts @@ -0,0 +1,62 @@ +import { isConformant as baseIsConformant } from '@fluentui/react-conformance'; +import type { IsConformantOptions } from '@fluentui/react-conformance'; + +function kebabCase(str: string): string { + return str + .replace(/([a-z])([A-Z])/g, '$1-$2') + .replace(/\s+/g, '-') + .toLowerCase(); +} + +export function isConformant( + testInfo: Omit, 'componentPath'> & { componentPath?: string }, +): void { + const name = kebabCase(testInfo.displayName); + + const defaultOptions: Partial> = { + tsConfig: { configName: 'tsconfig.spec.json' }, + componentPath: require.main?.filename.replace('.test', ''), + disabledTests: [ + // We don't support top-level exports + 'exported-top-level', + // We use kebab case naming for top-level files + 'has-top-level-file', + // Windmod components have no BEM static classnames — the public identity class is + // the group marker alone (xClassNames = { root: 'group/fui-' }). + 'component-has-static-classnames-object', + ], + disableTypeTests: true, + extraTests: { + 'has-top-level-file-extra': ({ displayName, Component }: IsConformantOptions) => { + it(`has corresponding top-level file 'src/${name}.ts' (has-top-level-file)`, () => { + const topLevelFile = require(`../${name}.ts`); + + expect(topLevelFile[displayName]).toBe(Component); + }); + }, + + // The component must have an export map entry in package.json for proper module + // resolution and type definitions (this package's dual ESM/CJS shape). + 'export-map-entry-exists': () => { + const packageJSON = require('../../package.json'); + + it('component has export map entry in package.json', () => { + const exportEntry = `./${name}`; + + expect(packageJSON.exports[exportEntry]).toEqual({ + import: { + types: `./dist/${name}.d.ts`, + default: `./lib/${name}.js`, + }, + require: { + types: `./dist/${name}.d.cts`, + default: `./lib-commonjs/${name}.cjs`, + }, + }); + }); + }, + }, + }; + + baseIsConformant(defaultOptions, testInfo); +} diff --git a/packages/react-components/react-windmod-preview/library/src/tooltip.ts b/packages/react-components/react-windmod-preview/library/src/tooltip.ts new file mode 100644 index 00000000000000..dc48c4ce178d56 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/tooltip.ts @@ -0,0 +1,12 @@ +export { Tooltip, tooltipClassNames, useTooltipStyles } from './components/Tooltip/index'; +export type { + OnVisibleChangeData, + TooltipAppearance, + TooltipProps, + TooltipSlots, + TooltipState, + TooltipTriggerProps, +} from './components/Tooltip/index'; + +/** Headless building blocks, re-exported for consumers composing their own Tooltip. */ +export { renderTooltip, useTooltip } from '@fluentui/react-headless-components-preview/tooltip'; diff --git a/packages/react-components/react-windmod-preview/library/tsconfig.json b/packages/react-components/react-windmod-preview/library/tsconfig.json new file mode 100644 index 00000000000000..c927c73ba4a9a5 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/tsconfig.json @@ -0,0 +1,31 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2019", + /* + * The repo's type-check executor re-roots --baseUrl at the package, which breaks the + * repo-relative `paths` mappings — and this package imports SUBPATH exports + * (`@fluentui/react-headless-components-preview/button`, `.../theme-class-names`) + * that only resolve through package.json `exports` maps. `bundler` resolution honors + * those maps against the BUILT dependencies (the type-check/build targets depend on + * `^build`, so dist typings exist). + */ + "moduleResolution": "bundler", + "noEmit": true, + "isolatedModules": true, + "importHelpers": true, + "jsx": "react", + "noUnusedLocals": true, + "preserveConstEnums": true + }, + "include": [], + "files": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/react-components/react-windmod-preview/library/tsconfig.lib.json b/packages/react-components/react-windmod-preview/library/tsconfig.lib.json new file mode 100644 index 00000000000000..53066fdd11fff0 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/tsconfig.lib.json @@ -0,0 +1,22 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "lib": ["ES2019", "dom"], + "declaration": true, + "declarationDir": "../../../../dist/out-tsc/types", + "outDir": "../../../../dist/out-tsc", + "inlineSources": true, + "types": ["static-assets", "environment"] + }, + "exclude": [ + "./src/testing/**", + "**/*.spec.ts", + "**/*.spec.tsx", + "**/*.test.ts", + "**/*.test.tsx", + "**/*.stories.ts", + "**/*.stories.tsx" + ], + "include": ["./src/**/*.ts", "./src/**/*.tsx"] +} diff --git a/packages/react-components/react-windmod-preview/library/tsconfig.spec.json b/packages/react-components/react-windmod-preview/library/tsconfig.spec.json new file mode 100644 index 00000000000000..7a1ef8b44b3bf7 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/tsconfig.spec.json @@ -0,0 +1,17 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "outDir": "dist", + "types": ["jest", "node"] + }, + "include": [ + "**/*.spec.ts", + "**/*.spec.tsx", + "**/*.test.ts", + "**/*.test.tsx", + "**/*.d.ts", + "./src/testing/**/*.ts", + "./src/testing/**/*.tsx" + ] +} diff --git a/packages/react-components/react-windmod-preview/stories/.storybook/main.cjs b/packages/react-components/react-windmod-preview/stories/.storybook/main.cjs new file mode 100644 index 00000000000000..4f80361aac4b14 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/.storybook/main.cjs @@ -0,0 +1,14 @@ +const rootMain = require('../../../../.storybook/main'); + +module.exports = /** @type {Omit} */ ({ + ...rootMain, + stories: [...rootMain.stories, '../stories/**/*.mdx', '../stories/**/index.stories.@(ts|tsx)'], + addons: [...rootMain.addons], + webpackFinal: (config, options) => { + const localConfig = { ...rootMain.webpackFinal(config, options) }; + + // add your own webpack tweaks if needed + + return localConfig; + }, +}); diff --git a/packages/react-components/react-windmod-preview/stories/.storybook/preview.js b/packages/react-components/react-windmod-preview/stories/.storybook/preview.js new file mode 100644 index 00000000000000..98274ed0b8095f --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/.storybook/preview.js @@ -0,0 +1,9 @@ +import * as rootPreview from '../../../../../.storybook/preview'; + +/** @type {typeof rootPreview.decorators} */ +export const decorators = [...rootPreview.decorators]; + +/** @type {typeof rootPreview.parameters} */ +export const parameters = { ...rootPreview.parameters }; + +export const tags = ['autodocs']; diff --git a/packages/react-components/react-windmod-preview/stories/.storybook/tsconfig.json b/packages/react-components/react-windmod-preview/stories/.storybook/tsconfig.json new file mode 100644 index 00000000000000..4cdd1ce9d006f1 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/.storybook/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "", + "allowJs": true, + "checkJs": true, + "types": ["static-assets", "environment"] + }, + "include": ["*.js"] +} diff --git a/packages/react-components/react-windmod-preview/stories/README.md b/packages/react-components/react-windmod-preview/stories/README.md new file mode 100644 index 00000000000000..9294f8cc3dcee0 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/README.md @@ -0,0 +1,17 @@ +# @fluentui/react-windmod-preview-stories + +Storybook stories for packages/react-components/react-windmod-preview + +## Usage + +To include within storybook specify stories globs: + +\`\`\`js +module.exports = { +stories: ['../packages/react-components/react-windmod-preview/stories/src/**/*.mdx', '../packages/react-components/react-windmod-preview/stories/src/**/index.stories.@(ts|tsx)'], +} +\`\`\` + +## API + +no public API available diff --git a/packages/react-components/react-windmod-preview/stories/eslint.config.js b/packages/react-components/react-windmod-preview/stories/eslint.config.js new file mode 100644 index 00000000000000..f8362c3e413031 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/eslint.config.js @@ -0,0 +1,10 @@ +// @ts-check + +const fluentPlugin = require('@fluentui/eslint-plugin'); + +module.exports = [ + ...fluentPlugin.configs['flat/react'], + { + rules: {}, + }, +]; diff --git a/packages/react-components/react-windmod-preview/stories/package.json b/packages/react-components/react-windmod-preview/stories/package.json new file mode 100644 index 00000000000000..b9969048297251 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/package.json @@ -0,0 +1,5 @@ +{ + "name": "@fluentui/react-windmod-preview-stories", + "version": "0.0.0", + "private": true +} diff --git a/packages/react-components/react-windmod-preview/stories/project.json b/packages/react-components/react-windmod-preview/stories/project.json new file mode 100644 index 00000000000000..2ea22545c6c5ed --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/project.json @@ -0,0 +1,8 @@ +{ + "name": "react-windmod-preview-stories", + "$schema": "../../../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "packages/react-components/react-windmod-preview/stories/src", + "tags": ["vNext", "platform:web", "type:stories"], + "implicitDependencies": [] +} diff --git a/packages/react-components/react-windmod-preview/stories/src/.gitkeep b/packages/react-components/react-windmod-preview/stories/src/.gitkeep new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/packages/react-components/react-windmod-preview/stories/src/index.ts b/packages/react-components/react-windmod-preview/stories/src/index.ts new file mode 100644 index 00000000000000..cb0ff5c3b541f6 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/react-components/react-windmod-preview/stories/tsconfig.json b/packages/react-components/react-windmod-preview/stories/tsconfig.json new file mode 100644 index 00000000000000..efc50169d1df18 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2019", + "noEmit": true, + "isolatedModules": true, + "importHelpers": true, + "jsx": "react", + "noUnusedLocals": true, + "preserveConstEnums": true + }, + "include": [], + "files": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./.storybook/tsconfig.json" + } + ] +} diff --git a/packages/react-components/react-windmod-preview/stories/tsconfig.lib.json b/packages/react-components/react-windmod-preview/stories/tsconfig.lib.json new file mode 100644 index 00000000000000..9486b224643d9f --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "lib": ["ES2019", "dom"], + "outDir": "../../../../dist/out-tsc", + "inlineSources": true, + "types": ["static-assets", "environment"] + }, + "include": ["./src/**/*.ts", "./src/**/*.tsx"] +} diff --git a/tools/workspace-plugin/src/executors/generate-api/lib/utils.ts b/tools/workspace-plugin/src/executors/generate-api/lib/utils.ts index ae526525a0645c..6a678174a27d74 100644 --- a/tools/workspace-plugin/src/executors/generate-api/lib/utils.ts +++ b/tools/workspace-plugin/src/executors/generate-api/lib/utils.ts @@ -146,10 +146,13 @@ export function getExportSubpathConfigs(options: NormalizedOptions): IConfigFile // they act as literal path segments that the subsequent "../" chain traverses through. // path.resolve(configDir, "/../../../../../../...") naturally normalizes to the correct path. const configDir = dirname(opts.config); + // Normalized to posix separators: path.resolve emits backslashes on Windows, which + // would fail the '/index.d.ts' suffix check below and silently skip every subpath + // rollup on Windows machines (CI on Linux was unaffected). const resolvedPrimaryEntry = resolve( configDir, primaryMainEntryTemplate.replace(//g, unscopedPackageName), - ); + ).replace(/\\/g, '/'); const indexDtsSuffix = '/index.d.ts'; if (!resolvedPrimaryEntry.endsWith(indexDtsSuffix)) { diff --git a/tsconfig.base.all.json b/tsconfig.base.all.json index 8d5d5cf60d08e6..ef56d6e9b69a03 100644 --- a/tsconfig.base.all.json +++ b/tsconfig.base.all.json @@ -122,12 +122,12 @@ "@fluentui/react-field": ["packages/react-components/react-field/library/src/index.ts"], "@fluentui/react-field-stories": ["packages/react-components/react-field/stories/src/index.ts"], "@fluentui/react-focus-management": ["packages/react-focus-management/src/index.ts"], - "@fluentui/react-headless-components-preview/*": [ - "packages/react-components/react-headless-components-preview/library/src/*.ts" - ], "@fluentui/react-headless-components-preview-stories": [ "packages/react-components/react-headless-components-preview/stories/src/index.ts" ], + "@fluentui/react-headless-components-preview/*": [ + "packages/react-components/react-headless-components-preview/library/src/*.ts" + ], "@fluentui/react-icons-compat": ["packages/react-components/react-icons-compat/library/src/index.ts"], "@fluentui/react-icons-compat-stories": ["packages/react-components/react-icons-compat/stories/src/index.ts"], "@fluentui/react-image": ["packages/react-components/react-image/library/src/index.ts"], @@ -254,13 +254,17 @@ "@fluentui/react-utilities-compat-stories": [ "packages/react-components/react-utilities-compat/stories/src/index.ts" ], + "@fluentui/react-windmod-preview": ["packages/react-components/react-windmod-preview/library/src/index.ts"], "@fluentui/recipes": ["packages/react-components/recipes/src/index.ts"], "@fluentui/storybook-llms-extractor": ["tools/storybook-llms-extractor/src/index.ts"], "@fluentui/theme-designer": ["packages/react-components/theme-designer/src/index.ts"], "@fluentui/tokens": ["packages/tokens/src/index.ts"], "@fluentui/visual-regression-assert": ["tools/visual-regression-assert/src/index.ts"], "@fluentui/visual-regression-utilities": ["tools/visual-regression-utilities/src/index.ts"], - "@fluentui/workspace-plugin": ["tools/workspace-plugin/src/index.ts"] + "@fluentui/workspace-plugin": ["tools/workspace-plugin/src/index.ts"], + "@fluentui/react-windmod-preview-stories": [ + "packages/react-components/react-windmod-preview/stories/src/index.ts" + ] } } } diff --git a/tsconfig.base.json b/tsconfig.base.json index 58d5e4845e447d..4b9d05e9cdc704 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -210,6 +210,10 @@ "@fluentui/react-utilities-compat-stories": [ "packages/react-components/react-utilities-compat/stories/src/index.ts" ], + "@fluentui/react-windmod-preview": ["packages/react-components/react-windmod-preview/library/src/index.ts"], + "@fluentui/react-windmod-preview-stories": [ + "packages/react-components/react-windmod-preview/stories/src/index.ts" + ], "@fluentui/recipes": ["packages/react-components/recipes/src/index.ts"], "@fluentui/storybook-llms-extractor": ["tools/storybook-llms-extractor/src/index.ts"], "@fluentui/theme-designer": ["packages/react-components/theme-designer/src/index.ts"], diff --git a/yarn.lock b/yarn.lock index 3aaf12f7a3dfc1..4583535de5a743 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4349,7 +4349,7 @@ __metadata: languageName: unknown linkType: soft -"@fluentui/react-headless-components-preview@npm:*, @fluentui/react-headless-components-preview@workspace:packages/react-components/react-headless-components-preview/library": +"@fluentui/react-headless-components-preview@npm:*, @fluentui/react-headless-components-preview@npm:^0.2.5, @fluentui/react-headless-components-preview@workspace:packages/react-components/react-headless-components-preview/library": version: 0.0.0-use.local resolution: "@fluentui/react-headless-components-preview@workspace:packages/react-components/react-headless-components-preview/library" dependencies: @@ -5737,7 +5737,7 @@ __metadata: languageName: unknown linkType: soft -"@fluentui/react-tailwind-theme-preview@workspace:packages/react-components/react-tailwind-theme-preview": +"@fluentui/react-tailwind-theme-preview@npm:^9.0.0-alpha.0, @fluentui/react-tailwind-theme-preview@workspace:packages/react-components/react-tailwind-theme-preview": version: 0.0.0-use.local resolution: "@fluentui/react-tailwind-theme-preview@workspace:packages/react-components/react-tailwind-theme-preview" languageName: unknown @@ -6051,6 +6051,30 @@ __metadata: languageName: unknown linkType: soft +"@fluentui/react-windmod-preview-stories@workspace:packages/react-components/react-windmod-preview/stories": + version: 0.0.0-use.local + resolution: "@fluentui/react-windmod-preview-stories@workspace:packages/react-components/react-windmod-preview/stories" + languageName: unknown + linkType: soft + +"@fluentui/react-windmod-preview@workspace:packages/react-components/react-windmod-preview/library": + version: 0.0.0-use.local + resolution: "@fluentui/react-windmod-preview@workspace:packages/react-components/react-windmod-preview/library" + dependencies: + "@fluentui/react-headless-components-preview": "npm:^0.2.5" + "@fluentui/react-shared-contexts": "npm:^9.26.3" + "@fluentui/react-tailwind-theme-preview": "npm:^9.0.0-alpha.0" + "@fluentui/react-utilities": "npm:^9.26.6" + "@swc/helpers": "npm:^0.5.1" + clsx: "npm:^2.1.1" + peerDependencies: + "@types/react": ">=16.14.0 <20.0.0" + "@types/react-dom": ">=16.9.0 <20.0.0" + react: ">=16.14.0 <20.0.0" + react-dom: ">=16.14.0 <20.0.0" + languageName: unknown + linkType: soft + "@fluentui/react-window-provider@npm:^2.3.2, @fluentui/react-window-provider@workspace:packages/react-window-provider": version: 0.0.0-use.local resolution: "@fluentui/react-window-provider@workspace:packages/react-window-provider" @@ -14895,7 +14919,7 @@ __metadata: languageName: node linkType: hard -"clsx@npm:2.1.1": +"clsx@npm:2.1.1, clsx@npm:^2.1.1": version: 2.1.1 resolution: "clsx@npm:2.1.1" checksum: 10c0/c4c8eb865f8c82baab07e71bfa8897c73454881c4f99d6bc81585aecd7c441746c1399d08363dc096c550cceaf97bd4ce1e8854e1771e9998d9f94c4fe075839 From 510b8c5d2161818df7bb8502adbaf4a394d4c570 Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Thu, 20 Aug 2026 19:54:51 -0700 Subject: [PATCH 004/246] feat(windmod): pilot stories with Griffel side-by-side + storybook wiring fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stories: Button (variant matrix + per-variant Griffel comparison grid), Tooltip (hover demos, 12 pinned placements, Griffel comparison), ThemeProvider (nested web-dark/teams-dark/high-contrast subtrees with a pinned top-layer tooltip proving theme vars reach the top layer). fix(storybook-addon-export-to-sandbox): the addon finds its own registration by matching the preset path with a forward-slash-only regex — on Windows the path has backslashes, so options were silently dropped and the full-source babel plugin crashed on undefined importMappings. Pattern now accepts both separators. Stories main.js also re-registers the addon locally (same workaround as the headless stories package) and fixes two generator template issues (main.cjs is not inferred as a storybook target; wrong root-main require depth). lint: drop 'use client' from the styles hooks (enforce-use-client), windmod lint green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- .storybook/main.js | 5 - migration/windmod/LEDGER.md | 42 +++++-- .../src/webpack.ts | 6 +- .../src/components/Button/useButtonStyles.ts | 2 - .../components/Tooltip/useTooltipStyles.ts | 2 - .../stories/.storybook/main.cjs | 14 --- .../stories/.storybook/main.js | 47 ++++++++ .../stories/package.json | 5 +- .../stories/src/Button/index.stories.tsx | 113 ++++++++++++++++++ .../stories/src/Provider/index.stories.tsx | 63 ++++++++++ .../stories/src/Tooltip/index.stories.tsx | 87 ++++++++++++++ .../stories/src/compare.module.css | 53 ++++++++ 12 files changed, 401 insertions(+), 38 deletions(-) delete mode 100644 packages/react-components/react-windmod-preview/stories/.storybook/main.cjs create mode 100644 packages/react-components/react-windmod-preview/stories/.storybook/main.js create mode 100644 packages/react-components/react-windmod-preview/stories/src/Button/index.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/Provider/index.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/Tooltip/index.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/compare.module.css diff --git a/.storybook/main.js b/.storybook/main.js index a6d608eb23bbcf..3606681f63777d 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -47,11 +47,6 @@ module.exports = /** @type {import('./types').StorybookConfig} */ ({ test: /\.stories\.tsx$/, include: /stories/, }, - /** - * windmod-preview stories import `*.module.css`; without this the sandbox export - * drops those imports and every exported story opens unstyled. - */ - cssModules: true, }, }), ], diff --git a/migration/windmod/LEDGER.md b/migration/windmod/LEDGER.md index e5f50f4184727c..0d581c7f49d85f 100644 --- a/migration/windmod/LEDGER.md +++ b/migration/windmod/LEDGER.md @@ -4,22 +4,38 @@ Status flow: `planned` → `pilot` → `audited` → `converted` → `validated` ## Infra -| Item | Status | Notes | -| -------------------------------------- | ------- | ------------------------------------------- | -| Branch styling/react-windmod | done | off master 06cbcbe0b1 | -| PLAN.md committed | done | | -| react-tailwind-theme-preview port | planned | source: branch styling/tailwind-css-modules | -| react-windmod-preview scaffold | planned | library/ + stories/ | -| ThemeProvider | planned | display:contents, verified viable | -| Stories Storybook + Tailwind v4 wiring | planned | | -| VR harness adaptation | planned | after pilot review | +| Item | Status | Notes | +| -------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Branch styling/react-windmod | done | off master 06cbcbe0b1 | +| PLAN.md committed | done | | +| Build infra port (executor, scripts) | done | css-modules compiler in build executor; jest+storybook wiring; react-icons → 2.0.337 for /headless | +| react-tailwind-theme-preview port | done | self-contained (theme-values.json + class-name constants in-package; tokens.ts = name inventory only); regenerated vs current tokens; dist builds (175,694B) | +| react-windmod-preview scaffold | done | generator + bespoke: type:module, subpath exports, moduleResolution bundler (type-check --baseUrl override defeats repo paths) | +| workspace-plugin Windows fix | done | generate-api subpath rollups skipped on Windows (backslash vs '/index.d.ts' suffix check) — fixed, 56 headless rollups emit | +| ThemeProvider | done | display:contents + headless Provider context; theme classes re-exported from /provider | +| Stories Storybook + Tailwind v4 wiring | done | root .storybook rules + package stories project (generator template had 2 bugs: main.cjs not inferred, wrong require depth) | +| Library build/type-check/lint | done | all green | +| VR harness adaptation | planned | after pilot review | +| Unit tests (conformance + behavior) | planned | isConformant adapted (export-map shape); write with pilot round 2 | ## Components (pilot) -| Component | Status | Notes | -| --------- | ------ | ---------------------------------------------------- | -| Button | pilot | variants w/o data-* (appearance/size/shape) | -| Tooltip | pilot | top-layer popover=hint + positioning + ThemeProvider | +| Component | Status | Notes | +| --------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Button | pilot-built | Button.module.css ported verbatim; wrapper reintroduces appearance/shape/size (headless omits them — defaults mirrored, drift risk documented); adds data-size/data-empty | +| Tooltip | pilot-built | REAL top-layer adaptation: data-placement re-key (logical vocab + rtl swap), pure-CSS arrow cross-axis, JS offset compensation (4/+6); [data-hidden] producer gone — no boundary hiding | + +### Pilot known gaps (for review discussion) + +- Arrow cross-axis position for -start/-end placements is a fixed 8px inset + (`--fui-positioning-arrow-cross`) — Griffel aligned it to the anchor from JS. + Zero-tolerance VR may flag it on those placements; centered placements should match. +- Button's icon filled/regular hover-swap rules target `:global(.fui-Icon-*)` classes — + verify against headless icons 2.0.337 markup in storybook (fork lineage carried them, + unconfirmed upstream). +- ButtonContext size inheritance (Toolbar et al.) not consumed — audit item for the + compound-component batches. +- generate-api emits `library/etc/*.api.md` per subpath — commit them once reviewed. ## Components (remaining headless coverage) diff --git a/packages/react-components/react-storybook-addon-export-to-sandbox/src/webpack.ts b/packages/react-components/react-storybook-addon-export-to-sandbox/src/webpack.ts index 8ebe39c87ae43c..4b51379589f001 100644 --- a/packages/react-components/react-storybook-addon-export-to-sandbox/src/webpack.ts +++ b/packages/react-components/react-storybook-addon-export-to-sandbox/src/webpack.ts @@ -16,7 +16,11 @@ export function webpack(config: WebpackFinalConfig, options: WebpackFinalOptions } const identity = (value: T) => value; -const addonFilePattern = /react-storybook-addon-export-to-sandbox\/[a-z/]+.[jt]s$/; +// Both path separators on purpose: on Windows the registered preset name is an absolute +// path with backslashes (e.g. `...\react-storybook-addon-export-to-sandbox\temp\preset.ts`); +// a forward-slash-only pattern fails to find the registration, silently drops the addon +// options, and the full-source babel plugin then crashes on undefined `importMappings`. +const addonFilePattern = /react-storybook-addon-export-to-sandbox[\\/][a-z\\/]+.[jt]s$/; const defaultOptions = { webpackRule: {}, babelLoaderOptionsUpdater: identity, diff --git a/packages/react-components/react-windmod-preview/library/src/components/Button/useButtonStyles.ts b/packages/react-components/react-windmod-preview/library/src/components/Button/useButtonStyles.ts index 2df0509564b733..8269aa52c99c85 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Button/useButtonStyles.ts +++ b/packages/react-components/react-windmod-preview/library/src/components/Button/useButtonStyles.ts @@ -1,5 +1,3 @@ -'use client'; - import { clsx } from 'clsx'; import type { ButtonState } from './Button.types'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Tooltip/useTooltipStyles.ts b/packages/react-components/react-windmod-preview/library/src/components/Tooltip/useTooltipStyles.ts index 22ba04fc0290d1..c35917d90697a2 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Tooltip/useTooltipStyles.ts +++ b/packages/react-components/react-windmod-preview/library/src/components/Tooltip/useTooltipStyles.ts @@ -1,5 +1,3 @@ -'use client'; - import { clsx } from 'clsx'; import type { TooltipState } from './Tooltip.types'; diff --git a/packages/react-components/react-windmod-preview/stories/.storybook/main.cjs b/packages/react-components/react-windmod-preview/stories/.storybook/main.cjs deleted file mode 100644 index 4f80361aac4b14..00000000000000 --- a/packages/react-components/react-windmod-preview/stories/.storybook/main.cjs +++ /dev/null @@ -1,14 +0,0 @@ -const rootMain = require('../../../../.storybook/main'); - -module.exports = /** @type {Omit} */ ({ - ...rootMain, - stories: [...rootMain.stories, '../stories/**/*.mdx', '../stories/**/index.stories.@(ts|tsx)'], - addons: [...rootMain.addons], - webpackFinal: (config, options) => { - const localConfig = { ...rootMain.webpackFinal(config, options) }; - - // add your own webpack tweaks if needed - - return localConfig; - }, -}); diff --git a/packages/react-components/react-windmod-preview/stories/.storybook/main.js b/packages/react-components/react-windmod-preview/stories/.storybook/main.js new file mode 100644 index 00000000000000..7eddeb8e8f7601 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/.storybook/main.js @@ -0,0 +1,47 @@ +const path = require('path'); + +const rootMain = require('../../../../../.storybook/main'); +const { + loadWorkspaceAddon, + getImportMappingsForExportToSandboxAddon, + processBabelLoaderOptions, +} = require('@fluentui/scripts-storybook'); + +const repoRoot = path.resolve(__dirname, '../../../../..'); +const tsConfigPath = path.resolve(repoRoot, 'tsconfig.base.json'); + +/** + * @param {string | { name?: string }} addon + */ +function isNotExportToSandboxAddon(addon) { + const name = typeof addon === 'string' ? addon : addon?.name ?? ''; + return !name.includes('react-storybook-addon-export-to-sandbox'); +} + +module.exports = /** @type {Omit} */ ({ + ...rootMain, + stories: [...rootMain.stories, '../src/**/*.mdx', '../src/**/index.stories.@(ts|tsx)'], + addons: [ + // The root-registered export-to-sandbox instance loses its options when composed from + // a nested stories project (its babel plugin then crashes on undefined importMappings) + // — same workaround as react-headless-components-preview: filter it out and + // re-register locally with full options. + ...rootMain.addons.filter(isNotExportToSandboxAddon), + loadWorkspaceAddon('@fluentui/react-storybook-addon-export-to-sandbox', { + tsConfigPath, + /** @type {import('../../../react-storybook-addon-export-to-sandbox/src/index').PresetConfig} */ + options: { + importMappings: getImportMappingsForExportToSandboxAddon(), + babelLoaderOptionsUpdater: processBabelLoaderOptions, + cssModules: true, + webpackRule: { + test: /\.stories\.tsx$/, + include: /stories/, + }, + }, + }), + ], + // No local webpack tweaks: the root webpackFinal already registers the Tailwind v4 + + // CSS Modules pipeline (rules.cssModulesRule / rules.tailwindThemeRule) that this + // package's module.css files need. +}); diff --git a/packages/react-components/react-windmod-preview/stories/package.json b/packages/react-components/react-windmod-preview/stories/package.json index b9969048297251..6e317306e6b823 100644 --- a/packages/react-components/react-windmod-preview/stories/package.json +++ b/packages/react-components/react-windmod-preview/stories/package.json @@ -1,5 +1,8 @@ { "name": "@fluentui/react-windmod-preview-stories", "version": "0.0.0", - "private": true + "private": true, + "scripts": { + "build-storybook": "yarn run -T storybook build -o ./dist/storybook" + } } diff --git a/packages/react-components/react-windmod-preview/stories/src/Button/index.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Button/index.stories.tsx new file mode 100644 index 00000000000000..3ff23235f19bcc --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/Button/index.stories.tsx @@ -0,0 +1,113 @@ +import * as React from 'react'; +import { Button, ThemeProvider } from '@fluentui/react-windmod-preview'; +import type { ButtonProps } from '@fluentui/react-windmod-preview'; +import { Button as GriffelButton, FluentProvider, webLightTheme } from '@fluentui/react-components'; +import { CalendarMonthRegular } from '@fluentui/react-icons/headless'; +import { CalendarMonthRegular as GriffelCalendarMonthRegular } from '@fluentui/react-icons'; + +import styles from '../compare.module.css'; + +export default { + title: 'Windmod/Button', + component: Button, +}; + +const appearances = ['secondary', 'primary', 'outline', 'subtle', 'transparent'] as const; +const sizes = ['small', 'medium', 'large'] as const; +const shapes = ['rounded', 'circular', 'square'] as const; + +export const Default = (): React.ReactNode => ( + +
+ {appearances.map(appearance => ( +
+ {sizes.map(size => ( + + ))} + + + + +
+ ))} +
+ {shapes.map(shape => ( + + ))} +
+
+
+); + +/** + * Every windmod variant next to its Griffel-suite twin (inside a FluentProvider). + * The pair in each row must be pixel-identical — this is the pilot's review surface. + */ +export const GriffelComparison = (): React.ReactNode => { + const variants: Array<{ label: string; props: Partial }> = [ + ...appearances.map(appearance => ({ label: appearance, props: { appearance } as Partial })), + ...sizes.map(size => ({ label: size, props: { size } as Partial })), + ...shapes.map(shape => ({ label: shape, props: { shape } as Partial })), + { label: 'disabled', props: { disabled: true } }, + { label: 'disabledFocusable', props: { disabledFocusable: true } }, + { label: 'primary disabled', props: { appearance: 'primary', disabled: true } }, + ]; + + return ( +
+
Variant
+
Windmod
+
Griffel
+ {variants.map(({ label, props }) => ( + +
{label}
+
+ + + +
+
+ + Button + +
+
+ ))} +
with icon
+
+ + + +
+
+ + }>Button + +
+
icon only
+
+ +
+
+ + } aria-label="Calendar" /> + +
+
+ ); +}; diff --git a/packages/react-components/react-windmod-preview/stories/src/Provider/index.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Provider/index.stories.tsx new file mode 100644 index 00000000000000..2557c33cae0d9a --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/Provider/index.stories.tsx @@ -0,0 +1,63 @@ +import * as React from 'react'; +import { + Button, + teamsDarkThemeClassName, + teamsHighContrastThemeClassName, + ThemeProvider, + Tooltip, + webDarkThemeClassName, +} from '@fluentui/react-windmod-preview'; + +import styles from '../compare.module.css'; + +export default { + title: 'Windmod/ThemeProvider', + component: ThemeProvider, +}; + +/** + * Nested theme subtrees from a `display: contents` provider. The dark subtree's tooltip + * opens in the TOP LAYER and stays dark-themed — top-layer elements keep their DOM + * ancestry, so the provider's custom properties cascade into them. + */ +export const NestedThemes = (): React.ReactNode => ( + +
+
+
+ + + + +
+
+ +
+
+ + + + +
+
+
+ +
+
+ + + +
+
+
+ +
+
+ + +
+
+
+
+
+); diff --git a/packages/react-components/react-windmod-preview/stories/src/Tooltip/index.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Tooltip/index.stories.tsx new file mode 100644 index 00000000000000..ef7bcde9d41ab8 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/Tooltip/index.stories.tsx @@ -0,0 +1,87 @@ +import * as React from 'react'; +import { Button, ThemeProvider, Tooltip } from '@fluentui/react-windmod-preview'; +import type { TooltipProps } from '@fluentui/react-windmod-preview'; +import { + Button as GriffelButton, + FluentProvider, + Tooltip as GriffelTooltip, + webLightTheme, +} from '@fluentui/react-components'; + +import styles from '../compare.module.css'; + +export default { + title: 'Windmod/Tooltip', + component: Tooltip, +}; + +export const Default = (): React.ReactNode => ( + +
+ + + + + + + + + +
+
+); + +const placements = [ + 'above-start', + 'above', + 'above-end', + 'before-top', + 'before', + 'before-bottom', + 'after-top', + 'after', + 'after-bottom', + 'below-start', + 'below', + 'below-end', +] as const; + +/** All 12 placements pinned open — exercises data-placement re-keying + the pure-CSS arrow. */ +export const Placements = (): React.ReactNode => ( + +
+ {placements.map(positioning => ( + + + + ))} +
+
+); + +/** + * Pinned-open windmod tooltip next to its Griffel-suite twin. Note the mechanisms differ + * (top-layer popover + CSS anchor positioning vs portal + react-positioning) — the + * BUBBLES must match pixel-for-pixel; small placement offsets are the audit surface. + */ +export const GriffelComparison = (): React.ReactNode => ( +
+ + + + + + + + Griffel + + +
+); diff --git a/packages/react-components/react-windmod-preview/stories/src/compare.module.css b/packages/react-components/react-windmod-preview/stories/src/compare.module.css new file mode 100644 index 00000000000000..c725a4997d5c47 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/compare.module.css @@ -0,0 +1,53 @@ +/* + * Plain CSS Modules (no Tailwind, no theme reference) — demo chrome for the pilot-review + * stories only. Deliberately styled with neutral literals so the harness itself never + * competes with the component styles under review. + */ + +.grid { + display: grid; + grid-template-columns: max-content 1fr 1fr; + gap: 12px 24px; + align-items: center; + max-width: 720px; +} + +.header { + font: 600 12px/16px system-ui, sans-serif; + color: #616161; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.label { + font: 400 13px/18px system-ui, sans-serif; + color: #242424; +} + +.row { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.stack { + display: flex; + flex-direction: column; + gap: 16px; +} + +.surface { + padding: 16px; + border-radius: 8px; + /* Windmod token — resolves inside a ThemeProvider subtree; transparent elsewhere. */ + background: var(--color-neutral-background-2, transparent); +} + +.spacer { + display: flex; + gap: 48px; + padding: 48px; + flex-wrap: wrap; + justify-content: center; +} From b60ba879694a1f9f7aaf3c7062dfa073b20e0f3e Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Thu, 20 Aug 2026 20:10:35 -0700 Subject: [PATCH 005/246] =?UTF-8?q?feat(windmod):=20pilot=20verified=20in?= =?UTF-8?q?=20storybook=20=E2=80=94=20placements,=20theming,=20comparisons?= =?UTF-8?q?=20green?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storybook smoke (screenshots in .scratch/windmod-shots/): Button matches its Griffel twin across every variant; all 12 tooltip placements render with correctly oriented arrows on the top-layer/CSS-anchor stack; nested ThemeProvider subtrees (web-dark/teams-dark/teams-HC) resolve, including a dark-themed tooltip IN THE TOP LAYER — the display:contents theming architecture holds in-browser. Story fixes: hint popovers are exclusive by spec, so pinned-open demo tooltips override the content slot with popover='manual'; classic (Griffel) icons in the demo layer only — @fluentui/react-icons/headless ships factories, not premade components (PLAN corrected); placements story gets a spaced grid. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- migration/windmod/LEDGER.md | 16 +++++++++++++ migration/windmod/PLAN.md | 23 +++++++++++-------- .../stories/src/Button/index.stories.tsx | 10 ++++---- .../stories/src/Provider/index.stories.tsx | 14 +++++++++-- .../stories/src/Tooltip/index.stories.tsx | 8 ++++--- .../stories/src/compare.module.css | 9 ++++++++ 6 files changed, 62 insertions(+), 18 deletions(-) diff --git a/migration/windmod/LEDGER.md b/migration/windmod/LEDGER.md index 0d581c7f49d85f..eec8e10f41dc59 100644 --- a/migration/windmod/LEDGER.md +++ b/migration/windmod/LEDGER.md @@ -25,8 +25,24 @@ Status flow: `planned` → `pilot` → `audited` → `converted` → `validated` | Button | pilot-built | Button.module.css ported verbatim; wrapper reintroduces appearance/shape/size (headless omits them — defaults mirrored, drift risk documented); adds data-size/data-empty | | Tooltip | pilot-built | REAL top-layer adaptation: data-placement re-key (logical vocab + rtl swap), pure-CSS arrow cross-axis, JS offset compensation (4/+6); [data-hidden] producer gone — no boundary hiding | +### Storybook verification 2026-08-20 (screenshots in .scratch/windmod-shots/) + +- Button GriffelComparison: side-by-side parity convincing across all 5 + appearances × 3 sizes × 3 shapes × 3 disabled states × icon variants. +- Tooltip Placements: all 12 placements render with correctly oriented arrows + (top-layer + CSS anchor positioning + data-placement re-key + pure-CSS arrow). +- ThemeProvider NestedThemes: web-dark/teams-dark/teams-HC subtrees all resolve; + the pinned top-layer tooltip inside the dark subtree renders DARK — the + display:contents theming architecture is proven in-browser. +- Learning: `popover='hint'` is exclusive by spec — multiple pinned-open + tooltips need `content={{ popover: 'manual' }}` (demo-only device). + ### Pilot known gaps (for review discussion) +- Headless icons: /headless ships FACTORIES only (2.0.337) — premade icon + components are still Griffel. Stories use classic icons in the demo layer; + the windmod library imports none. PLAN.md corrected. + - Arrow cross-axis position for -start/-end placements is a fixed 8px inset (`--fui-positioning-arrow-cross`) — Griffel aligned it to the anchor from JS. Zero-tolerance VR may flag it on those placements; centered placements should match. diff --git a/migration/windmod/PLAN.md b/migration/windmod/PLAN.md index 166adc32a52f3e..46a0413544a251 100644 --- a/migration/windmod/PLAN.md +++ b/migration/windmod/PLAN.md @@ -15,7 +15,7 @@ has not asked for them: `react-tailwind-theme` (`styling/tailwind-css-modules`): `tokens.css` (@theme inline, 467 tokens → utilities), `themes.css` (7 theme classes × 433 tokens, generated with lockstep assertions), `variants.css` (catalog - already named after the headless data-* vocabulary), the `fui.*` cascade + already named after the headless data-_ vocabulary), the `fui._` cascade layer order, and the CSS-Modules build pipeline. Standalone value: teams styling raw headless components get real Fluent themes. 2. **`@fluentui/react-windmod-preview`** — styled Fluent components composing @@ -30,10 +30,10 @@ has not asked for them: - **Name:** react-windmod-preview (portmanteau: tailWIND + CSS MODules). - **Theme layer is its own package** (react-tailwind-theme-preview). - **Per-component architecture — hook composition, not component wrapping:** - `useX(props, ref)` (headless; emits data-* state attrs) → `useXStyles(state)` + `useX(props, ref)` (headless; emits data-_ state attrs) → `useXStyles(state)` (ours: module class-map onto every slot's className, clsx-merged with consumer classNames; JS variant branching on state where headless emits no - data-* — e.g. Button appearance/size/shape; group marker per D15/D16) → + data-_ — e.g. Button appearance/size/shape; group marker per D15/D16) → `renderX(state)` (headless re-export). Full state access; upstream tactical edits (e.g. adding data-appearance) become optional in-pattern PRs, never blockers. @@ -48,9 +48,14 @@ has not asked for them: `popover="hint"`, Toaster `popover="manual"`, MenuPopover `popover="auto"`, non-modal Dialog `popover="manual"`). Top-layer keeps DOM ancestry → theme vars cascade in. -- **Icons: `@fluentui/react-icons/headless`** — published upstream since - 2.0.334 (verified in node_modules). Griffel-free; never import the classic - Griffel entrypoints. +- **Icons: `@fluentui/react-icons/headless`** — published upstream (verified at + 2.0.337; repo bumped from 2.0.311). CORRECTION (pilot finding): the headless + entry ships the Griffel-free FACTORIES (createFluentIcon/bundleIcon/wrapIcon) + and the attribute stylesheet, but NOT premade icon components — the premade + ones (CalendarMonthRegular, …) are still Griffel-styled in 2.0.337. Windmod's + library imports no icons; consumers/stories use classic premade icons for now + (Griffel rides along in the DEMO layer only). Track upstream for premade + headless components; the fork's promotion work remains the reference. - **Tokens stay kebab-case** (`--color-neutral-foreground-1`). Self-contained namespace; deliberately NOT interoperable with classic FluentProvider camelCase theming. Documented as a design decision. @@ -70,10 +75,10 @@ has not asked for them: stories Storybook wired with the Tailwind v4 pipeline. - **Phase 1 — Pilot (GATE: user review): Button + Tooltip + ThemeProvider.** Tooltip forces the theme provider, top-layer and positioning path; Button - covers variant-branching without data-*. Side-by-side stories vs the Griffel + covers variant-branching without data-\*. Side-by-side stories vs the Griffel suite for review. A third component only if the user asks after review. - **Phase 2 — Audit matrix:** all ~53 headless components × {DOM/slot parity - vs styled twin, data-* coverage vs module.css selector needs, top-layer CSS + vs styled twin, data-\* coverage vs module.css selector needs, top-layer CSS adaptation, port complexity} → batch plan + optional-upstream-PR list. - **Phase 3 — Batch conversion** under the established protocol: full contract per batch, batch+seam validation only, chunked per-package commits; full @@ -93,7 +98,7 @@ has not asked for them: → compile module.css → dist/styles.css + class-map JS. - VR harness (migration/griffel-to-tailwind/validation/) → adapt for cross-package A/B (our story render vs Griffel suite story render). -- DECISIONS.md (D2 layers, D15/D16 class-name & data-* contract, D20 nonce, +- DECISIONS.md (D2 layers, D15/D16 class-name & data-\* contract, D20 nonce, …) → contract carries forward unless a decision here supersedes it. ## Risk register diff --git a/packages/react-components/react-windmod-preview/stories/src/Button/index.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Button/index.stories.tsx index 3ff23235f19bcc..385c63813ecad0 100644 --- a/packages/react-components/react-windmod-preview/stories/src/Button/index.stories.tsx +++ b/packages/react-components/react-windmod-preview/stories/src/Button/index.stories.tsx @@ -2,8 +2,10 @@ import * as React from 'react'; import { Button, ThemeProvider } from '@fluentui/react-windmod-preview'; import type { ButtonProps } from '@fluentui/react-windmod-preview'; import { Button as GriffelButton, FluentProvider, webLightTheme } from '@fluentui/react-components'; -import { CalendarMonthRegular } from '@fluentui/react-icons/headless'; -import { CalendarMonthRegular as GriffelCalendarMonthRegular } from '@fluentui/react-icons'; +// Classic (Griffel-styled) icons on BOTH sides: @fluentui/react-icons/headless ships only +// the FACTORIES (createFluentIcon/bundleIcon) as of 2.0.337 — no premade Griffel-free +// components yet. Demo-layer-only; the windmod library itself imports no icons. +import { CalendarMonthRegular } from '@fluentui/react-icons'; import styles from '../compare.module.css'; @@ -94,7 +96,7 @@ export const GriffelComparison = (): React.ReactNode => {
- }>Button + }>Button
icon only
@@ -105,7 +107,7 @@ export const GriffelComparison = (): React.ReactNode => {
- } aria-label="Calendar" /> + } aria-label="Calendar" />
diff --git a/packages/react-components/react-windmod-preview/stories/src/Provider/index.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Provider/index.stories.tsx index 2557c33cae0d9a..8ecb170ca03845 100644 --- a/packages/react-components/react-windmod-preview/stories/src/Provider/index.stories.tsx +++ b/packages/react-components/react-windmod-preview/stories/src/Provider/index.stories.tsx @@ -26,7 +26,12 @@ export const NestedThemes = (): React.ReactNode => (
- +
@@ -35,7 +40,12 @@ export const NestedThemes = (): React.ReactNode => (
- +
diff --git a/packages/react-components/react-windmod-preview/stories/src/Tooltip/index.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Tooltip/index.stories.tsx index ef7bcde9d41ab8..3aed94014cc3d7 100644 --- a/packages/react-components/react-windmod-preview/stories/src/Tooltip/index.stories.tsx +++ b/packages/react-components/react-windmod-preview/stories/src/Tooltip/index.stories.tsx @@ -49,11 +49,13 @@ const placements = [ /** All 12 placements pinned open — exercises data-placement re-keying + the pure-CSS arrow. */ export const Placements = (): React.ReactNode => ( -
+
{placements.map(positioning => ( ( export const GriffelComparison = (): React.ReactNode => (
- + diff --git a/packages/react-components/react-windmod-preview/stories/src/compare.module.css b/packages/react-components/react-windmod-preview/stories/src/compare.module.css index c725a4997d5c47..0bb8c0da98c67b 100644 --- a/packages/react-components/react-windmod-preview/stories/src/compare.module.css +++ b/packages/react-components/react-windmod-preview/stories/src/compare.module.css @@ -51,3 +51,12 @@ flex-wrap: wrap; justify-content: center; } + +/* Room for a tooltip on every side of every trigger without overlapping neighbors. */ +.placementsGrid { + display: grid; + grid-template-columns: repeat(3, max-content); + gap: 110px 170px; + justify-content: center; + padding: 90px; +} From 08ad9e8bcdfc1e8bd4536e16f9d7284ad8137f52 Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Thu, 20 Aug 2026 21:42:36 -0700 Subject: [PATCH 006/246] =?UTF-8?q?refactor(windmod):=20address=20PR=20rev?= =?UTF-8?q?iew=20=E2=80=94=20Tailwind-first=20CSS,=20immutability,=20prett?= =?UTF-8?q?ier=203=20+=20class=20sorting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tailwind syntax throughout: fui-focus-outline uses @apply/@variant after (implicit content, collapsed inset); the tooltip arrow rides size-()/rotate-()/ rounded-bl-small/bg-clip-content utilities; content border is border border-solid border-transparent-stroke. Custom-property writes, clip-path and the two-layer drop-shadow stay CSS per review guidance. - placement-* variants promoted into the theme catalog (headless data-placement is on the surface itself, so the canonical &:where() form now applies); Tooltip.module.css consumes them, comments trimmed file-wide. - Styles hooks are immutable: new state + spread slots, components consume the return value. - prettier 3.9.6 + prettier-plugin-tailwindcss + @prettier/sync ported from the old branch (config, helpers, full-source preset); class sorting applied. tailwindFunctions:['clsx'] deliberately off — clsx argument order is semantic. - Stories restructured one-per-file re-exported via index.stories.tsx (addon convention). Together with the preset rebuild this fixes the docs-page 'Error: issues with data' (stale build required @prettier/sync; multi-export files only got fullSource on the last export). Docs verified in-browser. - lodash kebabCase in isConformant; bare directory imports (no /index); graphify entries dropped from .gitignore. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- .gitignore | 2 - .prettierignore | 4 + package.json | 6 +- .../src/fullsource.ts | 4 +- .../src/modifyImports.ts | 11 +- .../src/sliceStory.test.ts | 48 ++--- .../src/sliceStory.ts | 10 +- .../css/utilities.css | 31 +--- .../css/variants.css | 19 ++ .../library/src/button.ts | 4 +- .../src/components/Button/Button.module.css | 20 +- .../library/src/components/Button/Button.tsx | 4 +- .../src/components/Button/useButtonStyles.ts | 53 +++--- .../src/components/Tooltip/Tooltip.module.css | 174 ++++++++---------- .../src/components/Tooltip/Tooltip.tsx | 8 +- .../components/Tooltip/useTooltipStyles.ts | 37 ++-- .../library/src/provider.ts | 4 +- .../library/src/testing/isConformant.ts | 8 +- .../library/src/tooltip.ts | 4 +- .../src/Button/ButtonDefault.stories.tsx | 45 +++++ .../ButtonGriffelComparison.stories.tsx | 71 +++++++ .../stories/src/Button/index.stories.tsx | 112 +---------- .../ThemeProviderNestedThemes.stories.tsx | 68 +++++++ .../stories/src/Provider/index.stories.tsx | 69 +------ .../src/Tooltip/TooltipDefault.stories.tsx | 20 ++ .../TooltipGriffelComparison.stories.tsx | 30 +++ .../src/Tooltip/TooltipPlacements.stories.tsx | 42 +++++ .../stories/src/Tooltip/index.stories.tsx | 87 +-------- .../stories/src/compare.module.css | 8 +- prettier.config.js | 12 ++ scripts/prettier/src/prettier-helpers.js | 23 ++- yarn.lock | 94 +++++++++- 32 files changed, 623 insertions(+), 509 deletions(-) create mode 100644 packages/react-components/react-windmod-preview/stories/src/Button/ButtonDefault.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/Button/ButtonGriffelComparison.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/Provider/ThemeProviderNestedThemes.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/Tooltip/TooltipDefault.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/Tooltip/TooltipGriffelComparison.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/Tooltip/TooltipPlacements.stories.tsx diff --git a/.gitignore b/.gitignore index aaa1ec55c8d9c4..e552c079d12129 100644 --- a/.gitignore +++ b/.gitignore @@ -163,5 +163,3 @@ gulp-cache # session scratch space (never committed) .scratch/ -graphify-out/ -packages/graphify-out/ diff --git a/.prettierignore b/.prettierignore index 36acacc7b5f173..8599dd494c9fca 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,9 @@ # generated or imported files package-deps.json +# generated theme CSS — byte-exact against packages/tokens values (generator --check); +# prettier would rewrite value bytes (rgba spacing, trailing zeros, line wraps) +packages/react-components/react-tailwind-theme-preview/css/tokens.css +packages/react-components/react-tailwind-theme-preview/css/themes.css CHANGELOG.* # generated from `web-features` via `yarn generate-browser-support` **/browser-support-data.generated.json diff --git a/package.json b/package.json index 89d079a5e0eb91..234af65958c254 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "@oddbird/css-anchor-positioning": "0.4.0", "@phenomnomnominal/tsquery": "6.1.3", "@playwright/test": "1.56.1", + "@prettier/sync": "0.6.1", "@react-native/babel-preset": "0.73.21", "@rnx-kit/eslint-plugin": "0.8.6", "@rollup/plugin-node-resolve": "13.3.0", @@ -145,7 +146,6 @@ "@types/markdown-table": "2.0.0", "@types/micromatch": "4.0.2", "@types/node": "^22.0.0", - "@types/prettier": "2.7.2", "@types/progress": "2.0.5", "@types/react": "19.2.2", "@types/react-dom": "19.2.2", @@ -269,7 +269,8 @@ "postcss": "8.5.18", "postcss-loader": "4.1.0", "postcss-modules": "4.1.3", - "prettier": "2.8.8", + "prettier": "3.9.6", + "prettier-plugin-tailwindcss": "0.8.1", "progress": "2.0.3", "puppeteer": "24.42.0", "raw-loader": "4.0.2", @@ -372,7 +373,6 @@ "micromatch/braces": "^3.0.3", "nx/minimatch": "^9.0.7", "playwright": "1.56.1", - "prettier": "2.8.8", "prismjs": "1.30.0", "puppeteer": "24.42.0", "shell-quote": "^1.8.2", diff --git a/packages/react-components/babel-preset-storybook-full-source/src/fullsource.ts b/packages/react-components/babel-preset-storybook-full-source/src/fullsource.ts index e0221d52b16fb0..f4d02199db5968 100644 --- a/packages/react-components/babel-preset-storybook-full-source/src/fullsource.ts +++ b/packages/react-components/babel-preset-storybook-full-source/src/fullsource.ts @@ -1,5 +1,7 @@ import * as Babel from '@babel/core'; -import * as prettier from 'prettier'; +// Babel visitors are synchronous, but prettier 3's `format` is async — use the official +// synchronous wrapper (runs prettier in a worker thread under the hood). +import * as prettier from '@prettier/sync'; import * as fs from 'fs'; import * as nodePath from 'path'; diff --git a/packages/react-components/babel-preset-storybook-full-source/src/modifyImports.ts b/packages/react-components/babel-preset-storybook-full-source/src/modifyImports.ts index 03f8348eb73726..698ebe7199de42 100644 --- a/packages/react-components/babel-preset-storybook-full-source/src/modifyImports.ts +++ b/packages/react-components/babel-preset-storybook-full-source/src/modifyImports.ts @@ -29,10 +29,13 @@ export function modifyImportsPlugin(babel: typeof Babel, options: BabelPluginOpt parserOptions.plugins.push('typescript'); }, pre() { - this.imports = Object.keys(importMappings).reduce((acc, cur) => { - acc[importMappings[cur].replace] = []; - return acc; - }, {} as PluginState['imports']); + this.imports = Object.keys(importMappings).reduce( + (acc, cur) => { + acc[importMappings[cur].replace] = []; + return acc; + }, + {} as PluginState['imports'], + ); }, visitor: { Program: { diff --git a/packages/react-components/babel-preset-storybook-full-source/src/sliceStory.test.ts b/packages/react-components/babel-preset-storybook-full-source/src/sliceStory.test.ts index 58f54d5f4c76de..72937dd7f6b48a 100644 --- a/packages/react-components/babel-preset-storybook-full-source/src/sliceStory.test.ts +++ b/packages/react-components/babel-preset-storybook-full-source/src/sliceStory.test.ts @@ -17,13 +17,13 @@ function slice(source: string, targetStory: string): string { * Formats sliced output the same way the downstream pipeline does. Doubles as a * syntax guard: prettier throws on invalid output (e.g. `const args = {} = {}`). */ -function format(code: string): string { +async function format(code: string): Promise { return prettier.format(code, { parser: 'babel-ts' }); } describe('sliceStorySource', () => { describe('CSF3 render function with non-plain args parameter', () => { - it('unwraps a defaulted args parameter into a valid `const args` declaration', () => { + it('unwraps a defaulted args parameter into a valid `const args` declaration', async () => { const source = [ `import * as React from 'react';`, `import { Button } from '@fluentui/react-button';`, @@ -40,7 +40,7 @@ describe('sliceStorySource', () => { // Regression: raw AssignmentPattern id produced `const args = {…} = {…}`, // invalid syntax that prettier would reject. - expect(format(sliced)).toMatchInlineSnapshot(` + expect(await format(sliced)).toMatchInlineSnapshot(` "import * as React from \\"react\\"; import { Button } from \\"@fluentui/react-button\\"; export const WithDefault = () => { @@ -51,7 +51,7 @@ describe('sliceStorySource', () => { `); }); - it('unwraps a rest args parameter into a valid `const args` declaration', () => { + it('unwraps a rest args parameter into a valid `const args` declaration', async () => { const source = [ `import * as React from 'react';`, `import { Button } from '@fluentui/react-button';`, @@ -66,7 +66,7 @@ describe('sliceStorySource', () => { const sliced = slice(source, 'WithRest'); - expect(format(sliced)).toMatchInlineSnapshot(` + expect(await format(sliced)).toMatchInlineSnapshot(` "import * as React from \\"react\\"; import { Button } from \\"@fluentui/react-button\\"; export const WithRest = () => { @@ -77,7 +77,7 @@ describe('sliceStorySource', () => { `); }); - it('prefers merged meta/story args over the render param default', () => { + it('prefers merged meta/story args over the render param default', async () => { const source = [ `import * as React from 'react';`, `import { Button } from '@fluentui/react-button';`, @@ -93,7 +93,7 @@ describe('sliceStorySource', () => { const sliced = slice(source, 'Overrides'); - expect(format(sliced)).toMatchInlineSnapshot(` + expect(await format(sliced)).toMatchInlineSnapshot(` "import * as React from \\"react\\"; import { Button } from \\"@fluentui/react-button\\"; export const Overrides = () => { @@ -106,7 +106,7 @@ describe('sliceStorySource', () => { }); describe('CSF3 render method shorthand', () => { - it('normalizes a render method shorthand without args', () => { + it('normalizes a render method shorthand without args', async () => { const source = [ `import * as React from 'react';`, `import { Button } from '@fluentui/react-button';`, @@ -123,7 +123,7 @@ describe('sliceStorySource', () => { const sliced = slice(source, 'Base'); - expect(format(sliced)).toMatchInlineSnapshot(` + expect(await format(sliced)).toMatchInlineSnapshot(` "import * as React from \\"react\\"; import { Button } from \\"@fluentui/react-button\\"; export const Base = () => { @@ -133,7 +133,7 @@ describe('sliceStorySource', () => { `); }); - it('normalizes a render method shorthand with an args parameter', () => { + it('normalizes a render method shorthand with an args parameter', async () => { const source = [ `import * as React from 'react';`, `import { Button } from '@fluentui/react-button';`, @@ -151,7 +151,7 @@ describe('sliceStorySource', () => { const sliced = slice(source, 'WithArgs'); - expect(format(sliced)).toMatchInlineSnapshot(` + expect(await format(sliced)).toMatchInlineSnapshot(` "import * as React from \\"react\\"; import { Button } from \\"@fluentui/react-button\\"; export const WithArgs = () => { @@ -164,7 +164,7 @@ describe('sliceStorySource', () => { }); describe('meta-level render fallback', () => { - it('uses the meta render for a render-less story (arrow form)', () => { + it('uses the meta render for a render-less story (arrow form)', async () => { const source = [ `import * as React from 'react';`, `import { Button } from '@fluentui/react-button';`, @@ -183,7 +183,7 @@ describe('sliceStorySource', () => { const sliced = slice(source, 'Basic'); - expect(format(sliced)).toMatchInlineSnapshot(` + expect(await format(sliced)).toMatchInlineSnapshot(` "import * as React from \\"react\\"; import { Button } from \\"@fluentui/react-button\\"; export const Basic = () => { @@ -194,7 +194,7 @@ describe('sliceStorySource', () => { `); }); - it('uses the meta render for a render-less story (method shorthand)', () => { + it('uses the meta render for a render-less story (method shorthand)', async () => { const source = [ `import * as React from 'react';`, `import { Button } from '@fluentui/react-button';`, @@ -215,7 +215,7 @@ describe('sliceStorySource', () => { const sliced = slice(source, 'Basic'); - expect(format(sliced)).toMatchInlineSnapshot(` + expect(await format(sliced)).toMatchInlineSnapshot(` "import * as React from \\"react\\"; import { Button } from \\"@fluentui/react-button\\"; export const Basic = () => { @@ -226,7 +226,7 @@ describe('sliceStorySource', () => { `); }); - it('prefers a story-level render over the meta render', () => { + it('prefers a story-level render over the meta render', async () => { const source = [ `import * as React from 'react';`, `import { Button } from '@fluentui/react-button';`, @@ -246,7 +246,7 @@ describe('sliceStorySource', () => { const sliced = slice(source, 'Custom'); - expect(format(sliced)).toMatchInlineSnapshot(` + expect(await format(sliced)).toMatchInlineSnapshot(` "import * as React from \\"react\\"; import { Button } from \\"@fluentui/react-button\\"; export const Custom = () => { @@ -259,7 +259,7 @@ describe('sliceStorySource', () => { }); describe('module-level member assignment pruning', () => { - it('keeps non-CSF member assignments on reachable helpers (e.g. `Card.displayName`)', () => { + it('keeps non-CSF member assignments on reachable helpers (e.g. `Card.displayName`)', async () => { const source = [ `import * as React from 'react';`, ``, @@ -279,7 +279,7 @@ describe('sliceStorySource', () => { expect(sliced).toContain(`Card.displayName = 'Card'`); }); - it('keeps member assignments on non-component (lowercase) identifiers', () => { + it('keeps member assignments on non-component (lowercase) identifiers', async () => { const source = [ `import * as React from 'react';`, `import { Button } from '@fluentui/react-button';`, @@ -298,7 +298,7 @@ describe('sliceStorySource', () => { expect(sliced).toContain(`config.foo = 'bar'`); }); - it('still removes CSF2 story annotation assignments on the target story', () => { + it('still removes CSF2 story annotation assignments on the target story', async () => { const source = [ `import * as React from 'react';`, `import { Button } from '@fluentui/react-button';`, @@ -319,7 +319,7 @@ describe('sliceStorySource', () => { }); describe('dangling reference pruning', () => { - it('drops a member assignment when its target declaration is pruned', () => { + it('drops a member assignment when its target declaration is pruned', async () => { const source = [ `import * as React from 'react';`, `import { Button } from '@fluentui/react-button';`, @@ -342,7 +342,7 @@ describe('sliceStorySource', () => { expect(sliced).not.toContain(' { + it('keeps a member assignment when its target declaration survives', async () => { const source = [ `import * as React from 'react';`, `import { Button } from '@fluentui/react-button';`, @@ -362,7 +362,7 @@ describe('sliceStorySource', () => { expect(sliced).toContain(`Card.displayName = 'Card'`); }); - it('drops an identifier re-assignment when its declaration is pruned', () => { + it('drops an identifier re-assignment when its declaration is pruned', async () => { const source = [ `import * as React from 'react';`, `import { Button } from '@fluentui/react-button';`, @@ -381,7 +381,7 @@ describe('sliceStorySource', () => { expect(sliced).not.toContain('counter'); }); - it('keeps a side-effect statement referencing a global and a surviving binding', () => { + it('keeps a side-effect statement referencing a global and a surviving binding', async () => { const source = [ `import * as React from 'react';`, `import { Button } from '@fluentui/react-button';`, diff --git a/packages/react-components/babel-preset-storybook-full-source/src/sliceStory.ts b/packages/react-components/babel-preset-storybook-full-source/src/sliceStory.ts index 387d3982af6c1f..d23bbc66c267f8 100644 --- a/packages/react-components/babel-preset-storybook-full-source/src/sliceStory.ts +++ b/packages/react-components/babel-preset-storybook-full-source/src/sliceStory.ts @@ -56,7 +56,7 @@ export function sliceStorySource(babel: typeof Babel, source: string, options: S plugins: [createSliceStoryPlugin(babel, options, context)], }); - return context.handled ? result?.code ?? null : null; + return context.handled ? (result?.code ?? null) : null; } function createSliceStoryPlugin( @@ -503,8 +503,8 @@ function buildRenderFunction( const argsId = t.isAssignmentPattern(argsParam) ? argsParam.left : t.isRestElement(argsParam) - ? argsParam.argument - : argsParam; + ? argsParam.argument + : argsParam; // Precedence for the local `args`: merged meta/story args win; otherwise fall // back to the param's default (`args = `) so the author's intended @@ -565,8 +565,8 @@ function mergeArgs( const key = t.isIdentifier(property.key) ? property.key.name : t.isStringLiteral(property.key) - ? property.key.value - : undefined; + ? property.key.value + : undefined; if (key !== undefined) { if (!byKey.has(key)) { order.push({ kind: 'key', key }); diff --git a/packages/react-components/react-tailwind-theme-preview/css/utilities.css b/packages/react-components/react-tailwind-theme-preview/css/utilities.css index a4957e1d2fa59d..5bc16d1d99e69f 100644 --- a/packages/react-components/react-tailwind-theme-preview/css/utilities.css +++ b/packages/react-components/react-tailwind-theme-preview/css/utilities.css @@ -87,35 +87,24 @@ --fui-focus-outline-radius: initial; --fui-focus-outline-offset: initial; - border-top-color: transparent; - border-right-color: transparent; - border-bottom-color: transparent; - border-left-color: transparent; + @apply border-transparent; - &::after { - content: ''; - position: absolute; - pointer-events: none; - z-index: 1; + /* Tailwind's `after` variant supplies `content` itself. */ + @variant after { + @apply pointer-events-none absolute z-1; border: var(--fui-focus-outline-width, 2px) solid var(--fui-focus-outline-color, var(--color-stroke-focus-2)); border-radius: var(--fui-focus-outline-radius, var(--radius-medium)); /* `getOutlinePosition()`: `calc( * -1)` with no offset, else - `calc(0px - - )`. Both branches collapse into one expression - because an unset `--fui-focus-outline-offset` falls back to `0px`. */ - top: calc(0px - var(--fui-focus-outline-width, 2px) - var(--fui-focus-outline-offset, 0px)); - right: calc(0px - var(--fui-focus-outline-width, 2px) - var(--fui-focus-outline-offset, 0px)); - bottom: calc(0px - var(--fui-focus-outline-width, 2px) - var(--fui-focus-outline-offset, 0px)); - left: calc(0px - var(--fui-focus-outline-width, 2px) - var(--fui-focus-outline-offset, 0px)); + `calc(0px - - )` — one expression, since an unset offset falls + back to `0px`. */ + inset: calc(0px - var(--fui-focus-outline-width, 2px) - var(--fui-focus-outline-offset, 0px)); } - @media (forced-colors: active) { - &::after { - border-top-color: Highlight; - border-right-color: Highlight; - border-bottom-color: Highlight; - border-left-color: Highlight; + @variant forced-colors { + @variant after { + @apply border-[Highlight]; } } } diff --git a/packages/react-components/react-tailwind-theme-preview/css/variants.css b/packages/react-components/react-tailwind-theme-preview/css/variants.css index aeb42a4dba344f..edf0a1ada6941a 100644 --- a/packages/react-components/react-tailwind-theme-preview/css/variants.css +++ b/packages/react-components/react-tailwind-theme-preview/css/variants.css @@ -100,6 +100,25 @@ @custom-variant position-bottom (&:where([data-position='bottom'])); @custom-variant position-start-or-end (&:where([data-position='start'], [data-position='end'])); +/* + * Anchored-surface placement — the headless positioning hook stamps `data-placement` + * ON the positioned surface (logical vocabulary: above|below|before|after, with + * -start/-end (inline) or -top/-bottom (block) alignment suffixes) and keeps it in sync + * with the browser-resolved fallback. Prefix variants key the main axis; suffix variants + * key the alignment; the two `-centered` forms name the suffix-less (centered) cases per + * axis, for cross-axis rules that differ between horizontal and vertical placements. + */ +@custom-variant placement-above (&:where([data-placement^='above'])); +@custom-variant placement-below (&:where([data-placement^='below'])); +@custom-variant placement-before (&:where([data-placement^='before'])); +@custom-variant placement-after (&:where([data-placement^='after'])); +@custom-variant placement-start (&:where([data-placement$='-start'])); +@custom-variant placement-end (&:where([data-placement$='-end'])); +@custom-variant placement-top (&:where([data-placement$='-top'])); +@custom-variant placement-bottom (&:where([data-placement$='-bottom'])); +@custom-variant placement-centered-inline (&:where([data-placement='above'], [data-placement='below'])); +@custom-variant placement-centered-block (&:where([data-placement='before'], [data-placement='after'])); + /* * "Where is the scrollable DrawerBody scrolled to" — DrawerHeader / DrawerFooter reveal a * separator once the body has scrolled at all, and make it fully opaque while there is diff --git a/packages/react-components/react-windmod-preview/library/src/button.ts b/packages/react-components/react-windmod-preview/library/src/button.ts index 24b58550285de2..79b49f03b5d8c8 100644 --- a/packages/react-components/react-windmod-preview/library/src/button.ts +++ b/packages/react-components/react-windmod-preview/library/src/button.ts @@ -1,4 +1,4 @@ -export { Button, buttonClassNames, useButtonStyles } from './components/Button/index'; +export { Button, buttonClassNames, useButtonStyles } from './components/Button'; export type { ButtonAppearance, ButtonProps, @@ -6,7 +6,7 @@ export type { ButtonSize, ButtonSlots, ButtonState, -} from './components/Button/index'; +} from './components/Button'; /** Headless building blocks, re-exported for consumers composing their own Button. */ export { renderButton, useButton } from '@fluentui/react-headless-components-preview/button'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css b/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css index cb980e2e203077..1a3def84413055 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css +++ b/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css @@ -28,8 +28,8 @@ * SINGLE-valued, so they apply to every entry — no positional-list realignment (that is * the Avatar trap, §2.1, which is why Avatar's `margin` deliberately stays a shorthand). */ - transition-property: background-color, border-top-color, border-right-color, border-bottom-color, border-left-color, - color; + transition-property: + background-color, border-top-color, border-right-color, border-bottom-color, border-left-color, color; transition-timing-function: var(--ease-easy-ease); @variant hover { @@ -449,24 +449,32 @@ @variant focus-visible-fui { @apply border-t-stroke-focus-2 border-r-stroke-focus-2 border-b-stroke-focus-2 border-l-stroke-focus-2; - box-shadow: var(--shadow-2), 0 0 0 var(--spacing-thin) var(--color-stroke-focus-2) inset, + box-shadow: + var(--shadow-2), + 0 0 0 var(--spacing-thin) var(--color-stroke-focus-2) inset, 0 0 0 var(--spacing-thick) var(--color-neutral-foreground-on-brand) inset; @variant hover { @apply border-t-stroke-focus-2 border-r-stroke-focus-2 border-b-stroke-focus-2 border-l-stroke-focus-2; - box-shadow: var(--shadow-2), 0 0 0 var(--spacing-thin) var(--color-stroke-focus-2) inset; + box-shadow: + var(--shadow-2), + 0 0 0 var(--spacing-thin) var(--color-stroke-focus-2) inset; } } /* BUGFIX: Mozilla specific styles (Mozilla BugID: 1857642) */ @supports (-moz-appearance: button) { @variant focus-visible-fui { - box-shadow: var(--shadow-2), 0 0 0 calc(var(--spacing-thin) + 0.25px) var(--color-stroke-focus-2) inset, + box-shadow: + var(--shadow-2), + 0 0 0 calc(var(--spacing-thin) + 0.25px) var(--color-stroke-focus-2) inset, 0 0 0 var(--spacing-thick) var(--color-neutral-foreground-on-brand) inset; @variant hover { - box-shadow: var(--shadow-2), 0 0 0 calc(var(--spacing-thin) + 0.25px) var(--color-stroke-focus-2) inset; + box-shadow: + var(--shadow-2), + 0 0 0 calc(var(--spacing-thin) + 0.25px) var(--color-stroke-focus-2) inset; } } } diff --git a/packages/react-components/react-windmod-preview/library/src/components/Button/Button.tsx b/packages/react-components/react-windmod-preview/library/src/components/Button/Button.tsx index ed856025f66181..8cf5a1ff118ac1 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Button/Button.tsx +++ b/packages/react-components/react-windmod-preview/library/src/components/Button/Button.tsx @@ -23,9 +23,7 @@ export const Button: ForwardRefComponent = React.forwardRef((props, size, }; - useButtonStyles(state); - - return renderButton(state); + return renderButton(useButtonStyles(state)); // Casting is required due to lack of distributive union to support union on @types/react }) as ForwardRefComponent; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Button/useButtonStyles.ts b/packages/react-components/react-windmod-preview/library/src/components/Button/useButtonStyles.ts index 8269aa52c99c85..03c49801b377dd 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Button/useButtonStyles.ts +++ b/packages/react-components/react-windmod-preview/library/src/components/Button/useButtonStyles.ts @@ -5,10 +5,9 @@ import type { ButtonState } from './Button.types'; import styles from './Button.module.css'; /** - * Public identity class for Button — the Tailwind named-group marker. It is the ONLY - * public class; every other class name is a hashed CSS Modules ident. Style descendants - * with `group-*\/fui-button` variants (or `[class~='group/fui-button']` in plain CSS) - * rather than targeting internals. + * Public identity class for Button — the Tailwind named-group marker, and the ONLY public + * class (everything else is a hashed CSS Modules ident). Target it with `group-*\/fui-button` + * variants rather than internals. */ export const buttonClassNames: { root: string } = { root: 'group/fui-button', @@ -20,33 +19,25 @@ type ButtonRootDataAttributes = { }; /** - * Applies the Fluent visual contract to the headless Button state. - * - * `data-disabled`, `data-disabled-focusable`, `data-icon-only` and `data-icon-position` - * are already set by the headless `useButton`; the two below are windmod-only styling - * states. + * Applies the Fluent visual contract, returning new state (no slot mutation). The headless + * hook already stamps data-disabled/-disabled-focusable/-icon-only/-icon-position; + * data-size and data-empty are windmod-only styling states. */ export const useButtonStyles = (state: ButtonState): ButtonState => { - const { appearance, shape } = state; - - const root = state.root as ButtonState['root'] & ButtonRootDataAttributes; - - root['data-size'] = state.size; - root['data-empty'] = !state.root.children || undefined; - - // Module class FIRST (the group marker must never be classList[0] — nwsapi's :scope - // polyfill throws on the `/`), consumer className LAST so consumer overrides win. - state.root.className = clsx( - styles.root, - buttonClassNames.root, - styles[appearance], - styles[shape], - state.root.className, - ); - - if (state.icon) { - state.icon.className = clsx(styles.icon, state.icon.className); - } - - return state; + const { appearance, shape, size } = state; + + const root: ButtonState['root'] & ButtonRootDataAttributes = { + ...state.root, + 'data-size': size, + 'data-empty': !state.root.children || undefined, + // Module class FIRST (a group marker as classList[0] breaks nwsapi's :scope polyfill), + // consumer className LAST so consumer overrides win. + className: clsx(styles.root, buttonClassNames.root, styles[appearance], styles[shape], state.root.className), + }; + + return { + ...state, + root, + icon: state.icon && { ...state.icon, className: clsx(styles.icon, state.icon.className) }, + }; }; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Tooltip/Tooltip.module.css b/packages/react-components/react-windmod-preview/library/src/components/Tooltip/Tooltip.module.css index d80e8d7e43d3cb..56df271cf65cf7 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Tooltip/Tooltip.module.css +++ b/packages/react-components/react-windmod-preview/library/src/components/Tooltip/Tooltip.module.css @@ -1,163 +1,139 @@ @reference '#theme'; -/* Layer order — restated per module so stylesheet load order cannot decide the ranking; - re-declaring an existing layer is a no-op. */ +/* Layer order — restated per module so stylesheet load order cannot decide the ranking. */ @layer fui.theme, fui.base, fui.components, fui.components.l1, fui.components.l2, fui.components.l3, fui.components.l4, fui.components.l5, fui.utilities; /* - * Adapted from the Griffel-suite parity CSS for the headless top-layer model: - * - * - The content is a native `popover="hint"` element. The top layer owns display (no - * `hidden`/`data-open` display dance), but the UA [popover] defaults (border, padding, - * Canvas colors, `overflow: auto`) must all be overridden here — layered author rules - * still beat UA styles, layers only order the author origin. - * - Placement rides `data-placement` ON THE CONTENT (headless vocabulary: - * above|below|before|after with -start/-end/-top/-bottom suffixes, kept in sync with - * the browser-resolved fallback by the headless placement observer) — not - * react-positioning's `data-popper-placement` ancestor attribute. - * - The arrow is the headless-rendered `[data-arrow]` child (no class hook, no arrowRef): - * its cross-axis position is pure CSS (centered via auto margins; -start/-end offsets - * via --fui-positioning-arrow-cross), where react-positioning used to set inline - * coordinates from JS. - * - `before`/`after` are LOGICAL placements, but the arrow's edge offsets and rotation - * are physical geometry (`@noflip` in the Griffel source) — the rtl variant swaps them - * explicitly. + * Top-layer model: the content is a native `popover="hint"` element (the top layer owns + * show/hide; the UA [popover] defaults are overridden below), placement rides + * `data-placement` on the content (see the placement-* variants in the theme catalog), + * and the arrow is the class-less headless-rendered `[data-arrow]` child — styled from + * here because there is no slot/className hook for it (upstream candidate). */ @layer fui.components.l1 { .content { - @apply box-border max-w-240 cursor-default overflow-visible rounded-medium bg-neutral-background-1 px-11 pt-vertical-xs pb-vertical-s-nudge font-base text-base-200 leading-base-200 break-words text-neutral-foreground-1; + @apply box-border max-w-240 cursor-default overflow-visible rounded-medium border border-solid border-transparent-stroke bg-neutral-background-1 px-11 pt-vertical-xs pb-vertical-s-nudge font-base text-base-200 leading-base-200 break-words text-neutral-foreground-1; - border: 1px solid var(--color-transparent-stroke); - - /* TODO carried from the Griffel source: need versions of tokens.alias.shadow.shadow8 - etc. that work with filter. */ + /* TODO carried from the Griffel source: needs a filter-compatible shadow token. */ filter: drop-shadow(0 0 2px var(--color-neutral-shadow-ambient)) drop-shadow(0 4px 8px var(--color-neutral-shadow-key)); } -} -@layer fui.components.l1 { - /* `appearance` is a LOOK prop, so it rides a module class */ + /* `appearance` is a look prop — it rides a module class. */ .inverted { @apply bg-neutral-background-static text-neutral-foreground-static-inverted; } -} -@layer fui.components.l1 { .content { - /* Cross-axis inset for -start/-end/-top/-bottom arrow placements. The Griffel arrow - was positioned from JS to align with the anchor; this fixed inset is the pure-CSS - stand-in, overridable per consumer. */ + /* Cross-axis inset for aligned (-start/-end/-top/-bottom) arrows; Griffel computed + this from JS. Overridable per consumer. */ --fui-positioning-arrow-cross: 8px; - /* - * The arrow: a rotated square whose visible half is carved by the ::before clip-path. - * Sizes stay literal px on purpose — the JS side (Tooltip.tsx ARROW_HEIGHT) does the - * matching arithmetic in CSS pixels, so they must NOT be rescaled via --base-scale. - * 8.484px = 6px arrow height × 1.414 (diagonal); -4.242px = half, poking out of the - * surface. Rotation and the per-edge offsets are PHYSICAL geometry (`@noflip` in the - * Griffel source) — see the rtl block for the logical placements. - */ + /* A rotated square; the visible half is carved by the ::before clip-path. Literal px + on purpose: 8.484px = 6px arrow height × √2, -4.242px = half — paired with + ARROW_HEIGHT in Tooltip.tsx. Rotation and edge offsets are PHYSICAL geometry + (@noflip); the rtl block swaps the logical before/after placements. */ > :where([data-arrow]) { - @apply absolute box-border; - - z-index: -1; + @apply absolute -z-1 box-border size-(--fui-positioning-arrow-height) rotate-(--fui-positioning-arrow-angle) rounded-bl-small bg-inherit bg-clip-content; --fui-positioning-arrow-height: 8.484px; --fui-positioning-arrow-offset: -4.242px; - background-color: inherit; - background-clip: content-box; - - border-bottom-left-radius: var(--radius-small); /* @noflip */ - transform: rotate(var(--fui-positioning-arrow-angle)); /* @noflip */ + @variant before { + @apply -m-px block size-full rounded-bl-small border border-solid border-transparent-stroke bg-inherit; - height: var(--fui-positioning-arrow-height); - width: var(--fui-positioning-arrow-height); - - &::before { - @apply block h-full w-full; - - content: ''; - background-color: inherit; - - margin: -1px; - border: 1px solid var(--color-transparent-stroke); - - border-bottom-left-radius: var(--radius-small); /* @noflip */ clip-path: polygon(0% 0%, 100% 100%, 0% 100%); } } - /* Main axis: which edge the arrow pokes out of, and its rotation. */ - &:where([data-placement^='above']) > :where([data-arrow]) { - bottom: var(--fui-positioning-arrow-offset); + /* Main axis: the edge the arrow pokes out of, and its rotation. */ + @variant placement-above { + > :where([data-arrow]) { + @apply bottom-(--fui-positioning-arrow-offset); - --fui-positioning-arrow-angle: -45deg; + --fui-positioning-arrow-angle: -45deg; + } } - &:where([data-placement^='below']) > :where([data-arrow]) { - top: var(--fui-positioning-arrow-offset); + @variant placement-below { + > :where([data-arrow]) { + @apply top-(--fui-positioning-arrow-offset); - --fui-positioning-arrow-angle: 135deg; + --fui-positioning-arrow-angle: 135deg; + } } - /* Logical `before` = physical left of the trigger in LTR → arrow on the surface's - right edge (Griffel's popper-left case); `after` mirrors it. */ - &:where([data-placement^='before']) > :where([data-arrow]) { - right: var(--fui-positioning-arrow-offset); /* @noflip */ + /* Logical before = physical left of the trigger in LTR → arrow on the right edge. */ + @variant placement-before { + > :where([data-arrow]) { + @apply right-(--fui-positioning-arrow-offset); - --fui-positioning-arrow-angle: 225deg; + --fui-positioning-arrow-angle: 225deg; + } } - &:where([data-placement^='after']) > :where([data-arrow]) { - left: var(--fui-positioning-arrow-offset); /* @noflip */ + @variant placement-after { + > :where([data-arrow]) { + @apply left-(--fui-positioning-arrow-offset); - --fui-positioning-arrow-angle: 45deg; + --fui-positioning-arrow-angle: 45deg; + } } @variant rtl { - &:where([data-placement^='before']) > :where([data-arrow]) { - right: auto; - left: var(--fui-positioning-arrow-offset); + @variant placement-before { + > :where([data-arrow]) { + @apply right-auto left-(--fui-positioning-arrow-offset); - --fui-positioning-arrow-angle: 45deg; + --fui-positioning-arrow-angle: 45deg; + } } - &:where([data-placement^='after']) > :where([data-arrow]) { - left: auto; - right: var(--fui-positioning-arrow-offset); + @variant placement-after { + > :where([data-arrow]) { + @apply right-(--fui-positioning-arrow-offset) left-auto; - --fui-positioning-arrow-angle: 225deg; + --fui-positioning-arrow-angle: 225deg; + } } } - /* Cross axis: centered for suffix-less placements, near-edge inset for suffixed. */ - &:where([data-placement='above'], [data-placement='below']) > :where([data-arrow]) { - inset-inline: 0; - margin-inline: auto; + /* Cross axis: centered when suffix-less, near-edge inset when aligned. */ + @variant placement-centered-inline { + > :where([data-arrow]) { + @apply inset-x-0 mx-auto; + } } - &:where([data-placement$='-start']) > :where([data-arrow]) { - inset-inline-start: var(--fui-positioning-arrow-cross); + @variant placement-centered-block { + > :where([data-arrow]) { + @apply inset-y-0 my-auto; + } } - &:where([data-placement$='-end']) > :where([data-arrow]) { - inset-inline-end: var(--fui-positioning-arrow-cross); + @variant placement-start { + > :where([data-arrow]) { + @apply start-(--fui-positioning-arrow-cross); + } } - &:where([data-placement='before'], [data-placement='after']) > :where([data-arrow]) { - inset-block: 0; - margin-block: auto; + @variant placement-end { + > :where([data-arrow]) { + @apply end-(--fui-positioning-arrow-cross); + } } - &:where([data-placement$='-top']) > :where([data-arrow]) { - top: var(--fui-positioning-arrow-cross); + @variant placement-top { + > :where([data-arrow]) { + @apply top-(--fui-positioning-arrow-cross); + } } - &:where([data-placement$='-bottom']) > :where([data-arrow]) { - bottom: var(--fui-positioning-arrow-cross); + @variant placement-bottom { + > :where([data-arrow]) { + @apply bottom-(--fui-positioning-arrow-cross); + } } } } diff --git a/packages/react-components/react-windmod-preview/library/src/components/Tooltip/Tooltip.tsx b/packages/react-components/react-windmod-preview/library/src/components/Tooltip/Tooltip.tsx index a3256e6b4e9b75..b95627ce7920ed 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Tooltip/Tooltip.tsx +++ b/packages/react-components/react-windmod-preview/library/src/components/Tooltip/Tooltip.tsx @@ -30,17 +30,15 @@ export const Tooltip = (props: TooltipProps): JSXElement => { resolved.offset === undefined ? TOOLTIP_OFFSET + (withArrow ? ARROW_HEIGHT : 0) : typeof resolved.offset === 'number' && withArrow - ? resolved.offset + ARROW_HEIGHT - : resolved.offset; + ? resolved.offset + ARROW_HEIGHT + : resolved.offset; const state: TooltipState = { ...useTooltip({ ...rest, withArrow, positioning: { ...resolved, offset } }), appearance, }; - useTooltipStyles(state); - - return renderTooltip(state); + return renderTooltip(useTooltipStyles(state)); }; Tooltip.displayName = 'Tooltip'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Tooltip/useTooltipStyles.ts b/packages/react-components/react-windmod-preview/library/src/components/Tooltip/useTooltipStyles.ts index c35917d90697a2..bdf53689f36c82 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Tooltip/useTooltipStyles.ts +++ b/packages/react-components/react-windmod-preview/library/src/components/Tooltip/useTooltipStyles.ts @@ -6,30 +6,31 @@ import styles from './Tooltip.module.css'; /** * Public identity class for Tooltip — the Tailwind named-group marker on the content - * surface (Tooltip's only slot). The only public class; everything else is a hashed - * CSS Modules ident. + * surface (Tooltip's only slot), and the ONLY public class. */ export const tooltipClassNames: { root: string } = { root: 'group/fui-tooltip', }; /** - * Applies the Fluent visual contract to the headless Tooltip state. - * - * Visibility needs no class work: the content is a native `popover="hint"` element, so - * the top layer controls display, and the headless hook already stamps `data-open` and - * `data-placement` on the content for state/placement selectors (the arrow is the - * `[data-arrow]` child, styled from the content's module rules). + * Applies the Fluent visual contract, returning new state (no slot mutation). Only class + * assembly happens here: the headless hook already stamps `data-open` and `data-placement` + * on the content, the native `popover="hint"` top layer owns show/hide, and the arrow is + * the class-less `[data-arrow]` child styled from Tooltip.module.css. */ export const useTooltipStyles = (state: TooltipState): TooltipState => { - // Module class FIRST (the group marker must never be classList[0] — nwsapi's :scope - // polyfill throws on the `/`), consumer className LAST so consumer overrides win. - state.content.className = clsx( - styles.content, - tooltipClassNames.root, - state.appearance === 'inverted' && styles.inverted, - state.content.className, - ); - - return state; + return { + ...state, + content: { + ...state.content, + // Module class FIRST (a group marker as classList[0] breaks nwsapi's :scope + // polyfill), consumer className LAST so consumer overrides win. + className: clsx( + styles.content, + tooltipClassNames.root, + state.appearance === 'inverted' && styles.inverted, + state.content.className, + ), + }, + }; }; diff --git a/packages/react-components/react-windmod-preview/library/src/provider.ts b/packages/react-components/react-windmod-preview/library/src/provider.ts index a090ebe346965c..d9540355d63b8f 100644 --- a/packages/react-components/react-windmod-preview/library/src/provider.ts +++ b/packages/react-components/react-windmod-preview/library/src/provider.ts @@ -1,5 +1,5 @@ -export { ThemeProvider } from './components/Provider/index'; -export type { ThemeClassName, ThemeProviderProps } from './components/Provider/index'; +export { ThemeProvider } from './components/Provider'; +export type { ThemeClassName, ThemeProviderProps } from './components/Provider'; /** * The shipped theme class names, re-exported from the theme package so the provider and diff --git a/packages/react-components/react-windmod-preview/library/src/testing/isConformant.ts b/packages/react-components/react-windmod-preview/library/src/testing/isConformant.ts index a090d81a91b404..fd0c85edb4f65f 100644 --- a/packages/react-components/react-windmod-preview/library/src/testing/isConformant.ts +++ b/packages/react-components/react-windmod-preview/library/src/testing/isConformant.ts @@ -1,12 +1,6 @@ import { isConformant as baseIsConformant } from '@fluentui/react-conformance'; import type { IsConformantOptions } from '@fluentui/react-conformance'; - -function kebabCase(str: string): string { - return str - .replace(/([a-z])([A-Z])/g, '$1-$2') - .replace(/\s+/g, '-') - .toLowerCase(); -} +import { kebabCase } from 'lodash'; export function isConformant( testInfo: Omit, 'componentPath'> & { componentPath?: string }, diff --git a/packages/react-components/react-windmod-preview/library/src/tooltip.ts b/packages/react-components/react-windmod-preview/library/src/tooltip.ts index dc48c4ce178d56..9c66d07d6800a4 100644 --- a/packages/react-components/react-windmod-preview/library/src/tooltip.ts +++ b/packages/react-components/react-windmod-preview/library/src/tooltip.ts @@ -1,4 +1,4 @@ -export { Tooltip, tooltipClassNames, useTooltipStyles } from './components/Tooltip/index'; +export { Tooltip, tooltipClassNames, useTooltipStyles } from './components/Tooltip'; export type { OnVisibleChangeData, TooltipAppearance, @@ -6,7 +6,7 @@ export type { TooltipSlots, TooltipState, TooltipTriggerProps, -} from './components/Tooltip/index'; +} from './components/Tooltip'; /** Headless building blocks, re-exported for consumers composing their own Tooltip. */ export { renderTooltip, useTooltip } from '@fluentui/react-headless-components-preview/tooltip'; diff --git a/packages/react-components/react-windmod-preview/stories/src/Button/ButtonDefault.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Button/ButtonDefault.stories.tsx new file mode 100644 index 00000000000000..1889c897b76856 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/Button/ButtonDefault.stories.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import { Button, ThemeProvider } from '@fluentui/react-windmod-preview'; +import { CalendarMonthRegular } from '@fluentui/react-icons'; + +import styles from '../compare.module.css'; + +const appearances = ['secondary', 'primary', 'outline', 'subtle', 'transparent'] as const; +const sizes = ['small', 'medium', 'large'] as const; +const shapes = ['rounded', 'circular', 'square'] as const; + +export const Default = (): React.ReactNode => ( + +
+ {appearances.map(appearance => ( +
+ {sizes.map(size => ( + + ))} + + + + +
+ ))} +
+ {shapes.map(shape => ( + + ))} +
+
+
+); diff --git a/packages/react-components/react-windmod-preview/stories/src/Button/ButtonGriffelComparison.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Button/ButtonGriffelComparison.stories.tsx new file mode 100644 index 00000000000000..b618620d37d9d4 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/Button/ButtonGriffelComparison.stories.tsx @@ -0,0 +1,71 @@ +import * as React from 'react'; +import { Button, ThemeProvider } from '@fluentui/react-windmod-preview'; +import type { ButtonProps } from '@fluentui/react-windmod-preview'; +import { Button as GriffelButton, FluentProvider, webLightTheme } from '@fluentui/react-components'; +import { CalendarMonthRegular } from '@fluentui/react-icons'; + +import styles from '../compare.module.css'; + +const appearances = ['secondary', 'primary', 'outline', 'subtle', 'transparent'] as const; +const sizes = ['small', 'medium', 'large'] as const; +const shapes = ['rounded', 'circular', 'square'] as const; + +/** + * Every windmod variant next to its Griffel-suite twin (inside a FluentProvider). + * The pair in each row must be pixel-identical — this is the pilot's review surface. + */ +export const GriffelComparison = (): React.ReactNode => { + const variants: Array<{ label: string; props: Partial }> = [ + ...appearances.map(appearance => ({ label: appearance, props: { appearance } as Partial })), + ...sizes.map(size => ({ label: size, props: { size } as Partial })), + ...shapes.map(shape => ({ label: shape, props: { shape } as Partial })), + { label: 'disabled', props: { disabled: true } }, + { label: 'disabledFocusable', props: { disabledFocusable: true } }, + { label: 'primary disabled', props: { appearance: 'primary', disabled: true } }, + ]; + + return ( +
+
Variant
+
Windmod
+
Griffel
+ {variants.map(({ label, props }) => ( + +
{label}
+
+ + + +
+
+ + Button + +
+
+ ))} +
with icon
+
+ + + +
+
+ + }>Button + +
+
icon only
+
+ +
+
+ + } aria-label="Calendar" /> + +
+
+ ); +}; diff --git a/packages/react-components/react-windmod-preview/stories/src/Button/index.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Button/index.stories.tsx index 385c63813ecad0..94b5c1a48c6ed9 100644 --- a/packages/react-components/react-windmod-preview/stories/src/Button/index.stories.tsx +++ b/packages/react-components/react-windmod-preview/stories/src/Button/index.stories.tsx @@ -1,115 +1,9 @@ -import * as React from 'react'; -import { Button, ThemeProvider } from '@fluentui/react-windmod-preview'; -import type { ButtonProps } from '@fluentui/react-windmod-preview'; -import { Button as GriffelButton, FluentProvider, webLightTheme } from '@fluentui/react-components'; -// Classic (Griffel-styled) icons on BOTH sides: @fluentui/react-icons/headless ships only -// the FACTORIES (createFluentIcon/bundleIcon) as of 2.0.337 — no premade Griffel-free -// components yet. Demo-layer-only; the windmod library itself imports no icons. -import { CalendarMonthRegular } from '@fluentui/react-icons'; +import { Button } from '@fluentui/react-windmod-preview'; -import styles from '../compare.module.css'; +export { Default } from './ButtonDefault.stories'; +export { GriffelComparison } from './ButtonGriffelComparison.stories'; export default { title: 'Windmod/Button', component: Button, }; - -const appearances = ['secondary', 'primary', 'outline', 'subtle', 'transparent'] as const; -const sizes = ['small', 'medium', 'large'] as const; -const shapes = ['rounded', 'circular', 'square'] as const; - -export const Default = (): React.ReactNode => ( - -
- {appearances.map(appearance => ( -
- {sizes.map(size => ( - - ))} - - - - -
- ))} -
- {shapes.map(shape => ( - - ))} -
-
-
-); - -/** - * Every windmod variant next to its Griffel-suite twin (inside a FluentProvider). - * The pair in each row must be pixel-identical — this is the pilot's review surface. - */ -export const GriffelComparison = (): React.ReactNode => { - const variants: Array<{ label: string; props: Partial }> = [ - ...appearances.map(appearance => ({ label: appearance, props: { appearance } as Partial })), - ...sizes.map(size => ({ label: size, props: { size } as Partial })), - ...shapes.map(shape => ({ label: shape, props: { shape } as Partial })), - { label: 'disabled', props: { disabled: true } }, - { label: 'disabledFocusable', props: { disabledFocusable: true } }, - { label: 'primary disabled', props: { appearance: 'primary', disabled: true } }, - ]; - - return ( -
-
Variant
-
Windmod
-
Griffel
- {variants.map(({ label, props }) => ( - -
{label}
-
- - - -
-
- - Button - -
-
- ))} -
with icon
-
- - - -
-
- - }>Button - -
-
icon only
-
- -
-
- - } aria-label="Calendar" /> - -
-
- ); -}; diff --git a/packages/react-components/react-windmod-preview/stories/src/Provider/ThemeProviderNestedThemes.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Provider/ThemeProviderNestedThemes.stories.tsx new file mode 100644 index 00000000000000..d6b42c851f09ca --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/Provider/ThemeProviderNestedThemes.stories.tsx @@ -0,0 +1,68 @@ +import * as React from 'react'; +import { + Button, + teamsDarkThemeClassName, + teamsHighContrastThemeClassName, + ThemeProvider, + Tooltip, + webDarkThemeClassName, +} from '@fluentui/react-windmod-preview'; + +import styles from '../compare.module.css'; + +/** + * Nested theme subtrees from a `display: contents` provider. The dark subtree's tooltip + * opens in the TOP LAYER and stays dark-themed — top-layer elements keep their DOM + * ancestry, so the provider's custom properties cascade into them. + */ +export const NestedThemes = (): React.ReactNode => ( + +
+
+
+ + + + +
+
+ +
+
+ + + + +
+
+
+ +
+
+ + + +
+
+
+ +
+
+ + +
+
+
+
+
+); diff --git a/packages/react-components/react-windmod-preview/stories/src/Provider/index.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Provider/index.stories.tsx index 8ecb170ca03845..5713b1576b10d0 100644 --- a/packages/react-components/react-windmod-preview/stories/src/Provider/index.stories.tsx +++ b/packages/react-components/react-windmod-preview/stories/src/Provider/index.stories.tsx @@ -1,73 +1,8 @@ -import * as React from 'react'; -import { - Button, - teamsDarkThemeClassName, - teamsHighContrastThemeClassName, - ThemeProvider, - Tooltip, - webDarkThemeClassName, -} from '@fluentui/react-windmod-preview'; +import { ThemeProvider } from '@fluentui/react-windmod-preview'; -import styles from '../compare.module.css'; +export { NestedThemes } from './ThemeProviderNestedThemes.stories'; export default { title: 'Windmod/ThemeProvider', component: ThemeProvider, }; - -/** - * Nested theme subtrees from a `display: contents` provider. The dark subtree's tooltip - * opens in the TOP LAYER and stays dark-themed — top-layer elements keep their DOM - * ancestry, so the provider's custom properties cascade into them. - */ -export const NestedThemes = (): React.ReactNode => ( - -
-
-
- - - - -
-
- -
-
- - - - -
-
-
- -
-
- - - -
-
-
- -
-
- - -
-
-
-
-
-); diff --git a/packages/react-components/react-windmod-preview/stories/src/Tooltip/TooltipDefault.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Tooltip/TooltipDefault.stories.tsx new file mode 100644 index 00000000000000..fd7ca931e49ca4 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/Tooltip/TooltipDefault.stories.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; +import { Button, ThemeProvider, Tooltip } from '@fluentui/react-windmod-preview'; + +import styles from '../compare.module.css'; + +export const Default = (): React.ReactNode => ( + +
+ + + + + + + + + +
+
+); diff --git a/packages/react-components/react-windmod-preview/stories/src/Tooltip/TooltipGriffelComparison.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Tooltip/TooltipGriffelComparison.stories.tsx new file mode 100644 index 00000000000000..41c4bdd67764eb --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/Tooltip/TooltipGriffelComparison.stories.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; +import { Button, ThemeProvider, Tooltip } from '@fluentui/react-windmod-preview'; +import { + Button as GriffelButton, + FluentProvider, + Tooltip as GriffelTooltip, + webLightTheme, +} from '@fluentui/react-components'; + +import styles from '../compare.module.css'; + +/** + * Pinned-open windmod tooltip next to its Griffel-suite twin. The mechanisms differ + * (top-layer popover + CSS anchor positioning vs portal + react-positioning) — the + * BUBBLES must match pixel-for-pixel; small placement offsets are the audit surface. + */ +export const GriffelComparison = (): React.ReactNode => ( +
+ + + + + + + + Griffel + + +
+); diff --git a/packages/react-components/react-windmod-preview/stories/src/Tooltip/TooltipPlacements.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Tooltip/TooltipPlacements.stories.tsx new file mode 100644 index 00000000000000..af0e84f1f8abbe --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/Tooltip/TooltipPlacements.stories.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import { Button, ThemeProvider, Tooltip } from '@fluentui/react-windmod-preview'; +import type { TooltipProps } from '@fluentui/react-windmod-preview'; + +import styles from '../compare.module.css'; + +const placements = [ + 'above-start', + 'above', + 'above-end', + 'before-top', + 'before', + 'before-bottom', + 'after-top', + 'after', + 'after-bottom', + 'below-start', + 'below', + 'below-end', +] as const; + +/** All 12 placements pinned open — exercises data-placement re-keying + the pure-CSS arrow. */ +export const Placements = (): React.ReactNode => ( + +
+ {placements.map(positioning => ( + + + + ))} +
+
+); diff --git a/packages/react-components/react-windmod-preview/stories/src/Tooltip/index.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Tooltip/index.stories.tsx index 3aed94014cc3d7..4fd29870d6e97e 100644 --- a/packages/react-components/react-windmod-preview/stories/src/Tooltip/index.stories.tsx +++ b/packages/react-components/react-windmod-preview/stories/src/Tooltip/index.stories.tsx @@ -1,89 +1,10 @@ -import * as React from 'react'; -import { Button, ThemeProvider, Tooltip } from '@fluentui/react-windmod-preview'; -import type { TooltipProps } from '@fluentui/react-windmod-preview'; -import { - Button as GriffelButton, - FluentProvider, - Tooltip as GriffelTooltip, - webLightTheme, -} from '@fluentui/react-components'; +import { Tooltip } from '@fluentui/react-windmod-preview'; -import styles from '../compare.module.css'; +export { Default } from './TooltipDefault.stories'; +export { Placements } from './TooltipPlacements.stories'; +export { GriffelComparison } from './TooltipGriffelComparison.stories'; export default { title: 'Windmod/Tooltip', component: Tooltip, }; - -export const Default = (): React.ReactNode => ( - -
- - - - - - - - - -
-
-); - -const placements = [ - 'above-start', - 'above', - 'above-end', - 'before-top', - 'before', - 'before-bottom', - 'after-top', - 'after', - 'after-bottom', - 'below-start', - 'below', - 'below-end', -] as const; - -/** All 12 placements pinned open — exercises data-placement re-keying + the pure-CSS arrow. */ -export const Placements = (): React.ReactNode => ( - -
- {placements.map(positioning => ( - - - - ))} -
-
-); - -/** - * Pinned-open windmod tooltip next to its Griffel-suite twin. Note the mechanisms differ - * (top-layer popover + CSS anchor positioning vs portal + react-positioning) — the - * BUBBLES must match pixel-for-pixel; small placement offsets are the audit surface. - */ -export const GriffelComparison = (): React.ReactNode => ( -
- - - - - - - - Griffel - - -
-); diff --git a/packages/react-components/react-windmod-preview/stories/src/compare.module.css b/packages/react-components/react-windmod-preview/stories/src/compare.module.css index 0bb8c0da98c67b..0721b22de3cc29 100644 --- a/packages/react-components/react-windmod-preview/stories/src/compare.module.css +++ b/packages/react-components/react-windmod-preview/stories/src/compare.module.css @@ -13,14 +13,18 @@ } .header { - font: 600 12px/16px system-ui, sans-serif; + font: + 600 12px/16px system-ui, + sans-serif; color: #616161; text-transform: uppercase; letter-spacing: 0.04em; } .label { - font: 400 13px/18px system-ui, sans-serif; + font: + 400 13px/18px system-ui, + sans-serif; color: #242424; } diff --git a/prettier.config.js b/prettier.config.js index 475b7c83517c1c..2073260e7ea506 100644 --- a/prettier.config.js +++ b/prettier.config.js @@ -10,6 +10,18 @@ module.exports = { singleQuote: true, trailingComma: 'all', arrowParens: 'avoid', + // prettier 3 has no plugin auto-discovery - plugins must be declared explicitly. + // prettier-plugin-tailwindcss sorts Tailwind classes; in this repo its effect is + // sorting `@apply` lists in authored *.module.css files (windmod packages). + plugins: ['prettier-plugin-tailwindcss'], + // Tailwind v4 CSS-config mode: point the plugin at the canonical theme entry that + // component modules reference via `@reference '#theme'`. + tailwindStylesheet: './packages/react-components/react-tailwind-theme-preview/css/index.css', + // DO NOT add `tailwindFunctions: ['clsx']`: clsx argument order is semantic in this repo + // (module class first, group marker second, consumer className last) and the + // plugin may reorder across arguments. Class sorting in JS/TS call expressions is + // therefore deliberately NOT enabled; sorting applies to CSS `@apply` lists and + // class attributes. overrides: [ { files: 'apps/vr-tests/**/*', diff --git a/scripts/prettier/src/prettier-helpers.js b/scripts/prettier/src/prettier-helpers.js index c42e0ceeb1309e..faeced1c275de2 100644 --- a/scripts/prettier/src/prettier-helpers.js +++ b/scripts/prettier/src/prettier-helpers.js @@ -1,4 +1,4 @@ -const { execFileSync } = require('child_process'); +const { execSync } = require('child_process'); const fs = require('fs'); const path = require('path'); @@ -51,7 +51,11 @@ function runPrettier(files, config = {}) { const ext = path.extname(file).replace('.', ''); if (prettierSupportedFileExtensions.includes(ext)) { - acc.push(file); + // WHY IGNORE IS NEEDED?: prettier removes one of the '\' within replaceValue + // prettier-ignore + const escapedFileName = `"${file.replace(/\$/g, '\\\$')}"`; + + acc.push(escapedFileName); } return acc; }, /** @type {string[]} */ ([])); @@ -64,20 +68,21 @@ function runPrettier(files, config = {}) { // As of writing, Prettier's Node API (https://prettier.io/docs/en/api.html) only supports running // on a single file. So to easily format multiple files, we have to use the CLI. - // NOTE: arguments are passed as an argv array (no shell), so file names are never interpreted by a shell. - const args = [ + const cmd = [ + 'node', prettierBin, '--config', prettierRulesConfig, '--ignore-path', - prettierIgnorePath, - ...(logErrorsOnly ? ['--loglevel', 'warn'] : []), + `"${prettierIgnorePath}"`, + // `--loglevel` was renamed to `--log-level` in prettier 3 + ...(logErrorsOnly ? ['--log-level', 'warn'] : []), check ? '--check' : '--write', ...prettierSupportedFiles, - ]; + ].join(' '); try { - execFileSync(process.execPath, args, { stdio: 'inherit' }); + execSync(cmd, { stdio: 'inherit' }); return true; } catch { return false; @@ -100,7 +105,7 @@ function runPrettierForFolder(folderPath, config = {}) { } const fileExtensions = `.{${prettierSupportedFileExtensions.join(',')}}`; - const sourcePath = `${path.join(folderPath, nonRecursive ? '' : '**', '*')}${fileExtensions}`; + const sourcePath = `"${path.join(folderPath, nonRecursive ? '' : '**', '*')}${fileExtensions}"`; console.log(`Running prettier for ${sourcePath}`); diff --git a/yarn.lock b/yarn.lock index 4583535de5a743..aa7dc422f0cc17 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2886,6 +2886,7 @@ __metadata: "@oddbird/css-anchor-positioning": "npm:0.4.0" "@phenomnomnominal/tsquery": "npm:6.1.3" "@playwright/test": "npm:1.56.1" + "@prettier/sync": "npm:0.6.1" "@react-native/babel-preset": "npm:0.73.21" "@rnx-kit/eslint-plugin": "npm:0.8.6" "@rollup/plugin-node-resolve": "npm:13.3.0" @@ -2937,7 +2938,6 @@ __metadata: "@types/markdown-table": "npm:2.0.0" "@types/micromatch": "npm:4.0.2" "@types/node": "npm:^22.0.0" - "@types/prettier": "npm:2.7.2" "@types/progress": "npm:2.0.5" "@types/react": "npm:19.2.2" "@types/react-dom": "npm:19.2.2" @@ -3062,7 +3062,8 @@ __metadata: postcss: "npm:8.5.18" postcss-loader: "npm:4.1.0" postcss-modules: "npm:4.1.3" - prettier: "npm:2.8.8" + prettier: "npm:3.9.6" + prettier-plugin-tailwindcss: "npm:0.8.1" progress: "npm:2.0.3" puppeteer: "npm:24.42.0" raw-loader: "npm:4.0.2" @@ -8889,6 +8890,17 @@ __metadata: languageName: node linkType: hard +"@prettier/sync@npm:0.6.1": + version: 0.6.1 + resolution: "@prettier/sync@npm:0.6.1" + dependencies: + make-synchronized: "npm:^0.8.0" + peerDependencies: + prettier: "*" + checksum: 10c0/4cf6dd8c27f6425924ac749ca980693b447d39162b530e103f4613df470c1ec877f691d10796556b748ce8c954cc76e0f2a73e18e02b125d295168012c25df71 + languageName: node + linkType: hard + "@puppeteer/browsers@npm:2.13.0": version: 2.13.0 resolution: "@puppeteer/browsers@npm:2.13.0" @@ -11016,7 +11028,7 @@ __metadata: languageName: node linkType: hard -"@types/prettier@npm:*, @types/prettier@npm:2.7.2": +"@types/prettier@npm:*": version: 2.7.2 resolution: "@types/prettier@npm:2.7.2" checksum: 10c0/16ffbd1135c10027f118517d3b12aaaf3936be1f3c6e4c6c9c03d26d82077c2d86bf0dcad545417896f29e7d90faf058aae5c9db2e868be64298c644492ea29e @@ -23889,6 +23901,13 @@ __metadata: languageName: node linkType: hard +"make-synchronized@npm:^0.8.0": + version: 0.8.0 + resolution: "make-synchronized@npm:0.8.0" + checksum: 10c0/b8293d79e8752840a8103034866296b781c754286bf54864f4014b01f4b46710c5eaa04ffdabd7167513a4e72e7fec4396b36fb5aef16222fd5a268d1b4c041e + languageName: node + linkType: hard + "makeerror@npm:1.0.12": version: 1.0.12 resolution: "makeerror@npm:1.0.12" @@ -27339,7 +27358,74 @@ __metadata: languageName: node linkType: hard -"prettier@npm:2.8.8": +"prettier-plugin-tailwindcss@npm:0.8.1": + version: 0.8.1 + resolution: "prettier-plugin-tailwindcss@npm:0.8.1" + peerDependencies: + "@ianvs/prettier-plugin-sort-imports": "*" + "@prettier/plugin-hermes": "*" + "@prettier/plugin-oxc": "*" + "@prettier/plugin-pug": "*" + "@shopify/prettier-plugin-liquid": "*" + "@trivago/prettier-plugin-sort-imports": "*" + "@zackad/prettier-plugin-twig": "*" + prettier: ^3.0 + prettier-plugin-astro: "*" + prettier-plugin-css-order: "*" + prettier-plugin-jsdoc: "*" + prettier-plugin-marko: "*" + prettier-plugin-multiline-arrays: "*" + prettier-plugin-organize-attributes: "*" + prettier-plugin-organize-imports: "*" + prettier-plugin-sort-imports: "*" + prettier-plugin-svelte: "*" + peerDependenciesMeta: + "@ianvs/prettier-plugin-sort-imports": + optional: true + "@prettier/plugin-hermes": + optional: true + "@prettier/plugin-oxc": + optional: true + "@prettier/plugin-pug": + optional: true + "@shopify/prettier-plugin-liquid": + optional: true + "@trivago/prettier-plugin-sort-imports": + optional: true + "@zackad/prettier-plugin-twig": + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-css-order: + optional: true + prettier-plugin-jsdoc: + optional: true + prettier-plugin-marko: + optional: true + prettier-plugin-multiline-arrays: + optional: true + prettier-plugin-organize-attributes: + optional: true + prettier-plugin-organize-imports: + optional: true + prettier-plugin-sort-imports: + optional: true + prettier-plugin-svelte: + optional: true + checksum: 10c0/45b8d8686e6b27f06f710063b24c4b5fba4d201aaee7ed85dfdd6cb8e68b6d8f9cea2d8497999c4f72e4c3a8b93b078fb3b6ab35567b3bfaef4bf9ec43482a6f + languageName: node + linkType: hard + +"prettier@npm:3.9.6": + version: 3.9.6 + resolution: "prettier@npm:3.9.6" + bin: + prettier: bin/prettier.cjs + checksum: 10c0/9f7ddae234035868f3daab3dc34e6de7e54622185afb017378029f0c09a9c023248ccf6f34def0b17a0538e18c47aa1b3188bab72965d1bf735919baa0747e99 + languageName: node + linkType: hard + +"prettier@npm:^2.0.1, prettier@npm:^2.8.8": version: 2.8.8 resolution: "prettier@npm:2.8.8" bin: From b6f6b124ef70d0c7fa612bf7dd52dff3f1bc3e05 Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Thu, 20 Aug 2026 21:43:10 -0700 Subject: [PATCH 007/246] =?UTF-8?q?docs(windmod):=20ledger=20=E2=80=94=20P?= =?UTF-8?q?hase=202=20items=20from=20PR=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- migration/windmod/LEDGER.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/migration/windmod/LEDGER.md b/migration/windmod/LEDGER.md index eec8e10f41dc59..36f94bff647b7f 100644 --- a/migration/windmod/LEDGER.md +++ b/migration/windmod/LEDGER.md @@ -37,6 +37,19 @@ Status flow: `planned` → `pilot` → `audited` → `converted` → `validated` - Learning: `popover='hint'` is exclusive by spec — multiple pinned-open tooltips need `content={{ popover: 'manual' }}` (demo-only device). +### Phase 2 items from PR review (2026-08-20) + +- Shared look-prop types: suggest one home for the appearance/shape/size unions + and per-component Props/State extensions (ideally exported by the headless + package) instead of windmod redeclaring them — user asked to suggest, not + implement yet. +- Upstream tactical-edit candidate: headless Tooltip arrow has no + slot/className hook (arrowRef exists, nothing applies a class) — windmod must + style the `[data-arrow]` child via descendant selector until then. +- Story convention: ONE export per `*.stories.tsx`, re-exported through + `index.stories.tsx` — the export-to-sandbox addon injects `fullSource` only + on the last export otherwise (docs pages error with 'issues with data'). + ### Pilot known gaps (for review discussion) - Headless icons: /headless ships FACTORIES only (2.0.337) — premade icon From a2f2bc30ba4439e74b41a4c91e8080efc723cf3a Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Thu, 20 Aug 2026 22:12:22 -0700 Subject: [PATCH 008/246] feat(windmod): class-targetable tooltip arrow via headless arrowClassName MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tactical headless edit (user-approved): renderTooltip now applies the already-typed state.arrowClassName to the arrow div, mirroring the classic react-tooltip render (data-arrow kept). Windmod's useTooltipStyles assigns styles.arrow through it, and Tooltip.module.css styles .arrow directly — the per-placement rules compose the catalog placement variants with the content's named-group marker (group-placement-*/fui-tooltip) instead of a child selector. Also expanded the class-ordering comment (the '/' in the group marker breaks nwsapi's unescaped :scope polyfill when it is classList[0]). Headless type-check/tests green; placements story verified pixel-identical. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- .../src/components/Tooltip/renderTooltip.tsx | 2 +- .../src/components/Button/useButtonStyles.ts | 7 +- .../src/components/Tooltip/Tooltip.module.css | 125 +++++++----------- .../components/Tooltip/useTooltipStyles.ts | 13 +- 4 files changed, 65 insertions(+), 82 deletions(-) diff --git a/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/renderTooltip.tsx b/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/renderTooltip.tsx index 4e8d795e2ae94c..8d4047657389c4 100644 --- a/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/renderTooltip.tsx +++ b/packages/react-components/react-headless-components-preview/library/src/components/Tooltip/renderTooltip.tsx @@ -16,7 +16,7 @@ export const renderTooltip = (state: TooltipState): JSXElement => { {state.children} {state.shouldRenderTooltip && ( - {state.withArrow &&
} + {state.withArrow &&
} {state.content.children} )} diff --git a/packages/react-components/react-windmod-preview/library/src/components/Button/useButtonStyles.ts b/packages/react-components/react-windmod-preview/library/src/components/Button/useButtonStyles.ts index 03c49801b377dd..b7a5911219fee4 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Button/useButtonStyles.ts +++ b/packages/react-components/react-windmod-preview/library/src/components/Button/useButtonStyles.ts @@ -30,8 +30,11 @@ export const useButtonStyles = (state: ButtonState): ButtonState => { ...state.root, 'data-size': size, 'data-empty': !state.root.children || undefined, - // Module class FIRST (a group marker as classList[0] breaks nwsapi's :scope polyfill), - // consumer className LAST so consumer overrides win. + // Class order: module class, group marker, look classes, consumer className. The + // marker contains a '/' (Tailwind named-group syntax) and must never be classList[0]: + // jsdom's selector engine (nwsapi) builds its :scope polyfill from the first class + // without escaping, so a leading '/' class makes element.matches() throw in tests. + // Consumer-last is convention — overrides win via cascade layers, not class order. className: clsx(styles.root, buttonClassNames.root, styles[appearance], styles[shape], state.root.className), }; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Tooltip/Tooltip.module.css b/packages/react-components/react-windmod-preview/library/src/components/Tooltip/Tooltip.module.css index 56df271cf65cf7..d2cda3d7d83770 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Tooltip/Tooltip.module.css +++ b/packages/react-components/react-windmod-preview/library/src/components/Tooltip/Tooltip.module.css @@ -5,16 +5,21 @@ /* * Top-layer model: the content is a native `popover="hint"` element (the top layer owns - * show/hide; the UA [popover] defaults are overridden below), placement rides - * `data-placement` on the content (see the placement-* variants in the theme catalog), - * and the arrow is the class-less headless-rendered `[data-arrow]` child — styled from - * here because there is no slot/className hook for it (upstream candidate). + * show/hide; the UA [popover] defaults are overridden below) and placement rides + * `data-placement` on the content. The arrow carries `.arrow` via the headless + * `arrowClassName`; its per-placement rules compose the catalog's placement variants with + * the content's named-group marker (`group-placement-*\/fui-tooltip`). */ @layer fui.components.l1 { .content { @apply box-border max-w-240 cursor-default overflow-visible rounded-medium border border-solid border-transparent-stroke bg-neutral-background-1 px-11 pt-vertical-xs pb-vertical-s-nudge font-base text-base-200 leading-base-200 break-words text-neutral-foreground-1; + /* Cross-axis inset for aligned (-start/-end/-top/-bottom) arrows; Griffel computed + this from JS. Declared here (not on .arrow) so consumers can override it at the + surface and let it inherit. */ + --fui-positioning-arrow-cross: 8px; + /* TODO carried from the Griffel source: needs a filter-compatible shadow token. */ filter: drop-shadow(0 0 2px var(--color-neutral-shadow-ambient)) drop-shadow(0 4px 8px var(--color-neutral-shadow-key)); @@ -25,115 +30,85 @@ @apply bg-neutral-background-static text-neutral-foreground-static-inverted; } - .content { - /* Cross-axis inset for aligned (-start/-end/-top/-bottom) arrows; Griffel computed - this from JS. Overridable per consumer. */ - --fui-positioning-arrow-cross: 8px; + /* A rotated square; the visible half is carved by the ::before clip-path. Literal px + on purpose: 8.484px = 6px arrow height × √2, -4.242px = half — paired with + ARROW_HEIGHT in Tooltip.tsx. Rotation and edge offsets are PHYSICAL geometry + (@noflip); the rtl block swaps the logical before/after placements. */ + .arrow { + @apply absolute -z-1 box-border size-(--fui-positioning-arrow-height) rotate-(--fui-positioning-arrow-angle) rounded-bl-small bg-inherit bg-clip-content; - /* A rotated square; the visible half is carved by the ::before clip-path. Literal px - on purpose: 8.484px = 6px arrow height × √2, -4.242px = half — paired with - ARROW_HEIGHT in Tooltip.tsx. Rotation and edge offsets are PHYSICAL geometry - (@noflip); the rtl block swaps the logical before/after placements. */ - > :where([data-arrow]) { - @apply absolute -z-1 box-border size-(--fui-positioning-arrow-height) rotate-(--fui-positioning-arrow-angle) rounded-bl-small bg-inherit bg-clip-content; + --fui-positioning-arrow-height: 8.484px; + --fui-positioning-arrow-offset: -4.242px; - --fui-positioning-arrow-height: 8.484px; - --fui-positioning-arrow-offset: -4.242px; + @variant before { + @apply -m-px block size-full rounded-bl-small border border-solid border-transparent-stroke bg-inherit; - @variant before { - @apply -m-px block size-full rounded-bl-small border border-solid border-transparent-stroke bg-inherit; - - clip-path: polygon(0% 0%, 100% 100%, 0% 100%); - } + clip-path: polygon(0% 0%, 100% 100%, 0% 100%); } /* Main axis: the edge the arrow pokes out of, and its rotation. */ - @variant placement-above { - > :where([data-arrow]) { - @apply bottom-(--fui-positioning-arrow-offset); + @variant group-placement-above/fui-tooltip { + @apply bottom-(--fui-positioning-arrow-offset); - --fui-positioning-arrow-angle: -45deg; - } + --fui-positioning-arrow-angle: -45deg; } - @variant placement-below { - > :where([data-arrow]) { - @apply top-(--fui-positioning-arrow-offset); + @variant group-placement-below/fui-tooltip { + @apply top-(--fui-positioning-arrow-offset); - --fui-positioning-arrow-angle: 135deg; - } + --fui-positioning-arrow-angle: 135deg; } /* Logical before = physical left of the trigger in LTR → arrow on the right edge. */ - @variant placement-before { - > :where([data-arrow]) { - @apply right-(--fui-positioning-arrow-offset); + @variant group-placement-before/fui-tooltip { + @apply right-(--fui-positioning-arrow-offset); - --fui-positioning-arrow-angle: 225deg; - } + --fui-positioning-arrow-angle: 225deg; } - @variant placement-after { - > :where([data-arrow]) { - @apply left-(--fui-positioning-arrow-offset); + @variant group-placement-after/fui-tooltip { + @apply left-(--fui-positioning-arrow-offset); - --fui-positioning-arrow-angle: 45deg; - } + --fui-positioning-arrow-angle: 45deg; } @variant rtl { - @variant placement-before { - > :where([data-arrow]) { - @apply right-auto left-(--fui-positioning-arrow-offset); + @variant group-placement-before/fui-tooltip { + @apply right-auto left-(--fui-positioning-arrow-offset); - --fui-positioning-arrow-angle: 45deg; - } + --fui-positioning-arrow-angle: 45deg; } - @variant placement-after { - > :where([data-arrow]) { - @apply right-(--fui-positioning-arrow-offset) left-auto; + @variant group-placement-after/fui-tooltip { + @apply right-(--fui-positioning-arrow-offset) left-auto; - --fui-positioning-arrow-angle: 225deg; - } + --fui-positioning-arrow-angle: 225deg; } } /* Cross axis: centered when suffix-less, near-edge inset when aligned. */ - @variant placement-centered-inline { - > :where([data-arrow]) { - @apply inset-x-0 mx-auto; - } + @variant group-placement-centered-inline/fui-tooltip { + @apply inset-x-0 mx-auto; } - @variant placement-centered-block { - > :where([data-arrow]) { - @apply inset-y-0 my-auto; - } + @variant group-placement-centered-block/fui-tooltip { + @apply inset-y-0 my-auto; } - @variant placement-start { - > :where([data-arrow]) { - @apply start-(--fui-positioning-arrow-cross); - } + @variant group-placement-start/fui-tooltip { + @apply start-(--fui-positioning-arrow-cross); } - @variant placement-end { - > :where([data-arrow]) { - @apply end-(--fui-positioning-arrow-cross); - } + @variant group-placement-end/fui-tooltip { + @apply end-(--fui-positioning-arrow-cross); } - @variant placement-top { - > :where([data-arrow]) { - @apply top-(--fui-positioning-arrow-cross); - } + @variant group-placement-top/fui-tooltip { + @apply top-(--fui-positioning-arrow-cross); } - @variant placement-bottom { - > :where([data-arrow]) { - @apply bottom-(--fui-positioning-arrow-cross); - } + @variant group-placement-bottom/fui-tooltip { + @apply bottom-(--fui-positioning-arrow-cross); } } } diff --git a/packages/react-components/react-windmod-preview/library/src/components/Tooltip/useTooltipStyles.ts b/packages/react-components/react-windmod-preview/library/src/components/Tooltip/useTooltipStyles.ts index bdf53689f36c82..4e7f29e9149cbe 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Tooltip/useTooltipStyles.ts +++ b/packages/react-components/react-windmod-preview/library/src/components/Tooltip/useTooltipStyles.ts @@ -15,16 +15,21 @@ export const tooltipClassNames: { root: string } = { /** * Applies the Fluent visual contract, returning new state (no slot mutation). Only class * assembly happens here: the headless hook already stamps `data-open` and `data-placement` - * on the content, the native `popover="hint"` top layer owns show/hide, and the arrow is - * the class-less `[data-arrow]` child styled from Tooltip.module.css. + * on the content for CSS state selectors, and the native `popover="hint"` top layer owns + * show/hide. The arrow gets `styles.arrow` via `arrowClassName`; its per-placement rules + * key off the content's group marker (`group-placement-*\/fui-tooltip` variants). */ export const useTooltipStyles = (state: TooltipState): TooltipState => { return { ...state, + arrowClassName: clsx(styles.arrow, state.arrowClassName), content: { ...state.content, - // Module class FIRST (a group marker as classList[0] breaks nwsapi's :scope - // polyfill), consumer className LAST so consumer overrides win. + // Class order: module class, group marker, consumer className. The marker contains + // a '/' (Tailwind named-group syntax) and must never be classList[0]: jsdom's + // selector engine (nwsapi) builds its :scope polyfill from the first class without + // escaping, so a leading '/' class makes element.matches() throw in tests. + // Consumer-last is convention — overrides win via cascade layers, not class order. className: clsx( styles.content, tooltipClassNames.root, From 5a60c6131ec67417744242b189fe137133bdc58c Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Fri, 21 Aug 2026 04:09:46 -0700 Subject: [PATCH 009/246] =?UTF-8?q?refactor(windmod):=20address=20review?= =?UTF-8?q?=20round=202=20=E2=80=94=20layer=20decl=20only=20in=20theme,=20?= =?UTF-8?q?Tailwind-max=20Button,=20formatted=20generator=20output?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @layer order statement now lives ONLY in the theme stylesheet (loads first by contract; the build executor still prepends it to each dist/styles.css) — removed from every module.css, and each file uses single consolidated layer blocks. - Button.module.css: border/HC colors/forced-color-adjust/durations/easing/ font-sizes converted to utilities (arbitrary values for system colors); --fui-button-icon-spacing kebab-cased; icon position/size rules moved onto .icon with named-group variants; icon glyph-swap consolidated (.subtle, .transparent grouped) and scoped through .icon — the fui-Icon-* classes are the headless icons package's public swap contract (iconFilledClassName/ iconRegularClassName constants, stamped by bundleIcon for this purpose). - utilities.css: fui-focus-ring z-index/outline via @apply; comments cut file-wide (variants.css header, Tooltip/ThemeProvider modules, hooks) per review feedback. - Theme generator prettier-formats its output via @prettier/sync, so the .prettierignore exception for tokens/themes.css is gone (both the generator --check and prettier --check pass). - READMEs rewritten: theme README teaches the windmod ThemeProvider flow (no more @fluentui/react-components import) + theme-class-names subpath; windmod README documents the preview posture (tracks the headless preview) and the styling contract. Verified: build green; computed-style probe of windmod-vs-Griffel pairs (secondary, primary, icon spacing) shows zero diffs after the rewrite. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- .prettierignore | 4 - .../react-tailwind-theme-preview/README.md | 69 ++- .../css/themes.css | 47 ++- .../css/tokens.css | 6 +- .../css/utilities.css | 142 ++----- .../css/variants.css | 76 +--- .../scripts/generate-tokens-css.js | 12 +- .../react-windmod-preview/library/README.md | 50 ++- .../src/components/Button/Button.module.css | 396 ++++-------------- .../Provider/ThemeProvider.module.css | 11 +- .../src/components/Tooltip/Tooltip.module.css | 22 +- .../react-windmod-preview/stories/README.md | 2 +- 12 files changed, 249 insertions(+), 588 deletions(-) diff --git a/.prettierignore b/.prettierignore index 8599dd494c9fca..36acacc7b5f173 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,9 +1,5 @@ # generated or imported files package-deps.json -# generated theme CSS — byte-exact against packages/tokens values (generator --check); -# prettier would rewrite value bytes (rgba spacing, trailing zeros, line wraps) -packages/react-components/react-tailwind-theme-preview/css/tokens.css -packages/react-components/react-tailwind-theme-preview/css/themes.css CHANGELOG.* # generated from `web-features` via `yarn generate-browser-support` **/browser-support-data.generated.json diff --git a/packages/react-components/react-tailwind-theme-preview/README.md b/packages/react-components/react-tailwind-theme-preview/README.md index bcfbee800d66ef..1ecea06fe61777 100644 --- a/packages/react-components/react-tailwind-theme-preview/README.md +++ b/packages/react-components/react-tailwind-theme-preview/README.md @@ -1,62 +1,59 @@ # @fluentui/react-tailwind-theme-preview -**Shared Tailwind theme layer for [Fluent UI React](https://react.fluentui.dev)** +**Fluent UI themes and design tokens as a Tailwind v4 CSS layer** -Fluent UI React v9 component packages ship plain, precompiled CSS (`/dist/styles.css`, -reached automatically through each package's generated class map). That compiled CSS references -theme-level custom properties — the design tokens, the cascade `@layer` order, `--base-scale`, -`--spacing` and the four stroke widths. Something has to emit those **once per document**. That is -this package: it ships the theme classes (`.fui-theme-web-light`, `.fui-theme-web-dark`, -`.fui-theme-teams-light`, …, each containing only custom-property declarations) plus the web-light -values as `:root, :host` defaults. (It deliberately registers **no** `@property` rules: a non-empty custom-property -registry puts Blink's transition-start path on a page-global slow branch — see -`migration/griffel-to-tailwind/reports/perf-property-remedy.md`.) +Windmod component packages ship plain, precompiled CSS that references theme-level custom +properties — the design tokens, the cascade `@layer` order, `--base-scale`, `--spacing` and the +stroke widths. Something has to emit those **once per document**: this package. It ships the seven +theme classes (`.fui-theme-web-light`, `.fui-theme-web-dark`, `.fui-theme-teams-light`, …, each +containing only custom-property declarations) plus the web-light values as `:root, :host` defaults. +It deliberately registers **no** `@property` rules (a non-empty registry puts Blink's +transition-start on a page-global slow path). -> ⚠ This package exports no JavaScript. It is CSS only. +The only JavaScript export is the theme class-name constants; everything else is CSS. ## Usage -Install it alongside the components you use: - ```sh -npm install @fluentui/react-components @fluentui/react-tailwind-theme-preview +npm install @fluentui/react-windmod-preview @fluentui/react-tailwind-theme-preview ``` -Import the emitted stylesheet **exactly once**, at your document root, before your own styles: +Import the emitted stylesheet **exactly once**, at your document root, before your own styles — +theme styles must load first: ```js // src/main.jsx (or _app.tsx, root layout, etc.) import '@fluentui/react-tailwind-theme-preview/styles.css'; ``` -Then use components normally — you never import a component stylesheet yourself: +Then pick a theme with windmod's `ThemeProvider` (a `display: contents` element carrying the theme +class — any subtree can be themed, and nested providers override): ```jsx -import { FluentProvider, webLightThemeClassName, Button } from '@fluentui/react-components'; +import { Button, ThemeProvider, webDarkThemeClassName } from '@fluentui/react-windmod-preview'; export default function App() { return ( - + - + ); } ``` -`FluentProvider` applies the theme class you pass via `themeClassName` to its root (and propagates -it to portals); the token **values** come from this package's static CSS. Because a theme class is -just custom-property declarations, putting it on any DOM node themes that subtree, and nested -providers inherit the parent's class when the prop is omitted. +No provider is needed for the default web-light theme. This package also works standalone for teams +styling `@fluentui/react-headless-components-preview` directly — apply a theme class to any element +and reference the tokens. ### If you skip the import The theme's custom properties are missing entirely: color tokens resolve to nothing and numeric -spacing utilities compute to `0px` — components render unthemed with collapsed metrics. If -components look unstyled or padding and gaps look collapsed, this import is missing. +spacing utilities compute to `0px` — components render unthemed with collapsed metrics. ## Layering -All Fluent styles live in one cascade-layer family: +All Fluent styles live in one cascade-layer family, declared by this package's stylesheet (which is +why it must load before component styles): ```css @layer fui.theme, fui.base, fui.components, fui.components.l1, fui.components.l2, fui.components.l3, @@ -64,7 +61,7 @@ All Fluent styles live in one cascade-layer family: ``` - **Plain CSS consumers** need no setup. Unlayered CSS beats every layer, so your own selectors win - by default — no specificity games required. + by default. - **Tailwind consumers** who want their own utilities to beat Fluent component styles should declare the `fui` layer before importing Tailwind (cascade layer order is first-appearance): @@ -73,16 +70,16 @@ All Fluent styles live in one cascade-layer family: @import 'tailwindcss'; ``` -Levels `l3`–`l5` inside `fui.components` are deliberately left empty and are yours to use for -app-global, per-page and one-off overrides that should still lose to your unlayered CSS. +Levels `l3`–`l5` inside `fui.components` are deliberately empty — use them for app-global, per-page +and one-off overrides that should still lose to your unlayered CSS. ## Subpath exports -| Subpath | What it is | -| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -| `@fluentui/react-tailwind-theme-preview/styles.css` | The emitted, plain-CSS root artifact. **This is the consumer entry point.** | -| `@fluentui/react-tailwind-theme-preview` | `css/index.css` — Tailwind **source**, for `@reference`/`@import` from a Tailwind v4 build. Not plain CSS. | -| `@fluentui/react-tailwind-theme-preview/css/*` | The individual source layers (`index.css`, `tokens.css`, `variants.css`, `utilities.css`) for advanced setups. | +| Subpath | What it is | +| ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `@fluentui/react-tailwind-theme-preview/styles.css` | The emitted, plain-CSS root artifact. **This is the consumer entry point.** | +| `@fluentui/react-tailwind-theme-preview/theme-class-names` | The theme class-name constants (`webLightThemeClassName`, …) and the `ThemeClassName` type. | +| `@fluentui/react-tailwind-theme-preview` | `css/index.css` — Tailwind **source**, for `@reference`/`@import` from a Tailwind v4 build. Not plain CSS. | +| `@fluentui/react-tailwind-theme-preview/css/*` | The individual source layers (`index.css`, `tokens.css`, `variants.css`, `utilities.css`) for advanced setups. | -Only `styles.css` is consumable without a Tailwind toolchain; the `css/*` entries require Tailwind -v4 to compile. +Only `styles.css` and `theme-class-names` are consumable without a Tailwind toolchain. diff --git a/packages/react-components/react-tailwind-theme-preview/css/themes.css b/packages/react-components/react-tailwind-theme-preview/css/themes.css index 4e530e50dc7995..346af396c4ade4 100644 --- a/packages/react-components/react-tailwind-theme-preview/css/themes.css +++ b/packages/react-components/react-tailwind-theme-preview/css/themes.css @@ -398,10 +398,12 @@ --radius-5-x-large: 32px; --radius-6-x-large: 40px; --radius-circular: 10000px; - --font-base: 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', + --font-base: + 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', sans-serif; --font-monospace: Consolas, 'Courier New', Courier, monospace; - --font-numeric: Bahnschrift, 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, + --font-numeric: + Bahnschrift, 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', sans-serif; --text-base-100: 10px; --text-base-200: 12px; @@ -839,10 +841,12 @@ --radius-5-x-large: 32px; --radius-6-x-large: 40px; --radius-circular: 10000px; - --font-base: 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', + --font-base: + 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', sans-serif; --font-monospace: Consolas, 'Courier New', Courier, monospace; - --font-numeric: Bahnschrift, 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, + --font-numeric: + Bahnschrift, 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', sans-serif; --text-base-100: 10px; --text-base-200: 12px; @@ -1280,10 +1284,11 @@ --radius-5-x-large: 32px; --radius-6-x-large: 40px; --radius-circular: 10000px; - --font-base: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, 'Apple Color Emoji', 'Segoe UI Emoji', - sans-serif; + --font-base: + -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, 'Apple Color Emoji', 'Segoe UI Emoji', sans-serif; --font-monospace: Consolas, 'Courier New', Courier, monospace; - --font-numeric: Bahnschrift, 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, + --font-numeric: + Bahnschrift, 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', sans-serif; --text-base-100: 10px; --text-base-200: 12px; @@ -1721,10 +1726,11 @@ --radius-5-x-large: 32px; --radius-6-x-large: 40px; --radius-circular: 10000px; - --font-base: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, 'Apple Color Emoji', 'Segoe UI Emoji', - sans-serif; + --font-base: + -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, 'Apple Color Emoji', 'Segoe UI Emoji', sans-serif; --font-monospace: Consolas, 'Courier New', Courier, monospace; - --font-numeric: Bahnschrift, 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, + --font-numeric: + Bahnschrift, 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', sans-serif; --text-base-100: 10px; --text-base-200: 12px; @@ -2162,10 +2168,11 @@ --radius-5-x-large: 32px; --radius-6-x-large: 40px; --radius-circular: 10000px; - --font-base: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, 'Apple Color Emoji', 'Segoe UI Emoji', - sans-serif; + --font-base: + -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, 'Apple Color Emoji', 'Segoe UI Emoji', sans-serif; --font-monospace: Consolas, 'Courier New', Courier, monospace; - --font-numeric: Bahnschrift, 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, + --font-numeric: + Bahnschrift, 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', sans-serif; --text-base-100: 10px; --text-base-200: 12px; @@ -2603,10 +2610,11 @@ --radius-5-x-large: 32px; --radius-6-x-large: 40px; --radius-circular: 10000px; - --font-base: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, 'Apple Color Emoji', 'Segoe UI Emoji', - sans-serif; + --font-base: + -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, 'Apple Color Emoji', 'Segoe UI Emoji', sans-serif; --font-monospace: Consolas, 'Courier New', Courier, monospace; - --font-numeric: Bahnschrift, 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, + --font-numeric: + Bahnschrift, 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', sans-serif; --text-base-100: 10px; --text-base-200: 12px; @@ -3044,10 +3052,11 @@ --radius-5-x-large: 32px; --radius-6-x-large: 40px; --radius-circular: 10000px; - --font-base: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, 'Apple Color Emoji', 'Segoe UI Emoji', - sans-serif; + --font-base: + -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, 'Apple Color Emoji', 'Segoe UI Emoji', sans-serif; --font-monospace: Consolas, 'Courier New', Courier, monospace; - --font-numeric: Bahnschrift, 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, + --font-numeric: + Bahnschrift, 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', sans-serif; --text-base-100: 10px; --text-base-200: 12px; diff --git a/packages/react-components/react-tailwind-theme-preview/css/tokens.css b/packages/react-components/react-tailwind-theme-preview/css/tokens.css index 048dfcc70640b8..16f266a52b6a44 100644 --- a/packages/react-components/react-tailwind-theme-preview/css/tokens.css +++ b/packages/react-components/react-tailwind-theme-preview/css/tokens.css @@ -985,10 +985,12 @@ --radius-5-x-large: 32px; --radius-6-x-large: 40px; --radius-circular: 10000px; - --font-base: 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', + --font-base: + 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', sans-serif; --font-monospace: Consolas, 'Courier New', Courier, monospace; - --font-numeric: Bahnschrift, 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, + --font-numeric: + Bahnschrift, 'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', sans-serif; --text-base-100: 10px; --text-base-200: 12px; diff --git a/packages/react-components/react-tailwind-theme-preview/css/utilities.css b/packages/react-components/react-tailwind-theme-preview/css/utilities.css index 5bc16d1d99e69f..2da6f1bdd8b7c7 100644 --- a/packages/react-components/react-tailwind-theme-preview/css/utilities.css +++ b/packages/react-components/react-tailwind-theme-preview/css/utilities.css @@ -1,87 +1,24 @@ -/** - * Shared focus-ring utilities — the CSS replacement for react-tabster's Griffel focus - * factories (DECISIONS.md D6). Converted packages `@apply` these; the JS helpers - * (`createFocusOutlineStyle`, `createCustomFocusIndicatorStyle`) stay exported from - * react-tabster because unconverted and deprecated packages still call them. - * - * These are `@utility` (not plain classes) so component modules can `@apply` them INSIDE - * their own `@layer fui.*` blocks: `@apply` inlines a utility's declarations at the call - * site, so nothing is ever emitted into Tailwind's `@layer utilities` — which would - * outrank every `fui.*` layer (see CONVERSION_GUIDE.md "Toolchain traps"). - * - * ── Selector ──────────────────────────────────────────────────────────────────────── - * These utilities carry DECLARATIONS ONLY. The selector stays with the caller, which - * must wrap them in the shared `focus-visible-fui` / `focus-within-fui` variants - * (`[data-fui-focus-visible]` / `[data-fui-focus-within]`, still written by keyborg). - * That split is deliberate: `createCustomFocusIndicatorStyle`'s `customizeSelector` - * option (4 call sites) has no CSS equivalent, so the selector must stay authorable. - * - * ── Parameterisation ──────────────────────────────────────────────────────────────── - * The compiled Griffel output differs per component only in the values react-tabster's - * options expose (`outlineRadius`, `outlineColor`, `outlineWidth`, `outlineOffset`). - * Those become CSS custom properties here ("knobs"). With every knob unset the `var()` - * fallbacks reproduce the compiled defaults exactly — see the byte-comparisons below. - * - * ── Isolation (why each utility RESETS its knobs) ─────────────────────────────────── - * The knobs are deliberately UNREGISTERED (no `@property`), and each utility opens by - * resetting every knob it consumes to `initial` on the element it is applied to. - * Unregistered custom properties inherit, and `initial` on an unregistered custom - * property is the guaranteed-invalid value — so the resets keep the knobs self-scoped: - * a knob set on an ancestor can never leak into a nested component's focus indicator - * (SplitButton renders a Button inside a Button), and an unset knob still resolves to - * its `var()` fallback. To override a knob, set it on the consuming element in a - * declaration that beats the component's focus rule (after the `@apply` in the same - * rule, a higher layer, or unlayered consumer CSS) — or, for `fui-focus-outline`, on - * the `::after` pseudo-element itself, which the element-level resets never touch - * (that is where every in-repo caller sets its knobs). - * - * The knobs were previously registered with `@property … syntax:'*'; inherits:false`, - * which enforced both guarantees engine-side. The registrations were REMOVED - * (2026-08-04): any non-empty custom-property registry flips Blink's transition-start - * path onto a page-global slow branch costing ~9.4 µs per started CSS transition — - * paid by EVERY transition on the page, on properties unrelated to the registered - * ones, turning ~2.5× toggle-cost regressions in transition-heavy scenarios. See - * migration/griffel-to-tailwind/reports/perf-mechanism-diagnostic.md (mechanism + - * strip experiment) and reports/perf-property-remedy.md (this change and its gates). - * Do NOT reintroduce `@property` into shipped FluentUI CSS without re-running that - * perf gate. +/* + * Focus-ring utilities (CSS replacement for react-tabster's Griffel focus factories). + * + * `@utility` on purpose: `@apply` inlines the declarations at the call site inside the + * caller's `fui.*` layer — nothing lands in Tailwind's `utilities` layer, which would + * outrank every component layer. Declarations only; the caller supplies the selector via + * the `focus-visible-fui` / `focus-within-fui` variants. + * + * Parameterisation: the `--fui-focus-*` custom properties are knobs with `var()` + * fallbacks reproducing the compiled Griffel defaults. Each utility resets its knobs to + * `initial` (guaranteed-invalid for unregistered properties) so an ancestor's knob never + * leaks into a nested component (SplitButton nests Buttons); override a knob AFTER the + * `@apply`, from a higher layer, or (for fui-focus-outline) on the ::after itself. + * + * Knobs are deliberately NOT `@property`-registered: any non-empty registry puts Blink's + * transition-start on a page-global slow path (~9.4 µs per started transition). Do not + * reintroduce `@property` without re-running that perf gate. */ -/** - * `fui-focus-outline` — the DEFAULT ring produced by - * `createFocusOutlineStyle()` (react-tabster/src/focus/createFocusOutlineStyle.ts): - * a `::after` pseudo-element inset by the outline width. - * - * Byte-comparison against a compiled AOT consumer - * (react-accordion/library/lib-commonjs/components/AccordionHeader/ - * useAccordionHeaderStyles.styles.js:245-268, 341-353): - * - * [data-fui-focus-visible]{border-{top,right,left,bottom}-color:transparent} - * [data-fui-focus-visible]::after{content:""} - * [data-fui-focus-visible]::after{position:absolute} - * [data-fui-focus-visible]::after{pointer-events:none} - * [data-fui-focus-visible]::after{z-index:1} - * [data-fui-focus-visible]::after{border:2px solid var(--color-stroke-focus-2)} - * [data-fui-focus-visible]::after{border-radius:var(--radius-medium)} - * [data-fui-focus-visible]::after{top:calc(2px * -1)} (right/left/bottom idem) - * @media (forced-colors: active){…::after{border-{top,left,right,bottom}-color:Highlight}} - * - * The hardcoded `2px` is react-tabster's own FIXME ("tokens.strokeWidthThick causes some - * weird bugs") — it is reproduced verbatim, NOT "fixed" to a token (DECISIONS.md D6). - * - * `createFocusOutlineStyle` also emits `:focus{outline-style:none}` and - * `:focus-visible{outline-style:none}`. Those sit OUTSIDE the focus-visible selector, so - * they stay with the caller (Button already sets `outline-style: none` in its base). - * - * NOT exercised by any converted package yet — Button uses `fui-focus-ring` below. The - * first `createFocusOutlineStyle` conversion validates this one against its own AOT - * output before trusting it. - */ +/* Default ring (`createFocusOutlineStyle()`): a ::after inset by the outline width. */ @utility fui-focus-outline { - /* Knob isolation — see the header note. `initial` is the guaranteed-invalid value - for these unregistered properties: an ancestor's knob override stops here, the - `var()` fallbacks below stay live, and `::after`-level knob declarations (how all - in-repo callers parameterise this ring) are unaffected. */ --fui-focus-outline-width: initial; --fui-focus-outline-color: initial; --fui-focus-outline-radius: initial; @@ -89,16 +26,11 @@ @apply border-transparent; - /* Tailwind's `after` variant supplies `content` itself. */ @variant after { @apply pointer-events-none absolute z-1; border: var(--fui-focus-outline-width, 2px) solid var(--fui-focus-outline-color, var(--color-stroke-focus-2)); border-radius: var(--fui-focus-outline-radius, var(--radius-medium)); - - /* `getOutlinePosition()`: `calc( * -1)` with no offset, else - `calc(0px - - )` — one expression, since an unset offset falls - back to `0px`. */ inset: calc(0px - var(--fui-focus-outline-width, 2px) - var(--fui-focus-outline-offset, 0px)); } @@ -109,36 +41,9 @@ } } -/** - * `fui-focus-ring` — the Button-family indicator: no pseudo-element, the ring is drawn - * with the element's own border plus an inset `box-shadow`. Produced by - * `createCustomFocusIndicatorStyle({...})` with Button's option object. - * - * Byte-comparison against the compiled AOT output - * (react-button/library/lib-commonjs/components/Button/useButtonStyles.styles.js:41): - * - * .r1f29ykk[data-fui-focus-visible]{ - * border-color:var(--color-stroke-focus-2); - * border-radius:var(--radius-medium); - * border-width:1px; - * outline: solid var(--color-transparent-stroke); - * box-shadow:0 0 0 var(--color-stroke-focus-2) inset; - * z-index:1; - * } - * - * The two widths are shown as placeholders because upstream's AOT output references the - * raw `strokeWidthThick` / `strokeWidthThin` tokens, while the declarations below use the - * `--spacing-thick` / `--spacing-thin` equivalents required by the D4 addendum authoring - * rule (stroke widths live in the spacing namespace and must scale with `--base-scale`). - * Both resolve to 2px / 1px at the default root, so the computed values are unchanged. - * - * With all four knobs unset every declaration below computes to exactly that. The - * declaration ORDER is preserved too, which matters for `border-color` vs `border-width` - * against the `border:` shorthand a caller's base rule may set in a lower layer. - */ +/* Button-family ring (`createCustomFocusIndicatorStyle()`): own border + inset shadow. + Declaration order matters against a lower-layer `border:` shorthand. */ @utility fui-focus-ring { - /* Knob isolation — see the header note. Same-element overrides must come AFTER the - `@apply` (or from a higher layer / unlayered consumer CSS) to beat these resets. */ --fui-focus-ring-color: initial; --fui-focus-ring-radius: initial; --fui-focus-ring-width: initial; @@ -147,8 +52,11 @@ border-color: var(--fui-focus-ring-color, var(--color-stroke-focus-2)); border-radius: var(--fui-focus-ring-radius, var(--radius-medium)); border-width: var(--fui-focus-ring-width, 1px); - outline: var(--spacing-thick) solid var(--color-transparent-stroke); + + @apply outline-(length:--spacing-thick) outline-transparent-stroke outline-solid; + box-shadow: 0 0 0 var(--fui-focus-ring-inset-width, var(--spacing-thin)) var(--fui-focus-ring-color, var(--color-stroke-focus-2)) inset; - z-index: 1; + + @apply z-1; } diff --git a/packages/react-components/react-tailwind-theme-preview/css/variants.css b/packages/react-components/react-tailwind-theme-preview/css/variants.css index edf0a1ada6941a..25e9315c44ea92 100644 --- a/packages/react-components/react-tailwind-theme-preview/css/variants.css +++ b/packages/react-components/react-tailwind-theme-preview/css/variants.css @@ -1,71 +1,13 @@ /** - * Custom variant catalog — the single place state selectors are defined. - * Every variant is `:where()`-wrapped: zero added specificity, cascade decided by - * @layer order alone (DECISIONS.md D2). Names follow the headless-preview - * data-attribute vocabulary where one exists (reports/headless-precedent.md). - * - * Grow this catalog as packages convert; never define variants in component modules. - * - * ── COMPOSITION WITH NAMED GROUPS (DECISIONS.md D15) ────────────────────────────────── - * - * (Careful editing this block: a CSS comment ends at the first `*` immediately followed by a - * slash, so the group syntax is spelled `group-/` throughout rather than with - * an asterisk wildcard, which would terminate the comment and spill the prose into the - * stylesheet as invalid declarations.) - * - * Every SELECTOR variant below already composes as `group-/` for free — - * Tailwind v4 rewrites any `&:where(…)` variant into the group form, so a child module writes - * `@variant group-checked/fui-switch { … }` and get - * `.child:is(:where(.group\/fui-switch):where([data-checked], :checked) *)`. Verified - * against tailwindcss 4.3.3 for `checked`, `not-checked`, `size-small`, `disabled-control`, - * `hover`, `focus-within` and `rtl`. **This catalog needs NO additions to support named - * groups**, and a new entry becomes group-composable the moment it is added here. - * - * Two consequences that bite: - * - * 1. The `&:where(…)` shape is what makes composition work. An ancestor-form variant - * (`:where([x]) &`) still compiles on its own but SILENTLY stops composing under - * `group-*`. Keep every new entry in the canonical form. - * - * 2. `forced-colors` (below) is the ONE exception, because it is an AT-RULE variant — - * `@media (forced-colors: active)` has no element for `group-*` to scope. - * `group-forced-colors/fui-switch` is a hard build error - * (`Cannot use @variant with unknown variant`). Nest it the other way instead: - * - * @variant forced-colors { @variant group-checked/fui-switch { … } } - * - * Any future at-rule variant inherits the same restriction. - * - * ── WHAT IS DELIBERATELY NOT HERE ───────────────────────────────────────────────────── - * - * Five raw attribute-selector families in converted modules were examined for promotion and - * left raw. The cookbook bans variant DEFINITIONS in component modules, not attribute - * SELECTORS, so a raw selector is a legitimate authoring form; these are the cases where it - * is the better one. Recording them here so the next reader does not re-litigate: - * - * 1. `data-popper-placement` (react-popover PopoverSurface, react-tooltip Tooltip — 8 uses) - * is written ANCESTOR-form, `:where(:global([data-popper-placement^='top'])) &`. It is on - * the positioned WRAPPER, not on the styled element, and it is a prefix match. Consequence - * 1 above says an ancestor-form entry silently stops composing under `group-*`, so this - * cannot become a canonical entry at all. - * - * 2. `data-overflowing` / `data-overflow-menu` (react-overflow Overflow — 2 uses) are - * DESCENDANT selectors (`& [data-overflowing]`) that match arbitrary consumer children. - * A variant scopes `&`, so no entry can express them. - * - * 3. `data-size` on Avatar (123 uses across three modules) is written as multi-value bucket - * TABLES (`[data-size='16'], [data-size='20'], [data-size='24']`). The catalog names every - * one of those values individually, but only 4 of Avatar's 22 rules select a single value; - * naming the 18 unions would mean 18 write-once `-or-` entries and would fragment tables - * that are readable precisely because they are uniform. - * - * 4. `data-activedescendant-focusvisible` (react-combobox Option) gets no entry — D15.5 - * admits no additions. - * - * 5. Card's "has a description" gate is not an attribute at ALL — CardHeader keeps two - * mutually exclusive module-class maps. Promoting it would mean inventing - * `data-has-description` and rewriting the hook, which is out of scope for a CSS-only - * pass and against D15.6's fallback-only reading of `data-*`. + * Custom variant catalog — the single place state selectors are defined; never define + * variants in component modules. Names follow the headless data-attribute vocabulary. + * + * Every entry is the canonical `&:where(…)` form: zero added specificity (cascade is + * decided by @layer order and in-file order), and Tailwind rewrites that form into + * `group-/` automatically, so every entry composes with named groups. + * An ancestor-form entry (`:where([x]) &`) silently stops group-composing — don't add + * one. At-rule variants (`forced-colors`) cannot take a group prefix; nest the group + * variant inside them instead. */ /* direction — computed direction, correct under nesting (DECISIONS.md D5) */ diff --git a/packages/react-components/react-tailwind-theme-preview/scripts/generate-tokens-css.js b/packages/react-components/react-tailwind-theme-preview/scripts/generate-tokens-css.js index fa8ccdfc0cb52f..3bcbffae169a33 100644 --- a/packages/react-components/react-tailwind-theme-preview/scripts/generate-tokens-css.js +++ b/packages/react-components/react-tailwind-theme-preview/scripts/generate-tokens-css.js @@ -1121,11 +1121,19 @@ function main() { const modifiersIndex = argv.indexOf('--modifiers'); const modifiers = modifiersIndex >= 0 ? argv[modifiersIndex + 1] : undefined; + // Prettier-format the output so the generated files satisfy the repo formatter + // without a .prettierignore exception; `--check` stays byte-exact because it + // compares against the formatted contents. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const prettier = require('@prettier/sync'); + const format = (filePath, contents) => + prettier.format(contents, { ...(prettier.resolveConfig(filePath) ?? {}), filepath: filePath }); + const files = [ - { path: outPath, contents: render({ modifiers }) }, + { path: outPath, contents: format(outPath, render({ modifiers })) }, // themes.css is only written/checked alongside the canonical tokens.css — `--out` // runs are probe runs of the registration block. - ...(outIndex >= 0 ? [] : [{ path: THEMES_OUTPUT, contents: renderThemes() }]), + ...(outIndex >= 0 ? [] : [{ path: THEMES_OUTPUT, contents: format(THEMES_OUTPUT, renderThemes()) }]), ]; for (const file of files) { diff --git a/packages/react-components/react-windmod-preview/library/README.md b/packages/react-components/react-windmod-preview/library/README.md index b3d3f5fea075a1..0278708d4a7ed8 100644 --- a/packages/react-components/react-windmod-preview/library/README.md +++ b/packages/react-components/react-windmod-preview/library/README.md @@ -1,5 +1,51 @@ # @fluentui/react-windmod-preview -**React Windmod components for [Fluent UI React](https://react.fluentui.dev/)** +**Fluent UI React components styled with Tailwind v4 + CSS Modules ("windmod")** -These are not production-ready components and **should never be used in product**. This space is useful for testing new components whose APIs might change before final release. +Each component composes the corresponding +[`@fluentui/react-headless-components-preview`](../../react-headless-components-preview/library) +hooks (`useX` → `useXStyles` → `renderX`) with build-time-compiled CSS Modules that replicate the +Griffel suite's visuals. Theming is pure CSS via +[`@fluentui/react-tailwind-theme-preview`](../../react-tailwind-theme-preview) theme classes, +applied by `ThemeProvider`. No Griffel, no runtime style injection. + +> **Preview** — this package tracks `react-headless-components-preview`, which is itself in +> preview: APIs may change without notice, and coverage is limited to the components the headless +> package ships. Not production-ready. + +## Usage + +```sh +npm install @fluentui/react-windmod-preview @fluentui/react-tailwind-theme-preview +``` + +```js +// Once per document, before your own styles: +import '@fluentui/react-tailwind-theme-preview/styles.css'; +``` + +```jsx +import { Button, Tooltip, ThemeProvider, webDarkThemeClassName } from '@fluentui/react-windmod-preview'; + +export default function App() { + return ( + + + + + + ); +} +``` + +Component styles load automatically with the components (an ESM side-effect import of this +package's `dist/styles.css`); CommonJS/SSR consumers import +`@fluentui/react-windmod-preview/styles.css` themselves. + +## Styling contract + +- Slot `className` props merge last — your classes win via cascade layers (consumer CSS is + unlayered; all package styles live in `fui.*` layers). +- Each component's sole public identity class is its group marker (`group/fui-button`, …) for + Tailwind named-group targeting; internals use hashed idents and `data-*` state attributes + (`data-open`, `data-placement`, `data-size`, …). diff --git a/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css b/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css index 1a3def84413055..8d98af0db75159 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css +++ b/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css @@ -1,69 +1,37 @@ @reference '#theme'; -/* Layer order (DECISIONS.md D2) - restated per module so stylesheet load order cannot - decide the ranking; re-declaring an existing layer is a no-op. */ -@layer fui.theme, fui.base, fui.components, fui.components.l1, fui.components.l2, fui.components.l3, fui.components.l4, fui.components.l5, fui.utilities; - @layer fui.base { .root { - @apply m-horizontal-none box-border inline-flex min-w-96 items-center justify-center overflow-hidden rounded-medium bg-neutral-background-1 px-horizontal-m py-5 align-middle font-base text-base-300 leading-base-300 font-semibold text-neutral-foreground-1 no-underline; - - border: var(--spacing-thin) solid var(--color-neutral-stroke-1); - - outline-style: none; - - transition-duration: var(--duration-faster); - /* - * Longhands only. The Griffel source declared `background, border, color`, which is not - * 3 properties: per CSS Transitions L1 a shorthand in `transition-property` expands to - * every longhand it covers, so that list is 21 (8 `background-*` + 12 `border-*` + 1). - * Only these 6 ever change — the base `border: solid ` above is the file's ONLY - * border-width/style declaration and no state or appearance slice touches it, and no - * slice touches any non-color `background-*`. `cursor`, `outline-style` and - * `forced-color-adjust` do change on hover/active but were never in the list and are not - * added: the first two are not animatable/are discrete, and Griffel never transitioned - * them. See reports/transition-audit.md §2.2. - * - * Safe to expand here because `transition-duration` and `transition-timing-function` are - * SINGLE-valued, so they apply to every entry — no positional-list realignment (that is - * the Avatar trap, §2.1, which is why Avatar's `margin` deliberately stays a shorthand). - */ + @apply m-horizontal-none box-border inline-flex min-w-96 items-center justify-center overflow-hidden rounded-medium border-(length:--spacing-thin) border-solid border-neutral-stroke-1 bg-neutral-background-1 px-horizontal-m py-5 align-middle font-base text-base-300 leading-base-300 font-semibold text-neutral-foreground-1 no-underline duration-(--duration-faster) ease-(--ease-easy-ease) outline-none; + + /* Longhands only: a shorthand in transition-property expands to every longhand it + covers, and only these six ever change. */ transition-property: background-color, border-top-color, border-right-color, border-bottom-color, border-left-color, color; - transition-timing-function: var(--ease-easy-ease); @variant hover { @apply cursor-pointer border-neutral-stroke-1-hover bg-neutral-background-1-hover text-neutral-foreground-1-hover; } @variant hover-active { - @apply border-neutral-stroke-1-pressed bg-neutral-background-1-pressed text-neutral-foreground-1-pressed; - - outline-style: none; + @apply border-neutral-stroke-1-pressed bg-neutral-background-1-pressed text-neutral-foreground-1-pressed outline-none; } @media screen and (prefers-reduced-motion: reduce) { transition-duration: 0.01ms; } - /* High contrast styles */ @variant forced-colors { @variant focus { - border-color: ButtonText; + @apply border-[ButtonText]; } @variant hover { - background-color: HighlightText; - border-color: Highlight; - color: Highlight; - forced-color-adjust: none; + @apply border-[Highlight] bg-[HighlightText] text-[Highlight] forced-color-adjust-none; } @variant hover-active { - background-color: HighlightText; - border-color: Highlight; - color: Highlight; - forced-color-adjust: none; + @apply border-[Highlight] bg-[HighlightText] text-[Highlight] forced-color-adjust-none; } } @@ -71,7 +39,7 @@ @apply fui-focus-ring; } - /* BUGFIX: Mozilla specific styles (Mozilla BugID: 1857642) */ + /* Mozilla BugID 1857642 */ @supports (-moz-appearance: button) { @variant focus-visible-fui { box-shadow: 0 0 0 calc(var(--spacing-thin) + 0.25px) var(--color-stroke-focus-2) inset; @@ -80,35 +48,37 @@ } .icon { - @apply inline-flex size-20 items-center justify-center; + @apply inline-flex size-20 items-center justify-center text-[length:calc(20px*var(--base-scale))]; - /* `--base-scale` keeps the glyph and its 20px box scaling together (D4); at the - default 16px root this is exactly `font-size: 20px`, the compiled value. */ - font-size: calc(20px * var(--base-scale)); - - --fui-Button__icon--spacing: calc(6px * var(--base-scale)); + --fui-button-icon-spacing: calc(6px * var(--base-scale)); } } @layer fui.components.l1 { - .root { - @variant not-empty { - @variant icon-before { - & .icon { - margin-inline-end: var(--fui-Button__icon--spacing); - } + .icon { + @variant group-not-empty/fui-button { + @variant group-icon-before/fui-button { + @apply me-(--fui-button-icon-spacing); } - @variant icon-after { - & .icon { - margin-inline-start: var(--fui-Button__icon--spacing); - } + @variant group-icon-after/fui-button { + @apply ms-(--fui-button-icon-spacing); } } + + @variant group-size-small/fui-button { + @apply size-20 text-[length:calc(20px*var(--base-scale))]; + + --fui-button-icon-spacing: calc(4px * var(--base-scale)); + } + + @variant group-size-large/fui-button { + @apply size-24 text-[length:calc(24px*var(--base-scale))]; + + --fui-button-icon-spacing: calc(6px * var(--base-scale)); + } } -} -@layer fui.components.l1 { .outline { @apply bg-transparent-background; @@ -122,79 +92,34 @@ } .primary { - @apply bg-brand-background text-neutral-foreground-on-brand; - - border-top-color: transparent; - border-right-color: transparent; - border-bottom-color: transparent; - border-left-color: transparent; + @apply border-transparent bg-brand-background text-neutral-foreground-on-brand; @variant hover { - @apply bg-brand-background-hover text-neutral-foreground-on-brand; - - border-top-color: transparent; - border-right-color: transparent; - border-bottom-color: transparent; - border-left-color: transparent; + @apply border-transparent bg-brand-background-hover text-neutral-foreground-on-brand; } @variant hover-active { - @apply bg-brand-background-pressed text-neutral-foreground-on-brand; - - border-top-color: transparent; - border-right-color: transparent; - border-bottom-color: transparent; - border-left-color: transparent; + @apply border-transparent bg-brand-background-pressed text-neutral-foreground-on-brand; } @variant forced-colors { - background-color: Highlight; - border-top-color: HighlightText; - border-right-color: HighlightText; - border-bottom-color: HighlightText; - border-left-color: HighlightText; - color: HighlightText; - forced-color-adjust: none; + @apply border-[HighlightText] bg-[Highlight] text-[HighlightText] forced-color-adjust-none; @variant hover { - background-color: HighlightText; - border-top-color: Highlight; - border-right-color: Highlight; - border-bottom-color: Highlight; - border-left-color: Highlight; - color: Highlight; + @apply border-[Highlight] bg-[HighlightText] text-[Highlight]; } @variant hover-active { - background-color: HighlightText; - border-top-color: Highlight; - border-right-color: Highlight; - border-bottom-color: Highlight; - border-left-color: Highlight; - color: Highlight; + @apply border-[Highlight] bg-[HighlightText] text-[Highlight]; } } } .subtle { - @apply bg-subtle-background text-neutral-foreground-2; - - border-top-color: transparent; - border-right-color: transparent; - border-bottom-color: transparent; - border-left-color: transparent; + @apply border-transparent bg-subtle-background text-neutral-foreground-2; @variant hover { - @apply bg-subtle-background-hover text-neutral-foreground-2-hover; - - border-top-color: transparent; - border-right-color: transparent; - border-bottom-color: transparent; - border-left-color: transparent; - - /* The `.fui-Icon-filled` / `.fui-Icon-regular` swap that belongs here lives in the - l1 icon-swap block at the bottom of this file (D27 / S-J batch 1). `.icon` below is - this module's own local on react-button's icon slot, so its colour rule stays here. */ + @apply border-transparent bg-subtle-background-hover text-neutral-foreground-2-hover; & .icon { @apply text-neutral-foreground-2-brand-hover; @@ -202,15 +127,7 @@ } @variant hover-active { - @apply bg-subtle-background-pressed text-neutral-foreground-2-pressed; - - border-top-color: transparent; - border-right-color: transparent; - border-bottom-color: transparent; - border-left-color: transparent; - - /* Icon swap in the l1 block at the bottom of this file (D27 / S-J batch 1). `.icon` - is this module's own local and its colour rule stays here. */ + @apply border-transparent bg-subtle-background-pressed text-neutral-foreground-2-pressed; & .icon { @apply text-neutral-foreground-2-brand-pressed; @@ -219,117 +136,63 @@ @variant forced-colors { @variant hover { - color: Highlight; + @apply text-[Highlight]; & .icon { - color: Highlight; + @apply text-[Highlight]; } } @variant hover-active { - color: Highlight; + @apply text-[Highlight]; & .icon { - color: Highlight; + @apply text-[Highlight]; } } } } .transparent { - @apply bg-transparent-background text-neutral-foreground-2; - - border-top-color: transparent; - border-right-color: transparent; - border-bottom-color: transparent; - border-left-color: transparent; + @apply border-transparent bg-transparent-background text-neutral-foreground-2; @variant hover { - @apply bg-transparent-background-hover text-neutral-foreground-2-brand-hover; - - border-top-color: transparent; - border-right-color: transparent; - border-bottom-color: transparent; - border-left-color: transparent; - - /* Icon swap in the l1 block at the bottom of this file (D27 / S-J batch 1). */ + @apply border-transparent bg-transparent-background-hover text-neutral-foreground-2-brand-hover; } @variant hover-active { - @apply bg-transparent-background-pressed text-neutral-foreground-2-brand-pressed; - - border-top-color: transparent; - border-right-color: transparent; - border-bottom-color: transparent; - border-left-color: transparent; - - /* Icon swap in the l1 block at the bottom of this file (D27 / S-J batch 1). */ + @apply border-transparent bg-transparent-background-pressed text-neutral-foreground-2-brand-pressed; } @variant forced-colors { @variant hover { - @apply bg-transparent-background; - - color: Highlight; + @apply bg-transparent-background text-[Highlight]; } @variant hover-active { - @apply bg-transparent-background; - - color: Highlight; + @apply bg-transparent-background text-[Highlight]; } } } -} -@layer fui.components.l1 { .root { @variant size-small { @apply min-w-64 rounded-medium px-horizontal-s py-3 text-base-200 leading-base-200 font-regular; - - /* `padding: buttonSpacingSmall(3px) tokens.spacingHorizontalS`; 3px and 64px match no step */ } @variant size-large { @apply min-w-96 rounded-medium px-horizontal-l py-vertical-s text-base-400 leading-base-400 font-semibold; } - /* from useIconStyles.small — icon slot arg #4, keyed off the root's data-size */ - @variant size-small { - & .icon { - @apply size-20; - - font-size: calc(20px * var(--base-scale)); - - /* tokens.spacingHorizontalXS — custom property, so D4's literal form (see the reset) */ - --fui-Button__icon--spacing: calc(4px * var(--base-scale)); - } - } - - @variant size-large { - & .icon { - @apply size-24; - - font-size: calc(24px * var(--base-scale)); - - /* tokens.spacingHorizontalSNudge — custom property, so D4's literal form (see the reset) */ - --fui-Button__icon--spacing: calc(6px * var(--base-scale)); - } - } - @variant disabled-any { - @apply cursor-not-allowed border-t-neutral-stroke-disabled border-r-neutral-stroke-disabled border-b-neutral-stroke-disabled border-l-neutral-stroke-disabled bg-neutral-background-disabled text-neutral-foreground-disabled; + @apply cursor-not-allowed border-neutral-stroke-disabled bg-neutral-background-disabled text-neutral-foreground-disabled; & .icon { @apply text-neutral-foreground-disabled; } @variant hover { - @apply cursor-not-allowed border-t-neutral-stroke-disabled border-r-neutral-stroke-disabled border-b-neutral-stroke-disabled border-l-neutral-stroke-disabled bg-neutral-background-disabled text-neutral-foreground-disabled; - - /* The icon swap-BACK that belongs here is in the l1 icon-swap block at the bottom of - this file, written LAST there so it still beats the appearance swaps (D27 / S-J - batch 1). */ + @apply cursor-not-allowed border-neutral-stroke-disabled bg-neutral-background-disabled text-neutral-foreground-disabled; & .icon { @apply text-neutral-foreground-disabled; @@ -337,11 +200,7 @@ } @variant hover-active { - @apply cursor-not-allowed border-t-neutral-stroke-disabled border-r-neutral-stroke-disabled border-b-neutral-stroke-disabled border-l-neutral-stroke-disabled bg-neutral-background-disabled text-neutral-foreground-disabled; - - /* The icon swap-BACK that belongs here is in the l1 icon-swap block at the bottom of - this file, written LAST there so it still beats the appearance swaps (D27 / S-J - batch 1). */ + @apply cursor-not-allowed border-neutral-stroke-disabled bg-neutral-background-disabled text-neutral-foreground-disabled; & .icon { @apply text-neutral-foreground-disabled; @@ -349,47 +208,29 @@ } @variant forced-colors { - background-color: ButtonFace; - border-top-color: GrayText; - border-right-color: GrayText; - border-bottom-color: GrayText; - border-left-color: GrayText; - color: GrayText; + @apply border-[GrayText] bg-[ButtonFace] text-[GrayText]; & .icon { - color: GrayText; + @apply text-[GrayText]; } @variant focus { - border-top-color: GrayText; - border-right-color: GrayText; - border-bottom-color: GrayText; - border-left-color: GrayText; + @apply border-[GrayText]; } @variant hover { - background-color: ButtonFace; - border-top-color: GrayText; - border-right-color: GrayText; - border-bottom-color: GrayText; - border-left-color: GrayText; - color: GrayText; + @apply border-[GrayText] bg-[ButtonFace] text-[GrayText]; & .icon { - color: GrayText; + @apply text-[GrayText]; } } @variant hover-active { - background-color: ButtonFace; - border-top-color: GrayText; - border-right-color: GrayText; - border-bottom-color: GrayText; - border-left-color: GrayText; - color: GrayText; + @apply border-[GrayText] bg-[ButtonFace] text-[GrayText]; & .icon { - color: GrayText; + @apply text-[GrayText]; } } } @@ -422,32 +263,19 @@ .primary { @variant disabled-any { - border-top-color: transparent; - border-right-color: transparent; - border-bottom-color: transparent; - border-left-color: transparent; + @apply border-transparent; @variant hover { - border-top-color: transparent; - border-right-color: transparent; - border-bottom-color: transparent; - border-left-color: transparent; + @apply border-transparent; } @variant hover-active { - border-top-color: transparent; - border-right-color: transparent; - border-bottom-color: transparent; - border-left-color: transparent; + @apply border-transparent; } } - /* - * from useRootFocusStyles.primary — arg #11. Applied only for - * `appearance === 'primary'`, i.e. exactly when this class is on the element. - */ @variant focus-visible-fui { - @apply border-t-stroke-focus-2 border-r-stroke-focus-2 border-b-stroke-focus-2 border-l-stroke-focus-2; + @apply border-stroke-focus-2; box-shadow: var(--shadow-2), @@ -455,7 +283,7 @@ 0 0 0 var(--spacing-thick) var(--color-neutral-foreground-on-brand) inset; @variant hover { - @apply border-t-stroke-focus-2 border-r-stroke-focus-2 border-b-stroke-focus-2 border-l-stroke-focus-2; + @apply border-stroke-focus-2; box-shadow: var(--shadow-2), @@ -463,7 +291,7 @@ } } - /* BUGFIX: Mozilla specific styles (Mozilla BugID: 1857642) */ + /* Mozilla BugID 1857642 */ @supports (-moz-appearance: button) { @variant focus-visible-fui { box-shadow: @@ -482,64 +310,32 @@ .subtle { @variant disabled-any { - @apply bg-transparent-background; - - border-top-color: transparent; - border-right-color: transparent; - border-bottom-color: transparent; - border-left-color: transparent; + @apply border-transparent bg-transparent-background; @variant hover { - @apply bg-transparent-background; - - border-top-color: transparent; - border-right-color: transparent; - border-bottom-color: transparent; - border-left-color: transparent; + @apply border-transparent bg-transparent-background; } @variant hover-active { - @apply bg-transparent-background; - - border-top-color: transparent; - border-right-color: transparent; - border-bottom-color: transparent; - border-left-color: transparent; + @apply border-transparent bg-transparent-background; } } } .transparent { @variant disabled-any { - @apply bg-transparent-background; - - border-top-color: transparent; - border-right-color: transparent; - border-bottom-color: transparent; - border-left-color: transparent; + @apply border-transparent bg-transparent-background; @variant hover { - @apply bg-transparent-background; - - border-top-color: transparent; - border-right-color: transparent; - border-bottom-color: transparent; - border-left-color: transparent; + @apply border-transparent bg-transparent-background; } @variant hover-active { - @apply bg-transparent-background; - - border-top-color: transparent; - border-right-color: transparent; - border-bottom-color: transparent; - border-left-color: transparent; + @apply border-transparent bg-transparent-background; } } } -} -@layer fui.components.l1 { .root { @variant with-icon { @variant size-small { @@ -583,79 +379,53 @@ } } } -} - -/* - * - Within each appearance, `hover` is written before `hover-active` — Griffel separated them by specificity (0-2-0 vs 0-3-0), `:where()` does not, so pressed must come second to win. - */ - -@layer fui.components.l1 { - .subtle { - @variant hover { - & :global(.fui-Icon-filled) { - @apply inline; - } - - & :global(.fui-Icon-regular) { - @apply hidden; - } - } - - @variant hover-active { - & :global(.fui-Icon-filled) { - @apply inline; - } - - & :global(.fui-Icon-regular) { - @apply hidden; - } - } - } + /* Glyph swap on hover. `.fui-Icon-filled` / `.fui-Icon-regular` are the headless icons + package's PUBLIC swap contract (iconFilledClassName / iconRegularClassName, stamped by + bundleIcon for exactly this). Scoped through `.icon`; `hover` before `hover-active` + (same specificity under :where(), in-file order decides); the disabled swap-back is + written last so it wins over the appearance swaps. */ + .subtle, .transparent { @variant hover { - & :global(.fui-Icon-filled) { + & .icon :global(.fui-Icon-filled) { @apply inline; } - & :global(.fui-Icon-regular) { + & .icon :global(.fui-Icon-regular) { @apply hidden; } } @variant hover-active { - & :global(.fui-Icon-filled) { + & .icon :global(.fui-Icon-filled) { @apply inline; } - & :global(.fui-Icon-regular) { + & .icon :global(.fui-Icon-regular) { @apply hidden; } } } - /* - * from useRootDisabledStyles.base — arg #8. LAST in the file because arg #8 outranks the - * appearance args above; this is the swap-BACK that keeps a disabled button's glyph static. - */ .root { @variant disabled-any { @variant hover { - & :global(.fui-Icon-filled) { + & .icon :global(.fui-Icon-filled) { @apply hidden; } - & :global(.fui-Icon-regular) { + & .icon :global(.fui-Icon-regular) { @apply inline; } } @variant hover-active { - & :global(.fui-Icon-filled) { + & .icon :global(.fui-Icon-filled) { @apply hidden; } - & :global(.fui-Icon-regular) { + & .icon :global(.fui-Icon-regular) { @apply inline; } } diff --git a/packages/react-components/react-windmod-preview/library/src/components/Provider/ThemeProvider.module.css b/packages/react-components/react-windmod-preview/library/src/components/Provider/ThemeProvider.module.css index 3a91474d9ed644..ba4bad8f9848d5 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Provider/ThemeProvider.module.css +++ b/packages/react-components/react-windmod-preview/library/src/components/Provider/ThemeProvider.module.css @@ -1,14 +1,9 @@ @reference '#theme'; -/* Layer order — restated per module so stylesheet load order cannot decide the ranking; - re-declaring an existing layer is a no-op. */ -@layer fui.theme, fui.base, fui.components, fui.components.l1, fui.components.l2, fui.components.l3, fui.components.l4, fui.components.l5, fui.utilities; - @layer fui.base { - /* The provider element must not affect layout: custom properties inherit through the - DOM tree regardless of box generation, so `display: contents` gives the theme class - a carrier without introducing a box (verified to reach open top-layer popovers). */ + /* No layout impact: custom properties inherit through the DOM tree regardless of box + generation, including into top-layer popovers. */ .root { - display: contents; + @apply contents; } } diff --git a/packages/react-components/react-windmod-preview/library/src/components/Tooltip/Tooltip.module.css b/packages/react-components/react-windmod-preview/library/src/components/Tooltip/Tooltip.module.css index d2cda3d7d83770..d1a4d9edb96403 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Tooltip/Tooltip.module.css +++ b/packages/react-components/react-windmod-preview/library/src/components/Tooltip/Tooltip.module.css @@ -1,23 +1,13 @@ @reference '#theme'; -/* Layer order — restated per module so stylesheet load order cannot decide the ranking. */ -@layer fui.theme, fui.base, fui.components, fui.components.l1, fui.components.l2, fui.components.l3, fui.components.l4, fui.components.l5, fui.utilities; - -/* - * Top-layer model: the content is a native `popover="hint"` element (the top layer owns - * show/hide; the UA [popover] defaults are overridden below) and placement rides - * `data-placement` on the content. The arrow carries `.arrow` via the headless - * `arrowClassName`; its per-placement rules compose the catalog's placement variants with - * the content's named-group marker (`group-placement-*\/fui-tooltip`). - */ +/* The content is a native popover=hint element; placement rides data-placement on it, + and the arrow's placement rules key off the content's group marker. */ @layer fui.components.l1 { .content { @apply box-border max-w-240 cursor-default overflow-visible rounded-medium border border-solid border-transparent-stroke bg-neutral-background-1 px-11 pt-vertical-xs pb-vertical-s-nudge font-base text-base-200 leading-base-200 break-words text-neutral-foreground-1; - /* Cross-axis inset for aligned (-start/-end/-top/-bottom) arrows; Griffel computed - this from JS. Declared here (not on .arrow) so consumers can override it at the - surface and let it inherit. */ + /* Cross-axis inset for aligned arrows; on the surface so consumer overrides inherit. */ --fui-positioning-arrow-cross: 8px; /* TODO carried from the Griffel source: needs a filter-compatible shadow token. */ @@ -30,10 +20,8 @@ @apply bg-neutral-background-static text-neutral-foreground-static-inverted; } - /* A rotated square; the visible half is carved by the ::before clip-path. Literal px - on purpose: 8.484px = 6px arrow height × √2, -4.242px = half — paired with - ARROW_HEIGHT in Tooltip.tsx. Rotation and edge offsets are PHYSICAL geometry - (@noflip); the rtl block swaps the logical before/after placements. */ + /* Rotated square, half carved by the ::before clip-path. 8.484px = 6px × √2 — paired + with ARROW_HEIGHT in Tooltip.tsx. Geometry is physical; rtl swaps before/after. */ .arrow { @apply absolute -z-1 box-border size-(--fui-positioning-arrow-height) rotate-(--fui-positioning-arrow-angle) rounded-bl-small bg-inherit bg-clip-content; diff --git a/packages/react-components/react-windmod-preview/stories/README.md b/packages/react-components/react-windmod-preview/stories/README.md index 9294f8cc3dcee0..d98e495aaddb5f 100644 --- a/packages/react-components/react-windmod-preview/stories/README.md +++ b/packages/react-components/react-windmod-preview/stories/README.md @@ -8,7 +8,7 @@ To include within storybook specify stories globs: \`\`\`js module.exports = { -stories: ['../packages/react-components/react-windmod-preview/stories/src/**/*.mdx', '../packages/react-components/react-windmod-preview/stories/src/**/index.stories.@(ts|tsx)'], +stories: ['../packages/react-components/react-windmod-preview/stories/src/**/\*.mdx', '../packages/react-components/react-windmod-preview/stories/src/**/index.stories.@(ts|tsx)'], } \`\`\` From 551a5b0b2bda5d9dc3d122a7922ca4be694f9140 Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Fri, 21 Aug 2026 04:11:00 -0700 Subject: [PATCH 010/246] =?UTF-8?q?docs(windmod):=20ledger=20=E2=80=94=20i?= =?UTF-8?q?cons=20data-variant=20proposal=20+=20optional=20data-appearance?= =?UTF-8?q?=20edit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- migration/windmod/LEDGER.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/migration/windmod/LEDGER.md b/migration/windmod/LEDGER.md index 36f94bff647b7f..a9929fec4abf3b 100644 --- a/migration/windmod/LEDGER.md +++ b/migration/windmod/LEDGER.md @@ -46,6 +46,12 @@ Status flow: `planned` → `pilot` → `audited` → `converted` → `validated` - Upstream tactical-edit candidate: headless Tooltip arrow has no slot/className hook (arrowRef exists, nothing applies a class) — windmod must style the `[data-arrow]` child via descendant selector until then. +- Upstream icons proposal: stamp `data-fui-icon-variant="filled|regular"` in + bundleIcon so glyph-swap CSS can target attributes instead of the public + `fui-Icon-*` classes (icons live in a separate repo — proposal, not a + tactical edit). +- Optional headless edit if wanted: emit `data-appearance` on Button so + appearance-conditional icon colors can ride named-group variants too. - Story convention: ONE export per `*.stories.tsx`, re-exported through `index.stories.tsx` — the export-to-sandbox addon injects `fullSource` only on the last export otherwise (docs pages error with 'issues with data'). From a8b92aef5c3ab90b6eab27c4ae849b50d063a5b2 Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Fri, 21 Aug 2026 04:34:28 -0700 Subject: [PATCH 011/246] feat(windmod): glyph swap rides data-variant catalog variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit variant-filled / variant-regular added to the catalog over the data-variant attribute the (locally consumed) headless bundleIcon now stamps; Button's hover glyph swap uses them — zero fui-Icon-* class references remain in the compiled CSS. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- migration/windmod/LEDGER.md | 15 +++-- .../css/variants.css | 4 ++ .../src/components/Button/Button.module.css | 56 ++++++++++--------- 3 files changed, 46 insertions(+), 29 deletions(-) diff --git a/migration/windmod/LEDGER.md b/migration/windmod/LEDGER.md index a9929fec4abf3b..03a8b295258749 100644 --- a/migration/windmod/LEDGER.md +++ b/migration/windmod/LEDGER.md @@ -46,10 +46,17 @@ Status flow: `planned` → `pilot` → `audited` → `converted` → `validated` - Upstream tactical-edit candidate: headless Tooltip arrow has no slot/className hook (arrowRef exists, nothing applies a class) — windmod must style the `[data-arrow]` child via descendant selector until then. -- Upstream icons proposal: stamp `data-fui-icon-variant="filled|regular"` in - bundleIcon so glyph-swap CSS can target attributes instead of the public - `fui-Icon-*` classes (icons live in a separate repo — proposal, not a - tactical edit). +- DONE (user-approved tactical edit, 2026-08-21): icons fork branch + `feature/data-variant` stamps `data-variant="filled|regular"` in headless + bundleIcon; fluentui consumes it via a LOCAL-ONLY tarball resolution + (`file:../fluentui-system-icons/packages/react-icons/fluentui-react-icons-local.tgz`) + — REVERT the resolutions entry + yarn.lock before any merge/publish (same + protocol as the previous campaign), and upstream the icons change. Button's + glyph swap now rides `variant-filled`/`variant-regular` catalog variants; + zero `fui-Icon-*` class references remain in compiled CSS. To rebuild the + tarball: icons repo `yarn build:generate-chunks-and-atoms && yarn build:js`, + `npm pack`, copy to `fluentui-react-icons-local.tgz`, then here + `rm .yarn/cache/@fluentui-react-icons-file-*.zip && yarn install`. - Optional headless edit if wanted: emit `data-appearance` on Button so appearance-conditional icon colors can ride named-group variants too. - Story convention: ONE export per `*.stories.tsx`, re-exported through diff --git a/packages/react-components/react-tailwind-theme-preview/css/variants.css b/packages/react-components/react-tailwind-theme-preview/css/variants.css index 25e9315c44ea92..6f756ad8333394 100644 --- a/packages/react-components/react-tailwind-theme-preview/css/variants.css +++ b/packages/react-components/react-tailwind-theme-preview/css/variants.css @@ -61,6 +61,10 @@ @custom-variant placement-centered-inline (&:where([data-placement='above'], [data-placement='below'])); @custom-variant placement-centered-block (&:where([data-placement='before'], [data-placement='after'])); +/* Glyph variant — stamped by @fluentui/react-icons/headless bundleIcon on each glyph. */ +@custom-variant variant-filled (&:where([data-variant='filled'])); +@custom-variant variant-regular (&:where([data-variant='regular'])); + /* * "Where is the scrollable DrawerBody scrolled to" — DrawerHeader / DrawerFooter reveal a * separator once the body has scrolled at all, and make it fully opaque while there is diff --git a/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css b/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css index 8d98af0db75159..c40192cd6da260 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css +++ b/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css @@ -380,30 +380,32 @@ } } - /* Glyph swap on hover. `.fui-Icon-filled` / `.fui-Icon-regular` are the headless icons - package's PUBLIC swap contract (iconFilledClassName / iconRegularClassName, stamped by - bundleIcon for exactly this). Scoped through `.icon`; `hover` before `hover-active` - (same specificity under :where(), in-file order decides); the disabled swap-back is - written last so it wins over the appearance swaps. */ + /* Glyph swap on hover, keyed off the data-variant attribute bundleIcon stamps on each + glyph. In-file order carries the cascade: hover before hover-active, and the disabled + swap-back last so it wins over the appearance swaps. */ .subtle, .transparent { @variant hover { - & .icon :global(.fui-Icon-filled) { - @apply inline; - } + & .icon * { + @variant variant-filled { + @apply inline; + } - & .icon :global(.fui-Icon-regular) { - @apply hidden; + @variant variant-regular { + @apply hidden; + } } } @variant hover-active { - & .icon :global(.fui-Icon-filled) { - @apply inline; - } + & .icon * { + @variant variant-filled { + @apply inline; + } - & .icon :global(.fui-Icon-regular) { - @apply hidden; + @variant variant-regular { + @apply hidden; + } } } } @@ -411,22 +413,26 @@ .root { @variant disabled-any { @variant hover { - & .icon :global(.fui-Icon-filled) { - @apply hidden; - } + & .icon * { + @variant variant-filled { + @apply hidden; + } - & .icon :global(.fui-Icon-regular) { - @apply inline; + @variant variant-regular { + @apply inline; + } } } @variant hover-active { - & .icon :global(.fui-Icon-filled) { - @apply hidden; - } + & .icon * { + @variant variant-filled { + @apply hidden; + } - & .icon :global(.fui-Icon-regular) { - @apply inline; + @variant variant-regular { + @apply inline; + } } } } From e7fe699aa95e5415cf353aebeeff78aab3b6cec8 Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Fri, 21 Aug 2026 04:34:30 -0700 Subject: [PATCH 012/246] =?UTF-8?q?chore(windmod):=20LOCAL-ONLY=20?= =?UTF-8?q?=E2=80=94=20resolve=20react-icons=20to=20the=20data-variant=20f?= =?UTF-8?q?ork=20tarball?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REVERT BEFORE MERGE/PUBLISH. Points @fluentui/react-icons at ../fluentui-system-icons/packages/react-icons/fluentui-react-icons-local.tgz (fork branch feature/data-variant) until the data-variant change ships upstream. Same protocol as the previous campaign's local-only overrides. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- package.json | 1 + yarn.lock | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 234af65958c254..895fcb33caead6 100644 --- a/package.json +++ b/package.json @@ -356,6 +356,7 @@ "starter-templates" ], "resolutions": { + "@fluentui/react-icons": "file:../fluentui-system-icons/packages/react-icons/fluentui-react-icons-local.tgz", "@nx/devkit/minimatch": "^9.0.7", "@nx/jest/minimatch": "^9.0.7", "@phenomnomnominal/tsquery": "6.1.3", diff --git a/yarn.lock b/yarn.lock index aa7dc422f0cc17..dbb975c8c0dd72 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4515,15 +4515,15 @@ __metadata: languageName: node linkType: hard -"@fluentui/react-icons@npm:^2.0.237, @fluentui/react-icons@npm:^2.0.239, @fluentui/react-icons@npm:^2.0.245, @fluentui/react-icons@npm:^2.0.306": +"@fluentui/react-icons@file:../fluentui-system-icons/packages/react-icons/fluentui-react-icons-local.tgz::locator=%40fluentui%2Ffluentui-repo%40workspace%3A.": version: 2.0.337 - resolution: "@fluentui/react-icons@npm:2.0.337" + resolution: "@fluentui/react-icons@file:../fluentui-system-icons/packages/react-icons/fluentui-react-icons-local.tgz#../fluentui-system-icons/packages/react-icons/fluentui-react-icons-local.tgz::hash=be784d&locator=%40fluentui%2Ffluentui-repo%40workspace%3A." dependencies: "@griffel/react": "npm:^1.6.1" tslib: "npm:^2.1.0" peerDependencies: react: ">=16.8.0 <20.0.0" - checksum: 10c0/ac8e1756e9fb8654fd5a6b53afe714c79f1be7dbcf9244f889f8f6db26ebdefbf1e2a7ab50fd30bddbb90f8136557c9bf05ef329f8bfe3b5b7301534a1b06076 + checksum: 10c0/aa822345bf3d667e463eff633fed16cc5a906ec083a87e8068ba9f37487b168fb43d305fa62207a07adb84905257eb09629d37a1960e200138e8c97ef6a009ec languageName: node linkType: hard From 1a75e72a7556df07604eaa490a4424e55869253e Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Fri, 21 Aug 2026 05:10:20 -0700 Subject: [PATCH 013/246] refactor(windmod): drop numeric size-* variants, merge focus variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Numeric data-size variants (Avatar/Skeleton pixel scale, Text typography ramp) removed from the catalog — size-16 etc. read as Tailwind's own size-* utilities. Components consume the number with typed attr() instead (width: calc(attr(data-size type()) * 1px)); note left in place. - focus-visible / focus-within are now single catalog entries combining the keyborg attributes with the native pseudo-classes (dual-selector form, like checked); the -fui suffixed pair and the duplicate native-only entry are gone. Button + utilities updated. - Button stories use bundled icons (headless bundleIcon on the windmod side, classic on the Griffel side) so the data-variant hover swap is demonstrable. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- .../css/utilities.css | 2 +- .../css/variants.css | 83 +++---------------- .../src/components/Button/Button.module.css | 14 ++-- .../src/Button/ButtonDefault.stories.tsx | 14 ++-- .../ButtonGriffelComparison.stories.tsx | 15 ++-- 5 files changed, 37 insertions(+), 91 deletions(-) diff --git a/packages/react-components/react-tailwind-theme-preview/css/utilities.css b/packages/react-components/react-tailwind-theme-preview/css/utilities.css index 2da6f1bdd8b7c7..f8be1620d6950d 100644 --- a/packages/react-components/react-tailwind-theme-preview/css/utilities.css +++ b/packages/react-components/react-tailwind-theme-preview/css/utilities.css @@ -4,7 +4,7 @@ * `@utility` on purpose: `@apply` inlines the declarations at the call site inside the * caller's `fui.*` layer — nothing lands in Tailwind's `utilities` layer, which would * outrank every component layer. Declarations only; the caller supplies the selector via - * the `focus-visible-fui` / `focus-within-fui` variants. + * the `focus-visible` / `focus-within` variants. * * Parameterisation: the `--fui-focus-*` custom properties are knobs with `var()` * fallbacks reproducing the compiled Griffel defaults. Each utility resets its knobs to diff --git a/packages/react-components/react-tailwind-theme-preview/css/variants.css b/packages/react-components/react-tailwind-theme-preview/css/variants.css index 6f756ad8333394..f3feaa5c3db47a 100644 --- a/packages/react-components/react-tailwind-theme-preview/css/variants.css +++ b/packages/react-components/react-tailwind-theme-preview/css/variants.css @@ -188,51 +188,11 @@ ); /* - * Numeric scale — the SAME `data-size` attribute carrying a pixel-valued scale instead of - * a t-shirt one. Introduced by SkeletonItem, whose `SkeletonItemSize` union is exactly - * this set (react-skeleton/library/src/components/Skeleton/Skeleton.types.ts); Avatar and - * Persona use subsets of the same numbers. A component uses only the names it needs — - * an unreferenced variant emits nothing. - */ -@custom-variant size-8 (&:where([data-size='8'])); -@custom-variant size-12 (&:where([data-size='12'])); -@custom-variant size-14 (&:where([data-size='14'])); -@custom-variant size-16 (&:where([data-size='16'])); -@custom-variant size-20 (&:where([data-size='20'])); -@custom-variant size-22 (&:where([data-size='22'])); -@custom-variant size-24 (&:where([data-size='24'])); -@custom-variant size-28 (&:where([data-size='28'])); -@custom-variant size-32 (&:where([data-size='32'])); -@custom-variant size-36 (&:where([data-size='36'])); -@custom-variant size-40 (&:where([data-size='40'])); -@custom-variant size-48 (&:where([data-size='48'])); -@custom-variant size-52 (&:where([data-size='52'])); -@custom-variant size-56 (&:where([data-size='56'])); -@custom-variant size-64 (&:where([data-size='64'])); -@custom-variant size-72 (&:where([data-size='72'])); -@custom-variant size-92 (&:where([data-size='92'])); -@custom-variant size-96 (&:where([data-size='96'])); -@custom-variant size-120 (&:where([data-size='120'])); -@custom-variant size-128 (&:where([data-size='128'])); - -/* - * The Fluent TYPOGRAPHY ramp riding the same `data-size` attribute — Text's - * `size: 100 | 200 | … | 1000` (react-text Text.types.ts), which selects a `--fontSizeBase*` / - * `--lineHeightBase*` pair rather than a pixel dimension. The two numeric spaces cannot - * collide: no ramp step equals any of the twenty pixel sizes above (verified over both lists). - * `size-300` carries no rules — 300 is the base ramp step Text's own reset already sets, so the - * Griffel source had no `300` slice either — and is listed for completeness. - */ -@custom-variant size-100 (&:where([data-size='100'])); -@custom-variant size-200 (&:where([data-size='200'])); -@custom-variant size-300 (&:where([data-size='300'])); -@custom-variant size-400 (&:where([data-size='400'])); -@custom-variant size-500 (&:where([data-size='500'])); -@custom-variant size-600 (&:where([data-size='600'])); -@custom-variant size-700 (&:where([data-size='700'])); -@custom-variant size-800 (&:where([data-size='800'])); -@custom-variant size-900 (&:where([data-size='900'])); -@custom-variant size-1000 (&:where([data-size='1000'])); + * Numeric data-size values (Avatar/Skeleton pixel sizes, Text's typography ramp) get NO + * variants: size-16 etc. would read as Tailwind's own size-* utilities. Components + * consume the number directly with typed attr() instead, e.g. + * width: calc(attr(data-size type()) * 1px). + */ /* * `full` is a `data-size` value that is not a scale STEP at all: Drawer's @@ -450,7 +410,6 @@ /* interaction */ @custom-variant hover (&:where(:hover)); @custom-variant focus (&:where(:focus)); -@custom-variant focus-within (&:where(:focus-within)); /* * SearchBox's `focused` — a React state flag, NOT a pseudo-class, and deliberately not a @@ -577,33 +536,11 @@ @custom-variant disabled-control (&:where([disabled], [data-disabled], :disabled, [aria-disabled='true'])); @custom-variant enabled-control (&:where(:not([disabled], [data-disabled], :disabled, [aria-disabled='true']))); -/* - * Focus visibility — Fluent uses keyborg (react-tabster), which sets - * data-fui-focus-visible / data-fui-focus-within. These attributes are the visual - * contract (VR stories script them directly); native :focus-visible is intentionally - * NOT part of these variants (DECISIONS.md D6). - * - * The two are NOT symmetric, and the asymmetry is react-tabster's, not ours: - * `createCustomFocusIndicatorStyle`'s `createBaseSelector` returns `&[data-fui-focus-visible]` - * for selector 'focus' but `&[data-fui-focus-within]:focus-within` for selector - * 'focus-within' (react-tabster/src/focus/createCustomFocusIndicatorStyle.ts:69-76). - * Confirmed in compiled AOT output — react-switch's root reset emits - * `.r2i81i2[data-fui-focus-within]:focus-within::after{…}`. The native pseudo-class must - * stay in the variant: keyborg leaves `data-fui-focus-within` on the element after focus - * moves away, so the attribute alone would keep the ring painted. - */ -@custom-variant focus-visible-fui (&:where([data-fui-focus-visible])); -@custom-variant focus-within-fui (&:where([data-fui-focus-within]:focus-within)); - -/* - * The NATIVE `:focus-visible` pseudo-class, deliberately separate from the keyborg pair - * above. Several v9 styles files author a plain `':focus-visible'` rule alongside their - * keyborg indicator — Link's base slice does exactly this - * (`.f2hkw1w:focus-visible{outline-style:none}` in its compiled AOT output) — so the two - * selectors must stay independently addressable. Overrides Tailwind's built-in variant of - * the same name only to add the `:where()` wrapper every catalog entry carries. - */ -@custom-variant focus-visible (&:where(:focus-visible)); +/* Focus visibility: keyborg attributes (the Fluent visual contract) plus the native + pseudo-classes, one variant each. focus-within keeps the attribute:pseudo pairing — + keyborg leaves data-fui-focus-within behind after focus moves. */ +@custom-variant focus-visible (&:where([data-fui-focus-visible], :focus-visible)); +@custom-variant focus-within (&:where([data-fui-focus-within]:focus-within, :focus-within)); /* * Content presence — named for alignment with the CSS-native :empty selector diff --git a/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css b/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css index c40192cd6da260..5e38bbcf44564e 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css +++ b/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css @@ -35,13 +35,13 @@ } } - @variant focus-visible-fui { + @variant focus-visible { @apply fui-focus-ring; } /* Mozilla BugID 1857642 */ @supports (-moz-appearance: button) { - @variant focus-visible-fui { + @variant focus-visible { box-shadow: 0 0 0 calc(var(--spacing-thin) + 0.25px) var(--color-stroke-focus-2) inset; } } @@ -236,7 +236,7 @@ } } - @variant focus-visible-fui { + @variant focus-visible { @variant size-small { @apply rounded-small; } @@ -274,7 +274,7 @@ } } - @variant focus-visible-fui { + @variant focus-visible { @apply border-stroke-focus-2; box-shadow: @@ -293,7 +293,7 @@ /* Mozilla BugID 1857642 */ @supports (-moz-appearance: button) { - @variant focus-visible-fui { + @variant focus-visible { box-shadow: var(--shadow-2), 0 0 0 calc(var(--spacing-thin) + 0.25px) var(--color-stroke-focus-2) inset, @@ -351,7 +351,7 @@ .circular { @apply rounded-circular; - @variant focus-visible-fui { + @variant focus-visible { @apply rounded-circular; } } @@ -359,7 +359,7 @@ .square { @apply rounded-none; - @variant focus-visible-fui { + @variant focus-visible { @apply rounded-none; } } diff --git a/packages/react-components/react-windmod-preview/stories/src/Button/ButtonDefault.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Button/ButtonDefault.stories.tsx index 1889c897b76856..ca3a0533609a67 100644 --- a/packages/react-components/react-windmod-preview/stories/src/Button/ButtonDefault.stories.tsx +++ b/packages/react-components/react-windmod-preview/stories/src/Button/ButtonDefault.stories.tsx @@ -1,9 +1,13 @@ import * as React from 'react'; import { Button, ThemeProvider } from '@fluentui/react-windmod-preview'; -import { CalendarMonthRegular } from '@fluentui/react-icons'; +import { CalendarMonthFilled, CalendarMonthRegular } from '@fluentui/react-icons'; +import { bundleIcon } from '@fluentui/react-icons/headless'; import styles from '../compare.module.css'; +// Bundled pair: hover on subtle/transparent swaps regular → filled via data-variant. +const CalendarMonth = bundleIcon(CalendarMonthFilled, CalendarMonthRegular); + const appearances = ['secondary', 'primary', 'outline', 'subtle', 'transparent'] as const; const sizes = ['small', 'medium', 'large'] as const; const shapes = ['rounded', 'circular', 'square'] as const; @@ -18,13 +22,13 @@ export const Default = (): React.ReactNode => ( {appearance} {size} ))} - - - @@ -35,7 +39,7 @@ export const Default = (): React.ReactNode => ( ))}
{shapes.map(shape => ( - ))} diff --git a/packages/react-components/react-windmod-preview/stories/src/Button/ButtonGriffelComparison.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Button/ButtonGriffelComparison.stories.tsx index b618620d37d9d4..c3f2085a4cb477 100644 --- a/packages/react-components/react-windmod-preview/stories/src/Button/ButtonGriffelComparison.stories.tsx +++ b/packages/react-components/react-windmod-preview/stories/src/Button/ButtonGriffelComparison.stories.tsx @@ -2,10 +2,15 @@ import * as React from 'react'; import { Button, ThemeProvider } from '@fluentui/react-windmod-preview'; import type { ButtonProps } from '@fluentui/react-windmod-preview'; import { Button as GriffelButton, FluentProvider, webLightTheme } from '@fluentui/react-components'; -import { CalendarMonthRegular } from '@fluentui/react-icons'; +import { bundleIcon as griffelBundleIcon, CalendarMonthFilled, CalendarMonthRegular } from '@fluentui/react-icons'; +import { bundleIcon } from '@fluentui/react-icons/headless'; import styles from '../compare.module.css'; +// Each side bundles with its own mechanism: headless (data-variant) vs Griffel classic. +const CalendarMonth = bundleIcon(CalendarMonthFilled, CalendarMonthRegular); +const GriffelCalendarMonth = griffelBundleIcon(CalendarMonthFilled, CalendarMonthRegular); + const appearances = ['secondary', 'primary', 'outline', 'subtle', 'transparent'] as const; const sizes = ['small', 'medium', 'large'] as const; const shapes = ['rounded', 'circular', 'square'] as const; @@ -47,23 +52,23 @@ export const GriffelComparison = (): React.ReactNode => {
with icon
- +
- }>Button + }>Button
icon only
-
- } aria-label="Calendar" /> + } aria-label="Calendar" />
From e012eeb378ba376df689d5a6807505671c3d0c8d Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Fri, 21 Aug 2026 05:23:37 -0700 Subject: [PATCH 014/246] =?UTF-8?q?refactor(windmod):=20no=20union=20varia?= =?UTF-8?q?nts=20=E2=80=94=20comma-separate=20at=20the=20call=20site?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit position-start-or-end, scroll-state-middle-or-*, size-small-or-below removed; the placement centered unions become exact singles (placement-above-centered, …) and Tooltip's cross-axis rules use comma-separated group variants (@variant group-placement-above-centered/fui-tooltip, group-placement-below-centered/fui-tooltip). Catalog header documents the rule. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- .../css/variants.css | 35 +++++-------------- .../src/components/Tooltip/Tooltip.module.css | 4 +-- 2 files changed, 10 insertions(+), 29 deletions(-) diff --git a/packages/react-components/react-tailwind-theme-preview/css/variants.css b/packages/react-components/react-tailwind-theme-preview/css/variants.css index f3feaa5c3db47a..1df9d66b499e82 100644 --- a/packages/react-components/react-tailwind-theme-preview/css/variants.css +++ b/packages/react-components/react-tailwind-theme-preview/css/variants.css @@ -7,7 +7,8 @@ * `group-/` automatically, so every entry composes with named groups. * An ancestor-form entry (`:where([x]) &`) silently stops group-composing — don't add * one. At-rule variants (`forced-colors`) cannot take a group prefix; nest the group - * variant inside them instead. + * variant inside them instead. No union (-or-) entries: comma-separate variants at + * the call site (@variant hover, focus { … }). */ /* direction — computed direction, correct under nesting (DECISIONS.md D5) */ @@ -33,22 +34,17 @@ * axis, and InlineDrawer / OverlayDrawer branch on all three values independently. It is * also the reason Persona's text placement got its own `data-text-position` further down — * a generic `data-position` would have been ambiguous on a component that has both. - * - * `position-start-or-end` is the inline-axis pair the two drawers spell as one slice - * (`[data-position='start'], [data-position='end']`) — a union, hence the `-or-`. */ @custom-variant position-start (&:where([data-position='start'])); @custom-variant position-end (&:where([data-position='end'])); @custom-variant position-bottom (&:where([data-position='bottom'])); -@custom-variant position-start-or-end (&:where([data-position='start'], [data-position='end'])); /* * Anchored-surface placement — the headless positioning hook stamps `data-placement` * ON the positioned surface (logical vocabulary: above|below|before|after, with * -start/-end (inline) or -top/-bottom (block) alignment suffixes) and keeps it in sync * with the browser-resolved fallback. Prefix variants key the main axis; suffix variants - * key the alignment; the two `-centered` forms name the suffix-less (centered) cases per - * axis, for cross-axis rules that differ between horizontal and vertical placements. + * key the alignment; the `-centered` singles name the exact suffix-less values. */ @custom-variant placement-above (&:where([data-placement^='above'])); @custom-variant placement-below (&:where([data-placement^='below'])); @@ -58,8 +54,10 @@ @custom-variant placement-end (&:where([data-placement$='-end'])); @custom-variant placement-top (&:where([data-placement$='-top'])); @custom-variant placement-bottom (&:where([data-placement$='-bottom'])); -@custom-variant placement-centered-inline (&:where([data-placement='above'], [data-placement='below'])); -@custom-variant placement-centered-block (&:where([data-placement='before'], [data-placement='after'])); +@custom-variant placement-above-centered (&:where([data-placement='above'])); +@custom-variant placement-below-centered (&:where([data-placement='below'])); +@custom-variant placement-before-centered (&:where([data-placement='before'])); +@custom-variant placement-after-centered (&:where([data-placement='after'])); /* Glyph variant — stamped by @fluentui/react-icons/headless bundleIcon on each glyph. */ @custom-variant variant-filled (&:where([data-variant='filled'])); @@ -76,18 +74,13 @@ * knows. * * The four singles are `DrawerScrollState`'s complete union - * (react-drawer shared/DrawerBase.types.ts). Only the complement and the two `-or-` unions - * carry rules today; an unreferenced variant emits nothing. + * (react-drawer shared/DrawerBase.types.ts). An unreferenced variant emits nothing. */ @custom-variant scroll-state-none (&:where([data-scroll-state='none'])); @custom-variant scroll-state-top (&:where([data-scroll-state='top'])); @custom-variant scroll-state-middle (&:where([data-scroll-state='middle'])); @custom-variant scroll-state-bottom (&:where([data-scroll-state='bottom'])); @custom-variant not-scroll-state-none (&:where(:not([data-scroll-state='none']))); -@custom-variant scroll-state-middle-or-top (&:where([data-scroll-state='middle'], [data-scroll-state='top'])); -@custom-variant scroll-state-middle-or-bottom ( - &:where([data-scroll-state='middle'], [data-scroll-state='bottom']) -); /* generic states (native + data- fallbacks) */ @custom-variant disabled (&:where([disabled], [data-disabled], :disabled)); @@ -175,18 +168,6 @@ @custom-variant size-extra-large (&:where([data-size='extra-large'])); @custom-variant size-huge (&:where([data-size='huge'])); -/* - * Threshold form of the scale above — "at or below `small`". Badge's `smallToTiny` - * (`size === 'small' || 'extra-small' || 'tiny'`) gates two slices: the caption2Strong - * typography step and the `rounded` shape's smaller corner radius. Expressed as ONE - * variant rather than three duplicated blocks so the slice keeps its 1:1 mapping back to - * the Griffel source. Generalises safely: a component whose scale stops at `small` still - * matches only `size-small`. - */ -@custom-variant size-small-or-below ( - &:where([data-size='small'], [data-size='extra-small'], [data-size='tiny']) -); - /* * Numeric data-size values (Avatar/Skeleton pixel sizes, Text's typography ramp) get NO * variants: size-16 etc. would read as Tailwind's own size-* utilities. Components diff --git a/packages/react-components/react-windmod-preview/library/src/components/Tooltip/Tooltip.module.css b/packages/react-components/react-windmod-preview/library/src/components/Tooltip/Tooltip.module.css index d1a4d9edb96403..1eaa3fed4a3b39 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Tooltip/Tooltip.module.css +++ b/packages/react-components/react-windmod-preview/library/src/components/Tooltip/Tooltip.module.css @@ -75,11 +75,11 @@ } /* Cross axis: centered when suffix-less, near-edge inset when aligned. */ - @variant group-placement-centered-inline/fui-tooltip { + @variant group-placement-above-centered/fui-tooltip, group-placement-below-centered/fui-tooltip { @apply inset-x-0 mx-auto; } - @variant group-placement-centered-block/fui-tooltip { + @variant group-placement-before-centered/fui-tooltip, group-placement-after-centered/fui-tooltip { @apply inset-y-0 my-auto; } From b61eca4a78efdbb91428c972552e1b4215f82dba Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Fri, 21 Aug 2026 07:57:59 -0700 Subject: [PATCH 015/246] chore(windmod): keep working docs out of the repo Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- migration/windmod/BLOCKED_ON_UPSTREAM.md | 15 --- migration/windmod/LEDGER.md | 85 --------------- migration/windmod/PLAN.md | 129 ----------------------- 3 files changed, 229 deletions(-) delete mode 100644 migration/windmod/BLOCKED_ON_UPSTREAM.md delete mode 100644 migration/windmod/LEDGER.md delete mode 100644 migration/windmod/PLAN.md diff --git a/migration/windmod/BLOCKED_ON_UPSTREAM.md b/migration/windmod/BLOCKED_ON_UPSTREAM.md deleted file mode 100644 index 62cd03ec3348bf..00000000000000 --- a/migration/windmod/BLOCKED_ON_UPSTREAM.md +++ /dev/null @@ -1,15 +0,0 @@ -# Blocked on upstream headless coverage - -Components in @fluentui/react-components with NO counterpart in -@fluentui/react-headless-components-preview (as of master 06cbcbe0b1, -headless v0.2.5). We adopt them as upstream ships them; the export-map diff -script (Phase 0/2) flags additions on every upstream sync. - -- Table / DataGrid (react-table) -- Tree (react-tree) -- List (react-list) -- Carousel (react-carousel) -- Virtualizer (react-virtualizer) -- Text — typography components (react-text) -- Compat packages out of scope entirely: datepicker-compat, timepicker-compat, - calendar-compat, migration shims, deprecated react-alert/react-infobutton. diff --git a/migration/windmod/LEDGER.md b/migration/windmod/LEDGER.md deleted file mode 100644 index 03a8b295258749..00000000000000 --- a/migration/windmod/LEDGER.md +++ /dev/null @@ -1,85 +0,0 @@ -# Ledger - -Status flow: `planned` → `pilot` → `audited` → `converted` → `validated` (zero-tolerance VR vs Griffel suite). - -## Infra - -| Item | Status | Notes | -| -------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Branch styling/react-windmod | done | off master 06cbcbe0b1 | -| PLAN.md committed | done | | -| Build infra port (executor, scripts) | done | css-modules compiler in build executor; jest+storybook wiring; react-icons → 2.0.337 for /headless | -| react-tailwind-theme-preview port | done | self-contained (theme-values.json + class-name constants in-package; tokens.ts = name inventory only); regenerated vs current tokens; dist builds (175,694B) | -| react-windmod-preview scaffold | done | generator + bespoke: type:module, subpath exports, moduleResolution bundler (type-check --baseUrl override defeats repo paths) | -| workspace-plugin Windows fix | done | generate-api subpath rollups skipped on Windows (backslash vs '/index.d.ts' suffix check) — fixed, 56 headless rollups emit | -| ThemeProvider | done | display:contents + headless Provider context; theme classes re-exported from /provider | -| Stories Storybook + Tailwind v4 wiring | done | root .storybook rules + package stories project (generator template had 2 bugs: main.cjs not inferred, wrong require depth) | -| Library build/type-check/lint | done | all green | -| VR harness adaptation | planned | after pilot review | -| Unit tests (conformance + behavior) | planned | isConformant adapted (export-map shape); write with pilot round 2 | - -## Components (pilot) - -| Component | Status | Notes | -| --------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Button | pilot-built | Button.module.css ported verbatim; wrapper reintroduces appearance/shape/size (headless omits them — defaults mirrored, drift risk documented); adds data-size/data-empty | -| Tooltip | pilot-built | REAL top-layer adaptation: data-placement re-key (logical vocab + rtl swap), pure-CSS arrow cross-axis, JS offset compensation (4/+6); [data-hidden] producer gone — no boundary hiding | - -### Storybook verification 2026-08-20 (screenshots in .scratch/windmod-shots/) - -- Button GriffelComparison: side-by-side parity convincing across all 5 - appearances × 3 sizes × 3 shapes × 3 disabled states × icon variants. -- Tooltip Placements: all 12 placements render with correctly oriented arrows - (top-layer + CSS anchor positioning + data-placement re-key + pure-CSS arrow). -- ThemeProvider NestedThemes: web-dark/teams-dark/teams-HC subtrees all resolve; - the pinned top-layer tooltip inside the dark subtree renders DARK — the - display:contents theming architecture is proven in-browser. -- Learning: `popover='hint'` is exclusive by spec — multiple pinned-open - tooltips need `content={{ popover: 'manual' }}` (demo-only device). - -### Phase 2 items from PR review (2026-08-20) - -- Shared look-prop types: suggest one home for the appearance/shape/size unions - and per-component Props/State extensions (ideally exported by the headless - package) instead of windmod redeclaring them — user asked to suggest, not - implement yet. -- Upstream tactical-edit candidate: headless Tooltip arrow has no - slot/className hook (arrowRef exists, nothing applies a class) — windmod must - style the `[data-arrow]` child via descendant selector until then. -- DONE (user-approved tactical edit, 2026-08-21): icons fork branch - `feature/data-variant` stamps `data-variant="filled|regular"` in headless - bundleIcon; fluentui consumes it via a LOCAL-ONLY tarball resolution - (`file:../fluentui-system-icons/packages/react-icons/fluentui-react-icons-local.tgz`) - — REVERT the resolutions entry + yarn.lock before any merge/publish (same - protocol as the previous campaign), and upstream the icons change. Button's - glyph swap now rides `variant-filled`/`variant-regular` catalog variants; - zero `fui-Icon-*` class references remain in compiled CSS. To rebuild the - tarball: icons repo `yarn build:generate-chunks-and-atoms && yarn build:js`, - `npm pack`, copy to `fluentui-react-icons-local.tgz`, then here - `rm .yarn/cache/@fluentui-react-icons-file-*.zip && yarn install`. -- Optional headless edit if wanted: emit `data-appearance` on Button so - appearance-conditional icon colors can ride named-group variants too. -- Story convention: ONE export per `*.stories.tsx`, re-exported through - `index.stories.tsx` — the export-to-sandbox addon injects `fullSource` only - on the last export otherwise (docs pages error with 'issues with data'). - -### Pilot known gaps (for review discussion) - -- Headless icons: /headless ships FACTORIES only (2.0.337) — premade icon - components are still Griffel. Stories use classic icons in the demo layer; - the windmod library imports none. PLAN.md corrected. - -- Arrow cross-axis position for -start/-end placements is a fixed 8px inset - (`--fui-positioning-arrow-cross`) — Griffel aligned it to the anchor from JS. - Zero-tolerance VR may flag it on those placements; centered placements should match. -- Button's icon filled/regular hover-swap rules target `:global(.fui-Icon-*)` classes — - verify against headless icons 2.0.337 markup in storybook (fork lineage carried them, - unconfirmed upstream). -- ButtonContext size inheritance (Toolbar et al.) not consumed — audit item for the - compound-component batches. -- generate-api emits `library/etc/*.api.md` per subpath — commit them once reviewed. - -## Components (remaining headless coverage) - -Not yet enumerated — Phase 2 audit populates this section from the headless -export map (~53 components). diff --git a/migration/windmod/PLAN.md b/migration/windmod/PLAN.md deleted file mode 100644 index 46a0413544a251..00000000000000 --- a/migration/windmod/PLAN.md +++ /dev/null @@ -1,129 +0,0 @@ -# react-windmod-preview — Campaign Plan - -_Approved by the user 2026-08-20. This file is the durable source of truth; the -conversation is not. Resume protocol: read this file, then LEDGER.md, then the -latest report in reports/._ - -## What we are building - -Two production-quality packages under `packages/react-components/`, merge-ready -in discipline (beachball, changelogs, conformance, docs) even though upstream -has not asked for them: - -1. **`@fluentui/react-tailwind-theme-preview`** — Fluent themes/tokens as a - Tailwind v4 + CSS layer. Ported from the closed branch's - `react-tailwind-theme` (`styling/tailwind-css-modules`): `tokens.css` - (@theme inline, 467 tokens → utilities), `themes.css` (7 theme classes × - 433 tokens, generated with lockstep assertions), `variants.css` (catalog - already named after the headless data-_ vocabulary), the `fui._` cascade - layer order, and the CSS-Modules build pipeline. Standalone value: teams - styling raw headless components get real Fluent themes. -2. **`@fluentui/react-windmod-preview`** — styled Fluent components composing - `@fluentui/react-headless-components-preview` hooks with Tailwind v4 + CSS - Modules, visually parity-matched to the Griffel suite. `library/` + - `stories/` split and subpath exports mirror the headless preview 1:1, plus a - root barrel; unstylable/infra subpaths (`/positioning`, `/utils`) are - re-exported pass-through so one package imports everything. - -## Settled decisions (user-confirmed) - -- **Name:** react-windmod-preview (portmanteau: tailWIND + CSS MODules). -- **Theme layer is its own package** (react-tailwind-theme-preview). -- **Per-component architecture — hook composition, not component wrapping:** - `useX(props, ref)` (headless; emits data-_ state attrs) → `useXStyles(state)` - (ours: module class-map onto every slot's className, clsx-merged with - consumer classNames; JS variant branching on state where headless emits no - data-_ — e.g. Button appearance/size/shape; group marker per D15/D16) → - `renderX(state)` (headless re-export). Full state access; upstream tactical - edits (e.g. adding data-appearance) become optional in-pattern PRs, never - blockers. -- **ThemeProvider renders a `display:contents` div** carrying the theme class - (`fui-theme-web-light`, …) and composes the headless Provider context - (dir/targetDocument). Verified in Chrome 2026-08-20: vars on a - display:contents element inherit to children INCLUDING open top-layer - popovers inside it, and nested providers override element-locally - (probe: .scratch/display-contents-probe/). -- **No portal special-casing needed:** headless surfaces use the native - top-layer (PopoverSurface ``, Tooltip - `popover="hint"`, Toaster `popover="manual"`, MenuPopover `popover="auto"`, - non-modal Dialog `popover="manual"`). Top-layer keeps DOM ancestry → theme - vars cascade in. -- **Icons: `@fluentui/react-icons/headless`** — published upstream (verified at - 2.0.337; repo bumped from 2.0.311). CORRECTION (pilot finding): the headless - entry ships the Griffel-free FACTORIES (createFluentIcon/bundleIcon/wrapIcon) - and the attribute stylesheet, but NOT premade icon components — the premade - ones (CalendarMonthRegular, …) are still Griffel-styled in 2.0.337. Windmod's - library imports no icons; consumers/stories use classic premade icons for now - (Griffel rides along in the DEMO layer only). Track upstream for premade - headless components; the fork's promotion work remains the reference. -- **Tokens stay kebab-case** (`--color-neutral-foreground-1`). Self-contained - namespace; deliberately NOT interoperable with classic FluentProvider - camelCase theming. Documented as a design decision. -- **Parity bar: zero-tolerance VR** against the Griffel suite's rendering, - webLightTheme default, like the previous campaign. -- **Coverage = whatever headless ships.** We track upstream; see - BLOCKED_ON_UPSTREAM.md. Currently absent upstream: Table/DataGrid, Tree, - List, Carousel, Virtualizer, Text (typography). -- **Communication:** chat carries deltas/decisions/findings only; long-form - lives here (user feedback 2026-08-20). - -## Phases - -- **Phase 0 — Scaffold** (branch `styling/react-windmod` off master): - both packages per repo conventions (nx, tsconfig, eslint, beachball), theme - package ported + generator re-verified against current @fluentui/tokens, - stories Storybook wired with the Tailwind v4 pipeline. -- **Phase 1 — Pilot (GATE: user review): Button + Tooltip + ThemeProvider.** - Tooltip forces the theme provider, top-layer and positioning path; Button - covers variant-branching without data-\*. Side-by-side stories vs the Griffel - suite for review. A third component only if the user asks after review. -- **Phase 2 — Audit matrix:** all ~53 headless components × {DOM/slot parity - vs styled twin, data-\* coverage vs module.css selector needs, top-layer CSS - adaptation, port complexity} → batch plan + optional-upstream-PR list. -- **Phase 3 — Batch conversion** under the established protocol: full contract - per batch, batch+seam validation only, chunked per-package commits; full - sweeps only at phase boundaries. -- **Phase 4 — Ship polish:** 7 theme classes exercised (VR subset per theme), - bundle-isolation gate (adopt @fluentui/verify-bundle-isolation — no Griffel - in our bundles), docs, metrics, PR assembly. - -## Reuse map (source: branch `styling/tailwind-css-modules`) - -- react-tailwind-theme package → ported wholesale, renamed. -- Converted `useXStyles` hooks + `*.module.css` per component → primary - styling source; re-targeted onto headless state objects. Top-layer surfaces - (popover/dialog/menu/tooltip/toast) need real adaptation, not a port — the - old CSS assumed portaled DOM + positioning wrappers. -- CSS Modules build executor (commits c9942bcca9 + cc63894a05 on the branch) - → compile module.css → dist/styles.css + class-map JS. -- VR harness (migration/griffel-to-tailwind/validation/) → adapt for - cross-package A/B (our story render vs Griffel suite story render). -- DECISIONS.md (D2 layers, D15/D16 class-name & data-\* contract, D20 nonce, - …) → contract carries forward unless a decision here supersedes it. - -## Risk register - -1. Top-layer CSS adaptation (::backdrop, top-layer stacking, headless's own - usePositioning) — sized in Phase 2; late batches. Tooltip in the pilot - de-risks the pattern early. -2. Headless is v0.2.x and moving — pin exact versions per batch; re-run seam - checks on upstream sync; export-map diff script flags new components. -3. Icons: verify-bundle-isolation gate catches any Griffel leak via icon - imports. Fork branch `feature/headless-promotion` - (fluentui-system-icons) is reference only. -4. Variant-prop defaults (e.g. Button appearance='secondary') are applied - inside base hooks — our styles hooks branch on RESOLVED state, never - replicate defaults from props. - -## Working-docs hygiene - -Everything under `migration/windmod/` is working state, committed on this -branch, and STRIPPED in a single marked commit at PR-assembly time (Phase 4). -The eventual PR contains only the two packages + stories + docs/app wiring. - -## Files here - -- PLAN.md (this file) -- LEDGER.md — per-component status (pilot → audited → converted → validated) -- BLOCKED_ON_UPSTREAM.md — components with no headless counterpart -- reports/ — per-phase/batch reports From 6b59aa9f0044b3b2531ba9a54ac9d13b50b18d7e Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Fri, 21 Aug 2026 07:58:01 -0700 Subject: [PATCH 016/246] =?UTF-8?q?docs(windmod):=20comment=20sweep=20?= =?UTF-8?q?=E2=80=94=20standalone,=20minimal,=20no=20decision=20references?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every comment now stands alone: campaign/decision citations (DECISIONS.md, phase numbers, settled-with dates, probe/report paths) removed or rewritten as plain statements of the constraint. Headers cut hard across the theme package (index/emit/variants/utilities/build/generators), the ported pipeline scripts (ident, group-marker plugin, jest proxy/serializer, storybook theme entry) and the windmod hooks/components; the class-ordering rationale lives once in useButtonStyles with a pointer from the tooltip hook. Every touched file is majority code. Generated theme CSS regenerated; all gates pass (generator --check, theme-values --check, prettier); windmod build and tests green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- .../react-tailwind-theme-preview/build.js | 38 +- .../react-tailwind-theme-preview/css/emit.css | 50 +- .../css/index.css | 129 +----- .../css/themes.css | 2 +- .../css/tokens.css | 10 +- .../css/variants.css | 432 ++---------------- .../scripts/generate-theme-values.js | 23 +- .../scripts/generate-tokens-css.js | 134 ++---- .../scripts/probe-spacing-behavior.mjs | 5 +- .../theme-class-names.mjs | 26 +- .../theme-values.json | 4 +- .../src/components/Button/Button.module.css | 12 +- .../src/components/Button/useButtonStyles.ts | 20 +- .../src/components/Provider/ThemeProvider.tsx | 13 +- .../src/components/Tooltip/Tooltip.tsx | 21 +- .../components/Tooltip/useTooltipStyles.ts | 20 +- .../css-modules/globalize-group-markers.js | 48 +- scripts/css-modules/ident.js | 63 +-- scripts/jest/src/css-modules/proxy.js | 28 +- scripts/jest/src/css-modules/serializer.js | 42 +- scripts/storybook/src/tailwind-theme.css | 58 +-- .../src/executors/build/lib/css-modules.ts | 33 +- 22 files changed, 202 insertions(+), 1009 deletions(-) diff --git a/packages/react-components/react-tailwind-theme-preview/build.js b/packages/react-components/react-tailwind-theme-preview/build.js index 2a74fbf70da177..6c5caad03b6f4d 100644 --- a/packages/react-components/react-tailwind-theme-preview/build.js +++ b/packages/react-components/react-tailwind-theme-preview/build.js @@ -1,35 +1,13 @@ /** - * Emits the theme root artifact: `css/emit.css` → `dist/styles.css`, plain CSS. + * Emits `css/emit.css` → `dist/styles.css` (plain CSS): everything a component sheet + * references, nothing a component sheet duplicates. The artifact must contain zero + * `@property` rules — a non-empty registry puts Blink's transition-start on a + * page-global slow path. * - * DECISIONS.md D13 — the document imports the theme stylesheet EXACTLY ONCE; component - * packages' `dist/styles.css` contain component rules only and never embed this emission. - * The output holds the `@layer` order statement, the `@theme` custom properties - * (`--base-scale`, `--spacing`), the 4 stroke widths (`--spacing-thin` … `--spacing-thickest`, - * the only token registrations that emit a variable — border/outline widths do not consume - * the `--spacing-*` namespace, so modules reference them directly). The focus-knob - * `@property` registrations were REMOVED (Blink registry transition-start penalty — - * reports/perf-property-remedy.md); the artifact must contain ZERO `@property` rules. - * Everything a component sheet references, nothing a component sheet duplicates. - * - * NO `project.json` on purpose. The workspace plugin creates its nodes from `project.json` - * files and unconditionally infers `clean` / `format` / `type-check` targets (plus a - * `build` target overriding this one, once the project is tagged `vNext`), none of which a - * CSS-only package with no `src/`, no tsconfig and no TypeScript can satisfy. nx still sees - * this package — it is inferred from `package.json` (project name - * `@fluentui/react-tailwind-theme-preview`, targets `build` / `generate-tokens-css` / - * `verify-tokens-css` from the scripts above) — it just carries no nx tags. - * - * PACKAGING (Phase 3 / D13, settled): the theme reaches consumers as this published package, - * not as a suite-level convenience stylesheet. Consumers import - * `@fluentui/react-tailwind-theme-preview/styles.css` exactly once at their document root; see - * README.md. - * - * `dist/` is gitignored, and the release pipeline builds by nx tag (`nx run-many -t build -p - * tag:vNext`), which does not select an untagged project. So the emission is ALSO wired to - * the `prepack` script: `npm pack` and `npm publish` run it, which is what guarantees - * `dist/styles.css` is in the tarball regardless of whether a prior build step ran. - * - * Usage: node packages/react-components/react-tailwind-theme-preview/build.js + * No `project.json` on purpose: the workspace plugin would infer type-check/format/build + * targets a CSS-only package cannot satisfy; nx infers this project from package.json. + * The release pipeline selects by nx tag and skips untagged projects, so the emission is + * also wired to `prepack` to guarantee dist/styles.css lands in the tarball. */ const { mkdirSync, readFileSync, writeFileSync } = require('node:fs'); const { join } = require('node:path'); diff --git a/packages/react-components/react-tailwind-theme-preview/css/emit.css b/packages/react-components/react-tailwind-theme-preview/css/emit.css index 9f9294fb2ea233..2c9ee4650ecb01 100644 --- a/packages/react-components/react-tailwind-theme-preview/css/emit.css +++ b/packages/react-components/react-tailwind-theme-preview/css/emit.css @@ -1,48 +1,18 @@ /* - * The EMISSION entry for the shared theme layer (DECISIONS.md D13). + * Build entry for dist/styles.css — the one stylesheet a document imports. * - * `css/index.css` is the `@reference '#theme'` target: component `*.module.css` files - * reference it to get the layer order, `@theme` variables, custom variants and focus - * utilities — and `@reference` emits NOTHING, which is exactly why component packages' - * compiled `dist/styles.css` contain zero theme declarations. - * - * Something still has to emit those declarations once per document, or every numeric - * spacing utility resolves against a missing `--base-scale` and computes to 0px. That is - * this file: compiled by `build.js` into the standalone plain-CSS root artifact - * (`dist/styles.css`) which the consuming document imports exactly once — never bundled - * into a component package. - * - * What it emits: `--base-scale`, `--spacing` (NO focus-knob `@property` registrations — - * removed for the Blink registry transition-start penalty, see - * migration/griffel-to-tailwind/reports/perf-property-remedy.md), the 4 canonical - * `--stroke-width-*` variables (literal base-scale values — deliberately NOT - * `--spacing`-coupled), the 4 private `--spacing-thin/…` hooks aliasing them, and the 22 - * spacing custom properties (numeric-axis aliases, `calc(var(--spacing) * N)`). These - * come from the generated `css/tokens.css` and are the ONLY token registrations that also - * emit variables — border/outline/ring/decoration widths do not consume the `--spacing-*` - * namespace, so modules author them as a direct `var(--spacing-thin)`, which needs a real - * variable; and the `tokens.*` JS reference strings resolve against these canonical names - * (the old camelCase variables no longer exist in emitted CSS — option B). Every other - * Fluent token stays an `@theme inline` name that emits nothing. - * - * `source(none)` is mandatory. Without it Tailwind auto-detects content, scans this - * package, and dumps its findings into the utilities layer, which outranks every - * `fui.components.*` layer (CONVERSION_GUIDE.md "Toolchain traps"). + * `css/index.css` is the `@reference` target and emits nothing from component modules; + * something must emit the theme declarations once per document, and it is this file. + * `source(none)` is mandatory: without it Tailwind content-scans this package and dumps + * utilities into a layer that outranks every component layer. */ @import './index.css' source(none); /* - * Headless icons stylesheet (@fluentui/react-icons/headless), inlined into dist/styles.css at - * build time so the consuming document's single theme import also covers icons. The icon - * factories express styling as attributes (`data-fui-icon`, `data-fui-icon-rtl`, - * `data-fui-icon-hidden`, `data-fui-icon-font`) and the package ships the rules UNLAYERED - * by default — cascade layers are compared before specificity, so unlayered icon rules - * would beat every layered `fui.*` rule no matter how specific. Correct behavior inside - * the fluentui layering system requires assigning them a layer at import time; the - * user-decided altitude is `fui.components.l1`, the lowest component layer - * (griffel-zero-plan.md amendment D27): icon state rules arbitrate with base component - * rules by in-file source order, and l2+, utilities, and unlayered consumer CSS still win. - * The storybook/VR/docsite harness gets the identical import via - * scripts/storybook/src/tailwind-theme.css (documents that never load this artifact). + * Headless icons stylesheet, inlined so the single theme import also covers icons. The + * icons package ships these rules unlayered; layers are compared before specificity, so + * they must be assigned a layer at import time. fui.components.l1 lets icon state rules + * arbitrate with base components by source order while l2+, utilities and unlayered + * consumer CSS still win. */ @import '@fluentui/react-icons/headless/styles.css' layer(fui.components.l1); diff --git a/packages/react-components/react-tailwind-theme-preview/css/index.css b/packages/react-components/react-tailwind-theme-preview/css/index.css index beb8d3058ac08b..f5f096f35ff59e 100644 --- a/packages/react-components/react-tailwind-theme-preview/css/index.css +++ b/packages/react-components/react-tailwind-theme-preview/css/index.css @@ -1,42 +1,19 @@ /** - * @fluentui/react-tailwind-theme-preview — the `#theme` reference target for component - * `*.module.css` files (see migration/griffel-to-tailwind/CONVERSION_GUIDE.md). + * The `#theme` reference target for component `*.module.css` files. * - * Library constraints (differ deliberately from an app-level Tailwind setup): - * - NO preflight: a component library must not ship global resets. Only Tailwind's - * theme + utilities are imported; utilities reach consumers exclusively inlined - * through `@apply` in component modules. - * - Fluent design tokens are registered with `@theme inline` (./tokens.css, generated), - * which substitutes `var(--tokenName)` INTO each utility rather than aliasing it at - * `:root`. FluentProvider still owns the values — they stay scoped CSS custom - * properties resolved per-element, so per-provider and nested-provider theming are - * unchanged. A plain (non-inline) `@theme` alias would freeze resolution at `:root` - * and is FORBIDDEN. Literal `var(--tokenName)` remains valid everywhere. - * - The ONLY nyt-games theme carry-over is --base-scale (user requirement). + * Library constraints (deliberately unlike an app Tailwind setup): + * - No preflight — a component library must not ship global resets. Utilities reach + * consumers only inlined through `@apply`. + * - Tokens register via `@theme inline` (./tokens.css), substituting `var(--token)` + * into each utility so values stay per-element CSS custom properties. A non-inline + * `@theme` alias would freeze resolution at `:root` and must not be used. */ /* - * ONE layer family (settled with user 2026-07-27): the nyt-games layer structure, - * carried under the `fui` root. Mapping from nyt-games / Tailwind conventions: - * nyt `theme` → fui.theme (the @theme custom-property emission) - * nyt `base` → fui.base (levelless; hosts makeResetStyles output — - * Griffel's reset bucket lost to everything, so this layer - * sits below all component levels. Also the sanctioned slot - * for preflight if an app ever wants it; the library itself - * ships NO global resets.) - * nyt `components.lN` → fui.components.lN (implementation altitude: - * l1 = base library components, l2 = library compositions - * (styles applied over another component's hook output), - * l3–l5 = consumer space: app-global / page / bespoke. - * Winner order WITHIN a level = in-file source order; all - * selectors are :where()-flat. Rules written directly into - * `fui.components` (or any level) beat that layer's - * sublayers — probe-verified; use sparingly.) - * nyt `utilities` → fui.utilities (top of family: utilities beat component - * styles by design, but lose to unlayered consumer CSS) - * Verified caller-side: tailwindcss/utilities.css is a bare `@tailwind utilities;` with - * no layer name — cascade layers are assigned only by these import modifiers. Tailwind - * v4's own `components` layer receives no emissions (probe-verified). + * One cascade-layer family. fui.base is levelless (resets); l1 = base components, + * l2 = compositions, l3–l5 = consumer space. Winner order within a level is in-file + * source order (all selectors are :where()-flat). fui.utilities beats component styles + * but loses to unlayered consumer CSS. */ @layer fui.theme, fui.base, fui.components, fui.components.l1, fui.components.l2, fui.components.l3, fui.components.l4, fui.components.l5, fui.utilities; @@ -50,44 +27,17 @@ --base-scale: calc(1rem / 16px); /* - * DUAL SPACING (settled with user 2026-07-27) — both halves are live: - * - * 1. NUMERIC. Every numeric spacing/sizing utility (p-12, gap-8, w-64, …) reads as px - * but computes to rem via --base-scale: identical pixels at the default 16px root, - * scaling with user font-size preferences (intentional accessibility deviation). - * `--spacing-*: initial` drops Tailwind's own named spacing scale (px, 0.5, …) so - * only the multiplier below survives. - * 2. NAMED. ./tokens.css re-populates the namespace with the 22 Fluent spacing steps - * (p-horizontal-m, gap-vertical-s, …) PLUS the 4 stroke widths (p-thin, w-thick, - * gap-thicker, h-thickest). `initial` clears only what is registered BEFORE it, so - * the LATER import survives this reset — probe-verified, and the reason ./tokens.css - * must stay imported after this block. - * - * The 22 spacing keys are ALIASES OF THE NUMERIC AXIS — `calc(var(--spacing) * 12)`, - * the exact shape `p-12` compiles to — so named and numeric spacing are one scaling - * system with ONE density knob: the `--spacing` base below (theming Phase 1, settled - * with user 2026-07-29). Overriding `--spacing` on any subtree rescales both forms - * identically. Stroke widths are the deliberate exception: their PUBLIC variables are - * `--stroke-width-thin/thick/thicker/thickest`, literal `calc( * var(--base-scale))` - * — borders must not thin when layout density changes — and the `--spacing-thin/…` keys - * are PRIVATE hooks aliasing them for utility generation. No form reads the old - * camelCase names (gone — option B single vocabulary), so FluentProvider - * spacing/strokeWidth overrides do not reach utility-sourced spacing; all 7 shipped - * themes carry identical values for both sets (asserted: - * .scratch/layer-probe/assert-theme-spacing.js and assert-theme-stroke-width.js), so no - * shipped theme changes behavior. + * Numeric spacing utilities (p-12, gap-8, …) read as px but compute to rem via + * --base-scale, scaling with the root font-size. ./tokens.css re-adds the named + * Fluent spacing steps as aliases of the same numeric axis, so both forms share the + * one density knob below. Stroke widths are the exception: literal base-scale values + * under --stroke-width-* (borders must not thin with layout density). */ --spacing-*: initial; --spacing: calc(1px * var(--base-scale)); - /* - * Zero out Tailwind's default palette/typography so a stray `text-red-500` or - * `font-sans` fails the build instead of silently diverging from Fluent tokens. - * Structural utilities (flex, grid, items-*, overflow-*, …) remain available. - * - * `initial` clears whatever is registered at THIS point in the theme resolution, so - * ./tokens.css must be imported AFTER this block — see the note on the import below. - */ + /* Drop Tailwind's default palette/typography so a stray text-red-500 fails the build + instead of diverging from Fluent tokens. Structural utilities remain. */ --color-*: initial; --font-*: initial; --text-*: initial; @@ -103,45 +53,12 @@ } /* - * Fluent design tokens as Tailwind namespaces — GENERATED, do not edit by hand: - * node packages/react-components/react-tailwind-theme-preview/scripts/generate-tokens-css.js - * - * Gives every Fluent token a utility name (`bg-neutral-background-1`, `text-base-300`, - * `shadow-8`, `rounded-medium`, `leading-base-300`, `ease-easy-ease`, `duration-normal`, - * `z-popup`, …) that compiles to a DIRECT `var(--fluentToken)` reference via - * `@theme inline`. Literal `var(--tokenName)` authoring stays valid and unchanged. - * - * SPACING IS THE ONE EXCEPTION: `p-horizontal-m` / `gap-vertical-s` compile to the - * numeric-axis alias `calc(var(--spacing) * N)` and `w-thin` to - * `var(--stroke-width-thin)` — never to any old camelCase token variable (those names no - * longer exist in emitted CSS; option B) — see the dual-spacing note in the - * `@theme static` block above. - * - * ALL SPACING-NAMESPACE KEYS ARE ALSO EMITTED as real custom properties, together with - * the 4 canonical `--stroke-width-*` variables — a `@layer fui.theme { :root, :host { … } }` - * block at the end of the generated file, the only declarations it contains. Two reasons: - * (a) border, outline, ring, divide, underline-offset and decoration widths do NOT - * consume the `--spacing-*` namespace in v4 (`border-thin` does not exist; `border-2` is - * a hardcoded px literal, not a theme lookup), so modules author those properties as a - * direct `var(--spacing-thin)`, which needs a variable that exists; (b) the `tokens.*` - * JS constants in @fluentui/tokens are var() reference strings against these canonical - * names (charts inline styles etc.). Emitted once per document via ./emit.css exactly - * like `--base-scale`; `@reference` keeps it out of every component module. Probe: - * .scratch/layer-probe/check-stroke-namespace.mjs (consumes/does-not table) and - * check-stroke-emission.mjs (emission end to end). - * - * ORDER IS LOAD-BEARING: this import must stay AFTER the `@theme static` block above. - * Placed before it, `--color-*: initial` (and friends) wipe every registration and the - * utilities silently stop compiling — probe-verified, not theoretical. + * Generated Fluent token registration (`bg-neutral-background-1`, `rounded-medium`, …). + * Must stay AFTER the `@theme static` block — the `initial` resets above clear only + * what is registered before them. */ @import './tokens.css'; -/* - * SHIPPED THEME CLASSES (generated — theming Phase 2b). One class per shipped theme - * (.fui-theme-web-dark, …), each carrying ONLY the theme-variant canonical custom - * properties inside `@layer fui.theme`. A theme class on any DOM node themes that - * subtree; the `:root, :host` defaults above stay web light. `@reference` drops these - * rules from component modules (plain style rules emit nothing under reference), so the - * classes ship once per document via ./emit.css / dist/styles.css. - */ +/* Generated theme classes (.fui-theme-web-dark, …), custom properties only; the + `:root, :host` defaults in tokens.css stay web light. */ @import './themes.css'; diff --git a/packages/react-components/react-tailwind-theme-preview/css/themes.css b/packages/react-components/react-tailwind-theme-preview/css/themes.css index 346af396c4ade4..de8a445caa43ef 100644 --- a/packages/react-components/react-tailwind-theme-preview/css/themes.css +++ b/packages/react-components/react-tailwind-theme-preview/css/themes.css @@ -6,7 +6,7 @@ * Regenerate: node packages/react-components/react-tailwind-theme-preview/scripts/generate-tokens-css.js * Verify: node packages/react-components/react-tailwind-theme-preview/scripts/generate-tokens-css.js --check * - * SHIPPED THEME CLASSES (theming Phase 2b). Applying one of these classes to any + * SHIPPED THEME CLASSES. Applying one of these classes to any * DOM node themes that subtree — custom properties cascade. They contain ONLY * custom-property declarations, in the same fui.theme layer as the web-light * defaults at :root/:host (css/tokens.css), which they override element-locally. diff --git a/packages/react-components/react-tailwind-theme-preview/css/tokens.css b/packages/react-components/react-tailwind-theme-preview/css/tokens.css index 16f266a52b6a44..9406f3052625d2 100644 --- a/packages/react-components/react-tailwind-theme-preview/css/tokens.css +++ b/packages/react-components/react-tailwind-theme-preview/css/tokens.css @@ -10,11 +10,11 @@ * * `inline` is MANDATORY: it substitutes the canonical var(--token-name) into each * utility, so values resolve per-element — the `:root, :host` defaults below, - * overridden per subtree by the theme classes (css/themes.css, theming Phase 2b). + * overridden per subtree by the theme classes (css/themes.css). * A plain `@theme` alias would freeze resolution at `:root`, breaking scoped * theming. `reference` suppresses the self-referential aliases that plain * `inline` would emit now that each runtime variable IS its theme key (theming - * Phase 2a) — the values are emitted in the fui.theme block below, not by JS. + * the kebab-name model) — the values are emitted in the fui.theme block below, not by JS. * * SPACING IS THE ONE EXCEPTION: --spacing-horizontal-* / --spacing-vertical-* are * ALIASES OF THE NUMERIC AXIS — calc(var(--spacing) * N), the same shape p-12 @@ -540,9 +540,9 @@ * inline styles etc.). * * THE OLD camelCase NAMES (--colorNeutralBackground1, --spacingHorizontalM, …) - * ARE GONE for the ENTIRE token set (option B, theming Phase 2a): single + * ARE GONE for the ENTIRE token set: single * vocabulary, documented major break for hand-written consumer CSS. Theming - * Phase 2b removed FluentProvider's runtime theme style tag, so this block (plus + * the static-theme model removed FluentProvider's runtime theme style tag, so this block (plus * the theme classes in css/themes.css) is the ONLY writer of token values. * * Emitted ONCE PER DOCUMENT (D13): `@reference` drops this block, so component @@ -602,7 +602,7 @@ --spacing-vertical-xxxl: calc(var(--spacing) * 32); /* - * DEFAULT THEME VALUES (web light) — theming Phase 2b. Since the removal of + * DEFAULT THEME VALUES (web light). Since the removal of * FluentProvider's runtime theme style tag, these declarations are the sole * default value source for the theme-variant canonical variables. Non-default * themes are shipped as classes in css/themes.css (same layer); a theme class diff --git a/packages/react-components/react-tailwind-theme-preview/css/variants.css b/packages/react-components/react-tailwind-theme-preview/css/variants.css index 1df9d66b499e82..0f4e7eb9cfcd08 100644 --- a/packages/react-components/react-tailwind-theme-preview/css/variants.css +++ b/packages/react-components/react-tailwind-theme-preview/css/variants.css @@ -2,50 +2,33 @@ * Custom variant catalog — the single place state selectors are defined; never define * variants in component modules. Names follow the headless data-attribute vocabulary. * - * Every entry is the canonical `&:where(…)` form: zero added specificity (cascade is - * decided by @layer order and in-file order), and Tailwind rewrites that form into - * `group-/` automatically, so every entry composes with named groups. - * An ancestor-form entry (`:where([x]) &`) silently stops group-composing — don't add - * one. At-rule variants (`forced-colors`) cannot take a group prefix; nest the group - * variant inside them instead. No union (-or-) entries: comma-separate variants at - * the call site (@variant hover, focus { … }). + * Every entry is the canonical `&:where(…)` form: zero added specificity, and Tailwind + * rewrites it into `group-/` automatically, so every entry composes with + * named groups. Ancestor-form entries silently stop group-composing — do not add one. + * At-rule variants (forced-colors) cannot take a group prefix; nest the group variant + * inside them. No union entries: comma-separate variants at the call site + * (@variant hover, focus { … }). */ -/* direction — computed direction, correct under nesting (DECISIONS.md D5) */ +/* direction (computed, correct under nesting) */ @custom-variant rtl (&:where(:dir(rtl))); @custom-variant ltr (&:where(:dir(ltr))); -/* orientation (Divider, Slider, Tabs, Toolbar, …) */ +/* orientation */ @custom-variant vertical (&:where([data-orientation='vertical'])); @custom-variant horizontal (&:where([data-orientation='horizontal'])); -/* content alignment along the main axis (Divider, …) */ +/* content alignment along the main axis */ @custom-variant align-content-start (&:where([data-align-content='start'])); @custom-variant align-content-center (&:where([data-align-content='center'])); @custom-variant align-content-end (&:where([data-align-content='end'])); -/* - * Edge placement of a panel-shaped surface — Drawer's - * `position: 'start' | 'end' | 'bottom'` (react-drawer shared/DrawerBase.types.ts, the - * complete union). `data-position` is one of the headless preview's 25 attribute names - * (reports/headless-precedent.md). - * - * Deliberately NOT folded into `data-orientation` above: `bottom` names an EDGE, not an - * axis, and InlineDrawer / OverlayDrawer branch on all three values independently. It is - * also the reason Persona's text placement got its own `data-text-position` further down — - * a generic `data-position` would have been ambiguous on a component that has both. - */ +/* surface edge position (Drawer) */ @custom-variant position-start (&:where([data-position='start'])); @custom-variant position-end (&:where([data-position='end'])); @custom-variant position-bottom (&:where([data-position='bottom'])); -/* - * Anchored-surface placement — the headless positioning hook stamps `data-placement` - * ON the positioned surface (logical vocabulary: above|below|before|after, with - * -start/-end (inline) or -top/-bottom (block) alignment suffixes) and keeps it in sync - * with the browser-resolved fallback. Prefix variants key the main axis; suffix variants - * key the alignment; the `-centered` singles name the exact suffix-less values. - */ +/* anchored-surface placement — data-placement on the positioned surface: above|below|before|after main axis, -start/-end/-top/-bottom alignment, suffix-less = centered */ @custom-variant placement-above (&:where([data-placement^='above'])); @custom-variant placement-below (&:where([data-placement^='below'])); @custom-variant placement-before (&:where([data-placement^='before'])); @@ -59,52 +42,24 @@ @custom-variant placement-before-centered (&:where([data-placement='before'])); @custom-variant placement-after-centered (&:where([data-placement='after'])); -/* Glyph variant — stamped by @fluentui/react-icons/headless bundleIcon on each glyph. */ +/* bundled-icon glyph variant, stamped by @fluentui/react-icons/headless bundleIcon */ @custom-variant variant-filled (&:where([data-variant='filled'])); @custom-variant variant-regular (&:where([data-variant='regular'])); -/* - * "Where is the scrollable DrawerBody scrolled to" — DrawerHeader / DrawerFooter reveal a - * separator once the body has scrolled at all, and make it fully opaque while there is - * content hidden on their side of it. `data-scroll-state` is one of the headless preview's 25 - * attribute names and is stamped on exactly these elements there - * (react-headless-components-preview `DrawerHeader/useDrawerHeader.ts`), so the name is - * precedent, not invention. No native selector can see "the body is scrolled to the middle", - * so this is the fallback case D15.6 sanctions rather than a mirror of something CSS already - * knows. - * - * The four singles are `DrawerScrollState`'s complete union - * (react-drawer shared/DrawerBase.types.ts). An unreferenced variant emits nothing. - */ +/* scroll position of a scrollable body (Drawer) */ @custom-variant scroll-state-none (&:where([data-scroll-state='none'])); @custom-variant scroll-state-top (&:where([data-scroll-state='top'])); @custom-variant scroll-state-middle (&:where([data-scroll-state='middle'])); @custom-variant scroll-state-bottom (&:where([data-scroll-state='bottom'])); @custom-variant not-scroll-state-none (&:where(:not([data-scroll-state='none']))); -/* generic states (native + data- fallbacks) */ +/* generic states (native/ARIA + data- spellings of one state per entry) */ @custom-variant disabled (&:where([disabled], [data-disabled], :disabled)); @custom-variant enabled (&:where(:not([disabled], [data-disabled], :disabled))); @custom-variant checked (&:where([data-checked], :checked)); @custom-variant not-checked (&:where(:not([data-checked], :checked))); @custom-variant selected (&:where([data-selected], [aria-selected='true'])); - -/* - * Complement of `selected` above, in the same shape as `not-checked` / `not-focused` / - * `not-empty`. Added by the react-carousel conversion: `CarouselNavButton` and - * `CarouselNavImageButton` branch `selected ? rootSelected : rootUnselected` — a - * boolean-ish PAIR where BOTH halves carry declarations, so the complement has to be - * expressible as a selector rather than as an absent class. - * - * No `data-selected` mirror was added for either component (DECISIONS.md D15.6, resolved): - * both stamp `aria-selected={selected}` on the very element the rules key off - * (`useCarouselNavButton.ts` / `useCarouselNavImageButton.ts` `defaultProps`), so the - * native/ARIA half of `selected` already expresses the state where it is needed and a - * mirror would only widen invalidation. `aria-selected="false"` correctly fails - * `[aria-selected='true']` and therefore matches this complement. - */ @custom-variant not-selected (&:where(:not([data-selected], [aria-selected='true']))); - @custom-variant current (&:where([data-current], [aria-current]:not([aria-current='false']))); @custom-variant open (&:where([open], [data-open], :open)); @custom-variant expanded (&:where([data-expanded], [aria-expanded='true'])); @@ -114,51 +69,17 @@ @custom-variant read-only (&:where([readonly], [data-readonly], :read-only)); @custom-variant placeholder-shown (&:where([data-placeholder], :placeholder-shown)); @custom-variant indeterminate (&:where([data-indeterminate], :indeterminate)); - -/* - * Complement of `indeterminate` above. Needed by tri-state controls: Checkbox's `checked` - * prop is `true | false | 'mixed'`, which rides TWO independent presence attributes - * (`data-checked` + `data-indeterminate`), so its "plain unchecked" branch is the - * intersection `not-checked` ∩ `not-indeterminate` — `not-checked` alone would also match - * the mixed state. Radio/Switch, being genuinely boolean, never need this one. - */ @custom-variant not-indeterminate (&:where(:not([data-indeterminate], :indeterminate))); -/* - * Avatar's `active` / `activeAppearance` pair — the animated "this person is active" ring and - * shadow. `data-active` is stamped only for `active === 'active' | 'inactive'` (`unset` - * renders no attribute at all, reproducing the JS guard), so its PRESENCE is the "this avatar - * participates in the active/inactive transition" flag while its VALUE distinguishes the two - * halves. - * - * NAME COLLISION, resolved deliberately: the presence entry is `with-active`, NOT `active` — - * this catalog already binds `active` to the native `:active` pseudo-class (interaction - * section below), which is an unrelated state. `with-active` follows the `with-icon` / - * `with-label` shape: "the enum attribute is present at all". There is no `active-active` - * entry because no slice reads that value on its own — the presence entry already carries the - * shared half and `active-inactive` carries the override. - * - * `activeAppearance: 'ring' | 'shadow' | 'ring-shadow'` (Avatar.types.ts) is a three-value - * enum every slice reads as a UNION — "does this appearance include a ring", "does it include - * a shadow" — so the two `-or-` compounds are the entries Avatar actually uses. The three - * singles name the rest of the value space and emit nothing until a module references them. - */ +/* active appearance (SwatchPicker) */ @custom-variant with-active (&:where([data-active])); @custom-variant active-inactive (&:where([data-active='inactive'])); @custom-variant active-appearance-ring (&:where([data-active-appearance='ring'])); @custom-variant active-appearance-shadow (&:where([data-active-appearance='shadow'])); @custom-variant active-appearance-ring-shadow (&:where([data-active-appearance='ring-shadow'])); -@custom-variant active-appearance-ring-or-ring-shadow ( - &:where([data-active-appearance='ring'], [data-active-appearance='ring-shadow']) -); -@custom-variant active-appearance-shadow-or-ring-shadow ( - &:where([data-active-appearance='shadow'], [data-active-appearance='ring-shadow']) -); -/* - * scale (Button, Badge, Avatar, Spinner, …) — `size` is a scale prop, not a look prop, - * so it rides a data-attribute rather than a module class (DECISIONS.md D3). - */ +/* size scale. Numeric data-size values get NO variants (size-16 would read as a + * Tailwind utility) — coerce with typed attr(): calc(attr(data-size type()) * 1px) */ @custom-variant size-extra-tiny (&:where([data-size='extra-tiny'])); @custom-variant size-tiny (&:where([data-size='tiny'])); @custom-variant size-extra-small (&:where([data-size='extra-small'])); @@ -167,375 +88,82 @@ @custom-variant size-large (&:where([data-size='large'])); @custom-variant size-extra-large (&:where([data-size='extra-large'])); @custom-variant size-huge (&:where([data-size='huge'])); - -/* - * Numeric data-size values (Avatar/Skeleton pixel sizes, Text's typography ramp) get NO - * variants: size-16 etc. would read as Tailwind's own size-* utilities. Components - * consume the number directly with typed attr() instead, e.g. - * width: calc(attr(data-size type()) * 1px). - */ - -/* - * `full` is a `data-size` value that is not a scale STEP at all: Drawer's - * `size: 'small' | 'medium' | 'large' | 'full'` (react-drawer shared/DrawerBase.types.ts) ends - * in a fill-the-container mode. It rides the same attribute because it is the same public prop - * — a separate attribute would name something Drawer does not have — and it composes with - * `position-bottom` in both drawers' "full height on the bottom edge" slice. The other three - * values are the t-shirt entries above. - */ @custom-variant size-full (&:where([data-size='full'])); -/* - * ProgressBar's scale prop is `thickness`, not `size` — its props type explicitly - * `Omit`s `size`, so reusing `data-size` would name an attribute the component does not - * have. No nyt-games or headless-preview precedent exists for it (reports/ - * nyt-games-conventions.md, reports/headless-precedent.md), so the name is invented from - * the public prop. - */ +/* thickness (ProgressBar) */ @custom-variant thickness-medium (&:where([data-thickness='medium'])); @custom-variant thickness-large (&:where([data-thickness='large'])); -/* icon slot presence/placement (Button family, …) */ +/* icon slot states */ @custom-variant icon-only (&:where([data-icon-only])); @custom-variant with-icon (&:where([data-icon-position])); @custom-variant icon-before (&:where([data-icon-position='before'])); @custom-variant icon-after (&:where([data-icon-position='after'])); -/* - * AccordionHeader's own icon vocabulary: `expandIconPosition: 'start' | 'end'` - * (AccordionHeader.types.ts, the complete union) plus a `data-icon` presence flag carrying the - * `!state.icon` half of its no-icon padding slice. Both names are the headless preview's - * (reports/headless-precedent.md) — its own AccordionHeader stamps - * `data-expand-icon-position` on exactly this element. - * - * `icon` is the presence of the header's OWN icon slot and is a different question from - * `with-icon` above, which is the presence of `data-icon-position` on the Button family. The - * complement `not-icon` is the half AccordionHeader actually references, and it is spelled - * `not-` like every other complement here rather than `no-icon`, which would be the only - * name of its shape in the file. - */ +/* expand icon position (Accordion) */ @custom-variant expand-icon-position-start (&:where([data-expand-icon-position='start'])); @custom-variant expand-icon-position-end (&:where([data-expand-icon-position='end'])); @custom-variant icon (&:where([data-icon])); @custom-variant not-icon (&:where(:not([data-icon]))); -/* - * label slot presence/placement (Switch, Checkbox, Radio, …) — the same shape as the icon - * block above. `data-label-position` is one of the headless preview's 25 attribute names - * (reports/headless-precedent.md). Whether it is stamped conditionally is the CONVERTING - * COMPONENT's call, and the two converted so far differ: - * • react-switch stamps it ONLY when the label slot exists, because every slice that - * reads it is gated on `label && …` — its presence then doubles as the "has a label" - * signal (`with-label` below). A Switch rule that depends on `labelPosition` WITHOUT - * that gate (its root flex-direction) uses `data-orientation` / `@variant vertical` - * instead, which is always stamped. - * • react-checkbox stamps it UNCONDITIONALLY, because `inputStyles[labelPosition]` - * positions the hidden `` with no `label &&` gate. Its label-slot rules read - * the same attribute and are simply inert with no `.label` element to match. - * So `with-label` means "has a label" only for components in the first group; do not read - * it as a general presence signal. - * - * The three values here are react-switch's union (`'above' | 'after' | 'before'`). The - * rest of the family uses subsets plus one more value: Checkbox is `'before' | 'after'` - * (both already covered), Radio is `'after' | 'below'` — `label-below` was added by the - * Radio conversion. Radio belongs to the FIRST group: `labelStyles[labelPosition]` is the - * only slice that reads the attribute and it is gated on `label &&`, while its two - * ungated `labelPosition === 'below'` slices (root flex-direction, hidden-input box) use - * `data-orientation` exactly as Switch does. - */ +/* label presence/position */ @custom-variant with-label (&:where([data-label-position])); @custom-variant label-above (&:where([data-label-position='above'])); @custom-variant label-after (&:where([data-label-position='after'])); @custom-variant label-before (&:where([data-label-position='before'])); @custom-variant label-below (&:where([data-label-position='below'])); -/* - * RadioGroup's `layout` prop. `data-layout` is one of the headless preview's 25 attribute - * names (reports/headless-precedent.md) and is deliberately NOT folded into - * `data-orientation` above: the union includes `horizontal-stacked`, which is a layout mode - * rather than an axis, and `layout` is the public prop's own name. - * - * The three values are RadioGroup's complete union. Only `layout-vertical` carries rules - * today (the Griffel source styled `layout === 'vertical'` alone, leaving both horizontal - * modes on the base `flex-direction: row`); the other two name the rest of the attribute's - * value space and emit nothing until a module references them. - */ +/* layout */ @custom-variant layout-vertical (&:where([data-layout='vertical'])); @custom-variant layout-horizontal (&:where([data-layout='horizontal'])); @custom-variant layout-horizontal-stacked (&:where([data-layout='horizontal-stacked'])); - -/* - * The SAME `data-layout` attribute, reused by MessageBar for a disjoint value space: - * `'singleline' | 'multiline'`. The value stamped is the COMPUTED layout — `useMessageBar.ts` - * resolves the public `auto` to one of these two before it reaches the DOM — and - * MessageBarActions reads the identical value off its own element via MessageBar's context, - * which is why `layout-multiline` is referenced from two components' modules. - * - * Sharing the attribute with RadioGroup is deliberate, not an accident: both components' public - * prop is literally called `layout`, and neither can ever see the other's element, so the two - * value spaces cannot meet. `layout-singleline` names the other half and carries no rules — - * singleline is MessageBar's base look, exactly as in the Griffel source. - */ @custom-variant layout-singleline (&:where([data-layout='singleline'])); @custom-variant layout-multiline (&:where([data-layout='multiline'])); -/* - * Semantic status of a message surface — MessageBar's `intent` - * (`'info' | 'success' | 'warning' | 'error'`, MessageBar.types.ts, the complete union). It is - * stamped on the ROOT even though it also selects the icon slot's colour, because the icon's - * rules are descendant selectors from the root. - * - * `data-intent` has no headless-preview or nyt-games precedent, so the name comes from the - * public prop — the ProgressBar `data-thickness` rule. `intent-info` carries no rules: `info` - * is the base look and the Griffel source had no `info` slice either. - */ +/* intent (MessageBar) */ @custom-variant intent-info (&:where([data-intent='info'])); @custom-variant intent-success (&:where([data-intent='success'])); @custom-variant intent-warning (&:where([data-intent='warning'])); @custom-variant intent-error (&:where([data-intent='error'])); -/* - * Where a Persona's text sits relative to its avatar/presence coin. Persona's public prop - * is `textPosition: 'after' | 'before' | 'below'` and it drives the root's whole grid - * template, so the attribute is named from the prop (the ProgressBar `data-thickness` - * precedent): neither nyt-games nor the headless preview has a name for it, and the - * preview's generic `data-position` would be ambiguous here because Persona ALSO has a - * `textAlignment` prop. `text-position-after` is currently unreferenced — Persona's `after` - * slice is empty — and is listed for completeness; an unreferenced variant emits nothing. - */ +/* text position (Persona) */ @custom-variant text-position-after (&:where([data-text-position='after'])); @custom-variant text-position-before (&:where([data-text-position='before'])); @custom-variant text-position-below (&:where([data-text-position='below'])); -/* spacing opt-ins (Divider inset, …) — presence attributes, written as `|| undefined` */ +/* per-component booleans */ @custom-variant inset (&:where([data-inset])); - -/* - * "This element responds to pointer/keyboard interaction" — ListItem's - * `selectable || navigable` union, which its Griffel hook spells as the single slice - * `rootClickableOrSelectable` (display + cursor). Both halves gate exactly one slice and - * neither is used alone, so per the cookbook's boolean-pair rule the union rides ONE - * presence attribute instead of two attributes plus a union variant. - * - * The name is invented: neither the nyt-games catalog (reports/nyt-games-conventions.md, - * 95 variants) nor the headless preview's 25 data-attribute names - * (reports/headless-precedent.md) has an entry for it — the closest headless names, - * `data-has-actions` / `data-selected`, mean different things. Generalises to the rest of - * the row/option family (MenuItem, TreeItem, TableRow) whose Griffel sources gate the - * same `cursor: pointer` on the same "is this row actionable" condition. - */ @custom-variant interactive (&:where([data-interactive])); - -/* - * Link's `inline` boolean — "this link sits inside a run of text", which underlines it at - * rest. No headless-preview precedent exists for the name, so it follows the react-divider - * pilot's `data-inset` shape: a boolean styling opt-in as a presence attribute. - * NOTE for module authors: `inline` is also the name of Tailwind's `display: inline` - * utility. `@variant inline { … }` and `@apply inline` live in different registries and do - * not collide, but Link.module.css writes `display: inline` longhand anyway for clarity. - */ @custom-variant inline (&:where([data-inline])); - -/* - * MenuSplitGroup's `data-multiline` — "a child menu item has wrapped onto more than one - * line", which re-centres the split trigger. Unlike every other presence attribute in this - * catalog it is written IMPERATIVELY (`useMenuSplitGroup.ts` calls `toggleAttribute` from a - * ref callback), because the child that knows the answer can mount before the parent that - * renders it; the constant lives in the styles file as `menuSplitGroupMultilineAttr`. The - * attribute name predates this migration and is unchanged. - * - * No headless-preview precedent exists for the name, so it follows the react-divider pilot's - * `data-inset` shape: a boolean styling opt-in as a presence attribute. It generalises to - * any wrap-aware row, which is why it is catalogued rather than written as a hand-rolled - * attribute selector in the module. - */ @custom-variant multiline (&:where([data-multiline])); - -/* - * Adornment-slot presence (Input contentBefore/contentAfter, and the same pair on - * Textarea/Select/SpinButton/Combobox). Presence attributes on the ROOT, written as - * `|| undefined`: the styles hook branches on `state.contentBefore && …`, and both the - * root's own padding and the inner input's padding key off it, so the flag has to be - * readable from the root for descendant selectors. - */ @custom-variant content-before (&:where([data-content-before])); @custom-variant content-after (&:where([data-content-after])); - -/* - * MessageBarActions' `data-has-actions` — "this actions slot holds at least one action". - * `data-has-actions` is one of the headless preview's 25 attribute names - * (reports/headless-precedent.md). Written `hasActions || undefined` in the hook: a literal - * `false` would render `data-has-actions="false"`, which still MATCHES a presence selector and - * would therefore invert the complement. - * - * Boolean-ish pair per the cookbook, and the complement is the half the module uses — an - * actions element with nothing in it is hidden rather than left as an empty grid cell. - */ @custom-variant has-actions (&:where([data-has-actions])); @custom-variant not-has-actions (&:where(:not([data-has-actions]))); -/* - * SpinButton's `data-spin-active` — "this is the increment/decrement button currently - * repeating". Derived from `spinState === 'up'` / `=== 'down'` and stamped on the matching - * button only (`useSpinButtonStyles.styles.ts`), presence-only, with the same `|| undefined` - * discipline as `has-actions` above. - * - * No headless-preview or nyt-games precedent exists for the name, so it follows the - * react-divider pilot's `data-inset` shape: a boolean styling opt-in as a presence attribute. - * It is NOT folded into `pressed` / `active`: the spin repeat continues while the pointer is - * held OR the arrow key is held, and it stays set across the repeat interval, which neither - * pseudo-class tracks. - */ +/* held spin repeat (SpinButton) — persists across the repeat interval, unlike :active */ @custom-variant spin-active (&:where([data-spin-active])); /* interaction */ @custom-variant hover (&:where(:hover)); @custom-variant focus (&:where(:focus)); - -/* - * SearchBox's `focused` — a React state flag, NOT a pseudo-class, and deliberately not a - * union with one. `useSearchBoxBase_unstable` sets it from `onFocus`/`onBlur` handlers on - * the root plus a `relatedTarget` containment check, so it stays true while focus moves - * between the `` and the dismiss button, and it is what the Griffel hook branched on - * (`!focused && …`). `:focus-within` is a near-neighbour, not an equal: it is live where the - * React flag is a render behind, and it would fire on elements whose hook never set the - * state. Folding either into the other is the "half-applying dual-source variant" trap - * reports/nyt-games-conventions.md flags. - * - * Name from the headless preview's `data-focused` (one of its 25 attribute names — - * reports/headless-precedent.md; `react-headless-components-preview`'s own SearchBox stamps - * exactly this on the root). nyt-games spells the same attribute `focus`, a name this - * catalog already binds to the native `:focus` pseudo-class. - * - * Boolean-ish pair per the cookbook: ONE presence attribute plus a `:not()` complement — - * every v9 slice that reads it reads the complement. - */ @custom-variant focused (&:where([data-focused])); @custom-variant not-focused (&:where(:not([data-focused]))); - -/* - * UNION compounds — the `-or-` in the name is load-bearing. `hover-active` below is an - * INTERSECTION (`:hover:active`); these are selector lists. The Griffel sources spell - * them `':hover,:focus-within'` / `':active,:focus-within'` with the comment "DO NOT add - * a space between the selectors! It changes the behavior of make-styles" — i.e. one slice - * whose declarations apply in either state. They recur across the whole input family - * (Input, Textarea, Select, SpinButton, Combobox root borders). - */ -@custom-variant hover-or-focus-within (&:where(:hover, :focus-within)); -@custom-variant active-or-focus-within (&:where(:active, :focus-within)); - -/* - * `:not(:focus-within)` complement — the "field is not focused" guard v9 field controls - * use to hold their invalid/rest border while the focus indicator owns the focused look. - * - * ⚠ CONVERSION TRAP (found by react-input's A/B probe; applies to Textarea/Select too). - * Griffel authors that slice as `':not(:focus-within),:hover:not(:focus-within)'`. The - * second term IS a strict subset of the first, so it adds nothing to MATCHING — but it is - * not redundant, it is a SPECIFICITY HACK. Griffel's atomics are not `:where()`-wrapped: - * `.x:hover:not(:focus-within)` scores (0,3,0) and therefore outranks the hover-bucket - * atomics at (0,2,0), letting a `d`-bucket rule beat the `h` bucket. `.x:not(:focus-within)` - * scores only (0,2,0) and correctly LOSES to `:active` at (0,2,0) on bucket order. - * - * `:where()` flattens both to (0,1,0) and the asymmetry disappears, so a converted module - * must SPLIT the slice: the plain branch goes before the module's hover/active blocks, and - * a `@variant hover { @variant not-focus-within { … } }` restatement goes after them. - * Collapsing them into one block silently changes the invalid-plus-hover border. - * Worked example: Input.module.css, "useRootStyles.invalid, SECOND branch". - */ @custom-variant not-focus-within (&:where(:not(:focus-within))); - -/* - * Bare `:active`, distinct from `pressed` above: several v9 styles files (Textarea's - * `outlineInteractive`, Input, Select, …) author a plain `:active` rule, and a converted - * component must reproduce the COMPILED selector — `pressed` additionally matches - * `[data-pressed]`, which Griffel never emitted for these. - * - * Also distinct from Avatar's `data-active` attribute, which is catalogued as `with-active` / - * `active-inactive` precisely because this name was already taken. See that block above. - */ @custom-variant active (&:where(:active)); - -/* - * Griffel's authored "pressed" compound — `:hover:active,:active:focus-visible` — used - * verbatim by Button/ToggleButton/MenuButton/SplitButton and many other v9 styles files. - * Kept distinct from `pressed` above, which is the headless-preview vocabulary - * (`[data-pressed], :active`); converting a styled component must reproduce the compiled - * selector, not the headless one. Note the `:focus-visible` here is the NATIVE - * pseudo-class, not keyborg's attribute — that is what Griffel emitted. - */ @custom-variant hover-active (&:where(:hover:active, :active:focus-visible)); - -/* - * Complement of the `hover` + `hover-active` PAIR above — "the element is at rest". - * `:hover:active` is a strict subset of `:hover`, so the union of the two variants is - * `:hover, :active:focus-visible` and this is its exact negation. - * - * Why it exists (react-toolbar's ToolbarToggleButton / ToolbarRadioButton): Griffel - * resolved a flat `d`-bucket atomic against a LATER slice's `:hover` atomic by - * SPECIFICITY (0-1-0 vs 0-2-0), so a wrapping component's flat override could beat the - * wrapped component's flat rules while still losing to its hover/pressed ones. Cascade - * layers cannot express that interleaving — a higher altitude wins in every state — and - * `:where()` flattens the specificity that used to arbitrate it. Restricting the wrapper's - * flat rule to the rest state reproduces the winner exactly, in every state, without - * either component having to know the other's values. - * - * Use it only for that shape. A component styling its OWN element never needs it: it - * writes its rest rule first and its `hover` / `hover-active` rules after, and file order - * decides. - */ @custom-variant at-rest (&:where(:not(:hover, :active:focus-visible))); -/* - * `disabled || disabledFocusable` — the condition v9 components actually branch on. - * A disabled-but-focusable control renders NO `disabled` attribute (react-aria's - * `useARIAButtonProps` sets `disabled: disabled && !disabledFocusable` and - * `aria-disabled: true`), and an `
`/`
`-rendered button drops `disabled` - * entirely — so neither `:disabled` nor `[disabled]` alone reproduces the JS condition. - */ +/* disabled family */ @custom-variant disabled-focusable (&:where([data-disabled-focusable])); -@custom-variant disabled-any (&:where([disabled], [data-disabled], :disabled, [data-disabled-focusable])); - -/* - * The form-CONTROL disabled compound: `':disabled, &[aria-disabled=\"true\"]'` and its - * complement `':enabled:not([aria-disabled=\"true\"])'`, authored verbatim by - * react-switch's hidden-`` reset. As of that conversion this is the ONLY styles - * file in the repo that spells the selector this way (verified by grep over every - * `packages/react-components/*​/library/src/components/*​/*.styles.ts`), but the shape - * generalises to the rest of the hidden-input family (Checkbox, Radio) when they convert: - * the control renders `aria-disabled` instead of `disabled` when it must stay focusable, - * so neither `:disabled` nor `[disabled]` alone reproduces the compiled selector. - * - * This is a SEPARATE entry rather than a widening of `disabled`/`enabled` above, and that - * separation is load-bearing: react-link renders `aria-disabled` for - * `disabled || disabledFocusable` (useLinkState.ts) while its Griffel hook gates the - * disabled slice on `disabled` alone (`disabled && styles.disabled`). Folding - * `[aria-disabled='true']` into the generic `disabled` variant would newly apply disabled - * styling to every `disabledFocusable` Link. Likewise `disabled-any` stays untouched: - * Button's condition is `disabled || disabledFocusable`, already carried by - * `[data-disabled-focusable]`. - */ @custom-variant disabled-control (&:where([disabled], [data-disabled], :disabled, [aria-disabled='true'])); @custom-variant enabled-control (&:where(:not([disabled], [data-disabled], :disabled, [aria-disabled='true']))); -/* Focus visibility: keyborg attributes (the Fluent visual contract) plus the native - pseudo-classes, one variant each. focus-within keeps the attribute:pseudo pairing — - keyborg leaves data-fui-focus-within behind after focus moves. */ +/* focus visibility: keyborg attribute + native pseudo-class. focus-within keeps the + * attribute:pseudo pairing — keyborg leaves the attribute behind after focus moves */ @custom-variant focus-visible (&:where([data-fui-focus-visible], :focus-visible)); @custom-variant focus-within (&:where([data-fui-focus-within]:focus-within, :focus-within)); -/* - * Content presence — named for alignment with the CSS-native :empty selector - * (nyt-games catalog; user-settled 2026-07-27, replacing the pilot's 'childless'). - * data-empty mirrors 'no JSX children' from the styles hook — the DOM may still - * contain slot elements (an icon-only Button root is data-empty but not :empty), - * which is why the attribute leads and :empty is the unstamped-element fallback. - */ +/* content presence — data-empty mirrors 'no JSX children'; :empty is the unstamped fallback */ @custom-variant empty (&:where([data-empty], :empty)); @custom-variant not-empty (&:where(:not([data-empty], :empty))); - -/* forced colors / high contrast */ -@custom-variant forced-colors { - @media (forced-colors: active) { - @slot; - } -} diff --git a/packages/react-components/react-tailwind-theme-preview/scripts/generate-theme-values.js b/packages/react-components/react-tailwind-theme-preview/scripts/generate-theme-values.js index a31c1c4b2ea9a9..fe61af01d7fca1 100644 --- a/packages/react-components/react-tailwind-theme-preview/scripts/generate-theme-values.js +++ b/packages/react-components/react-tailwind-theme-preview/scripts/generate-theme-values.js @@ -1,20 +1,18 @@ // @ts-check /** * Emits `theme-values.json` — a committed snapshot of the 7 shipped theme objects' - * token values (theming Phase 2b). + * token values. * - * WHY A SNAPSHOT: `@fluentui/react-tailwind-theme`'s generator + * WHY A SNAPSHOT: this package's generator * (`scripts/generate-tokens-css.js`) deliberately has no dependency edge on this * package's BUILD output — a fresh clone must be able to regenerate/verify its CSS * without building anything. The theme VALUES, however, are computed * (`createLightTheme(brandWeb)`, …), so they cannot be text-scraped from source the way * `tokens.ts` is. This committed JSON is the bridge: the CSS generator reads it, and - * `src/themes/themeValues.test.ts` asserts on every jest run that it deep-equals the - * computed themes — drift fails CI from this package's side. + * `--check` verifies it against the built tokens package. * - * Regenerating (only needed when a theme value intentionally changes): - * yarn nx run tokens:build # the script reads lib-commonjs - * yarn workspace @fluentui/tokens generate-theme-values + * Regenerating (when a theme value intentionally changes): `yarn nx run tokens:build`, + * then `node scripts/generate-theme-values.js` from this package. * * `--check` exits non-zero when the committed file is missing or stale. */ @@ -73,10 +71,9 @@ function render() { $schema: 'fluent-theme-values', description: 'Committed snapshot of the shipped Fluent theme objects (theme key -> value). ' + - 'Consumed by @fluentui/react-tailwind-theme/scripts/generate-tokens-css.js to emit the static theme ' + - 'CSS classes; drift against the computed themes fails src/themes/themeValues.test.ts. ' + - 'Regenerate with `yarn workspace @fluentui/tokens generate-theme-values` after `yarn nx run tokens:build`.', - generatedBy: 'packages/tokens/scripts/generate-theme-values.js', + 'Consumed by scripts/generate-tokens-css.js to emit the static theme CSS classes. ' + + 'Regenerate after yarn nx run tokens:build.', + generatedBy: 'packages/react-components/react-tailwind-theme-preview/scripts/generate-theme-values.js', source: `${packageJson.name}@${packageJson.version}`, themes, }; @@ -91,7 +88,7 @@ function main() { if (check) { if (!fs.existsSync(OUTPUT)) { - console.error(`MISSING: ${OUTPUT} — run \`yarn workspace @fluentui/tokens generate-theme-values\`.`); + console.error(`MISSING: ${OUTPUT} — run \`node scripts/generate-theme-values.js\`.`); process.exitCode = 1; return; } @@ -99,7 +96,7 @@ function main() { const existing = fs.readFileSync(OUTPUT, 'utf8').replace(/\r\n/g, '\n'); if (existing !== contents) { - console.error(`STALE: ${OUTPUT} — run \`yarn workspace @fluentui/tokens generate-theme-values\`.`); + console.error(`STALE: ${OUTPUT} — run \`node scripts/generate-theme-values.js\`.`); process.exitCode = 1; return; } diff --git a/packages/react-components/react-tailwind-theme-preview/scripts/generate-tokens-css.js b/packages/react-components/react-tailwind-theme-preview/scripts/generate-tokens-css.js index 3bcbffae169a33..c0f8763d2f435b 100644 --- a/packages/react-components/react-tailwind-theme-preview/scripts/generate-tokens-css.js +++ b/packages/react-components/react-tailwind-theme-preview/scripts/generate-tokens-css.js @@ -1,80 +1,24 @@ // @ts-check /** - * Generates `css/tokens.css` — the `@theme inline` registration that gives every Fluent - * design token a Tailwind utility name (`bg-neutral-background-1`, `text-base-300`, - * `shadow-8`, `rounded-medium`, …). + * Generates `css/tokens.css` — the `@theme inline reference` registration giving every + * Fluent token a Tailwind utility name (`bg-neutral-background-1`, `rounded-medium`, …) + * — and `css/themes.css` — one class per shipped theme. * - * WHY `inline` IS MANDATORY - * ------------------------- - * A naive `@theme { --color-x: }` emits the value into `:root` and compiles - * `bg-x` to `background-color: var(--color-x)` resolved ONCE, at `:root`, where Fluent - * token VALUES do not exist — FluentProvider writes them on `.fui-FluentProviderN` - * elements. Every provider (and every nested provider) would render the same frozen - * value. FORBIDDEN. + * `inline` substitutes the token variable into each utility so it resolves per-element; + * a plain `@theme` alias would freeze resolution at `:root`. `reference` suppresses the + * self-referential alias emission that self-named registrations (`--color-x: var(--color-x)`) + * would otherwise produce (~19KB of cyclic declarations). * - * `inline` substitutes the theme value into the utility itself, so `bg-x` compiles to - * `background-color: var(--color-neutral-background-1)` and resolves per-element against - * the nearest FluentProvider — nested themes keep working (DECISIONS.md D4's theming - * guarantee is preserved; only the authoring surface changes). + * Spacing: the 22 spacing tokens register as aliases of the numeric axis + * (`calc(var(--spacing) * N)`), so named and numeric spacing share one density knob. + * The 4 strokeWidth tokens keep literal base-scale values under `--stroke-width-*` + * (borders must not thin with layout density); `--spacing-thin/…` are private hooks + * feeding utility generation. All of these are also emitted as real custom properties, + * because border/outline/ring/decoration widths do not consume the spacing namespace. * - * THEMING PHASE 2a — canonical kebab names; `reference` ADOPTED (supersedes the earlier - * rejection) - * ----------------------------------------------------------------------------------- - * Since theming Phase 2a the runtime CSS variable of every token IS its Tailwind theme - * key (`--color-neutral-background-1`, `--text-base-300`, `--shadow-2`, …; durations are - * the one exception, see NAMESPACES). Each registration is therefore a SELF-NAMED - * reference (`--color-x: var(--color-x)`), which plain `inline` treats as a by-name use - * and answers by emitting a self-referential alias at `:root, :host` — 400+ cyclic - * (guaranteed-invalid at `:root`) declarations that define nothing and cost ~19KB - * (probe: .scratch/phase2a-theming/probe/). `reference` suppresses exactly that - * emission and nothing else — probed byte-identical utility output. - * - * `reference` was REJECTED pre-Phase-2a ("suppresses the alias even when a by-name - * reference exists, emitting CSS against a variable nothing defines"). That reason is - * retired: FluentProvider's runtime theme tag now writes the canonical kebab names on - * every provider element (alongside the legacy camelCase names, until Phase 2b removes - * the tag), so `var(--color-neutral-background-1)` IS defined wherever tokens were ever - * defined. Resolution semantics are unchanged from the camelCase era: per-element, - * against the nearest provider; undefined outside any provider. - * - * SPACING IS DIFFERENT — see SPACING_SCALE / STROKE_WIDTH_SCALE below. The 22 - * spacingHorizontal / spacingVertical tokens register under `--spacing-*` as ALIASES OF - * THE NUMERIC AXIS — `calc(var(--spacing) * )`, where the multiplier is the - * token's canonical px divided by the `--spacing` base px (theming Phase 1, settled with - * user 2026-07-29; supersedes the literal `calc( * var(--base-scale))` form). Probe- - * verified: `@theme inline` carries the `var(--spacing)` reference VERBATIM into each - * utility, so `p-horizontal-m` compiles to `padding: calc(var(--spacing) * 12)` — the - * same shape as `p-12` — and BOTH respond identically to a subtree `--spacing` override. - * `--spacing` (css/index.css: `calc(1px * var(--base-scale))`) is the single density knob. - * - * THE 4 strokeWidth TOKENS ARE THE DELIBERATE EXCEPTION: their PUBLIC variables are - * `--stroke-width-thin/thick/thicker/thickest` with LITERAL base-scale values - * (`calc( * var(--base-scale))`), NOT coupled to `--spacing` — borders must not thin - * when layout density changes. The `--spacing-thin/…` names still exist, but ONLY as - * PRIVATE internal hooks (`--spacing-thin: var(--stroke-width-thin)`) that feed Tailwind - * utility generation (`w-thin`, `p-thick`, …) and the sanctioned direct-var authoring in - * component modules; the public set-contract is `--stroke-width-*`. See STROKE_WIDTH_SCALE. - * - * ALL ENTRIES ARE ALSO EMITTED AS REAL CUSTOM PROPERTIES (`emit: true`): the 22 spacing - * canonicals, the 4 `--stroke-width-*` canonicals, and the 4 private hooks. `@theme inline` - * registers a utility name but emits NO variable, and border/outline/ring/decoration widths - * do NOT consume the spacing namespace (probe-measured — see - * `.scratch/layer-probe/check-stroke-namespace.mjs`), so those properties must be authored - * as a direct `var(--spacing-thin)` reference, which needs a real variable. - * - * THE OLD camelCase NAMES (`--colorNeutralBackground1`, `--fontSizeBase300`, - * `--spacingHorizontalM`, `--strokeWidthThin`, …) NO LONGER EXIST in emitted CSS or in - * any shipped read path (theming Phase 2a extends Phase 1's option B to the FULL token - * set: single vocabulary; a documented major break for hand-written consumer CSS against - * the old names). The `tokens.*` JS constants in @fluentui/tokens are repointed to the - * canonical kebab names — this generator ASSERTS that lockstep for EVERY token on every - * run. (FluentProvider's runtime theme tag writes token values under BOTH vocabularies - * on provider elements until theming Phase 2b removes it — the camelCase half feeds - * nothing shipped and exists only as unbroken-interim insurance for hand-written - * consumer reads.) - * - * The emission rides the once-per-document artifact (css/emit.css -> dist/styles.css) - * and is suppressed in component modules by `@reference`, exactly like `--base-scale`. + * tokens.ts is consumed as the token-name inventory; theme VALUES come from the + * committed theme-values.json snapshot; the emitted class names are asserted against + * theme-class-names.mjs. * * Run: node packages/react-components/react-tailwind-theme-preview/scripts/generate-tokens-css.js * Verify: node packages/react-components/react-tailwind-theme-preview/scripts/generate-tokens-css.js --check @@ -96,7 +40,7 @@ const THEME_CLASSNAMES_SOURCE = path.join(PACKAGE_ROOT, 'theme-class-names.mjs') const DEFAULT_OUTPUT = path.join(PACKAGE_ROOT, 'css', 'tokens.css'); const THEMES_OUTPUT = path.join(PACKAGE_ROOT, 'css', 'themes.css'); -/** The theme whose values are emitted as the `:root, :host` defaults (theming Phase 2b). */ +/** The theme whose values are emitted as the `:root, :host` defaults. */ const DEFAULT_THEME = 'webLightTheme'; const GENERATOR_ID = 'packages/react-components/react-tailwind-theme-preview/scripts/generate-tokens-css.js'; @@ -128,8 +72,7 @@ function kebabCase(name) { /** * Fluent's spacing scale — the ONE namespace whose registered values are NOT - * `var(--fluentToken)` references (dual spacing support, settled with user 2026-07-27; - * numeric-axis alias restructure settled with user 2026-07-29 — theming Phase 1). + * `var(--fluentToken)` references (numeric-axis aliases). * * WHY NUMERIC-AXIS ALIASES AND NOT `var(--spacingHorizontalM)` OR PX LITERALS * --------------------------------------------------------------------------- @@ -146,7 +89,7 @@ function kebabCase(name) { * The earlier literal form (`calc(12px * var(--base-scale))`) was arithmetically * identical at default but bypassed `--spacing`, so a subtree density override reached * numeric utilities and missed named ones. Probe-verified that `@theme inline` carries - * the `var(--spacing)` reference verbatim into utilities (.scratch/phase1-theming/). + * the `var(--spacing)` reference verbatim into utilities. * * COST, ACCEPTED: a FluentProvider `theme` override of `spacingHorizontalM` no longer * reaches utility-sourced spacing (a literal `var(--spacingHorizontalM)` still honours it, @@ -158,7 +101,7 @@ function kebabCase(name) { * the name is a convention, not a constraint — the same is already true of a literal * `var(--spacingHorizontalM)` in a `padding-block` declaration. * - * `utility` is the EXPLICIT suffix table (user-specified). It deliberately does NOT go + * `utility` is the EXPLICIT suffix table. It deliberately does NOT go * through `kebabCase` — that would turn `SNudge` into `s-nudge` correctly but `XXS` into * `xxs` only by accident of the acronym rule; pinning the table keeps these names stable * and independent of the generic algorithm. @@ -185,7 +128,7 @@ const SPACING_SCALE = [ /** * Fluent's stroke widths — border/outline/divider thickness — joining the SAME `--spacing-*` - * namespace as SPACING_SCALE, on the same literal-value terms (settled with user 2026-07-27). + * namespace as SPACING_SCALE, on the same literal-value terms (). * * WHY THE SPACING NAMESPACE AND NOT A `--border-width-*` NAMESPACE * --------------------------------------------------------------- @@ -195,8 +138,7 @@ const SPACING_SCALE = [ * (`stroke-2` → `stroke-width: 2`), which is the wrong property. So there is nothing to * register these against that would give `border-thin` a meaning. * - * PROBE-MEASURED, not reasoned (`.scratch/layer-probe/check-stroke-namespace.mjs` registers a - * `--spacing-thin` on the real css/index.css and compiles a candidate list): + * PROBE-MEASURED, not reasoned: * CONSUMES `--spacing-*` p m gap space-x w h min-w max-w size inset top start basis * translate scroll-m scroll-p indent leading (+ every axis/side form) * DOES NOT border border-t/b/s/x divide-x outline outline-offset ring @@ -211,13 +153,13 @@ const SPACING_SCALE = [ * CANONICAL `--stroke-width-*` VALUES STAY LITERAL `calc( * var(--base-scale))` — * DELIBERATELY NOT `--spacing`-COUPLED * ----------------------------------------------------------------------------------- - * Theming Phase 1 (settled with user 2026-07-29) rebased the 22 spacing tokens onto the + * Theming the alias model () rebased the 22 spacing tokens onto the * `--spacing` numeric axis; these four are the intentional exception. `--spacing` is the * layout DENSITY knob — a subtree that halves it should compress padding and gaps, but * borders must NOT thin with it: a 1px hairline is a 1px hairline at any density. Stroke * widths therefore keep the raw `--base-scale` form and ignore `--spacing` overrides. * - * NAME SPLIT (user amendment): the PUBLIC set-contract variable is + * NAME SPLIT: the PUBLIC set-contract variable is * `--stroke-width-`, emitted with the literal value; the spacing-namespace * `--spacing-` names are PRIVATE internal hooks registered/emitted as * `var(--stroke-width-)` so Tailwind's spacing-consuming utility families @@ -226,9 +168,6 @@ const SPACING_SCALE = [ * in `@theme` — Tailwind's `--stroke-width-*` namespace drives SVG `stroke-width` * utilities, which would be the wrong property. * - * All 7 shipped themes carry byte-identical strokeWidth values — asserted before shipping in - * `.scratch/layer-probe/assert-theme-stroke-width.js` (1/2/3/4px, zero divergence). - * * `global` is the key in `packages/tokens/src/global/strokeWidths.ts`; `px` is asserted against * it at generation time, exactly like SPACING_SCALE. * @@ -437,7 +376,7 @@ function readThemeClassNames() { /** * Reads the committed `packages/tokens/theme-values.json` snapshot — the VALUE source for - * the theme emission (theming Phase 2b). The snapshot exists because theme values are + * the theme emission. The snapshot exists because theme values are * computed (`createLightTheme(brandWeb)`, …) and so cannot be text-scraped the way * `tokens.ts` is; `packages/tokens/src/themes/themeValues.test.ts` asserts on every jest * run that it deep-equals the computed themes. @@ -648,8 +587,7 @@ const NAMESPACES = [ // Durations are the ONE Phase-2a family whose canonical RUNTIME variable // (`--duration-fast`) differs from its Tailwind theme key // (`--transition-duration-fast`): the family keeps the shorter custom namespace - // (user decision — consistent with the token vocabulary, no first-class variable - // namespace worth occupying), while the theme key must be what the installed + //, while the theme key must be what the installed // utility registry reads. The registered value `var(--duration-fast)` carries the // canonical reference into `duration-*` utilities verbatim. canonicalNamespace: 'duration', @@ -729,7 +667,7 @@ function readTokens() { // Every token value must be a var() reference, optionally with a fallback (zIndex*). // Hyphens allowed: the 26 spacing/stroke tokens reference canonical kebab-case names - // (theming Phase 1 option B). + //. for (const { name, value } of tokens) { if (!/^var\(--[A-Za-z][A-Za-z0-9_-]*(?:, ?[^)]+)?\)$/.test(value)) { throw new Error(`${TOKENS_SOURCE}: token \`${name}\` has an unexpected value \`${value}\`.`); @@ -785,7 +723,7 @@ function classify(name) { kind: 'register', group, themeKey: `--${group.namespace}-${kebab}`, - // The canonical RUNTIME variable name (theming Phase 2a): identical to the theme + // The canonical RUNTIME variable name: identical to the theme // key for every namespace except duration (see NAMESPACES). canonical: `--${group.canonicalNamespace || group.namespace}-${kebab}`, }; @@ -814,7 +752,7 @@ function render(options = {}) { // upstream scale change throws here instead of silently shipping a stale hardcoded value. readSpacingScale(); readStrokeWidthScale(); - // Theming Phase 2b: the default (web light) theme values are emitted at `:root, :host` + // Theming the static-theme model: the default (web light) theme values are emitted at `:root, :host` // right below the spacing/stroke block — this artifact is now the SOLE value source for // the canonical token variables (FluentProvider's runtime theme style tag is gone). const { variantTokens, themes } = analyzeThemeEmission(tokens); @@ -908,11 +846,11 @@ function render(options = {}) { out.push(' *'); out.push(' * `inline` is MANDATORY: it substitutes the canonical var(--token-name) into each'); out.push(' * utility, so values resolve per-element — the `:root, :host` defaults below,'); - out.push(' * overridden per subtree by the theme classes (css/themes.css, theming Phase 2b).'); + out.push(' * overridden per subtree by the theme classes (css/themes.css).'); out.push(' * A plain `@theme` alias would freeze resolution at `:root`, breaking scoped'); out.push(' * theming. `reference` suppresses the self-referential aliases that plain'); out.push(' * `inline` would emit now that each runtime variable IS its theme key (theming'); - out.push(' * Phase 2a) — the values are emitted in the fui.theme block below, not by JS.'); + out.push(' * the kebab-name model) — the values are emitted in the fui.theme block below, not by JS.'); out.push(' *'); out.push(' * SPACING IS THE ONE EXCEPTION: --spacing-horizontal-* / --spacing-vertical-* are'); out.push(' * ALIASES OF THE NUMERIC AXIS — calc(var(--spacing) * N), the same shape p-12'); @@ -959,9 +897,9 @@ function render(options = {}) { out.push(' * inline styles etc.).'); out.push(' *'); out.push(' * THE OLD camelCase NAMES (--colorNeutralBackground1, --spacingHorizontalM, …)'); - out.push(' * ARE GONE for the ENTIRE token set (option B, theming Phase 2a): single'); + out.push(' * ARE GONE for the ENTIRE token set: single'); out.push(' * vocabulary, documented major break for hand-written consumer CSS. Theming'); - out.push(" * Phase 2b removed FluentProvider's runtime theme style tag, so this block (plus"); + out.push(" * the static-theme model removed FluentProvider's runtime theme style tag, so this block (plus"); out.push(' * the theme classes in css/themes.css) is the ONLY writer of token values.'); out.push(' *'); out.push(' * Emitted ONCE PER DOCUMENT (D13): `@reference` drops this block, so component'); @@ -997,7 +935,7 @@ function render(options = {}) { out.push(...emittedVariables); out.push(''); out.push(' /*'); - out.push(' * DEFAULT THEME VALUES (web light) — theming Phase 2b. Since the removal of'); + out.push(' * DEFAULT THEME VALUES (web light). Since the removal of'); out.push(" * FluentProvider's runtime theme style tag, these declarations are the sole"); out.push(' * default value source for the theme-variant canonical variables. Non-default'); out.push(' * themes are shipped as classes in css/themes.css (same layer); a theme class'); @@ -1018,7 +956,7 @@ function render(options = {}) { } /** - * Renders `css/themes.css` — one CSS class per shipped theme (theming Phase 2b). + * Renders `css/themes.css` — one CSS class per shipped theme. * * THE THEMING CONTRACT: a theme class on ANY DOM node themes that node's subtree (custom * properties cascade); FluentProvider's `themeClassName` prop applies one to its root and @@ -1051,7 +989,7 @@ function renderThemes() { out.push(` * Regenerate: node ${GENERATOR_ID}`); out.push(` * Verify: node ${GENERATOR_ID} --check`); out.push(' *'); - out.push(' * SHIPPED THEME CLASSES (theming Phase 2b). Applying one of these classes to any'); + out.push(' * SHIPPED THEME CLASSES. Applying one of these classes to any'); out.push(' * DOM node themes that subtree — custom properties cascade. They contain ONLY'); out.push(' * custom-property declarations, in the same fui.theme layer as the web-light'); out.push(' * defaults at :root/:host (css/tokens.css), which they override element-locally.'); diff --git a/packages/react-components/react-tailwind-theme-preview/scripts/probe-spacing-behavior.mjs b/packages/react-components/react-tailwind-theme-preview/scripts/probe-spacing-behavior.mjs index b3ce33b8d2d8f4..1cd75de047a96a 100644 --- a/packages/react-components/react-tailwind-theme-preview/scripts/probe-spacing-behavior.mjs +++ b/packages/react-components/react-tailwind-theme-preview/scripts/probe-spacing-behavior.mjs @@ -1,7 +1,6 @@ // @ts-check /** - * Behavior probes for the spacing token structure (theming Phase 1, option B — settled - * with user 2026-07-29). Loads the REAL compiled theme artifact (dist/styles.css) plus + * Behavior probes for the spacing token structure. Loads the REAL compiled theme artifact (dist/styles.css) plus * utilities compiled from the real css/index.css into headless Chromium and asserts * computed styles — jsdom cannot resolve var()/calc() chains, so this is a browser test * by necessity. @@ -17,7 +16,7 @@ * C. STROKE DECOUPLING — `var(--stroke-width-thin)` / `var(--spacing-thin)` do NOT * respond to a `--spacing` override: borders must not thin when layout density * changes (deliberate design exception). - * D. OLD NAMES ARE GONE (option B) — `var(--spacingHorizontalM)` / + * D. OLD NAMES ARE GONE — `var(--spacingHorizontalM)` / * `var(--strokeWidthThin)` do NOT resolve anywhere in the emitted CSS. * E. DEFAULT ARITHMETIC — at a 16px root everything computes to the canonical px. * diff --git a/packages/react-components/react-tailwind-theme-preview/theme-class-names.mjs b/packages/react-components/react-tailwind-theme-preview/theme-class-names.mjs index e4021f3d8b0709..0b8e9a1518d297 100644 --- a/packages/react-components/react-tailwind-theme-preview/theme-class-names.mjs +++ b/packages/react-components/react-tailwind-theme-preview/theme-class-names.mjs @@ -1,20 +1,8 @@ /** - * CSS class names for the shipped Fluent themes. - * - * Each class is emitted, at build time, by `@fluentui/react-tailwind-theme-preview` - * inside `@layer fui.theme` and contains ONLY custom-property declarations — the - * canonical kebab-case token variables (`--color-neutral-background-1: …`). - * Applying one of these classes to any DOM node themes that node's subtree; the - * default (web light) values are emitted at `:root, :host`, so a theme class is - * only needed for non-default themes or scoped theming. - * - * These constants are the JS read path for the class names — pass one to - * react-windmod-preview's `ThemeProvider` or put it on any element directly. - * - * LOCKSTEP CONTRACT: `scripts/generate-tokens-css.js` derives the emitted class - * names from the theme export names in `theme-values.json` and asserts they equal - * the constants in this file (it text-reads this file). Renaming a theme or a - * class requires updating both in the same change. + * Class names for the shipped themes (emitted in css/themes.css; apply one to any + * element to theme its subtree — web-light is the :root default). The generator asserts + * these constants match the emitted classes; rename both together. Keep + * theme-class-names.cjs identical. */ export const webLightThemeClassName = 'fui-theme-web-light'; @@ -25,11 +13,7 @@ export const teamsHighContrastThemeClassName = 'fui-theme-teams-high-contrast'; export const teamsLightV21ThemeClassName = 'fui-theme-teams-light-v21'; export const teamsDarkV21ThemeClassName = 'fui-theme-teams-dark-v21'; -/** - * Map from the historical theme export name (`webLightTheme`, …) to its CSS class - * name. Handy for harnesses that enumerate themes; the per-theme constants above - * are the primary read path. - */ +/** Theme export name → class name. */ export const themeClassNames = { webLightTheme: webLightThemeClassName, webDarkTheme: webDarkThemeClassName, diff --git a/packages/react-components/react-tailwind-theme-preview/theme-values.json b/packages/react-components/react-tailwind-theme-preview/theme-values.json index 30eb7f57f13da2..7db1cfbc2cfd0a 100644 --- a/packages/react-components/react-tailwind-theme-preview/theme-values.json +++ b/packages/react-components/react-tailwind-theme-preview/theme-values.json @@ -1,7 +1,7 @@ { "$schema": "fluent-theme-values", - "description": "Committed snapshot of the shipped Fluent theme objects (theme key -> value). Consumed by @fluentui/react-tailwind-theme/scripts/generate-tokens-css.js to emit the static theme CSS classes; drift against the computed themes fails src/themes/themeValues.test.ts. Regenerate with `yarn workspace @fluentui/tokens generate-theme-values` after `yarn nx run tokens:build`.", - "generatedBy": "packages/tokens/scripts/generate-theme-values.js", + "description": "Committed snapshot of the shipped Fluent theme objects (theme key -> value). Consumed by scripts/generate-tokens-css.js to emit the static theme CSS classes. Regenerate after yarn nx run tokens:build.", + "generatedBy": "packages/react-components/react-tailwind-theme-preview/scripts/generate-theme-values.js", "source": "@fluentui/react-tailwind-theme-preview@9.0.0-alpha.0", "themes": { "webLightTheme": { diff --git a/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css b/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css index 5e38bbcf44564e..961112b77a5b6c 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css +++ b/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css @@ -184,7 +184,7 @@ @apply min-w-96 rounded-medium px-horizontal-l py-vertical-s text-base-400 leading-base-400 font-semibold; } - @variant disabled-any { + @variant disabled, disabled-focusable { @apply cursor-not-allowed border-neutral-stroke-disabled bg-neutral-background-disabled text-neutral-foreground-disabled; & .icon { @@ -248,7 +248,7 @@ } .outline { - @variant disabled-any { + @variant disabled, disabled-focusable { @apply bg-transparent-background; @variant hover { @@ -262,7 +262,7 @@ } .primary { - @variant disabled-any { + @variant disabled, disabled-focusable { @apply border-transparent; @variant hover { @@ -309,7 +309,7 @@ } .subtle { - @variant disabled-any { + @variant disabled, disabled-focusable { @apply border-transparent bg-transparent-background; @variant hover { @@ -323,7 +323,7 @@ } .transparent { - @variant disabled-any { + @variant disabled, disabled-focusable { @apply border-transparent bg-transparent-background; @variant hover { @@ -411,7 +411,7 @@ } .root { - @variant disabled-any { + @variant disabled, disabled-focusable { @variant hover { & .icon * { @variant variant-filled { diff --git a/packages/react-components/react-windmod-preview/library/src/components/Button/useButtonStyles.ts b/packages/react-components/react-windmod-preview/library/src/components/Button/useButtonStyles.ts index b7a5911219fee4..4a48b224c73ddf 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Button/useButtonStyles.ts +++ b/packages/react-components/react-windmod-preview/library/src/components/Button/useButtonStyles.ts @@ -4,11 +4,7 @@ import type { ButtonState } from './Button.types'; import styles from './Button.module.css'; -/** - * Public identity class for Button — the Tailwind named-group marker, and the ONLY public - * class (everything else is a hashed CSS Modules ident). Target it with `group-*\/fui-button` - * variants rather than internals. - */ +/** The only public class — the Tailwind named-group marker; internals are hashed idents. */ export const buttonClassNames: { root: string } = { root: 'group/fui-button', }; @@ -18,11 +14,8 @@ type ButtonRootDataAttributes = { 'data-empty'?: true; }; -/** - * Applies the Fluent visual contract, returning new state (no slot mutation). The headless - * hook already stamps data-disabled/-disabled-focusable/-icon-only/-icon-position; - * data-size and data-empty are windmod-only styling states. - */ +/** Applies the visual contract, returning new state. The headless hook already stamps + * data-disabled/-disabled-focusable/-icon-only/-icon-position; these two are style-only. */ export const useButtonStyles = (state: ButtonState): ButtonState => { const { appearance, shape, size } = state; @@ -30,11 +23,8 @@ export const useButtonStyles = (state: ButtonState): ButtonState => { ...state.root, 'data-size': size, 'data-empty': !state.root.children || undefined, - // Class order: module class, group marker, look classes, consumer className. The - // marker contains a '/' (Tailwind named-group syntax) and must never be classList[0]: - // jsdom's selector engine (nwsapi) builds its :scope polyfill from the first class - // without escaping, so a leading '/' class makes element.matches() throw in tests. - // Consumer-last is convention — overrides win via cascade layers, not class order. + // The marker's '/' must never be classList[0]: jsdom's nwsapi builds its :scope + // polyfill from the first class unescaped, so element.matches() would throw. className: clsx(styles.root, buttonClassNames.root, styles[appearance], styles[shape], state.root.className), }; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Provider/ThemeProvider.tsx b/packages/react-components/react-windmod-preview/library/src/components/Provider/ThemeProvider.tsx index 0819d6bdb98cce..1e80ca9c906d85 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Provider/ThemeProvider.tsx +++ b/packages/react-components/react-windmod-preview/library/src/components/Provider/ThemeProvider.tsx @@ -8,16 +8,9 @@ import type { ThemeProviderProps } from './ThemeProvider.types'; import styles from './ThemeProvider.module.css'; -/** - * Applies a Fluent theme to a subtree by rendering a `display: contents` div carrying - * the theme class — the theme's custom properties cascade from it without introducing a - * layout box, and they reach top-layer surfaces (popover/dialog) opened from inside the - * subtree, since those keep their DOM ancestry. Also composes the headless Provider so - * descendants read `dir`/`targetDocument` from context. - * - * The theme stylesheet itself (`@fluentui/react-tailwind-theme-preview/styles.css`) must - * be imported once per document — this component only selects which theme applies. - */ +/** Themes a subtree: a display:contents div carrying the theme class (custom properties + * cascade from it, including into top-layer popovers) plus the headless Provider context. + * The theme stylesheet must be imported once per document; this only selects the theme. */ export const ThemeProvider = React.forwardRef((props, ref) => { const { theme, dir, targetDocument, className, children, ...rest } = props; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Tooltip/Tooltip.tsx b/packages/react-components/react-windmod-preview/library/src/components/Tooltip/Tooltip.tsx index b95627ce7920ed..5324cabd72d0a8 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Tooltip/Tooltip.tsx +++ b/packages/react-components/react-windmod-preview/library/src/components/Tooltip/Tooltip.tsx @@ -7,21 +7,13 @@ import { resolvePositioningShorthand } from '@fluentui/react-headless-components import type { TooltipProps, TooltipState } from './Tooltip.types'; import { useTooltipStyles } from './useTooltipStyles'; -/** - * Griffel-parity constants: the classic Tooltip positions at offset 4, and with an arrow - * react-positioning's `mergeArrowOffset` added the 6px arrow height (4 + 6 = 10). The - * headless `usePositioning` applies offsets as margins and does no arrow arithmetic, so - * the same numbers are applied here. The 6px height also drives the CSS constants in - * Tooltip.module.css (8.484px diagonal / -4.242px edge offset) — change them together. - */ +/** Griffel parity: base offset 4; withArrow adds the 6px arrow height (pairs with the + * 8.484px/−4.242px constants in Tooltip.module.css). */ const TOOLTIP_OFFSET = 4; const ARROW_HEIGHT = 6; -/** - * Tooltip renders a non-modal floating label or description anchored to a trigger - * element. Windmod Tooltip: the headless tooltip (native `popover="hint"` top layer + - * CSS anchor positioning) decorated with the Fluent visual contract. - */ +/** Tooltip: the headless tooltip (native popover=hint + CSS anchor positioning) with the + * Fluent visual contract. */ export const Tooltip = (props: TooltipProps): JSXElement => { const { appearance = 'normal', positioning = 'above', withArrow = false, ...rest } = props; @@ -43,8 +35,5 @@ export const Tooltip = (props: TooltipProps): JSXElement => { Tooltip.displayName = 'Tooltip'; -/** - * Marks the Tooltip component as a FluentTriggerComponent so trigger utilities clone - * props through it. Type-cast to avoid exposing internal types in the public API. - */ +/** Lets trigger utilities clone props through Tooltip. */ (Tooltip as FluentTriggerComponent).isFluentTriggerComponent = true; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Tooltip/useTooltipStyles.ts b/packages/react-components/react-windmod-preview/library/src/components/Tooltip/useTooltipStyles.ts index 4e7f29e9149cbe..b51aacb15c96b8 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Tooltip/useTooltipStyles.ts +++ b/packages/react-components/react-windmod-preview/library/src/components/Tooltip/useTooltipStyles.ts @@ -4,32 +4,20 @@ import type { TooltipState } from './Tooltip.types'; import styles from './Tooltip.module.css'; -/** - * Public identity class for Tooltip — the Tailwind named-group marker on the content - * surface (Tooltip's only slot), and the ONLY public class. - */ +/** The only public class — the named-group marker on the content (Tooltip's only slot). */ export const tooltipClassNames: { root: string } = { root: 'group/fui-tooltip', }; -/** - * Applies the Fluent visual contract, returning new state (no slot mutation). Only class - * assembly happens here: the headless hook already stamps `data-open` and `data-placement` - * on the content for CSS state selectors, and the native `popover="hint"` top layer owns - * show/hide. The arrow gets `styles.arrow` via `arrowClassName`; its per-placement rules - * key off the content's group marker (`group-placement-*\/fui-tooltip` variants). - */ +/** Class assembly only: the headless hook stamps data-open/data-placement, the hint + * popover owns show/hide, and the arrow gets styles.arrow via arrowClassName. */ export const useTooltipStyles = (state: TooltipState): TooltipState => { return { ...state, arrowClassName: clsx(styles.arrow, state.arrowClassName), content: { ...state.content, - // Class order: module class, group marker, consumer className. The marker contains - // a '/' (Tailwind named-group syntax) and must never be classList[0]: jsdom's - // selector engine (nwsapi) builds its :scope polyfill from the first class without - // escaping, so a leading '/' class makes element.matches() throw in tests. - // Consumer-last is convention — overrides win via cascade layers, not class order. + // Marker never first — see useButtonStyles. className: clsx( styles.content, tooltipClassNames.root, diff --git a/scripts/css-modules/globalize-group-markers.js b/scripts/css-modules/globalize-group-markers.js index 7c995af059c083..11171bf83042cc 100644 --- a/scripts/css-modules/globalize-group-markers.js +++ b/scripts/css-modules/globalize-group-markers.js @@ -1,47 +1,11 @@ // @ts-check /** - * PostCSS plugin — keep Tailwind named-group / named-peer markers OUT of CSS-Modules hashing. - * - * ── The failure it fixes (DECISIONS.md D15, reports/named-groups-design.md §2.4) ────────── - * - * A named group is written in JSX as a literal, unhashed string: - * - * clsx('group/fui-switch', switchClassNames.root, styles.root, state.root.className) - * - * and read from another module — possibly in another package — as - * - * @variant group-checked/fui-switch { … } - * - * which Tailwind compiles to a selector containing `.group\/fui-switch`. - * - * Both `postcss-modules` and `css-loader` scope EVERY class selector in a `*.module.css` - * file, and they cannot tell this one apart from a real local. Without this plugin the - * compiled selector becomes `.fuicm-switch-group-fui-switch-xxxxxx`, which nothing in the - * DOM ever matches. There is no error and no warning: the rules simply never apply, VR - * passes because nothing changed visually, and the feature silently does not exist. That is - * why this is a blocking prerequisite for the marker rollout rather than a nicety. - * - * ── The fix ───────────────────────────────────────────────────────────────────────────── - * - * Wrap the marker segment in `:global(…)`, which BOTH CSS-Modules implementations honour and - * strip, leaving the class unscoped and out of the exported class map: - * - * .fuicm-switch-thumb-xxxxxx:is(:where(.group\/fui-switch):where([data-checked]) *) - * ↑ still scoped ↑ untouched, matches the JSX literal - * - * ── Where it runs ─────────────────────────────────────────────────────────────────────── - * - * - package build: BETWEEN `tailwindcss()` and `postcssModules()` - * (tools/workspace-plugin/src/executors/build/lib/css-modules.ts) — Tailwind must have - * emitted the selector before this can rewrite it, and CSS Modules must see the - * `:global()` before it scopes. - * - VR storybook: appended to `postcssOptions.plugins` - * (apps/vr-tests-react-components/.storybook/main.js). webpack runs loaders - * right-to-left, so postcss-loader already runs before css-loader. - * - * Prior art: nyt-games uses `@accelint/postcss-tailwind-css-modules@1.1.0` for exactly this. - * A local plugin is preferred over adding a third-party runtime dependency to the build of a - * Microsoft-shipped library. + * PostCSS plugin wrapping Tailwind named-group/peer markers (.group/name, .peer/name) + * in :global() BEFORE postcss-modules runs, so the marker class survives as authored + * instead of being hashed — a hashed marker compiles to a selector the DOM never matches, + * with no error. Must run between tailwindcss() and postcssModules() in every pipeline; + * the build asserts no marker leaks into a class map. + * Prior art: @accelint/postcss-tailwind-css-modules does the same transform. */ /** diff --git a/scripts/css-modules/ident.js b/scripts/css-modules/ident.js index e5e774d23aa14d..678134298edd75 100644 --- a/scripts/css-modules/ident.js +++ b/scripts/css-modules/ident.js @@ -1,56 +1,17 @@ // @ts-check /** - * THE single source of truth for generated CSS-Modules class names ("idents"). - * - * Three pipelines turn `*.module.css` into class names and all three must agree on the - * SHAPE of what they produce; only then can one snapshot serializer strip them all and one - * conformance rule describe them: - * - * 1. package build — `postcss-modules` `generateScopedName` - * (tools/workspace-plugin/src/executors/build/lib/css-modules.ts) - * 2. VR storybook — `css-loader` `modules.getLocalIdent` - * (apps/vr-tests-react-components/.storybook/main.js) - * 3. jest — the `moduleNameMapper` Proxy - * (scripts/jest/src/css-modules/proxy.js) - * - * This file is required by RELATIVE PATH from all three, deliberately: it must stay free of - * workspace requires (jest.preset.js documents why — `@fluentui/scripts-jest` pulls in - * `@fluentui/scripts-monorepo`, which walks the repo on load) and it must be requirable from - * `tools/workspace-plugin`, which does not depend on any `scripts/*` package. - * - * ── The scheme ────────────────────────────────────────────────────────────────────────── - * - * fuicm--- - * fuicm-switch-root-a3f2c1 - * fuicm-message-bar-actions-container-action-7d10be - * - * ALL LOWERCASE, `-` separated, nothing else. Rationale, point by point: - * - * - `fuicm-` prefix — a HARD CONTRACT. scripts/jest/src/css-modules/serializer.js strips - * every `fuicm-…` token from snapshots the way Griffel's jest serializer stripped atomics - * (DECISIONS.md D9). Changing the prefix silently reintroduces generated class names into - * every committed snapshot. - * - No `-module__` infix and no `--` separator. The previous shape - * (`fuicm-[name]__[local]--[hash:base64:4]`) mixed three separator styles and carried a - * redundant `module` token; a single `-` reads better in a DOM inspector and in a diff. - * - No base64. base64 is case-SENSITIVE, so it put uppercase letters into class names that - * are otherwise lowercase, and its alphabet includes `+` and `/` which have to be - * scrubbed anyway. Hex is lowercase by construction. 6 hex chars = 24 bits. - * - Lowercase everywhere, including the component and the local, so the whole generated - * surface is case-insensitively unique. Module-local class names are authored - * lowercase-kebab for the same reason; `toKebabCase` is the safety net that keeps the - * invariant true even where a module still declares a camelCase local. - * - * ── Why the hash is over the NAME and not the CSS ──────────────────────────────────────── - * The digest seeds on ` ` (never the file's bytes, - * which is what css-loader's default does), so shipped class names are stable across content - * edits: a cosmetic CSS change does not churn every identifier in `dist/styles.css`. What the - * digest buys is UNIQUENESS — two packages that both declare `.root` in a same-named file - * still differ, because the package name is in the seed. - * - * The kebab-casing happens AFTER hashing, on the display half only. Two locals that kebab to - * the same string (`fooBar` / `foo-bar`) therefore still receive different hashes and cannot - * collide. + * Single source of truth for generated CSS-Modules class names: + * fuicm--- (all lowercase, - separated) + * + * Three pipelines must agree on this shape — the package build (postcss-modules), the + * storybook css-loader, and the jest proxy — so one serializer can strip them and one + * conformance rule can describe them. Required by RELATIVE path from all three on + * purpose: this file must stay free of workspace requires. + * + * The hash seeds on — never file contents — so + * shipped names are stable across CSS edits but unique across packages. Kebab-casing + * happens after hashing, so locals that kebab to the same string cannot collide. The + * fuicm- prefix is a hard contract with scripts/jest/src/css-modules/serializer.js. */ const { createHash } = require('node:crypto'); diff --git a/scripts/jest/src/css-modules/proxy.js b/scripts/jest/src/css-modules/proxy.js index b6657529f0ec8c..52a22c5ef0194c 100644 --- a/scripts/jest/src/css-modules/proxy.js +++ b/scripts/jest/src/css-modules/proxy.js @@ -1,23 +1,8 @@ /** - * Jest `moduleNameMapper` target for `*.module.css` imports. - * - * Jest never runs webpack, so `import styles from './X.module.css'` has to resolve to - * something. This returns a deterministic name for any property read, in the same - * lowercase-kebab, `fuicm-`-prefixed shape the two real pipelines emit - * (`fuicm---` — see scripts/css-modules/ident.js). The snapshot - * serializer in ./serializer.js strips every `fuicm-…` token, so component snapshots stay - * free of generated class names — the role Griffel's jest serializer played for Griffel - * atomics (migration/griffel-to-tailwind/reports/DECISIONS.md D9). - * - * WHY THE NAME IS SHORTER HERE: `moduleNameMapper` maps every `*.module.css` in the repo to - * this ONE module and gives it no way to learn which file was imported, so the component and - * hash segments cannot be computed. They are dropped rather than faked — `fuicm-root` is - * honestly "the `root` local of some module", and everything the toolchain actually keys on - * (the `fuicm-` prefix, the lowercase-kebab alphabet) is identical. `generateTestIdent` owns - * that decision so the shape stays defined in one place. - * - * `__esModule: false` makes SWC/babel interop wrap this as the default export, which is - * what `import styles from ...` reads. + * Jest moduleNameMapper target for *.module.css imports. Returns a deterministic + * fuicm-prefixed name for any property read (see scripts/css-modules/ident.js); the + * component/hash segments are dropped because the mapper cannot know which file was + * imported. ./serializer.js strips these tokens from snapshots. */ const { generateTestIdent } = require('../../../css-modules/ident'); @@ -30,9 +15,8 @@ module.exports = new Proxy( return undefined; } - // Keep module-system and promise probing intact. NOTE: `default` is deliberately - // NOT excluded — it is a legitimate CSS class name (Divider's default appearance) - // and CJS interop only ever reads `__esModule` off this object. + // `default` is deliberately not excluded — it is a legitimate class name, and CJS + // interop only reads __esModule. if (key === '__esModule' || key === 'then') { return undefined; } diff --git a/scripts/jest/src/css-modules/serializer.js b/scripts/jest/src/css-modules/serializer.js index fd68b0f2872b21..53b5953cb92c98 100644 --- a/scripts/jest/src/css-modules/serializer.js +++ b/scripts/jest/src/css-modules/serializer.js @@ -1,41 +1,9 @@ /** - * Jest snapshot serializer that strips generated CSS-Modules class names. - * - * Counterpart to Griffel's retired jest serializer: converted components render - * `class="group/fui-divider fui-Divider fuicm-divider-root-a3f2c1"`, and only the stable, - * public tokens belong in a snapshot. Generated names carry a digest over the package name, - * the source-relative path and the local (webpack and the package build: - * `fuicm---`; jest: `fuicm-` from ./proxy.js — see - * scripts/css-modules/ident.js), so snapshots that kept them would churn whenever a module - * is renamed or moved. - * - * NOT stripped, deliberately: the `group/fui-*` named-group marker. It is unhashed by - * construction (that is the entire point — DECISIONS.md D15) and it is public DOM surface, - * so it belongs in the snapshot exactly the way `fui-Divider` does. Extending the match to - * hide it would hide a public contract. - * - * pretty-format hands each attribute value to the registered plugins as a string, which - * is why `test`/`print` operate on strings rather than DOM nodes — same contract as - * Griffel's jest serializer (removed from the repo in the S-J closing batch). - * - * IMPORTANT — why `print` edits in place instead of split/join: - * pretty-format offers a plugin every string in the tree, not just class attributes. A - * test that snapshots a whole HTML *document string* (react-provider's - * `FluentProvider-node.test.tsx` / `-hydrate.test.tsx` do exactly this) hands the entire - * multi-line markup to this plugin because one class inside it is generated. Rebuilding - * that string with `split(/\s+/).join(' ')` silently collapsed every newline and indent. - * The algorithm below is therefore the same shape as Griffel's: a targeted - * `String.replace` for the generated tokens, one whole-string `trim()`, then a trim of - * the *inside* of any `class="…"` / `className="…"` value. All other whitespace — and - * therefore the snapshot's line structure — is preserved byte for byte. - * - * KNOWN LIMITATION: pretty-format uses the first plugin whose `test()` passes. In a - * whole-HTML-string snapshot containing BOTH generated CSS-Modules classes and Griffel - * atomics, this plugin wins and the Griffel atomics are left in place. DOM-node snapshots - * are unaffected (each attribute value is offered to the plugins separately), which is - * what every converted package uses. - * - * See migration/griffel-to-tailwind/reports/DECISIONS.md D9. + * Jest snapshot serializer stripping generated (fuicm-*) class names — they digest the + * module path, so keeping them would churn snapshots on renames. The group/fui-* marker + * is public DOM surface and stays. `print` edits in place (targeted String.replace, not + * split/join): whole-document string snapshots flow through here too, and rebuilding the + * string would collapse their line structure. */ const GENERATED_CLASS_PREFIX = 'fuicm-'; diff --git a/scripts/storybook/src/tailwind-theme.css b/scripts/storybook/src/tailwind-theme.css index 5795928b7f05f4..6f60e13b2ea705 100644 --- a/scripts/storybook/src/tailwind-theme.css +++ b/scripts/storybook/src/tailwind-theme.css @@ -1,55 +1,13 @@ /* - * Emits the shared Tailwind theme layer ONCE per storybook document. - * - * This is THE single copy for the whole repo. Every storybook `preview.js` reaches it — the - * root `.storybook/preview.js` imports it, and the ~76 per-package / per-app previews import - * the root preview as a module, so the side effect propagates to all of them. The VR app - * imports it directly for the same reason. One file, one emission point, one theme. - * - * Component `*.module.css` files open with `@reference '#theme'`, which makes the - * theme's variables/variants/utilities available for `@apply` + `@variant` but emits - * NOTHING. The compiled utilities therefore reference `var(--spacing, calc(1px * - * var(--base-scale)))`, and `--base-scale` has to exist at `:root` or every numeric - * spacing utility is invalid at computed-value time (verified: min-width resolves to - * 0px without it, 8px with it). - * - * `source(none)` disables Tailwind's automatic content detection, so this emits only - * the theme custom properties + focus-knob `@property` registrations (1,770 bytes total, - * of which 377 are the two `@layer fui.theme` variable blocks) — no preflight, no utility - * classes, and therefore zero rendering impact on unconverted packages' VR baselines. That - * `source(none)` discipline is what makes it safe to load this into EVERY storybook, - * including the ones whose packages are still on Griffel. - * - * The SECOND variable block holds `--spacing-thin/thick/thicker/thickest` (the Fluent - * stroke widths). Those four are the one part of the token registration that must exist as - * real variables rather than `@theme inline` names: border/outline/ring/decoration widths do - * not consume the `--spacing-*` namespace, so modules reference `var(--spacing-thin)` - * directly. Byte-identical to the theme package's own `dist/styles.css` emission — verified - * by compiling both (.scratch/layer-probe/check-stroke-emission.mjs). - * - * Routed through Tailwind by `rules.tailwindThemeRule`, which matches this file by name; - * `rules.excludeTailwindCssFromDefaultCssRule` keeps storybook's implicit plain-CSS rule off it. - * + * Emits the shared Tailwind theme layer once per storybook document — every preview + * imports this (directly or via the root preview). Component `*.module.css` files only + * `@reference` the theme, so without this emission spacing computes to 0px and tokens + * resolve to nothing. `source(none)` keeps Tailwind from content-scanning, so the file + * emits variables only and cannot affect unconverted packages. + * Routed through Tailwind by `rules.tailwindThemeRule` (matched by filename). */ @import '@fluentui/react-tailwind-theme-preview' source(none); -/* - * Headless icons stylesheet (@fluentui/react-icons/headless, 2.0.337+ — the Griffel-free - * icon factories windmod uses, which express styling as attributes: `data-fui-icon`, - * `data-fui-icon-rtl`, `data-fui-icon-hidden`, `data-fui-icon-font`). REQUIRED: without it - * headless icons lose `display`, the RTL flip, high-contrast handling, and bundled pairs - * render BOTH variants at once. Classic (Griffel) icons don't read this file. - * - * The icons package ships these rules UNLAYERED by default (its own back-compat posture). - * Cascade layers are compared before specificity, so unlayered icon rules would beat every - * layered `fui.*` rule no matter how specific — adhering to this repo's layering system - * therefore requires assigning them a layer at import time. `fui.components.l1` (the - * lowest component layer) is the chosen altitude: windmod base components live in l1, so - * icon state rules arbitrate with them by in-file source order, and everything above - * (l2+, utilities, unlayered consumer CSS) still wins. - * - * Mirrors the same import in @fluentui/react-tailwind-theme-preview's css/emit.css (the - * published root artifact); this copy serves storybook documents, which import this entry - * rather than dist/styles.css. - */ +/* Headless icons stylesheet, layered at l1 for the same reasons as the theme package's + own emit.css (which serves published consumers; this copy serves storybooks). */ @import '@fluentui/react-icons/headless/styles.css' layer(fui.components.l1); diff --git a/tools/workspace-plugin/src/executors/build/lib/css-modules.ts b/tools/workspace-plugin/src/executors/build/lib/css-modules.ts index 8ba45b22307ce4..2818de9d443ed0 100644 --- a/tools/workspace-plugin/src/executors/build/lib/css-modules.ts +++ b/tools/workspace-plugin/src/executors/build/lib/css-modules.ts @@ -1,7 +1,7 @@ /** * Package-build CSS emission for Tailwind-flavoured CSS Modules. * - * Implements DECISIONS.md D1 + D13 (migration/griffel-to-tailwind/reports/DECISIONS.md): + * Implements + D13: * source of truth is `src/**\/*.module.css`; the PACKAGE BUILD compiles it so that * consumers never run Tailwind and never see CSS-Modules syntax. * @@ -76,7 +76,7 @@ const cssModulesIdent: { /** * `:global()`-wraps Tailwind's `group/…` / `peer/…` markers so CSS Modules cannot hash them. - * BLOCKING prerequisite for named groups — see that file's header and DECISIONS.md D15. + * BLOCKING prerequisite for named groups — see that file's header. */ // eslint-disable-next-line @typescript-eslint/no-var-requires const globalizeGroupMarkers: (options?: { @@ -95,22 +95,10 @@ export const CANONICAL_LAYER_STATEMENT = const CSS_MODULE_GLOB = '**/*.module.css'; /** - * One aggregated stylesheet per package. Deliberate, and re-confirmed in Phase 3 (worklist - * item 11): splitting this per component was evaluated and DROPPED for the migration PR, - * recorded as an independent follow-up. - * - * The case for splitting: every fixture in a package pays the whole package sheet. - * `metrics/batch3/monosize-react-badge.json` — all three badge fixtures charge an identical - * `css` gz of 2517, which is why `PresenceBadge` measures +26.3% while `Badge` and - * `CounterBadge` measure −22.7%/−22.8%. - * - * The case against, which won: the regression only appears in single-component micro-bundles - * and washes out in aggregate (`Suite ENTIRE LIBRARY` −2.1%), while turning `./styles.css` - * into N subpaths is a public export-surface break for consumers who import that subpath - * directly (SSR/CJS setups). The work is contained to three functions here - * (`writeAggregatedStylesheet()`, `renderEsmClassMap()`, and the `stylesheetSpecifier` - * computation) plus each converted package's `exports`/`files`, with NO authoring changes — - * so it can land independently at any time without redoing anything. + * One aggregated stylesheet per package. Splitting per component was evaluated and + * dropped: the per-fixture size regression only appears in single-component + * micro-bundles and washes out in aggregate, while N stylesheet subpaths would break + * the public ./styles.css export for SSR/CJS consumers. */ const STYLESHEET_RELATIVE_PATH = 'dist/styles.css'; @@ -278,7 +266,7 @@ export async function compileCssModuleSource({ // ORDER IS LOAD-BEARING and this is the only position that works: Tailwind has to have // emitted `.group\/fui-switch` before it can be rewritten, and postcssModules has to see // the `:global()` wrapper before it scopes. Drop this plugin and every named-group rule - // compiles to a selector the DOM never matches — with no error (DECISIONS.md D15). + // compiles to a selector the DOM never matches — with no error. globalizeGroupMarkers({ onRewrite: ({ count }) => { globalizedMarkers += count; @@ -306,8 +294,7 @@ export async function compileCssModuleSource({ /** * A `group/…` marker that reached CSS Modules unwrapped gets SCOPED, and the only visible * trace is that it appears as a key in the exported class map — postcss-modules ignores - * anything inside `:global()`, so a correctly handled marker is absent from the map entirely - * (verified, reports/named-groups-design.md §2.4). + * anything inside `:global()`, so a correctly handled marker is absent from the map entirely. * * The failure is otherwise completely silent: the compiled selector is well-formed, nothing * warns, and VR passes because the rules simply never match. Fail the build instead. @@ -319,7 +306,7 @@ function assertGroupMarkersSurvived(relativePath: string, classMap: Record Date: Fri, 21 Aug 2026 11:02:45 -0700 Subject: [PATCH 017/246] chore: finish the comment sweep in storybook rules and the css-modules spec Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- scripts/storybook/src/rules.js | 5 ++--- .../src/executors/build/lib/css-modules.spec.ts | 8 +++----- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/scripts/storybook/src/rules.js b/scripts/storybook/src/rules.js index de8393a860d186..9b136cf00f35a5 100644 --- a/scripts/storybook/src/rules.js +++ b/scripts/storybook/src/rules.js @@ -89,7 +89,7 @@ const tailwindPostcssLoader = { * * Without it css-loader hashes the marker and every named-group rule compiles to a * selector the DOM never matches — silently, with VR still green. See - * scripts/css-modules/globalize-group-markers.js and DECISIONS.md D15. + * scripts/css-modules/globalize-group-markers.js. */ globalizeGroupMarkers(), ], @@ -108,8 +108,7 @@ const tailwindPostcssLoader = { * produce byte-identical class names for the same module — see scripts/css-modules/ident.js. * * The `fuicm-` prefix is a hard contract with the jest snapshot serializer, which strips - * these exactly like Griffel's jest serializer stripped atomics - * (migration/griffel-to-tailwind/reports/DECISIONS.md D9). + * these exactly like Griffel's jest serializer stripped atomics. * * @type {import("webpack").RuleSetRule} */ diff --git a/tools/workspace-plugin/src/executors/build/lib/css-modules.spec.ts b/tools/workspace-plugin/src/executors/build/lib/css-modules.spec.ts index 097218c8de0be0..73a7e9797d7890 100644 --- a/tools/workspace-plugin/src/executors/build/lib/css-modules.spec.ts +++ b/tools/workspace-plugin/src/executors/build/lib/css-modules.spec.ts @@ -14,8 +14,7 @@ import { import { type NormalizedOptions } from './shared'; /** - * Guardrail for the two halves of the generated-class-name contract - * (migration/griffel-to-tailwind/reports/DECISIONS.md D9 + D15). + * Guardrail for the two halves of the generated-class-name contract. * * Both failures these cover are SILENT — the CSS stays well-formed, nothing warns, and the * visual-regression suite passes because nothing changed visually: @@ -123,8 +122,7 @@ describe('compileCssModuleSource', () => { }); /** - * StackShim's permanently UNLAYERED descendant rules - * (migration/griffel-to-tailwind/reports/s4-v8-layering-decision.md §2.1, §3.4). + * StackShim's permanently UNLAYERED descendant rules. * * `@fluentui/react-migration-v8-v9` exists to render hybrid v8/v9 trees, so a StackShim's * children are routinely v8 components styled by merge-styles — which injects UNLAYERED at @@ -241,7 +239,7 @@ describe('compileCssModuleSource — StackShim unlayered descendant rules (S4)', * `cleanOutput` does not clear `dist/` (api-extractor owns it), so deleting a package's last * `*.module.css` used to leave `dist/styles.css` behind forever on incremental local builds — * a stale stylesheet that nothing imports but that `"./styles.css"` still resolves for - * consumers (reports/phase15-css-emission.md open question 4). + * consumers. */ describe('compileCssModules — orphaned dist/styles.css', () => { let projectRoot: string; From a3d8c03a2d850b7a1a89d62eef3c3972b54f99cd Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Fri, 21 Aug 2026 11:02:59 -0700 Subject: [PATCH 018/246] test(windmod): add the VR comparison stories and close the icon glyph cascade gap Shared Button/Tooltip scenes rendered by both the windmod and Griffel suites, diffed pixel-for-pixel by the scratch VR runner against a static storybook build. The glyph swap moves to the important tier: Griffel-compiled icon components carry unlayered base classes that beat any layered visibility rule. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- .../src/components/Button/Button.module.css | 20 +++--- .../stories/.storybook/main.js | 13 +++- .../ButtonGriffelComparison.stories.tsx | 19 ++++-- .../stories/src/vr/ButtonGriffel.stories.tsx | 13 ++++ .../stories/src/vr/ButtonVrScene.tsx | 55 ++++++++++++++++ .../stories/src/vr/ButtonWindmod.stories.tsx | 14 +++++ .../stories/src/vr/TooltipGriffel.stories.tsx | 12 ++++ .../stories/src/vr/TooltipVrScene.tsx | 62 +++++++++++++++++++ .../stories/src/vr/TooltipWindmod.stories.tsx | 12 ++++ .../stories/src/vr/index.stories.tsx | 8 +++ .../stories/tsconfig.json | 2 + 11 files changed, 214 insertions(+), 16 deletions(-) create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/ButtonGriffel.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/ButtonVrScene.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/ButtonWindmod.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/TooltipGriffel.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/TooltipVrScene.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/TooltipWindmod.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx diff --git a/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css b/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css index 961112b77a5b6c..ff51e92d028148 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css +++ b/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css @@ -382,17 +382,19 @@ /* Glyph swap on hover, keyed off the data-variant attribute bundleIcon stamps on each glyph. In-file order carries the cascade: hover before hover-active, and the disabled - swap-back last so it wins over the appearance swaps. */ + swap-back last so it wins over the appearance swaps. `!` keeps the swap in the same + importance tier as the icons stylesheet's hidden rule; normal declarations would lose + to the icon components' unlayered base classes. */ .subtle, .transparent { @variant hover { & .icon * { @variant variant-filled { - @apply inline; + @apply inline!; } @variant variant-regular { - @apply hidden; + @apply hidden!; } } } @@ -400,11 +402,11 @@ @variant hover-active { & .icon * { @variant variant-filled { - @apply inline; + @apply inline!; } @variant variant-regular { - @apply hidden; + @apply hidden!; } } } @@ -415,11 +417,11 @@ @variant hover { & .icon * { @variant variant-filled { - @apply hidden; + @apply hidden!; } @variant variant-regular { - @apply inline; + @apply inline!; } } } @@ -427,11 +429,11 @@ @variant hover-active { & .icon * { @variant variant-filled { - @apply hidden; + @apply hidden!; } @variant variant-regular { - @apply inline; + @apply inline!; } } } diff --git a/packages/react-components/react-windmod-preview/stories/.storybook/main.js b/packages/react-components/react-windmod-preview/stories/.storybook/main.js index 7eddeb8e8f7601..3a61eec4929c72 100644 --- a/packages/react-components/react-windmod-preview/stories/.storybook/main.js +++ b/packages/react-components/react-windmod-preview/stories/.storybook/main.js @@ -14,7 +14,7 @@ const tsConfigPath = path.resolve(repoRoot, 'tsconfig.base.json'); * @param {string | { name?: string }} addon */ function isNotExportToSandboxAddon(addon) { - const name = typeof addon === 'string' ? addon : addon?.name ?? ''; + const name = typeof addon === 'string' ? addon : (addon?.name ?? ''); return !name.includes('react-storybook-addon-export-to-sandbox'); } @@ -41,6 +41,17 @@ module.exports = /** @type {Omit { - const variants: Array<{ label: string; props: Partial }> = [ - ...appearances.map(appearance => ({ label: appearance, props: { appearance } as Partial })), - ...sizes.map(size => ({ label: size, props: { size } as Partial })), - ...shapes.map(shape => ({ label: shape, props: { shape } as Partial })), + const variants: Array<{ label: string; props: LookProps }> = [ + ...appearances.map(appearance => ({ label: appearance, props: { appearance } })), + ...sizes.map(size => ({ label: size, props: { size } })), + ...shapes.map(shape => ({ label: shape, props: { shape } })), { label: 'disabled', props: { disabled: true } }, { label: 'disabledFocusable', props: { disabledFocusable: true } }, { label: 'primary disabled', props: { appearance: 'primary', disabled: true } }, @@ -44,7 +51,7 @@ export const GriffelComparison = (): React.ReactNode => {
- Button + Button
diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/ButtonGriffel.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/ButtonGriffel.stories.tsx new file mode 100644 index 00000000000000..385d869c6f4f45 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/ButtonGriffel.stories.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import { Button, FluentProvider, webLightTheme } from '@fluentui/react-components'; +import { bundleIcon, CalendarMonthFilled, CalendarMonthRegular } from '@fluentui/react-icons'; + +import { ButtonVrScene } from './ButtonVrScene'; + +const CalendarMonth = bundleIcon(CalendarMonthFilled, CalendarMonthRegular); + +export const ButtonGriffel = (): React.ReactNode => ( + + + +); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/ButtonVrScene.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/ButtonVrScene.tsx new file mode 100644 index 00000000000000..ed7147bf6bcac7 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/ButtonVrScene.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; + +const appearances = ['secondary', 'primary', 'outline', 'subtle', 'transparent'] as const; +const sizes = ['small', 'medium', 'large'] as const; +const shapes = ['rounded', 'circular', 'square'] as const; + +type ButtonLike = React.ComponentType<{ + appearance?: (typeof appearances)[number]; + size?: (typeof sizes)[number]; + shape?: (typeof shapes)[number]; + disabled?: boolean; + disabledFocusable?: boolean; + icon?: React.ReactElement; + iconPosition?: 'before' | 'after'; + 'aria-label'?: string; + children?: React.ReactNode; +}>; + +/** One scene, two implementations — the VR runner diffs the renders pixel for pixel. */ +export const ButtonVrScene = ({ Button, Icon }: { Button: ButtonLike; Icon: React.ComponentType }): React.ReactNode => ( +
+ {appearances.map(appearance => ( +
+ {sizes.map(size => ( + + ))} + + + + +
+ ))} +
+ {shapes.map(shape => ( + + ))} + {shapes.map(shape => ( +
+
+); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/ButtonWindmod.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/ButtonWindmod.stories.tsx new file mode 100644 index 00000000000000..d61b51877f554d --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/ButtonWindmod.stories.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import { Button, ThemeProvider } from '@fluentui/react-windmod-preview'; +import { CalendarMonthFilled, CalendarMonthRegular } from '@fluentui/react-icons'; +import { bundleIcon } from '@fluentui/react-icons/headless'; + +import { ButtonVrScene } from './ButtonVrScene'; + +const CalendarMonth = bundleIcon(CalendarMonthFilled, CalendarMonthRegular); + +export const ButtonWindmod = (): React.ReactNode => ( + + + +); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/TooltipGriffel.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/TooltipGriffel.stories.tsx new file mode 100644 index 00000000000000..8a89307d6e12b3 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/TooltipGriffel.stories.tsx @@ -0,0 +1,12 @@ +import * as React from 'react'; +import { Button, FluentProvider, Tooltip, webLightTheme } from '@fluentui/react-components'; + +import { TooltipVrScene } from './TooltipVrScene'; + +const pin = (text: string) => text; + +export const TooltipGriffel = (): React.ReactNode => ( + + + +); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/TooltipVrScene.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/TooltipVrScene.tsx new file mode 100644 index 00000000000000..b438ebfd516887 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/TooltipVrScene.tsx @@ -0,0 +1,62 @@ +import * as React from 'react'; + +type TooltipLike = React.ComponentType<{ + content: unknown; + relationship: 'label'; + visible?: boolean; + withArrow?: boolean; + appearance?: string; + positioning?: string; + children: React.ReactElement; +}>; + +type ButtonLike = React.ComponentType<{ children?: React.ReactNode }>; + +const placements = ['above', 'below', 'before', 'after', 'above-start', 'below-end']; + +/** + * Pinned-open tooltips (griffel portals / windmod top layer both escape the root, so the + * runner captures the viewport for this scene). `pin` builds the content value per + * implementation (windmod needs a manual-popover slot object; hint popovers are exclusive). + */ +export const TooltipVrScene = ({ + Tooltip, + Button, + pin, +}: { + Tooltip: TooltipLike; + Button: ButtonLike; + pin: (text: string) => unknown; +}): React.ReactNode => ( +
+ {placements.map(positioning => ( + + + + ))} + + + + + + +
+); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/TooltipWindmod.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/TooltipWindmod.stories.tsx new file mode 100644 index 00000000000000..84a879ef58a690 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/TooltipWindmod.stories.tsx @@ -0,0 +1,12 @@ +import * as React from 'react'; +import { Button, ThemeProvider, Tooltip } from '@fluentui/react-windmod-preview'; + +import { TooltipVrScene } from './TooltipVrScene'; + +const pin = (text: string) => ({ children: text, popover: 'manual' }); + +export const TooltipWindmod = (): React.ReactNode => ( + + + +); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx new file mode 100644 index 00000000000000..dfeb7587e1f2a6 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx @@ -0,0 +1,8 @@ +export { ButtonWindmod } from './ButtonWindmod.stories'; +export { ButtonGriffel } from './ButtonGriffel.stories'; +export { TooltipWindmod } from './TooltipWindmod.stories'; +export { TooltipGriffel } from './TooltipGriffel.stories'; + +export default { + title: 'Windmod/VR', +}; diff --git a/packages/react-components/react-windmod-preview/stories/tsconfig.json b/packages/react-components/react-windmod-preview/stories/tsconfig.json index efc50169d1df18..641828aee0b119 100644 --- a/packages/react-components/react-windmod-preview/stories/tsconfig.json +++ b/packages/react-components/react-windmod-preview/stories/tsconfig.json @@ -2,6 +2,8 @@ "extends": "../../../../tsconfig.base.json", "compilerOptions": { "target": "ES2019", + /* Subpath-export resolution — see library/tsconfig.json. */ + "moduleResolution": "bundler", "noEmit": true, "isolatedModules": true, "importHelpers": true, From e365d73e951d5ef7113a8f27b01941a8bf1e0dbf Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Fri, 21 Aug 2026 12:13:43 -0700 Subject: [PATCH 019/246] feat(windmod): add Label Headless useLabel + Tailwind CSS-Modules styling, pixel-identical to the Griffel suite (size/weight/disabled/required grid). First behavioral tests in the package alongside conformance; spec tsconfig gains the static-assets type for module.css imports. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- .../library/etc/label.api.md | 48 +++++++++++++++++ .../library/etc/react-windmod-preview.api.md | 37 +++++++++++++ .../library/package.json | 10 ++++ .../src/components/Label/Label.module.css | 48 +++++++++++++++++ .../src/components/Label/Label.test.tsx | 47 ++++++++++++++++ .../library/src/components/Label/Label.tsx | 29 ++++++++++ .../src/components/Label/Label.types.ts | 26 +++++++++ .../library/src/components/Label/index.ts | 3 ++ .../src/components/Label/useLabelStyles.ts | 33 ++++++++++++ .../library/src/index.ts | 3 ++ .../library/src/label.ts | 5 ++ .../library/tsconfig.spec.json | 2 +- .../src/Label/LabelDefault.stories.tsx | 35 ++++++++++++ .../Label/LabelGriffelComparison.stories.tsx | 54 +++++++++++++++++++ .../stories/src/Label/index.stories.tsx | 9 ++++ .../stories/src/vr/LabelGriffel.stories.tsx | 10 ++++ .../stories/src/vr/LabelVrScene.tsx | 39 ++++++++++++++ .../stories/src/vr/LabelWindmod.stories.tsx | 10 ++++ .../stories/src/vr/index.stories.tsx | 2 + 19 files changed, 449 insertions(+), 1 deletion(-) create mode 100644 packages/react-components/react-windmod-preview/library/etc/label.api.md create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Label/Label.module.css create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Label/Label.test.tsx create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Label/Label.tsx create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Label/Label.types.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Label/index.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Label/useLabelStyles.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/label.ts create mode 100644 packages/react-components/react-windmod-preview/stories/src/Label/LabelDefault.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/Label/LabelGriffelComparison.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/Label/index.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/LabelGriffel.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/LabelVrScene.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/LabelWindmod.stories.tsx diff --git a/packages/react-components/react-windmod-preview/library/etc/label.api.md b/packages/react-components/react-windmod-preview/library/etc/label.api.md new file mode 100644 index 00000000000000..817c4d2c354600 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/etc/label.api.md @@ -0,0 +1,48 @@ +## API Report File for "@fluentui/react-windmod-preview" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { LabelProps as LabelProps_2 } from '@fluentui/react-headless-components-preview/label'; +import { LabelSlots } from '@fluentui/react-headless-components-preview/label'; +import type { LabelState as LabelState_2 } from '@fluentui/react-headless-components-preview/label'; +import { renderLabel } from '@fluentui/react-headless-components-preview/label'; +import { useLabel } from '@fluentui/react-headless-components-preview/label'; + +// @public +export const Label: ForwardRefComponent; + +// @public +export const labelClassNames: { + root: string; +}; + +// @public +export type LabelProps = LabelProps_2 & { + size?: LabelSize; + weight?: LabelWeight; +}; + +// @public +export type LabelSize = 'small' | 'medium' | 'large'; + +export { LabelSlots } + +// @public +export type LabelState = LabelState_2 & Required>; + +// @public +export type LabelWeight = 'regular' | 'semibold'; + +export { renderLabel } + +export { useLabel } + +// @public +export const useLabelStyles: (state: LabelState) => LabelState; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md b/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md index 7c131ffcf40ee4..cac91fbc2a9b55 100644 --- a/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md +++ b/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md @@ -9,10 +9,14 @@ import { ButtonSlots } from '@fluentui/react-headless-components-preview/button' import type { ButtonState as ButtonState_2 } from '@fluentui/react-headless-components-preview/button'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; +import type { LabelProps as LabelProps_2 } from '@fluentui/react-headless-components-preview/label'; +import { LabelSlots } from '@fluentui/react-headless-components-preview/label'; +import type { LabelState as LabelState_2 } from '@fluentui/react-headless-components-preview/label'; import { OnVisibleChangeData } from '@fluentui/react-headless-components-preview/tooltip'; import { Provider } from '@fluentui/react-headless-components-preview/provider'; import * as React_2 from 'react'; import { renderButton } from '@fluentui/react-headless-components-preview/button'; +import { renderLabel } from '@fluentui/react-headless-components-preview/label'; import { renderProvider } from '@fluentui/react-headless-components-preview/provider'; import { renderTooltip } from '@fluentui/react-headless-components-preview/tooltip'; import { teamsDarkThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; @@ -27,6 +31,7 @@ import { TooltipSlots } from '@fluentui/react-headless-components-preview/toolti import type { TooltipState as TooltipState_2 } from '@fluentui/react-headless-components-preview/tooltip'; import { TooltipTriggerProps } from '@fluentui/react-headless-components-preview/tooltip'; import { useButton } from '@fluentui/react-headless-components-preview/button'; +import { useLabel } from '@fluentui/react-headless-components-preview/label'; import { useProvider } from '@fluentui/react-headless-components-preview/provider'; import { useTooltip } from '@fluentui/react-headless-components-preview/tooltip'; import { webDarkThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; @@ -61,12 +66,39 @@ export { ButtonSlots } // @public export type ButtonState = ButtonState_2 & Required>; +// @public +export const Label: ForwardRefComponent; + +// @public +export const labelClassNames: { + root: string; +}; + +// @public +export type LabelProps = LabelProps_2 & { + size?: LabelSize; + weight?: LabelWeight; +}; + +// @public +export type LabelSize = 'small' | 'medium' | 'large'; + +export { LabelSlots } + +// @public +export type LabelState = LabelState_2 & Required>; + +// @public +export type LabelWeight = 'regular' | 'semibold'; + export { OnVisibleChangeData } export { Provider } export { renderButton } +export { renderLabel } + export { renderProvider } export { renderTooltip } @@ -132,6 +164,11 @@ export { useButton } // @public export const useButtonStyles: (state: ButtonState) => ButtonState; +export { useLabel } + +// @public +export const useLabelStyles: (state: LabelState) => LabelState; + export { useProvider } export { useTooltip } diff --git a/packages/react-components/react-windmod-preview/library/package.json b/packages/react-components/react-windmod-preview/library/package.json index 2333707f16e9f3..bacecaef4c5c81 100644 --- a/packages/react-components/react-windmod-preview/library/package.json +++ b/packages/react-components/react-windmod-preview/library/package.json @@ -61,6 +61,16 @@ "default": "./lib-commonjs/button.cjs" } }, + "./label": { + "import": { + "types": "./dist/label.d.ts", + "default": "./lib/label.js" + }, + "require": { + "types": "./dist/label.d.cts", + "default": "./lib-commonjs/label.cjs" + } + }, "./provider": { "import": { "types": "./dist/provider.d.ts", diff --git a/packages/react-components/react-windmod-preview/library/src/components/Label/Label.module.css b/packages/react-components/react-windmod-preview/library/src/components/Label/Label.module.css new file mode 100644 index 00000000000000..ce100a19f28ca4 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Label/Label.module.css @@ -0,0 +1,48 @@ +@reference '#theme'; + +@layer fui.base { + .root { + @apply font-base text-neutral-foreground-1; + } + + .required { + @apply ps-horizontal-xs text-palette-red-foreground-3; + } +} + +@layer fui.components.l1 { + .root { + @variant size-small { + @apply text-base-200 leading-base-200; + } + + @variant size-medium { + @apply text-base-300 leading-base-300; + } + + @variant size-large { + @apply text-base-400 leading-base-400 font-semibold; + } + + @variant disabled { + @apply text-neutral-foreground-disabled; + + /* data-disabled lands on the root only; the required indicator is a DOM child. */ + & .required { + @apply text-neutral-foreground-disabled; + } + + @variant forced-colors { + @apply text-[GrayText]; + + & .required { + @apply text-[GrayText]; + } + } + } + } + + .semibold { + @apply font-semibold; + } +} diff --git a/packages/react-components/react-windmod-preview/library/src/components/Label/Label.test.tsx b/packages/react-components/react-windmod-preview/library/src/components/Label/Label.test.tsx new file mode 100644 index 00000000000000..faca13dac1e79e --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Label/Label.test.tsx @@ -0,0 +1,47 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; + +import { Label } from './Label'; +import { isConformant } from '../../testing/isConformant'; +import type { LabelState } from './Label.types'; +import { labelClassNames, useLabelStyles } from './useLabelStyles'; + +describe('Label', () => { + isConformant({ + Component: Label, + displayName: 'Label', + requiredProps: { children: "I'm a label." }, + }); + + it('stamps data-size and keeps the group marker out of classList[0]', () => { + const { getByText } = render( + <> + + + , + ); + + expect(getByText('Default').getAttribute('data-size')).toBe('medium'); + expect(getByText('Large').getAttribute('data-size')).toBe('large'); + expect(getByText('Default').className).toContain(labelClassNames.root); + expect(getByText('Default').classList[0]).not.toContain('/'); + }); + + it('does not mutate the state it is given', () => { + const state = { + components: { root: 'label', required: 'span' }, + disabled: false, + required: { as: 'span' }, + root: { as: 'label', className: 'consumer' }, + size: 'medium', + weight: 'semibold', + } as unknown as LabelState; + + const styled = useLabelStyles(state); + + expect(styled).not.toBe(state); + expect(state.root).not.toHaveProperty('data-size'); + expect(state.root.className).toBe('consumer'); + expect(styled.root.className).toContain('consumer'); + }); +}); diff --git a/packages/react-components/react-windmod-preview/library/src/components/Label/Label.tsx b/packages/react-components/react-windmod-preview/library/src/components/Label/Label.tsx new file mode 100644 index 00000000000000..c371c89bb34a47 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Label/Label.tsx @@ -0,0 +1,29 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { renderLabel, useLabel } from '@fluentui/react-headless-components-preview/label'; + +import type { LabelProps, LabelState } from './Label.types'; +import { useLabelStyles } from './useLabelStyles'; + +/** + * Labels give form elements an accessible, visible name. Windmod Label: the headless label + * decorated with the Fluent visual contract (Tailwind v4 + CSS Modules). + */ +export const Label: ForwardRefComponent = React.forwardRef((props, ref) => { + // Look props belong to windmod — the headless hook neither accepts nor resolves them. + // Defaults mirror @fluentui/react-label's styled useLabel. + const { size = 'medium', weight = 'regular', ...rest } = props; + + const state: LabelState = { + ...useLabel(rest, ref), + size, + weight, + }; + + return renderLabel(useLabelStyles(state)); + // Casting is required due to lack of distributive union to support union on @types/react +}) as ForwardRefComponent; + +Label.displayName = 'Label'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Label/Label.types.ts b/packages/react-components/react-windmod-preview/library/src/components/Label/Label.types.ts new file mode 100644 index 00000000000000..f93f028b24330b --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Label/Label.types.ts @@ -0,0 +1,26 @@ +import type { + LabelProps as LabelHeadlessProps, + LabelState as LabelHeadlessState, +} from '@fluentui/react-headless-components-preview/label'; + +export type { LabelSlots } from '@fluentui/react-headless-components-preview/label'; + +/** Size of the Label. */ +export type LabelSize = 'small' | 'medium' | 'large'; + +/** Font weight of the Label. Size `'large'` is semibold regardless of this prop. */ +export type LabelWeight = 'regular' | 'semibold'; + +/** + * Windmod Label props: the headless label plus the look props the headless surface + * deliberately omits (they exist purely to select styles). + */ +export type LabelProps = LabelHeadlessProps & { + /** @default 'medium' */ + size?: LabelSize; + /** @default 'regular' */ + weight?: LabelWeight; +}; + +/** Windmod Label state: headless state plus the resolved look props. */ +export type LabelState = LabelHeadlessState & Required>; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Label/index.ts b/packages/react-components/react-windmod-preview/library/src/components/Label/index.ts new file mode 100644 index 00000000000000..ee1b1d90829022 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Label/index.ts @@ -0,0 +1,3 @@ +export { Label } from './Label'; +export { labelClassNames, useLabelStyles } from './useLabelStyles'; +export type { LabelProps, LabelSize, LabelSlots, LabelState, LabelWeight } from './Label.types'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Label/useLabelStyles.ts b/packages/react-components/react-windmod-preview/library/src/components/Label/useLabelStyles.ts new file mode 100644 index 00000000000000..89a6c026dae6a8 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Label/useLabelStyles.ts @@ -0,0 +1,33 @@ +import { clsx } from 'clsx'; + +import type { LabelState } from './Label.types'; + +import styles from './Label.module.css'; + +/** The only public class — the Tailwind named-group marker; internals are hashed idents. */ +export const labelClassNames: { root: string } = { + root: 'group/fui-label', +}; + +type LabelRootDataAttributes = { + 'data-size'?: LabelState['size']; +}; + +/** Applies the visual contract, returning new state. The headless hook already stamps + * data-disabled/-required; data-size is style-only. */ +export const useLabelStyles = (state: LabelState): LabelState => { + const { size, weight } = state; + + const root: LabelState['root'] & LabelRootDataAttributes = { + ...state.root, + 'data-size': size, + // Marker never first — see useButtonStyles. + className: clsx(styles.root, labelClassNames.root, weight === 'semibold' && styles.semibold, state.root.className), + }; + + return { + ...state, + root, + required: state.required && { ...state.required, className: clsx(styles.required, state.required.className) }, + }; +}; diff --git a/packages/react-components/react-windmod-preview/library/src/index.ts b/packages/react-components/react-windmod-preview/library/src/index.ts index cd9d8e51a5f52d..d0c4c242be3d28 100644 --- a/packages/react-components/react-windmod-preview/library/src/index.ts +++ b/packages/react-components/react-windmod-preview/library/src/index.ts @@ -1,6 +1,9 @@ export { Button, buttonClassNames, renderButton, useButton, useButtonStyles } from './button'; export type { ButtonAppearance, ButtonProps, ButtonShape, ButtonSize, ButtonSlots, ButtonState } from './button'; +export { Label, labelClassNames, renderLabel, useLabel, useLabelStyles } from './label'; +export type { LabelProps, LabelSize, LabelSlots, LabelState, LabelWeight } from './label'; + export { renderTooltip, Tooltip, tooltipClassNames, useTooltip, useTooltipStyles } from './tooltip'; export type { OnVisibleChangeData, diff --git a/packages/react-components/react-windmod-preview/library/src/label.ts b/packages/react-components/react-windmod-preview/library/src/label.ts new file mode 100644 index 00000000000000..3df8138d5998c7 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/label.ts @@ -0,0 +1,5 @@ +export { Label, labelClassNames, useLabelStyles } from './components/Label'; +export type { LabelProps, LabelSize, LabelSlots, LabelState, LabelWeight } from './components/Label'; + +/** Headless building blocks, re-exported for consumers composing their own Label. */ +export { renderLabel, useLabel } from '@fluentui/react-headless-components-preview/label'; diff --git a/packages/react-components/react-windmod-preview/library/tsconfig.spec.json b/packages/react-components/react-windmod-preview/library/tsconfig.spec.json index 7a1ef8b44b3bf7..0eec63ea994112 100644 --- a/packages/react-components/react-windmod-preview/library/tsconfig.spec.json +++ b/packages/react-components/react-windmod-preview/library/tsconfig.spec.json @@ -3,7 +3,7 @@ "compilerOptions": { "module": "ESNext", "outDir": "dist", - "types": ["jest", "node"] + "types": ["jest", "node", "static-assets"] }, "include": [ "**/*.spec.ts", diff --git a/packages/react-components/react-windmod-preview/stories/src/Label/LabelDefault.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Label/LabelDefault.stories.tsx new file mode 100644 index 00000000000000..fd39e1353aaf7a --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/Label/LabelDefault.stories.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { Label, ThemeProvider } from '@fluentui/react-windmod-preview'; + +import styles from '../compare.module.css'; + +const sizes = ['small', 'medium', 'large'] as const; +const weights = ['regular', 'semibold'] as const; + +export const Default = (): React.ReactNode => ( + +
+ {sizes.map(size => + weights.map(weight => ( +
+ + + + + +
+ )), + )} +
+
+); diff --git a/packages/react-components/react-windmod-preview/stories/src/Label/LabelGriffelComparison.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Label/LabelGriffelComparison.stories.tsx new file mode 100644 index 00000000000000..edac91db34862e --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/Label/LabelGriffelComparison.stories.tsx @@ -0,0 +1,54 @@ +import * as React from 'react'; +import { Label, ThemeProvider } from '@fluentui/react-windmod-preview'; +import { FluentProvider, Label as GriffelLabel, webLightTheme } from '@fluentui/react-components'; + +import styles from '../compare.module.css'; + +const sizes = ['small', 'medium', 'large'] as const; +const weights = ['regular', 'semibold'] as const; + +type LookProps = { + size?: (typeof sizes)[number]; + weight?: (typeof weights)[number]; + disabled?: boolean; + required?: boolean | string; +}; + +/** + * Every windmod variant next to its Griffel-suite twin (inside a FluentProvider). + * The pair in each row must be pixel-identical. + */ +export const GriffelComparison = (): React.ReactNode => { + const variants: Array<{ label: string; props: LookProps }> = [ + ...sizes.map(size => ({ label: size, props: { size } })), + ...weights.map(weight => ({ label: weight, props: { weight } })), + { label: 'large + regular', props: { size: 'large' as const, weight: 'regular' as const } }, + { label: 'required', props: { required: true } }, + { label: 'custom indicator', props: { required: '(required)' } }, + { label: 'disabled', props: { disabled: true } }, + { label: 'disabled required', props: { disabled: true, required: true } }, + ]; + + return ( +
+
Variant
+
Windmod
+
Griffel
+ {variants.map(({ label, props }) => ( + +
{label}
+
+ + + +
+
+ + Label + +
+
+ ))} +
+ ); +}; diff --git a/packages/react-components/react-windmod-preview/stories/src/Label/index.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Label/index.stories.tsx new file mode 100644 index 00000000000000..c75f6b59fd1d54 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/Label/index.stories.tsx @@ -0,0 +1,9 @@ +import { Label } from '@fluentui/react-windmod-preview'; + +export { Default } from './LabelDefault.stories'; +export { GriffelComparison } from './LabelGriffelComparison.stories'; + +export default { + title: 'Windmod/Label', + component: Label, +}; diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/LabelGriffel.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/LabelGriffel.stories.tsx new file mode 100644 index 00000000000000..811171181119ff --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/LabelGriffel.stories.tsx @@ -0,0 +1,10 @@ +import * as React from 'react'; +import { FluentProvider, Label, webLightTheme } from '@fluentui/react-components'; + +import { LabelVrScene } from './LabelVrScene'; + +export const LabelGriffel = (): React.ReactNode => ( + + + +); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/LabelVrScene.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/LabelVrScene.tsx new file mode 100644 index 00000000000000..0948d17d5aba7f --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/LabelVrScene.tsx @@ -0,0 +1,39 @@ +import * as React from 'react'; + +const sizes = ['small', 'medium', 'large'] as const; +const weights = ['regular', 'semibold'] as const; + +type LabelLike = React.ComponentType<{ + size?: (typeof sizes)[number]; + weight?: (typeof weights)[number]; + disabled?: boolean; + required?: boolean | string; + children?: React.ReactNode; +}>; + +/** No bare text in the scene: only the Griffel side inherits FluentProvider typography. */ +export const LabelVrScene = ({ Label }: { Label: LabelLike }): React.ReactNode => ( +
+ {sizes.map(size => + weights.map(weight => ( +
+ + + + + +
+ )), + )} +
+); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/LabelWindmod.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/LabelWindmod.stories.tsx new file mode 100644 index 00000000000000..62353aff403a52 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/LabelWindmod.stories.tsx @@ -0,0 +1,10 @@ +import * as React from 'react'; +import { Label, ThemeProvider } from '@fluentui/react-windmod-preview'; + +import { LabelVrScene } from './LabelVrScene'; + +export const LabelWindmod = (): React.ReactNode => ( + + + +); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx index dfeb7587e1f2a6..5940e237eb93c3 100644 --- a/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx +++ b/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx @@ -1,5 +1,7 @@ export { ButtonWindmod } from './ButtonWindmod.stories'; export { ButtonGriffel } from './ButtonGriffel.stories'; +export { LabelWindmod } from './LabelWindmod.stories'; +export { LabelGriffel } from './LabelGriffel.stories'; export { TooltipWindmod } from './TooltipWindmod.stories'; export { TooltipGriffel } from './TooltipGriffel.stories'; From a17891d42147d61842ab1aaedf32cdda50d13112 Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Fri, 21 Aug 2026 12:16:57 -0700 Subject: [PATCH 020/246] fix(windmod): exempt VR scene helpers from the extraneous-dependencies rule Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- .../react-windmod-preview/stories/src/vr/ButtonVrScene.tsx | 1 + .../react-windmod-preview/stories/src/vr/LabelVrScene.tsx | 1 + .../react-windmod-preview/stories/src/vr/TooltipVrScene.tsx | 1 + 3 files changed, 3 insertions(+) diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/ButtonVrScene.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/ButtonVrScene.tsx index ed7147bf6bcac7..150426d5a4915a 100644 --- a/packages/react-components/react-windmod-preview/stories/src/vr/ButtonVrScene.tsx +++ b/packages/react-components/react-windmod-preview/stories/src/vr/ButtonVrScene.tsx @@ -1,3 +1,4 @@ +// eslint-disable-next-line import/no-extraneous-dependencies -- story-only helper outside the *.stories.tsx devDependencies exemption import * as React from 'react'; const appearances = ['secondary', 'primary', 'outline', 'subtle', 'transparent'] as const; diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/LabelVrScene.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/LabelVrScene.tsx index 0948d17d5aba7f..efae9be8f30074 100644 --- a/packages/react-components/react-windmod-preview/stories/src/vr/LabelVrScene.tsx +++ b/packages/react-components/react-windmod-preview/stories/src/vr/LabelVrScene.tsx @@ -1,3 +1,4 @@ +// eslint-disable-next-line import/no-extraneous-dependencies -- story-only helper outside the *.stories.tsx devDependencies exemption import * as React from 'react'; const sizes = ['small', 'medium', 'large'] as const; diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/TooltipVrScene.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/TooltipVrScene.tsx index b438ebfd516887..53fd2bd4e3f795 100644 --- a/packages/react-components/react-windmod-preview/stories/src/vr/TooltipVrScene.tsx +++ b/packages/react-components/react-windmod-preview/stories/src/vr/TooltipVrScene.tsx @@ -1,3 +1,4 @@ +// eslint-disable-next-line import/no-extraneous-dependencies -- story-only helper outside the *.stories.tsx devDependencies exemption import * as React from 'react'; type TooltipLike = React.ComponentType<{ From 0cf26cd74830341c5fea8c3236c266e4310cc8bf Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Fri, 21 Aug 2026 12:20:21 -0700 Subject: [PATCH 021/246] chore: refresh the local icons tarball checksum [local-only, revert before merge] Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- yarn.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/yarn.lock b/yarn.lock index dbb975c8c0dd72..d6b733791cbf57 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4517,13 +4517,13 @@ __metadata: "@fluentui/react-icons@file:../fluentui-system-icons/packages/react-icons/fluentui-react-icons-local.tgz::locator=%40fluentui%2Ffluentui-repo%40workspace%3A.": version: 2.0.337 - resolution: "@fluentui/react-icons@file:../fluentui-system-icons/packages/react-icons/fluentui-react-icons-local.tgz#../fluentui-system-icons/packages/react-icons/fluentui-react-icons-local.tgz::hash=be784d&locator=%40fluentui%2Ffluentui-repo%40workspace%3A." + resolution: "@fluentui/react-icons@file:../fluentui-system-icons/packages/react-icons/fluentui-react-icons-local.tgz#../fluentui-system-icons/packages/react-icons/fluentui-react-icons-local.tgz::hash=7ae61c&locator=%40fluentui%2Ffluentui-repo%40workspace%3A." dependencies: "@griffel/react": "npm:^1.6.1" tslib: "npm:^2.1.0" peerDependencies: react: ">=16.8.0 <20.0.0" - checksum: 10c0/aa822345bf3d667e463eff633fed16cc5a906ec083a87e8068ba9f37487b168fb43d305fa62207a07adb84905257eb09629d37a1960e200138e8c97ef6a009ec + checksum: 10c0/40161a31aea211780cb49c61626b1d424c3d8e1100edb1b410bbc30ea4d50e9285ec349388e5edbbfc4f4bced751abb767c3b1d74b4b1ca68931ee9800d58962 languageName: node linkType: hard From 08e91a306770ddc6915bcbf9a186e1cf5f6fb0c1 Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Fri, 21 Aug 2026 12:35:43 -0700 Subject: [PATCH 022/246] refactor(windmod): style the required indicator via its group variant and unlayer the provider root Children get their own class blocks with parent state arriving through group-/fui-; the provider's display:contents leaves the layer system entirely so nothing can override box generation. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- .../library/src/components/Label/Label.module.css | 15 ++++++++------- .../components/Provider/ThemeProvider.module.css | 10 ++++------ 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/packages/react-components/react-windmod-preview/library/src/components/Label/Label.module.css b/packages/react-components/react-windmod-preview/library/src/components/Label/Label.module.css index ce100a19f28ca4..8a102528949fae 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Label/Label.module.css +++ b/packages/react-components/react-windmod-preview/library/src/components/Label/Label.module.css @@ -27,17 +27,18 @@ @variant disabled { @apply text-neutral-foreground-disabled; - /* data-disabled lands on the root only; the required indicator is a DOM child. */ - & .required { - @apply text-neutral-foreground-disabled; + @variant forced-colors { + @apply text-[GrayText]; } + } + } + + .required { + @variant group-disabled/fui-label { + @apply text-neutral-foreground-disabled; @variant forced-colors { @apply text-[GrayText]; - - & .required { - @apply text-[GrayText]; - } } } } diff --git a/packages/react-components/react-windmod-preview/library/src/components/Provider/ThemeProvider.module.css b/packages/react-components/react-windmod-preview/library/src/components/Provider/ThemeProvider.module.css index ba4bad8f9848d5..17db52bf957b28 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Provider/ThemeProvider.module.css +++ b/packages/react-components/react-windmod-preview/library/src/components/Provider/ThemeProvider.module.css @@ -1,9 +1,7 @@ @reference '#theme'; -@layer fui.base { - /* No layout impact: custom properties inherit through the DOM tree regardless of box - generation, including into top-layer popovers. */ - .root { - @apply contents; - } +/* Unlayered: no layered rule may ever override box generation here. Custom properties + inherit through the DOM tree regardless, including into top-layer popovers. */ +.root { + @apply contents; } From dc968560224186b38a51a2bf6511c9b241fa3d0d Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Fri, 21 Aug 2026 12:53:27 -0700 Subject: [PATCH 023/246] refactor(windmod): move appearance-scoped icon rules to group variants via data-appearance The root stamps data-appearance alongside data-size; appearance catalog variants let every icon state rule live in the top-level .icon block at equal specificity, with source order resolving the disabled swap-back. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- .../css/variants.css | 4 + .../src/components/Button/Button.module.css | 84 +++++++++++-------- .../src/components/Button/useButtonStyles.ts | 4 +- 3 files changed, 54 insertions(+), 38 deletions(-) diff --git a/packages/react-components/react-tailwind-theme-preview/css/variants.css b/packages/react-components/react-tailwind-theme-preview/css/variants.css index 0f4e7eb9cfcd08..a44ce7116efc31 100644 --- a/packages/react-components/react-tailwind-theme-preview/css/variants.css +++ b/packages/react-components/react-tailwind-theme-preview/css/variants.css @@ -126,6 +126,10 @@ @custom-variant intent-warning (&:where([data-intent='warning'])); @custom-variant intent-error (&:where([data-intent='error'])); +/* appearance (Button) */ +@custom-variant appearance-subtle (&:where([data-appearance='subtle'])); +@custom-variant appearance-transparent (&:where([data-appearance='transparent'])); + /* text position (Persona) */ @custom-variant text-position-after (&:where([data-text-position='after'])); @custom-variant text-position-before (&:where([data-text-position='before'])); diff --git a/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css b/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css index ff51e92d028148..d37a6f2081e045 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css +++ b/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css @@ -120,33 +120,39 @@ @variant hover { @apply border-transparent bg-subtle-background-hover text-neutral-foreground-2-hover; - - & .icon { - @apply text-neutral-foreground-2-brand-hover; - } } @variant hover-active { @apply border-transparent bg-subtle-background-pressed text-neutral-foreground-2-pressed; - - & .icon { - @apply text-neutral-foreground-2-brand-pressed; - } } @variant forced-colors { @variant hover { @apply text-[Highlight]; - - & .icon { - @apply text-[Highlight]; - } } @variant hover-active { @apply text-[Highlight]; + } + } + } + + .icon { + @variant group-appearance-subtle/fui-button { + @variant group-hover/fui-button { + @apply text-neutral-foreground-2-brand-hover; + } + + @variant group-hover-active/fui-button { + @apply text-neutral-foreground-2-brand-pressed; + } + + @variant forced-colors { + @variant group-hover/fui-button { + @apply text-[Highlight]; + } - & .icon { + @variant group-hover-active/fui-button { @apply text-[Highlight]; } } @@ -187,51 +193,27 @@ @variant disabled, disabled-focusable { @apply cursor-not-allowed border-neutral-stroke-disabled bg-neutral-background-disabled text-neutral-foreground-disabled; - & .icon { - @apply text-neutral-foreground-disabled; - } - @variant hover { @apply cursor-not-allowed border-neutral-stroke-disabled bg-neutral-background-disabled text-neutral-foreground-disabled; - - & .icon { - @apply text-neutral-foreground-disabled; - } } @variant hover-active { @apply cursor-not-allowed border-neutral-stroke-disabled bg-neutral-background-disabled text-neutral-foreground-disabled; - - & .icon { - @apply text-neutral-foreground-disabled; - } } @variant forced-colors { @apply border-[GrayText] bg-[ButtonFace] text-[GrayText]; - & .icon { - @apply text-[GrayText]; - } - @variant focus { @apply border-[GrayText]; } @variant hover { @apply border-[GrayText] bg-[ButtonFace] text-[GrayText]; - - & .icon { - @apply text-[GrayText]; - } } @variant hover-active { @apply border-[GrayText] bg-[ButtonFace] text-[GrayText]; - - & .icon { - @apply text-[GrayText]; - } } } } @@ -247,6 +229,34 @@ } } + /* Group-variant form; equal specificity to the appearance-hover icon-color rules + above, so source order alone must put the disabled swap-back after them. */ + .icon { + @variant group-disabled/fui-button, group-disabled-focusable/fui-button { + @apply text-neutral-foreground-disabled; + + @variant group-hover/fui-button { + @apply text-neutral-foreground-disabled; + } + + @variant group-hover-active/fui-button { + @apply text-neutral-foreground-disabled; + } + + @variant forced-colors { + @apply text-[GrayText]; + + @variant group-hover/fui-button { + @apply text-[GrayText]; + } + + @variant group-hover-active/fui-button { + @apply text-[GrayText]; + } + } + } + } + .outline { @variant disabled, disabled-focusable { @apply bg-transparent-background; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Button/useButtonStyles.ts b/packages/react-components/react-windmod-preview/library/src/components/Button/useButtonStyles.ts index 4a48b224c73ddf..13a9f8b15dcc43 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Button/useButtonStyles.ts +++ b/packages/react-components/react-windmod-preview/library/src/components/Button/useButtonStyles.ts @@ -10,17 +10,19 @@ export const buttonClassNames: { root: string } = { }; type ButtonRootDataAttributes = { + 'data-appearance'?: ButtonState['appearance']; 'data-size'?: ButtonState['size']; 'data-empty'?: true; }; /** Applies the visual contract, returning new state. The headless hook already stamps - * data-disabled/-disabled-focusable/-icon-only/-icon-position; these two are style-only. */ + * data-disabled/-disabled-focusable/-icon-only/-icon-position; these three are style-only. */ export const useButtonStyles = (state: ButtonState): ButtonState => { const { appearance, shape, size } = state; const root: ButtonState['root'] & ButtonRootDataAttributes = { ...state.root, + 'data-appearance': appearance, 'data-size': size, 'data-empty': !state.root.children || undefined, // The marker's '/' must never be classList[0]: jsdom's nwsapi builds its :scope From f2873cc850ad36b5c4dbd644d4c6ca2786cedacd Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Fri, 21 Aug 2026 13:10:05 -0700 Subject: [PATCH 024/246] refactor(windmod): stamp the marker pair through a single helper componentMarkers emits `fui- group/fui-` in fixed order: the identity class keeps classList[0] slash-free (nwsapi :scope/:has() rewrite breaks on `/`), while the named-group class keeps consumer Tailwind group variants working with no setup. Per-hook ordering comments are gone; the helper carries the one constraint. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- .../react-windmod-preview/library/README.md | 11 +++- .../library/config/tests.cjs | 2 + .../src/components/Button/useButtonStyles.ts | 9 ++- .../src/components/Label/Label.test.tsx | 6 +- .../src/components/Label/useLabelStyles.ts | 8 +-- .../components/Tooltip/useTooltipStyles.ts | 8 +-- .../library/src/testing/isConformant.ts | 4 +- .../library/src/utils/groupMarker.ts | 12 ++++ .../library/tsconfig.spec.json | 2 +- .../executors/build/lib/css-modules.spec.ts | 57 +++++++++++++++++++ 10 files changed, 98 insertions(+), 21 deletions(-) create mode 100644 packages/react-components/react-windmod-preview/library/src/utils/groupMarker.ts diff --git a/packages/react-components/react-windmod-preview/library/README.md b/packages/react-components/react-windmod-preview/library/README.md index 0278708d4a7ed8..52d23be26a8d94 100644 --- a/packages/react-components/react-windmod-preview/library/README.md +++ b/packages/react-components/react-windmod-preview/library/README.md @@ -46,6 +46,11 @@ package's `dist/styles.css`); CommonJS/SSR consumers import - Slot `className` props merge last — your classes win via cascade layers (consumer CSS is unlayered; all package styles live in `fui.*` layers). -- Each component's sole public identity class is its group marker (`group/fui-button`, …) for - Tailwind named-group targeting; internals use hashed idents and `data-*` state attributes - (`data-open`, `data-placement`, `data-size`, …). +- Each component's root carries a pair of public identity classes: `fui-button` (the documented + identity class — safe for consumer CSS and `querySelector`, no escaping needed) and + `group/fui-button` (Tailwind's real named-group class). Internals use hashed idents and + `data-*` state attributes (`data-open`, `data-placement`, `data-size`, …). +- Children inside a component can target it directly with `group-/fui-` + (e.g. `group-disabled/fui-button:text-red-500`) — no group name declaration required. + Consumers may additionally add their own `group/name` via `className` to disambiguate nested + instances of the same component, but are never required to. diff --git a/packages/react-components/react-windmod-preview/library/config/tests.cjs b/packages/react-components/react-windmod-preview/library/config/tests.cjs index 2e211ae9e21420..c6c67de97059e8 100644 --- a/packages/react-components/react-windmod-preview/library/config/tests.cjs +++ b/packages/react-components/react-windmod-preview/library/config/tests.cjs @@ -1 +1,3 @@ /** Jest test setup file. */ + +require('@testing-library/jest-dom'); diff --git a/packages/react-components/react-windmod-preview/library/src/components/Button/useButtonStyles.ts b/packages/react-components/react-windmod-preview/library/src/components/Button/useButtonStyles.ts index 13a9f8b15dcc43..b8787e78eebaa3 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Button/useButtonStyles.ts +++ b/packages/react-components/react-windmod-preview/library/src/components/Button/useButtonStyles.ts @@ -1,12 +1,13 @@ import { clsx } from 'clsx'; +import { componentMarkers } from '../../utils/groupMarker'; import type { ButtonState } from './Button.types'; import styles from './Button.module.css'; -/** The only public class — the Tailwind named-group marker; internals are hashed idents. */ +/** The only public classes — see componentMarkers; internals are hashed idents. */ export const buttonClassNames: { root: string } = { - root: 'group/fui-button', + root: componentMarkers('button'), }; type ButtonRootDataAttributes = { @@ -25,9 +26,7 @@ export const useButtonStyles = (state: ButtonState): ButtonState => { 'data-appearance': appearance, 'data-size': size, 'data-empty': !state.root.children || undefined, - // The marker's '/' must never be classList[0]: jsdom's nwsapi builds its :scope - // polyfill from the first class unescaped, so element.matches() would throw. - className: clsx(styles.root, buttonClassNames.root, styles[appearance], styles[shape], state.root.className), + className: clsx(buttonClassNames.root, styles.root, styles[appearance], styles[shape], state.root.className), }; return { diff --git a/packages/react-components/react-windmod-preview/library/src/components/Label/Label.test.tsx b/packages/react-components/react-windmod-preview/library/src/components/Label/Label.test.tsx index faca13dac1e79e..aa979ff2169332 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Label/Label.test.tsx +++ b/packages/react-components/react-windmod-preview/library/src/components/Label/Label.test.tsx @@ -13,7 +13,7 @@ describe('Label', () => { requiredProps: { children: "I'm a label." }, }); - it('stamps data-size and keeps the group marker out of classList[0]', () => { + it('stamps data-size and the marker class', () => { const { getByText } = render( <> @@ -24,7 +24,9 @@ describe('Label', () => { expect(getByText('Default').getAttribute('data-size')).toBe('medium'); expect(getByText('Large').getAttribute('data-size')).toBe('large'); expect(getByText('Default').className).toContain(labelClassNames.root); - expect(getByText('Default').classList[0]).not.toContain('/'); + expect(getByText('Default')).toHaveClass('fui-label'); + expect(getByText('Default')).toHaveClass('group/fui-label'); + expect(getByText('Default').classList[0]).toBe('fui-label'); }); it('does not mutate the state it is given', () => { diff --git a/packages/react-components/react-windmod-preview/library/src/components/Label/useLabelStyles.ts b/packages/react-components/react-windmod-preview/library/src/components/Label/useLabelStyles.ts index 89a6c026dae6a8..539a0a7288ef6d 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Label/useLabelStyles.ts +++ b/packages/react-components/react-windmod-preview/library/src/components/Label/useLabelStyles.ts @@ -1,12 +1,13 @@ import { clsx } from 'clsx'; +import { componentMarkers } from '../../utils/groupMarker'; import type { LabelState } from './Label.types'; import styles from './Label.module.css'; -/** The only public class — the Tailwind named-group marker; internals are hashed idents. */ +/** The only public classes — see componentMarkers; internals are hashed idents. */ export const labelClassNames: { root: string } = { - root: 'group/fui-label', + root: componentMarkers('label'), }; type LabelRootDataAttributes = { @@ -21,8 +22,7 @@ export const useLabelStyles = (state: LabelState): LabelState => { const root: LabelState['root'] & LabelRootDataAttributes = { ...state.root, 'data-size': size, - // Marker never first — see useButtonStyles. - className: clsx(styles.root, labelClassNames.root, weight === 'semibold' && styles.semibold, state.root.className), + className: clsx(labelClassNames.root, styles.root, weight === 'semibold' && styles.semibold, state.root.className), }; return { diff --git a/packages/react-components/react-windmod-preview/library/src/components/Tooltip/useTooltipStyles.ts b/packages/react-components/react-windmod-preview/library/src/components/Tooltip/useTooltipStyles.ts index b51aacb15c96b8..3f6cdaa980faf4 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Tooltip/useTooltipStyles.ts +++ b/packages/react-components/react-windmod-preview/library/src/components/Tooltip/useTooltipStyles.ts @@ -1,12 +1,13 @@ import { clsx } from 'clsx'; +import { componentMarkers } from '../../utils/groupMarker'; import type { TooltipState } from './Tooltip.types'; import styles from './Tooltip.module.css'; -/** The only public class — the named-group marker on the content (Tooltip's only slot). */ +/** The only public classes — see componentMarkers; the content is Tooltip's only slot. */ export const tooltipClassNames: { root: string } = { - root: 'group/fui-tooltip', + root: componentMarkers('tooltip'), }; /** Class assembly only: the headless hook stamps data-open/data-placement, the hint @@ -17,10 +18,9 @@ export const useTooltipStyles = (state: TooltipState): TooltipState => { arrowClassName: clsx(styles.arrow, state.arrowClassName), content: { ...state.content, - // Marker never first — see useButtonStyles. className: clsx( - styles.content, tooltipClassNames.root, + styles.content, state.appearance === 'inverted' && styles.inverted, state.content.className, ), diff --git a/packages/react-components/react-windmod-preview/library/src/testing/isConformant.ts b/packages/react-components/react-windmod-preview/library/src/testing/isConformant.ts index fd0c85edb4f65f..0b5374b286cb21 100644 --- a/packages/react-components/react-windmod-preview/library/src/testing/isConformant.ts +++ b/packages/react-components/react-windmod-preview/library/src/testing/isConformant.ts @@ -15,8 +15,8 @@ export function isConformant( 'exported-top-level', // We use kebab case naming for top-level files 'has-top-level-file', - // Windmod components have no BEM static classnames — the public identity class is - // the group marker alone (xClassNames = { root: 'group/fui-' }). + // Windmod components have no BEM static classnames — the public identity is the + // marker pair (xClassNames = { root: 'fui- group/fui-' }; see componentMarkers). 'component-has-static-classnames-object', ], disableTypeTests: true, diff --git a/packages/react-components/react-windmod-preview/library/src/utils/groupMarker.ts b/packages/react-components/react-windmod-preview/library/src/utils/groupMarker.ts new file mode 100644 index 00000000000000..15dc47a0b20df3 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/utils/groupMarker.ts @@ -0,0 +1,12 @@ +/** + * Returns a component's pair of public identity classes, in the fixed order `fui- + * group/fui-`. `fui-` must be first: jsdom's nwsapi builds its `:scope` polyfill + * (and therefore `:has()`) from `element.classList[0]` run through the unescaped global + * `escape()`, which mishandles `/` and throws — putting the slash-free class first guarantees + * `classList[0]` is never the `/`-bearing one. `group/` is Tailwind's real named-group + * class, unchanged, so a consumer can target a component's children with `group-/fui- + * ` with no configuration of their own. + */ +export function componentMarkers(name: string): string { + return `fui-${name} group/fui-${name}`; +} diff --git a/packages/react-components/react-windmod-preview/library/tsconfig.spec.json b/packages/react-components/react-windmod-preview/library/tsconfig.spec.json index 0eec63ea994112..1a9b8a392ed9fb 100644 --- a/packages/react-components/react-windmod-preview/library/tsconfig.spec.json +++ b/packages/react-components/react-windmod-preview/library/tsconfig.spec.json @@ -3,7 +3,7 @@ "compilerOptions": { "module": "ESNext", "outDir": "dist", - "types": ["jest", "node", "static-assets"] + "types": ["jest", "node", "static-assets", "@testing-library/jest-dom"] }, "include": [ "**/*.spec.ts", diff --git a/tools/workspace-plugin/src/executors/build/lib/css-modules.spec.ts b/tools/workspace-plugin/src/executors/build/lib/css-modules.spec.ts index 73a7e9797d7890..f447e47efc3a27 100644 --- a/tools/workspace-plugin/src/executors/build/lib/css-modules.spec.ts +++ b/tools/workspace-plugin/src/executors/build/lib/css-modules.spec.ts @@ -13,6 +13,13 @@ import { } from './css-modules'; import { type NormalizedOptions } from './shared'; +/** + * Required by relative path, same as css-modules.ts's own import of it — see that file's + * header for why (it lives outside this project's `rootDir`). + */ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { globalizeSelector } = require('../../../../../../scripts/css-modules/globalize-group-markers.js'); + /** * Guardrail for the two halves of the generated-class-name contract. * @@ -119,6 +126,56 @@ describe('compileCssModuleSource', () => { expect(first.classMap.root).not.toEqual(otherPackage.classMap.root); }); }); + + describe('fui-globalize-group-markers plugin', () => { + it('wraps a group marker in :global() without altering its name', () => { + const { selector, rewrites } = globalizeSelector('.group\\/fui-switch'); + + expect(rewrites).toBe(1); + expect(selector).toBe(':global(.group\\/fui-switch)'); + }); + + it('wraps a peer marker the same way as a group marker', () => { + const { selector, rewrites } = globalizeSelector('.peer\\/fui-input'); + + expect(rewrites).toBe(1); + expect(selector).toBe(':global(.peer\\/fui-input)'); + }); + + it('is idempotent: a marker already inside :global() is left untouched', () => { + const { selector, rewrites } = globalizeSelector(':global(.group\\/fui-switch)'); + + expect(rewrites).toBe(0); + expect(selector).toBe(':global(.group\\/fui-switch)'); + }); + + it('running the wrap twice in sequence produces the same result as once', () => { + const once = globalizeSelector('.group\\/fui-switch'); + const twice = globalizeSelector(once.selector); + + expect(twice.rewrites).toBe(0); + expect(twice.selector).toBe(once.selector); + }); + + it('renames nothing when a peer marker is compiled through the real chain', async () => { + const source = ` + @custom-variant disabled (&:where([disabled], [data-disabled])); + + .sib { + @variant peer-disabled/fui-input { opacity: 0.5; } + } + `; + const { css, classMap } = await compileCssModuleSource({ + source, + absolutePath: ABSOLUTE_PATH, + packageName: PACKAGE_NAME, + relativePath: RELATIVE_PATH, + }); + + expect(css).toContain('.peer\\/fui-input'); + expect(Object.keys(classMap)).not.toContain('peer/fui-input'); + }); + }); }); /** From 7f94fe23ceeb748ac0c2375c3c482d4101b2907d Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Fri, 21 Aug 2026 13:37:30 -0700 Subject: [PATCH 025/246] feat(windmod): add Divider Headless useDivider + Tailwind CSS-Modules styling, pixel-identical to the Griffel suite across appearances, orientations, alignments, insets and childless rendering. data-empty follows the strict undefined gate; vertical passes through untouched to preserve the headless data-orientation stamp. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- .../library/etc/divider.api.md | 49 ++++++ .../library/etc/react-windmod-preview.api.md | 38 +++++ .../library/package.json | 10 ++ .../src/components/Divider/Divider.module.css | 159 ++++++++++++++++++ .../src/components/Divider/Divider.test.tsx | 116 +++++++++++++ .../src/components/Divider/Divider.tsx | 31 ++++ .../src/components/Divider/Divider.types.ts | 29 ++++ .../library/src/components/Divider/index.ts | 3 + .../components/Divider/useDividerStyles.ts | 38 +++++ .../library/src/divider.ts | 11 ++ .../library/src/index.ts | 3 + .../src/Divider/DividerDefault.stories.tsx | 41 +++++ .../DividerGriffelComparison.stories.tsx | 54 ++++++ .../stories/src/Divider/index.stories.tsx | 9 + .../stories/src/vr/DividerGriffel.stories.tsx | 10 ++ .../stories/src/vr/DividerVrScene.tsx | 66 ++++++++ .../stories/src/vr/DividerWindmod.stories.tsx | 10 ++ .../stories/src/vr/index.stories.tsx | 2 + 18 files changed, 679 insertions(+) create mode 100644 packages/react-components/react-windmod-preview/library/etc/divider.api.md create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Divider/Divider.module.css create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Divider/Divider.test.tsx create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Divider/Divider.tsx create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Divider/Divider.types.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Divider/index.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Divider/useDividerStyles.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/divider.ts create mode 100644 packages/react-components/react-windmod-preview/stories/src/Divider/DividerDefault.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/Divider/DividerGriffelComparison.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/Divider/index.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/DividerGriffel.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/DividerVrScene.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/DividerWindmod.stories.tsx diff --git a/packages/react-components/react-windmod-preview/library/etc/divider.api.md b/packages/react-components/react-windmod-preview/library/etc/divider.api.md new file mode 100644 index 00000000000000..965c80efc9f875 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/etc/divider.api.md @@ -0,0 +1,49 @@ +## API Report File for "@fluentui/react-windmod-preview" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { DividerProps as DividerProps_2 } from '@fluentui/react-headless-components-preview/divider'; +import { DividerSlots } from '@fluentui/react-headless-components-preview/divider'; +import type { DividerState as DividerState_2 } from '@fluentui/react-headless-components-preview/divider'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { renderDivider } from '@fluentui/react-headless-components-preview/divider'; +import { useDivider } from '@fluentui/react-headless-components-preview/divider'; + +// @public +export const Divider: ForwardRefComponent; + +// @public +export type DividerAlignContent = 'start' | 'center' | 'end'; + +// @public +export type DividerAppearance = 'brand' | 'default' | 'strong' | 'subtle'; + +// @public +export const dividerClassNames: { + root: string; +}; + +// @public +export type DividerProps = DividerProps_2 & { + alignContent?: DividerAlignContent; + appearance?: DividerAppearance; + inset?: boolean; +}; + +export { DividerSlots } + +// @public +export type DividerState = DividerState_2 & Required>; + +export { renderDivider } + +export { useDivider } + +// @public +export const useDividerStyles: (state: DividerState) => DividerState; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md b/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md index cac91fbc2a9b55..6fb57262021d26 100644 --- a/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md +++ b/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md @@ -7,6 +7,9 @@ import type { ButtonProps as ButtonProps_2 } from '@fluentui/react-headless-components-preview/button'; import { ButtonSlots } from '@fluentui/react-headless-components-preview/button'; import type { ButtonState as ButtonState_2 } from '@fluentui/react-headless-components-preview/button'; +import type { DividerProps as DividerProps_2 } from '@fluentui/react-headless-components-preview/divider'; +import { DividerSlots } from '@fluentui/react-headless-components-preview/divider'; +import type { DividerState as DividerState_2 } from '@fluentui/react-headless-components-preview/divider'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; import type { JSXElement } from '@fluentui/react-utilities'; import type { LabelProps as LabelProps_2 } from '@fluentui/react-headless-components-preview/label'; @@ -16,6 +19,7 @@ import { OnVisibleChangeData } from '@fluentui/react-headless-components-preview import { Provider } from '@fluentui/react-headless-components-preview/provider'; import * as React_2 from 'react'; import { renderButton } from '@fluentui/react-headless-components-preview/button'; +import { renderDivider } from '@fluentui/react-headless-components-preview/divider'; import { renderLabel } from '@fluentui/react-headless-components-preview/label'; import { renderProvider } from '@fluentui/react-headless-components-preview/provider'; import { renderTooltip } from '@fluentui/react-headless-components-preview/tooltip'; @@ -31,6 +35,7 @@ import { TooltipSlots } from '@fluentui/react-headless-components-preview/toolti import type { TooltipState as TooltipState_2 } from '@fluentui/react-headless-components-preview/tooltip'; import { TooltipTriggerProps } from '@fluentui/react-headless-components-preview/tooltip'; import { useButton } from '@fluentui/react-headless-components-preview/button'; +import { useDivider } from '@fluentui/react-headless-components-preview/divider'; import { useLabel } from '@fluentui/react-headless-components-preview/label'; import { useProvider } from '@fluentui/react-headless-components-preview/provider'; import { useTooltip } from '@fluentui/react-headless-components-preview/tooltip'; @@ -66,6 +71,32 @@ export { ButtonSlots } // @public export type ButtonState = ButtonState_2 & Required>; +// @public +export const Divider: ForwardRefComponent; + +// @public +export type DividerAlignContent = 'start' | 'center' | 'end'; + +// @public +export type DividerAppearance = 'brand' | 'default' | 'strong' | 'subtle'; + +// @public +export const dividerClassNames: { + root: string; +}; + +// @public +export type DividerProps = DividerProps_2 & { + alignContent?: DividerAlignContent; + appearance?: DividerAppearance; + inset?: boolean; +}; + +export { DividerSlots } + +// @public +export type DividerState = DividerState_2 & Required>; + // @public export const Label: ForwardRefComponent; @@ -97,6 +128,8 @@ export { Provider } export { renderButton } +export { renderDivider } + export { renderLabel } export { renderProvider } @@ -164,6 +197,11 @@ export { useButton } // @public export const useButtonStyles: (state: ButtonState) => ButtonState; +export { useDivider } + +// @public +export const useDividerStyles: (state: DividerState) => DividerState; + export { useLabel } // @public diff --git a/packages/react-components/react-windmod-preview/library/package.json b/packages/react-components/react-windmod-preview/library/package.json index bacecaef4c5c81..45d2029c718c9c 100644 --- a/packages/react-components/react-windmod-preview/library/package.json +++ b/packages/react-components/react-windmod-preview/library/package.json @@ -61,6 +61,16 @@ "default": "./lib-commonjs/button.cjs" } }, + "./divider": { + "import": { + "types": "./dist/divider.d.ts", + "default": "./lib/divider.js" + }, + "require": { + "types": "./dist/divider.d.cts", + "default": "./lib-commonjs/divider.cjs" + } + }, "./label": { "import": { "types": "./dist/label.d.ts", diff --git a/packages/react-components/react-windmod-preview/library/src/components/Divider/Divider.module.css b/packages/react-components/react-windmod-preview/library/src/components/Divider/Divider.module.css new file mode 100644 index 00000000000000..7eb553661a8e5e --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Divider/Divider.module.css @@ -0,0 +1,159 @@ +@reference '#theme'; + +/* The two line segments are the root's ::before / ::after. They carry no module class, so + they are reached with Tailwind's built-in before/after variants, which also supply + `content: var(--tw-content)` — registered with initial-value "" — for every state. */ + +@layer fui.base { + .root { + @apply relative box-border flex grow flex-row items-center text-center font-base text-base-200 leading-base-200 font-regular; + + @variant before, after { + @apply box-border flex grow; + } + } +} + +@layer fui.components.l1 { + .brand { + @apply text-brand-foreground-1; + + @variant before, after { + @apply border-brand-stroke-1; + } + } + + .default { + @apply text-neutral-foreground-2; + + @variant before, after { + @apply border-neutral-stroke-2; + } + } + + .subtle { + @apply text-neutral-foreground-3; + + @variant before, after { + @apply border-neutral-stroke-3; + } + } + + .strong { + @apply text-neutral-foreground-1; + + @variant before, after { + @apply border-neutral-stroke-1; + } + } + + /* No `border-solid` anywhere below: it sets border-style on all four sides, and with only + one side's width declared the other three fall back to `medium` and draw a full box. The + per-side width utilities emit their own `border-*-style: var(--tw-border-style)`, whose + registered initial-value is solid. */ + .root { + @variant horizontal { + @apply w-full; + + @variant before, after { + @apply min-w-8 border-t-(length:--spacing-thin); + } + + @variant inset { + @apply px-12; + } + + @variant align-content-start { + @apply text-start; + + @variant before { + @apply me-12 max-w-8; + } + + @variant after { + @apply ms-12; + } + } + + @variant align-content-center { + @variant before { + @apply me-12; + } + + @variant after { + @apply ms-12; + } + } + + @variant align-content-end { + @apply text-end; + + @variant before { + @apply me-12; + } + + @variant after { + @apply ms-12 max-w-8; + } + } + } + + @variant vertical { + @apply min-h-20 flex-col; + + @variant before, after { + @apply min-h-8 border-e-(length:--spacing-thin); + } + + @variant not-empty { + @apply min-h-84; + } + + @variant inset { + @apply my-12; + } + + @variant align-content-start { + @variant before { + @apply mb-12 max-h-8; + } + + @variant after { + @apply mt-12; + } + } + + @variant align-content-center { + @variant before { + @apply mb-12; + } + + @variant after { + @apply mt-12; + } + } + + @variant align-content-end { + @variant before { + @apply mb-12; + } + + @variant after { + @apply mt-12 max-h-8; + } + } + } + + /* Must stay last in this block: it zeroes the alignment margins at equal specificity, + so source order alone carries the cascade. Orientation-independent, as in Griffel. */ + @variant empty { + @variant before { + @apply me-0 mb-0; + } + + @variant after { + @apply ms-0 mt-0; + } + } + } +} diff --git a/packages/react-components/react-windmod-preview/library/src/components/Divider/Divider.test.tsx b/packages/react-components/react-windmod-preview/library/src/components/Divider/Divider.test.tsx new file mode 100644 index 00000000000000..e24e8c6faee14d --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Divider/Divider.test.tsx @@ -0,0 +1,116 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; + +import { Divider } from './Divider'; +import { isConformant } from '../../testing/isConformant'; +import type { DividerState } from './Divider.types'; +import { dividerClassNames, useDividerStyles } from './useDividerStyles'; + +describe('Divider', () => { + isConformant({ + Component: Divider, + displayName: 'Divider', + }); + + it('stamps data-align-content and the marker class', () => { + const { getByTestId } = render( + <> + Default + + End + + , + ); + + expect(getByTestId('default').getAttribute('data-align-content')).toBe('center'); + expect(getByTestId('end').getAttribute('data-align-content')).toBe('end'); + expect(getByTestId('default').className).toContain(dividerClassNames.root); + expect(getByTestId('default')).toHaveClass('fui-divider'); + expect(getByTestId('default')).toHaveClass('group/fui-divider'); + expect(getByTestId('default').classList[0]).toBe('fui-divider'); + }); + + it('swaps exactly one module class per appearance', () => { + const { getByTestId } = render( + <> + Default + + Brand + + , + ); + + // appearance is the one look prop carried by a module class rather than a data attribute. + const defaultClasses = getByTestId('default').className.split(' '); + const brandClasses = getByTestId('brand').className.split(' '); + + expect(brandClasses).toHaveLength(defaultClasses.length); + expect(brandClasses.filter(name => !defaultClasses.includes(name))).toHaveLength(1); + }); + + it('stamps data-inset only when inset', () => { + const { getByTestId } = render( + <> + Plain + + Inset + + , + ); + + expect(getByTestId('plain').hasAttribute('data-inset')).toBe(false); + expect(getByTestId('inset').hasAttribute('data-inset')).toBe(true); + }); + + it('stamps data-empty only when children are undefined', () => { + const { getByTestId } = render( + <> + + Content + {''} + {0} + , + ); + + expect(getByTestId('empty').hasAttribute('data-empty')).toBe(true); + expect(getByTestId('withChildren').hasAttribute('data-empty')).toBe(false); + // Falsy-but-defined children (renderDivider's own gate is `=== undefined`, not truthiness). + expect(getByTestId('falsyString').hasAttribute('data-empty')).toBe(false); + expect(getByTestId('falsyNumber').hasAttribute('data-empty')).toBe(false); + }); + + it('passes vertical through to the headless hook', () => { + const { getByTestId } = render( + <> + Horizontal + + Vertical + + , + ); + + expect(getByTestId('horizontal').getAttribute('data-orientation')).toBe('horizontal'); + expect(getByTestId('vertical').getAttribute('data-orientation')).toBe('vertical'); + expect(getByTestId('vertical').getAttribute('aria-orientation')).toBe('vertical'); + }); + + it('does not mutate the state it is given', () => { + const state = { + alignContent: 'center', + appearance: 'default', + components: { root: 'div', wrapper: 'div' }, + inset: true, + root: { as: 'div', children: 'Content', className: 'consumer' }, + vertical: false, + wrapper: { as: 'div' }, + } as unknown as DividerState; + + const styled = useDividerStyles(state); + + expect(styled).not.toBe(state); + expect(state.root).not.toHaveProperty('data-align-content'); + expect(state.root.className).toBe('consumer'); + expect(styled.root.className).toContain('consumer'); + expect(styled.wrapper).toBe(state.wrapper); + }); +}); diff --git a/packages/react-components/react-windmod-preview/library/src/components/Divider/Divider.tsx b/packages/react-components/react-windmod-preview/library/src/components/Divider/Divider.tsx new file mode 100644 index 00000000000000..d01fb7e2ccca79 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Divider/Divider.tsx @@ -0,0 +1,31 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { renderDivider, useDivider } from '@fluentui/react-headless-components-preview/divider'; + +import type { DividerProps, DividerState } from './Divider.types'; +import { useDividerStyles } from './useDividerStyles'; + +/** + * A Divider separates and distinguishes sections of content. Windmod Divider: the headless + * divider decorated with the Fluent visual contract (Tailwind v4 + CSS Modules). + */ +export const Divider: ForwardRefComponent = React.forwardRef((props, ref) => { + // Look props belong to windmod — the headless hook neither accepts nor resolves them. + // Defaults mirror @fluentui/react-divider's styled useDivider. `vertical` is deliberately + // absent: the headless hook owns it and derives data-orientation from it. + const { alignContent = 'center', appearance = 'default', inset = false, ...rest } = props; + + const state: DividerState = { + ...useDivider(rest, ref), + alignContent, + appearance, + inset, + }; + + return renderDivider(useDividerStyles(state)); + // Casting is required due to lack of distributive union to support union on @types/react +}) as ForwardRefComponent; + +Divider.displayName = 'Divider'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Divider/Divider.types.ts b/packages/react-components/react-windmod-preview/library/src/components/Divider/Divider.types.ts new file mode 100644 index 00000000000000..78aa12f6727b32 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Divider/Divider.types.ts @@ -0,0 +1,29 @@ +import type { + DividerProps as DividerHeadlessProps, + DividerState as DividerHeadlessState, +} from '@fluentui/react-headless-components-preview/divider'; + +export type { DividerSlots } from '@fluentui/react-headless-components-preview/divider'; + +/** Alignment of the Divider's content along the divider line. */ +export type DividerAlignContent = 'start' | 'center' | 'end'; + +/** Appearance of the Divider. */ +export type DividerAppearance = 'brand' | 'default' | 'strong' | 'subtle'; + +/** + * Windmod Divider props: the headless divider plus the look props the headless surface + * deliberately omits (they exist purely to select styles). `vertical` is not among them — + * it stays a headless prop. + */ +export type DividerProps = DividerHeadlessProps & { + /** @default 'center' */ + alignContent?: DividerAlignContent; + /** @default 'default' */ + appearance?: DividerAppearance; + /** @default false */ + inset?: boolean; +}; + +/** Windmod Divider state: headless state plus the resolved look props. */ +export type DividerState = DividerHeadlessState & Required>; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Divider/index.ts b/packages/react-components/react-windmod-preview/library/src/components/Divider/index.ts new file mode 100644 index 00000000000000..4758fa6a0251f0 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Divider/index.ts @@ -0,0 +1,3 @@ +export { Divider } from './Divider'; +export { dividerClassNames, useDividerStyles } from './useDividerStyles'; +export type { DividerAlignContent, DividerAppearance, DividerProps, DividerSlots, DividerState } from './Divider.types'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Divider/useDividerStyles.ts b/packages/react-components/react-windmod-preview/library/src/components/Divider/useDividerStyles.ts new file mode 100644 index 00000000000000..b53e59b65ac5f2 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Divider/useDividerStyles.ts @@ -0,0 +1,38 @@ +import { clsx } from 'clsx'; + +import { componentMarkers } from '../../utils/groupMarker'; +import type { DividerState } from './Divider.types'; + +import styles from './Divider.module.css'; + +/** The only public classes — see componentMarkers; internals are hashed idents. */ +export const dividerClassNames: { root: string } = { + root: componentMarkers('divider'), +}; + +type DividerRootDataAttributes = { + 'data-align-content'?: DividerState['alignContent']; + 'data-inset'?: true; + 'data-empty'?: true; +}; + +/** Applies the visual contract, returning new state. The headless hook already stamps + * data-orientation; these three are style-only. `data-empty` must test `=== undefined` — + * renderDivider gates the wrapper on the identical comparison, so a falsiness test would + * diverge for `children=""` and `children={0}`. The wrapper slot carries no styles. */ +export const useDividerStyles = (state: DividerState): DividerState => { + const { alignContent, appearance, inset } = state; + + const root: DividerState['root'] & DividerRootDataAttributes = { + ...state.root, + 'data-align-content': alignContent, + 'data-inset': inset || undefined, + 'data-empty': state.root.children === undefined || undefined, + className: clsx(dividerClassNames.root, styles.root, styles[appearance], state.root.className), + }; + + return { + ...state, + root, + }; +}; diff --git a/packages/react-components/react-windmod-preview/library/src/divider.ts b/packages/react-components/react-windmod-preview/library/src/divider.ts new file mode 100644 index 00000000000000..ec8930efaa166c --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/divider.ts @@ -0,0 +1,11 @@ +export { Divider, dividerClassNames, useDividerStyles } from './components/Divider'; +export type { + DividerAlignContent, + DividerAppearance, + DividerProps, + DividerSlots, + DividerState, +} from './components/Divider'; + +/** Headless building blocks, re-exported for consumers composing their own Divider. */ +export { renderDivider, useDivider } from '@fluentui/react-headless-components-preview/divider'; diff --git a/packages/react-components/react-windmod-preview/library/src/index.ts b/packages/react-components/react-windmod-preview/library/src/index.ts index d0c4c242be3d28..bbaecfa0593ba3 100644 --- a/packages/react-components/react-windmod-preview/library/src/index.ts +++ b/packages/react-components/react-windmod-preview/library/src/index.ts @@ -1,6 +1,9 @@ export { Button, buttonClassNames, renderButton, useButton, useButtonStyles } from './button'; export type { ButtonAppearance, ButtonProps, ButtonShape, ButtonSize, ButtonSlots, ButtonState } from './button'; +export { Divider, dividerClassNames, renderDivider, useDivider, useDividerStyles } from './divider'; +export type { DividerAlignContent, DividerAppearance, DividerProps, DividerSlots, DividerState } from './divider'; + export { Label, labelClassNames, renderLabel, useLabel, useLabelStyles } from './label'; export type { LabelProps, LabelSize, LabelSlots, LabelState, LabelWeight } from './label'; diff --git a/packages/react-components/react-windmod-preview/stories/src/Divider/DividerDefault.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Divider/DividerDefault.stories.tsx new file mode 100644 index 00000000000000..cf0cccd583a676 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/Divider/DividerDefault.stories.tsx @@ -0,0 +1,41 @@ +import * as React from 'react'; +import { Divider, ThemeProvider } from '@fluentui/react-windmod-preview'; + +import styles from '../compare.module.css'; + +const alignments = ['start', 'center', 'end'] as const; +const appearances = ['default', 'brand', 'subtle', 'strong'] as const; + +export const Default = (): React.ReactNode => ( + +
+ {appearances.map(appearance => + alignments.map(alignContent => ( +
+
+ + {appearance} {alignContent} + +
+
+ + inset + +
+
+ +
+
+ + vertical + +
+
+ +
+
+ )), + )} +
+
+); diff --git a/packages/react-components/react-windmod-preview/stories/src/Divider/DividerGriffelComparison.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Divider/DividerGriffelComparison.stories.tsx new file mode 100644 index 00000000000000..a34dba8d7b19ad --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/Divider/DividerGriffelComparison.stories.tsx @@ -0,0 +1,54 @@ +import * as React from 'react'; +import { Divider, ThemeProvider } from '@fluentui/react-windmod-preview'; +import { Divider as GriffelDivider, FluentProvider, webLightTheme } from '@fluentui/react-components'; + +import styles from '../compare.module.css'; + +const alignments = ['start', 'center', 'end'] as const; +const appearances = ['default', 'brand', 'subtle', 'strong'] as const; + +type LookProps = { + alignContent?: (typeof alignments)[number]; + appearance?: (typeof appearances)[number]; + inset?: boolean; + vertical?: boolean; +}; + +/** + * Every windmod variant next to its Griffel-suite twin (inside a FluentProvider). + * The pair in each row must be pixel-identical. + */ +export const GriffelComparison = (): React.ReactNode => { + const variants: Array<{ label: string; props: LookProps; childless?: boolean }> = [ + ...appearances.map(appearance => ({ label: appearance, props: { appearance } })), + ...alignments.map(alignContent => ({ label: alignContent, props: { alignContent } })), + { label: 'inset', props: { inset: true } }, + { label: 'childless', props: {}, childless: true }, + { label: 'vertical', props: { vertical: true } }, + { label: 'vertical inset', props: { vertical: true, inset: true } }, + { label: 'vertical childless', props: { vertical: true }, childless: true }, + ]; + + return ( +
+
Variant
+
Windmod
+
Griffel
+ {variants.map(({ label, props, childless }) => ( + +
{label}
+
+ + {childless ? undefined : 'Divider'} + +
+
+ + {childless ? undefined : 'Divider'} + +
+
+ ))} +
+ ); +}; diff --git a/packages/react-components/react-windmod-preview/stories/src/Divider/index.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Divider/index.stories.tsx new file mode 100644 index 00000000000000..7923f17f319887 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/Divider/index.stories.tsx @@ -0,0 +1,9 @@ +import { Divider } from '@fluentui/react-windmod-preview'; + +export { Default } from './DividerDefault.stories'; +export { GriffelComparison } from './DividerGriffelComparison.stories'; + +export default { + title: 'Windmod/Divider', + component: Divider, +}; diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/DividerGriffel.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/DividerGriffel.stories.tsx new file mode 100644 index 00000000000000..3e9e60329c7af8 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/DividerGriffel.stories.tsx @@ -0,0 +1,10 @@ +import * as React from 'react'; +import { Divider, FluentProvider, webLightTheme } from '@fluentui/react-components'; + +import { DividerVrScene } from './DividerVrScene'; + +export const DividerGriffel = (): React.ReactNode => ( + + + +); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/DividerVrScene.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/DividerVrScene.tsx new file mode 100644 index 00000000000000..0a9efb8f9e831a --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/DividerVrScene.tsx @@ -0,0 +1,66 @@ +// eslint-disable-next-line import/no-extraneous-dependencies -- story-only helper outside the *.stories.tsx devDependencies exemption +import * as React from 'react'; + +const alignments = ['start', 'center', 'end'] as const; +const appearances = ['default', 'brand', 'subtle', 'strong'] as const; +const insets = [false, true] as const; +const withContents = [true, false] as const; + +type DividerLike = React.ComponentType<{ + alignContent?: (typeof alignments)[number]; + appearance?: (typeof appearances)[number]; + inset?: boolean; + vertical?: boolean; + children?: React.ReactNode; +}>; + +/** + * No bare text in the scene: only the Griffel side inherits FluentProvider typography. + * Childless cells must pass `undefined`, the value both renderDivider and the childless + * style gate compare against. Vertical cells are auto-height so the 20px/84px minimums + * are the observable geometry. + */ +export const DividerVrScene = ({ Divider }: { Divider: DividerLike }): React.ReactNode => ( +
+ {appearances.map(appearance => + alignments.map(alignContent => ( +
+ {insets.map(inset => + withContents.map(withContent => ( +
+ + {withContent ? 'Content' : undefined} + +
+ )), + )} +
+ )), + )} + + {appearances.map(appearance => + alignments.map(alignContent => ( +
+ {insets.map(inset => + withContents.map(withContent => ( +
+ + {withContent ? 'Content' : undefined} + +
+ )), + )} +
+ )), + )} + + {/* Narrow containers: the only place the 8px min-width floor on ::before/::after binds. */} +
+ {alignments.map(alignContent => ( +
+ A rather long divider caption +
+ ))} +
+
+); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/DividerWindmod.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/DividerWindmod.stories.tsx new file mode 100644 index 00000000000000..adfa441b0f53bc --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/DividerWindmod.stories.tsx @@ -0,0 +1,10 @@ +import * as React from 'react'; +import { Divider, ThemeProvider } from '@fluentui/react-windmod-preview'; + +import { DividerVrScene } from './DividerVrScene'; + +export const DividerWindmod = (): React.ReactNode => ( + + + +); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx index 5940e237eb93c3..fa37ab0ae82e32 100644 --- a/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx +++ b/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx @@ -1,5 +1,7 @@ export { ButtonWindmod } from './ButtonWindmod.stories'; export { ButtonGriffel } from './ButtonGriffel.stories'; +export { DividerWindmod } from './DividerWindmod.stories'; +export { DividerGriffel } from './DividerGriffel.stories'; export { LabelWindmod } from './LabelWindmod.stories'; export { LabelGriffel } from './LabelGriffel.stories'; export { TooltipWindmod } from './TooltipWindmod.stories'; From 5384e0510bfadc6c6f7674237aadae07c9e979ce Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Fri, 21 Aug 2026 14:13:44 -0700 Subject: [PATCH 026/246] refactor(windmod): move component resting styles from fui.base into fui.components.l1 fui.base belongs to the theme for global element resets; component styles live in the component levels, with in-file source order carrying the base-vs-state cascade (catalog variants are :where()-flat, so specificity always ties). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- .../library/src/components/Button/Button.module.css | 4 +--- .../library/src/components/Divider/Divider.module.css | 4 +--- .../library/src/components/Label/Label.module.css | 4 +--- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css b/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css index d37a6f2081e045..e9de3d7f859e3f 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css +++ b/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css @@ -1,6 +1,6 @@ @reference '#theme'; -@layer fui.base { +@layer fui.components.l1 { .root { @apply m-horizontal-none box-border inline-flex min-w-96 items-center justify-center overflow-hidden rounded-medium border-(length:--spacing-thin) border-solid border-neutral-stroke-1 bg-neutral-background-1 px-horizontal-m py-5 align-middle font-base text-base-300 leading-base-300 font-semibold text-neutral-foreground-1 no-underline duration-(--duration-faster) ease-(--ease-easy-ease) outline-none; @@ -52,9 +52,7 @@ --fui-button-icon-spacing: calc(6px * var(--base-scale)); } -} -@layer fui.components.l1 { .icon { @variant group-not-empty/fui-button { @variant group-icon-before/fui-button { diff --git a/packages/react-components/react-windmod-preview/library/src/components/Divider/Divider.module.css b/packages/react-components/react-windmod-preview/library/src/components/Divider/Divider.module.css index 7eb553661a8e5e..8d3f9af4813756 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Divider/Divider.module.css +++ b/packages/react-components/react-windmod-preview/library/src/components/Divider/Divider.module.css @@ -4,7 +4,7 @@ they are reached with Tailwind's built-in before/after variants, which also supply `content: var(--tw-content)` — registered with initial-value "" — for every state. */ -@layer fui.base { +@layer fui.components.l1 { .root { @apply relative box-border flex grow flex-row items-center text-center font-base text-base-200 leading-base-200 font-regular; @@ -12,9 +12,7 @@ @apply box-border flex grow; } } -} -@layer fui.components.l1 { .brand { @apply text-brand-foreground-1; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Label/Label.module.css b/packages/react-components/react-windmod-preview/library/src/components/Label/Label.module.css index 8a102528949fae..02c06fe8ade5b7 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Label/Label.module.css +++ b/packages/react-components/react-windmod-preview/library/src/components/Label/Label.module.css @@ -1,6 +1,6 @@ @reference '#theme'; -@layer fui.base { +@layer fui.components.l1 { .root { @apply font-base text-neutral-foreground-1; } @@ -8,9 +8,7 @@ .required { @apply ps-horizontal-xs text-palette-red-foreground-3; } -} -@layer fui.components.l1 { .root { @variant size-small { @apply text-base-200 leading-base-200; From f995dcb719e4e69b1c86b18bc1cd6c5b5201b741 Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Fri, 21 Aug 2026 14:46:07 -0700 Subject: [PATCH 027/246] refactor(windmod): author each module class as a single block One block per class: resting look first, variants nested after, inter-class cascade carried by block order. Appearance disabled sub-blocks re-assert what the generic disabled rules can no longer win by position; the glyph swap lives group-composed in the icon block with the swap-back last. Also restores the disabled outline background and aligns disabled primary's forced-colors border with Griffel. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- .../src/components/Button/Button.module.css | 435 +++++++++--------- .../src/components/Divider/Divider.module.css | 74 ++- .../src/components/Label/Label.module.css | 8 +- 3 files changed, 262 insertions(+), 255 deletions(-) diff --git a/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css b/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css index e9de3d7f859e3f..b72e770b588957 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css +++ b/packages/react-components/react-windmod-preview/library/src/components/Button/Button.module.css @@ -45,97 +45,107 @@ box-shadow: 0 0 0 calc(var(--spacing-thin) + 0.25px) var(--color-stroke-focus-2) inset; } } - } - .icon { - @apply inline-flex size-20 items-center justify-center text-[length:calc(20px*var(--base-scale))]; + @variant size-small { + @apply min-w-64 rounded-medium px-horizontal-s py-3 text-base-200 leading-base-200 font-regular; + } - --fui-button-icon-spacing: calc(6px * var(--base-scale)); - } + @variant size-large { + @apply min-w-96 rounded-medium px-horizontal-l py-vertical-s text-base-400 leading-base-400 font-semibold; + } - .icon { - @variant group-not-empty/fui-button { - @variant group-icon-before/fui-button { - @apply me-(--fui-button-icon-spacing); + /* The appearance blocks all follow this one at equal specificity, so each of them + re-asserts the disabled background and foreground it would otherwise win back. */ + @variant disabled, disabled-focusable { + @apply cursor-not-allowed border-neutral-stroke-disabled bg-neutral-background-disabled text-neutral-foreground-disabled; + + @variant hover { + @apply cursor-not-allowed border-neutral-stroke-disabled bg-neutral-background-disabled text-neutral-foreground-disabled; } - @variant group-icon-after/fui-button { - @apply ms-(--fui-button-icon-spacing); + @variant hover-active { + @apply cursor-not-allowed border-neutral-stroke-disabled bg-neutral-background-disabled text-neutral-foreground-disabled; } - } - @variant group-size-small/fui-button { - @apply size-20 text-[length:calc(20px*var(--base-scale))]; + @variant forced-colors { + @apply border-[GrayText] bg-[ButtonFace] text-[GrayText]; - --fui-button-icon-spacing: calc(4px * var(--base-scale)); - } + @variant focus { + @apply border-[GrayText]; + } - @variant group-size-large/fui-button { - @apply size-24 text-[length:calc(24px*var(--base-scale))]; + @variant hover { + @apply border-[GrayText] bg-[ButtonFace] text-[GrayText]; + } - --fui-button-icon-spacing: calc(6px * var(--base-scale)); + @variant hover-active { + @apply border-[GrayText] bg-[ButtonFace] text-[GrayText]; + } + } } - } - - .outline { - @apply bg-transparent-background; - @variant hover { - @apply bg-transparent-background-hover; - } + @variant focus-visible { + @variant size-small { + @apply rounded-small; + } - @variant hover-active { - @apply bg-transparent-background-pressed; + @variant size-large { + @apply rounded-large; + } } - } - - .primary { - @apply border-transparent bg-brand-background text-neutral-foreground-on-brand; - @variant hover { - @apply border-transparent bg-brand-background-hover text-neutral-foreground-on-brand; - } + @variant with-icon { + @variant size-small { + @apply py-1; + } - @variant hover-active { - @apply border-transparent bg-brand-background-pressed text-neutral-foreground-on-brand; + @variant size-large { + @apply py-7; + } } - @variant forced-colors { - @apply border-[HighlightText] bg-[Highlight] text-[HighlightText] forced-color-adjust-none; + @variant icon-only { + @variant size-small { + @apply max-w-24 min-w-24 p-1; + } - @variant hover { - @apply border-[Highlight] bg-[HighlightText] text-[Highlight]; + @variant size-medium { + @apply max-w-32 min-w-32 p-5; } - @variant hover-active { - @apply border-[Highlight] bg-[HighlightText] text-[Highlight]; + @variant size-large { + @apply max-w-40 min-w-40 p-7; } } } - .subtle { - @apply border-transparent bg-subtle-background text-neutral-foreground-2; + .icon { + @apply inline-flex size-20 items-center justify-center text-[length:calc(20px*var(--base-scale))]; - @variant hover { - @apply border-transparent bg-subtle-background-hover text-neutral-foreground-2-hover; + --fui-button-icon-spacing: calc(6px * var(--base-scale)); + + @variant group-not-empty/fui-button { + @variant group-icon-before/fui-button { + @apply me-(--fui-button-icon-spacing); + } + + @variant group-icon-after/fui-button { + @apply ms-(--fui-button-icon-spacing); + } } - @variant hover-active { - @apply border-transparent bg-subtle-background-pressed text-neutral-foreground-2-pressed; + @variant group-size-small/fui-button { + @apply size-20 text-[length:calc(20px*var(--base-scale))]; + + --fui-button-icon-spacing: calc(4px * var(--base-scale)); } - @variant forced-colors { - @variant hover { - @apply text-[Highlight]; - } + @variant group-size-large/fui-button { + @apply size-24 text-[length:calc(24px*var(--base-scale))]; - @variant hover-active { - @apply text-[Highlight]; - } + --fui-button-icon-spacing: calc(6px * var(--base-scale)); } - } - .icon { @variant group-appearance-subtle/fui-button { @variant group-hover/fui-button { @apply text-neutral-foreground-2-brand-hover; @@ -155,81 +165,41 @@ } } } - } - - .transparent { - @apply border-transparent bg-transparent-background text-neutral-foreground-2; - - @variant hover { - @apply border-transparent bg-transparent-background-hover text-neutral-foreground-2-brand-hover; - } - @variant hover-active { - @apply border-transparent bg-transparent-background-pressed text-neutral-foreground-2-brand-pressed; - } - - @variant forced-colors { - @variant hover { - @apply bg-transparent-background text-[Highlight]; - } - - @variant hover-active { - @apply bg-transparent-background text-[Highlight]; - } - } - } - - .root { - @variant size-small { - @apply min-w-64 rounded-medium px-horizontal-s py-3 text-base-200 leading-base-200 font-regular; - } - - @variant size-large { - @apply min-w-96 rounded-medium px-horizontal-l py-vertical-s text-base-400 leading-base-400 font-semibold; - } - - @variant disabled, disabled-focusable { - @apply cursor-not-allowed border-neutral-stroke-disabled bg-neutral-background-disabled text-neutral-foreground-disabled; - - @variant hover { - @apply cursor-not-allowed border-neutral-stroke-disabled bg-neutral-background-disabled text-neutral-foreground-disabled; - } - - @variant hover-active { - @apply cursor-not-allowed border-neutral-stroke-disabled bg-neutral-background-disabled text-neutral-foreground-disabled; - } - - @variant forced-colors { - @apply border-[GrayText] bg-[ButtonFace] text-[GrayText]; - - @variant focus { - @apply border-[GrayText]; - } - - @variant hover { - @apply border-[GrayText] bg-[ButtonFace] text-[GrayText]; - } + /* Glyph swap on hover, keyed off the data-variant attribute bundleIcon stamps on each + glyph. Anchored on .icon via the group-composed form so every icon rule shares equal + specificity and in-block order carries the cascade: appearance-hover swaps before the + disabled swap-back, which must win last. `!` keeps the swap in the same importance tier + as the icons stylesheet's hidden rule; normal declarations would lose to the icon + components' unlayered base classes. */ + @variant group-appearance-subtle/fui-button, group-appearance-transparent/fui-button { + @variant group-hover/fui-button { + & * { + @variant variant-filled { + @apply inline!; + } - @variant hover-active { - @apply border-[GrayText] bg-[ButtonFace] text-[GrayText]; + @variant variant-regular { + @apply hidden!; + } } } - } - @variant focus-visible { - @variant size-small { - @apply rounded-small; - } + @variant group-hover-active/fui-button { + & * { + @variant variant-filled { + @apply inline!; + } - @variant size-large { - @apply rounded-large; + @variant variant-regular { + @apply hidden!; + } + } } } - } - /* Group-variant form; equal specificity to the appearance-hover icon-color rules - above, so source order alone must put the disabled swap-back after them. */ - .icon { + /* Group-variant form; equal specificity to the appearance-hover icon-color and glyph-swap + rules above, so source order alone must put the disabled swap-back after them. */ @variant group-disabled/fui-button, group-disabled-focusable/fui-button { @apply text-neutral-foreground-disabled; @@ -252,10 +222,42 @@ @apply text-[GrayText]; } } + + & * { + @variant group-hover/fui-button { + @variant variant-filled { + @apply hidden!; + } + + @variant variant-regular { + @apply inline!; + } + } + + @variant group-hover-active/fui-button { + @variant variant-filled { + @apply hidden!; + } + + @variant variant-regular { + @apply inline!; + } + } + } } } .outline { + @apply bg-transparent-background; + + @variant hover { + @apply bg-transparent-background-hover; + } + + @variant hover-active { + @apply bg-transparent-background-pressed; + } + @variant disabled, disabled-focusable { @apply bg-transparent-background; @@ -270,18 +272,53 @@ } .primary { + @apply border-transparent bg-brand-background text-neutral-foreground-on-brand; + + @variant hover { + @apply border-transparent bg-brand-background-hover text-neutral-foreground-on-brand; + } + + @variant hover-active { + @apply border-transparent bg-brand-background-pressed text-neutral-foreground-on-brand; + } + + @variant forced-colors { + @apply border-[HighlightText] bg-[Highlight] text-[HighlightText] forced-color-adjust-none; + + @variant hover { + @apply border-[Highlight] bg-[HighlightText] text-[Highlight]; + } + + @variant hover-active { + @apply border-[Highlight] bg-[HighlightText] text-[Highlight]; + } + } + @variant disabled, disabled-focusable { - @apply border-transparent; + @apply border-transparent bg-neutral-background-disabled text-neutral-foreground-disabled; @variant hover { - @apply border-transparent; + @apply border-transparent bg-neutral-background-disabled text-neutral-foreground-disabled; } @variant hover-active { - @apply border-transparent; + @apply border-transparent bg-neutral-background-disabled text-neutral-foreground-disabled; + } + + @variant forced-colors { + @apply border-[GrayText] bg-[ButtonFace] text-[GrayText]; + + @variant hover { + @apply border-[GrayText] bg-[ButtonFace] text-[GrayText]; + } + + @variant hover-active { + @apply border-[GrayText] bg-[ButtonFace] text-[GrayText]; + } } } + /* Focus outranks disabled, so it stays after the disabled sub-block. */ @variant focus-visible { @apply border-stroke-focus-2; @@ -317,134 +354,110 @@ } .subtle { - @variant disabled, disabled-focusable { - @apply border-transparent bg-transparent-background; + @apply border-transparent bg-subtle-background text-neutral-foreground-2; + + @variant hover { + @apply border-transparent bg-subtle-background-hover text-neutral-foreground-2-hover; + } + + @variant hover-active { + @apply border-transparent bg-subtle-background-pressed text-neutral-foreground-2-pressed; + } + @variant forced-colors { @variant hover { - @apply border-transparent bg-transparent-background; + @apply text-[Highlight]; } @variant hover-active { - @apply border-transparent bg-transparent-background; + @apply text-[Highlight]; } } - } - .transparent { @variant disabled, disabled-focusable { - @apply border-transparent bg-transparent-background; + @apply border-transparent bg-transparent-background text-neutral-foreground-disabled; @variant hover { - @apply border-transparent bg-transparent-background; + @apply border-transparent bg-transparent-background text-neutral-foreground-disabled; } @variant hover-active { - @apply border-transparent bg-transparent-background; + @apply border-transparent bg-transparent-background text-neutral-foreground-disabled; } - } - } - .root { - @variant with-icon { - @variant size-small { - @apply py-1; - } + @variant forced-colors { + @apply text-[GrayText]; - @variant size-large { - @apply py-7; + @variant hover { + @apply text-[GrayText]; + } + + @variant hover-active { + @apply text-[GrayText]; + } } } } - .circular { - @apply rounded-circular; + .transparent { + @apply border-transparent bg-transparent-background text-neutral-foreground-2; - @variant focus-visible { - @apply rounded-circular; + @variant hover { + @apply border-transparent bg-transparent-background-hover text-neutral-foreground-2-brand-hover; } - } - - .square { - @apply rounded-none; - @variant focus-visible { - @apply rounded-none; + @variant hover-active { + @apply border-transparent bg-transparent-background-pressed text-neutral-foreground-2-brand-pressed; } - } - - .root { - @variant icon-only { - @variant size-small { - @apply max-w-24 min-w-24 p-1; - } - @variant size-medium { - @apply max-w-32 min-w-32 p-5; + @variant forced-colors { + @variant hover { + @apply bg-transparent-background text-[Highlight]; } - @variant size-large { - @apply max-w-40 min-w-40 p-7; + @variant hover-active { + @apply bg-transparent-background text-[Highlight]; } } - } - /* Glyph swap on hover, keyed off the data-variant attribute bundleIcon stamps on each - glyph. In-file order carries the cascade: hover before hover-active, and the disabled - swap-back last so it wins over the appearance swaps. `!` keeps the swap in the same - importance tier as the icons stylesheet's hidden rule; normal declarations would lose - to the icon components' unlayered base classes. */ - .subtle, - .transparent { - @variant hover { - & .icon * { - @variant variant-filled { - @apply inline!; - } + @variant disabled, disabled-focusable { + @apply border-transparent bg-transparent-background text-neutral-foreground-disabled; - @variant variant-regular { - @apply hidden!; - } + @variant hover { + @apply border-transparent bg-transparent-background text-neutral-foreground-disabled; } - } - @variant hover-active { - & .icon * { - @variant variant-filled { - @apply inline!; + @variant hover-active { + @apply border-transparent bg-transparent-background text-neutral-foreground-disabled; + } + + @variant forced-colors { + @apply text-[GrayText]; + + @variant hover { + @apply text-[GrayText]; } - @variant variant-regular { - @apply hidden!; + @variant hover-active { + @apply text-[GrayText]; } } } } - .root { - @variant disabled, disabled-focusable { - @variant hover { - & .icon * { - @variant variant-filled { - @apply hidden!; - } + .circular { + @apply rounded-circular; - @variant variant-regular { - @apply inline!; - } - } - } + @variant focus-visible { + @apply rounded-circular; + } + } - @variant hover-active { - & .icon * { - @variant variant-filled { - @apply hidden!; - } + .square { + @apply rounded-none; - @variant variant-regular { - @apply inline!; - } - } - } + @variant focus-visible { + @apply rounded-none; } } } diff --git a/packages/react-components/react-windmod-preview/library/src/components/Divider/Divider.module.css b/packages/react-components/react-windmod-preview/library/src/components/Divider/Divider.module.css index 8d3f9af4813756..9d35e85b0dc10d 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Divider/Divider.module.css +++ b/packages/react-components/react-windmod-preview/library/src/components/Divider/Divider.module.css @@ -11,45 +11,11 @@ @variant before, after { @apply box-border flex grow; } - } - - .brand { - @apply text-brand-foreground-1; - - @variant before, after { - @apply border-brand-stroke-1; - } - } - - .default { - @apply text-neutral-foreground-2; - - @variant before, after { - @apply border-neutral-stroke-2; - } - } - - .subtle { - @apply text-neutral-foreground-3; - - @variant before, after { - @apply border-neutral-stroke-3; - } - } - - .strong { - @apply text-neutral-foreground-1; - - @variant before, after { - @apply border-neutral-stroke-1; - } - } - /* No `border-solid` anywhere below: it sets border-style on all four sides, and with only - one side's width declared the other three fall back to `medium` and draw a full box. The - per-side width utilities emit their own `border-*-style: var(--tw-border-style)`, whose - registered initial-value is solid. */ - .root { + /* No `border-solid` anywhere below: it sets border-style on all four sides, and with only + one side's width declared the other three fall back to `medium` and draw a full box. The + per-side width utilities emit their own `border-*-style: var(--tw-border-style)`, whose + registered initial-value is solid. */ @variant horizontal { @apply w-full; @@ -154,4 +120,36 @@ } } } + + .brand { + @apply text-brand-foreground-1; + + @variant before, after { + @apply border-brand-stroke-1; + } + } + + .default { + @apply text-neutral-foreground-2; + + @variant before, after { + @apply border-neutral-stroke-2; + } + } + + .subtle { + @apply text-neutral-foreground-3; + + @variant before, after { + @apply border-neutral-stroke-3; + } + } + + .strong { + @apply text-neutral-foreground-1; + + @variant before, after { + @apply border-neutral-stroke-1; + } + } } diff --git a/packages/react-components/react-windmod-preview/library/src/components/Label/Label.module.css b/packages/react-components/react-windmod-preview/library/src/components/Label/Label.module.css index 02c06fe8ade5b7..7497dd93702668 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/Label/Label.module.css +++ b/packages/react-components/react-windmod-preview/library/src/components/Label/Label.module.css @@ -3,13 +3,7 @@ @layer fui.components.l1 { .root { @apply font-base text-neutral-foreground-1; - } - - .required { - @apply ps-horizontal-xs text-palette-red-foreground-3; - } - .root { @variant size-small { @apply text-base-200 leading-base-200; } @@ -32,6 +26,8 @@ } .required { + @apply ps-horizontal-xs text-palette-red-foreground-3; + @variant group-disabled/fui-label { @apply text-neutral-foreground-disabled; From ded9ac6271bf012b1cb5f658c92344a334980c00 Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Fri, 21 Aug 2026 14:48:08 -0700 Subject: [PATCH 028/246] feat(windmod): add Link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Headless useLink + Tailwind CSS-Modules styling, pixel-identical to the Griffel suite. One @variant disabled carries both disabled and disabledFocusable — the headless hook folds them before stamping. Element-type overrides ride module classes; the double-underline focus treatment is authored last so source order keeps it ahead of the disabled no-underline. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- .../library/etc/link.api.md | 45 ++++++ .../library/etc/react-windmod-preview.api.md | 34 +++++ .../library/package.json | 10 ++ .../src/components/Link/Link.module.css | 61 ++++++++ .../library/src/components/Link/Link.test.tsx | 132 ++++++++++++++++++ .../library/src/components/Link/Link.tsx | 30 ++++ .../library/src/components/Link/Link.types.ts | 24 ++++ .../library/src/components/Link/index.ts | 3 + .../src/components/Link/useLinkStyles.ts | 42 ++++++ .../library/src/index.ts | 3 + .../react-windmod-preview/library/src/link.ts | 5 + .../stories/src/Link/LinkDefault.stories.tsx | 35 +++++ .../Link/LinkGriffelComparison.stories.tsx | 55 ++++++++ .../stories/src/Link/index.stories.tsx | 9 ++ .../stories/src/vr/LinkGriffel.stories.tsx | 10 ++ .../stories/src/vr/LinkVrScene.tsx | 95 +++++++++++++ .../stories/src/vr/LinkWindmod.stories.tsx | 10 ++ .../stories/src/vr/index.stories.tsx | 2 + 18 files changed, 605 insertions(+) create mode 100644 packages/react-components/react-windmod-preview/library/etc/link.api.md create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Link/Link.module.css create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Link/Link.test.tsx create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Link/Link.tsx create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Link/Link.types.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Link/index.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Link/useLinkStyles.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/link.ts create mode 100644 packages/react-components/react-windmod-preview/stories/src/Link/LinkDefault.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/Link/LinkGriffelComparison.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/Link/index.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/LinkGriffel.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/LinkVrScene.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/LinkWindmod.stories.tsx diff --git a/packages/react-components/react-windmod-preview/library/etc/link.api.md b/packages/react-components/react-windmod-preview/library/etc/link.api.md new file mode 100644 index 00000000000000..a7faca404908fd --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/etc/link.api.md @@ -0,0 +1,45 @@ +## API Report File for "@fluentui/react-windmod-preview" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { LinkProps as LinkProps_2 } from '@fluentui/react-headless-components-preview/link'; +import { LinkSlots } from '@fluentui/react-headless-components-preview/link'; +import type { LinkState as LinkState_2 } from '@fluentui/react-headless-components-preview/link'; +import { renderLink } from '@fluentui/react-headless-components-preview/link'; +import { useLink } from '@fluentui/react-headless-components-preview/link'; + +// @public +export const Link: ForwardRefComponent; + +// @public +export type LinkAppearance = 'default' | 'subtle'; + +// @public +export const linkClassNames: { + root: string; +}; + +// @public +export type LinkProps = LinkProps_2 & { + appearance?: LinkAppearance; + inline?: boolean; +}; + +export { LinkSlots } + +// @public +export type LinkState = LinkState_2 & Required>; + +export { renderLink } + +export { useLink } + +// @public +export const useLinkStyles: (state: LinkState) => LinkState; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md b/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md index 6fb57262021d26..5dd0ad0470099f 100644 --- a/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md +++ b/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md @@ -15,12 +15,16 @@ import type { JSXElement } from '@fluentui/react-utilities'; import type { LabelProps as LabelProps_2 } from '@fluentui/react-headless-components-preview/label'; import { LabelSlots } from '@fluentui/react-headless-components-preview/label'; import type { LabelState as LabelState_2 } from '@fluentui/react-headless-components-preview/label'; +import type { LinkProps as LinkProps_2 } from '@fluentui/react-headless-components-preview/link'; +import { LinkSlots } from '@fluentui/react-headless-components-preview/link'; +import type { LinkState as LinkState_2 } from '@fluentui/react-headless-components-preview/link'; import { OnVisibleChangeData } from '@fluentui/react-headless-components-preview/tooltip'; import { Provider } from '@fluentui/react-headless-components-preview/provider'; import * as React_2 from 'react'; import { renderButton } from '@fluentui/react-headless-components-preview/button'; import { renderDivider } from '@fluentui/react-headless-components-preview/divider'; import { renderLabel } from '@fluentui/react-headless-components-preview/label'; +import { renderLink } from '@fluentui/react-headless-components-preview/link'; import { renderProvider } from '@fluentui/react-headless-components-preview/provider'; import { renderTooltip } from '@fluentui/react-headless-components-preview/tooltip'; import { teamsDarkThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; @@ -37,6 +41,7 @@ import { TooltipTriggerProps } from '@fluentui/react-headless-components-preview import { useButton } from '@fluentui/react-headless-components-preview/button'; import { useDivider } from '@fluentui/react-headless-components-preview/divider'; import { useLabel } from '@fluentui/react-headless-components-preview/label'; +import { useLink } from '@fluentui/react-headless-components-preview/link'; import { useProvider } from '@fluentui/react-headless-components-preview/provider'; import { useTooltip } from '@fluentui/react-headless-components-preview/tooltip'; import { webDarkThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; @@ -122,6 +127,28 @@ export type LabelState = LabelState_2 & Required; + +// @public +export type LinkAppearance = 'default' | 'subtle'; + +// @public +export const linkClassNames: { + root: string; +}; + +// @public +export type LinkProps = LinkProps_2 & { + appearance?: LinkAppearance; + inline?: boolean; +}; + +export { LinkSlots } + +// @public +export type LinkState = LinkState_2 & Required>; + export { OnVisibleChangeData } export { Provider } @@ -132,6 +159,8 @@ export { renderDivider } export { renderLabel } +export { renderLink } + export { renderProvider } export { renderTooltip } @@ -207,6 +236,11 @@ export { useLabel } // @public export const useLabelStyles: (state: LabelState) => LabelState; +export { useLink } + +// @public +export const useLinkStyles: (state: LinkState) => LinkState; + export { useProvider } export { useTooltip } diff --git a/packages/react-components/react-windmod-preview/library/package.json b/packages/react-components/react-windmod-preview/library/package.json index 45d2029c718c9c..fc18ae3048514f 100644 --- a/packages/react-components/react-windmod-preview/library/package.json +++ b/packages/react-components/react-windmod-preview/library/package.json @@ -81,6 +81,16 @@ "default": "./lib-commonjs/label.cjs" } }, + "./link": { + "import": { + "types": "./dist/link.d.ts", + "default": "./lib/link.js" + }, + "require": { + "types": "./dist/link.d.cts", + "default": "./lib-commonjs/link.cjs" + } + }, "./provider": { "import": { "types": "./dist/provider.d.ts", diff --git a/packages/react-components/react-windmod-preview/library/src/components/Link/Link.module.css b/packages/react-components/react-windmod-preview/library/src/components/Link/Link.module.css new file mode 100644 index 00000000000000..f9980cf21b3f61 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Link/Link.module.css @@ -0,0 +1,61 @@ +@reference '#theme'; + +@layer fui.components.l1 { + .root { + @apply m-0 box-border inline cursor-pointer [overflow:inherit] bg-transparent p-0 text-start font-base text-base-300 font-regular [text-overflow:inherit] text-brand-foreground-link no-underline decoration-(length:--spacing-thin) select-text; + + @variant hover { + @apply text-brand-foreground-link-hover underline; + } + + @variant active { + @apply text-brand-foreground-link-pressed underline; + } + + @variant appearance-subtle { + @apply text-neutral-foreground-2-link; + + @variant hover { + @apply text-neutral-foreground-2-link-hover underline; + } + + @variant active { + @apply text-neutral-foreground-2-link-pressed underline; + } + } + + @variant inline { + @apply underline; + } + + @variant disabled { + @apply cursor-not-allowed text-neutral-foreground-disabled no-underline; + + @variant hover, active { + @apply text-neutral-foreground-disabled no-underline; + } + + @variant forced-colors { + @apply text-[GrayText]; + + @variant hover, active { + @apply text-[GrayText]; + } + } + } + + /* Must stay last in this block: the catalog variants are specificity-neutral `:where()`, + so only source order keeps the focus underline ahead of the disabled `no-underline`. */ + @variant focus-visible { + @apply underline decoration-stroke-focus-2 decoration-double outline-none; + } + } + + .href { + @apply text-[length:inherit]; + } + + .button { + @apply border-none; + } +} diff --git a/packages/react-components/react-windmod-preview/library/src/components/Link/Link.test.tsx b/packages/react-components/react-windmod-preview/library/src/components/Link/Link.test.tsx new file mode 100644 index 00000000000000..024443362c14c2 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Link/Link.test.tsx @@ -0,0 +1,132 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; + +import { Link } from './Link'; +import { isConformant } from '../../testing/isConformant'; +import type { LinkState } from './Link.types'; +import { linkClassNames, useLinkStyles } from './useLinkStyles'; + +import styles from './Link.module.css'; + +describe('Link', () => { + isConformant({ + Component: Link, + displayName: 'Link', + requiredProps: { children: 'Link' }, + }); + + it('stamps data-appearance and the marker class', () => { + const { getByText } = render( + <> + Default + Subtle + , + ); + + expect(getByText('Default').getAttribute('data-appearance')).toBe('default'); + expect(getByText('Subtle').getAttribute('data-appearance')).toBe('subtle'); + expect(getByText('Default').className).toContain(linkClassNames.root); + expect(getByText('Default')).toHaveClass('fui-link'); + expect(getByText('Default')).toHaveClass('group/fui-link'); + expect(getByText('Default').classList[0]).toBe('fui-link'); + }); + + it('stamps data-inline only when inline', () => { + const { getByText } = render( + <> + Plain + Inline + , + ); + + expect(getByText('Plain').hasAttribute('data-inline')).toBe(false); + expect(getByText('Inline').hasAttribute('data-inline')).toBe(true); + }); + + it('renders the element type the headless hook resolves', () => { + const { getByText } = render( + <> + Button + Anchor + Span + , + ); + + expect(getByText('Button').tagName).toBe('BUTTON'); + expect(getByText('Button').getAttribute('type')).toBe('button'); + expect(getByText('Anchor').tagName).toBe('A'); + expect(getByText('Anchor').getAttribute('tabindex')).toBe('0'); + expect(getByText('Span').tagName).toBe('SPAN'); + expect(getByText('Span').getAttribute('role')).toBe('button'); + }); + + it('applies an element-type module class to exactly the roots that earn one', () => { + const { getByText } = render( + <> + Anchor + Anchor without href + Button + Span + , + ); + + expect(getByText('Anchor')).toHaveClass(styles.href); + expect(getByText('Anchor')).not.toHaveClass(styles.button); + expect(getByText('Button')).toHaveClass(styles.button); + expect(getByText('Button')).not.toHaveClass(styles.href); + expect(getByText('Anchor without href')).not.toHaveClass(styles.href); + expect(getByText('Anchor without href')).not.toHaveClass(styles.button); + expect(getByText('Span')).not.toHaveClass(styles.href); + expect(getByText('Span')).not.toHaveClass(styles.button); + }); + + it('drops href from a disabled anchor', () => { + const { getByText } = render( + + Disabled anchor + , + ); + + expect(getByText('Disabled anchor').tagName).toBe('A'); + expect(getByText('Disabled anchor').hasAttribute('href')).toBe(false); + // The styles hook reads state.root after the headless hook strips href, so the anchor + // falls back to the root font size — matching Griffel, which reads the same value. + expect(getByText('Disabled anchor')).not.toHaveClass(styles.href); + }); + + it('receives data-disabled for both disabled and disabledFocusable', () => { + const { getByText } = render( + <> + Enabled + Disabled + Disabled focusable + , + ); + + // The CSS selects the disabled look through `data-disabled` alone; headless folds + // disabledFocusable into it. + expect(getByText('Enabled').hasAttribute('data-disabled')).toBe(false); + expect(getByText('Disabled').hasAttribute('data-disabled')).toBe(true); + expect(getByText('Disabled focusable').hasAttribute('data-disabled')).toBe(true); + expect(getByText('Disabled').hasAttribute('data-disabled-focusable')).toBe(false); + expect(getByText('Disabled focusable').hasAttribute('data-disabled-focusable')).toBe(true); + }); + + it('does not mutate the state it is given', () => { + const state = { + appearance: 'default', + components: { root: 'a' }, + disabled: false, + disabledFocusable: false, + inline: false, + root: { as: 'a', href: 'https://example.com', className: 'consumer' }, + } as unknown as LinkState; + + const styled = useLinkStyles(state); + + expect(styled).not.toBe(state); + expect(state.root).not.toHaveProperty('data-appearance'); + expect(state.root.className).toBe('consumer'); + expect(styled.root.className).toContain('consumer'); + }); +}); diff --git a/packages/react-components/react-windmod-preview/library/src/components/Link/Link.tsx b/packages/react-components/react-windmod-preview/library/src/components/Link/Link.tsx new file mode 100644 index 00000000000000..cb1c063c453036 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Link/Link.tsx @@ -0,0 +1,30 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { renderLink, useLink } from '@fluentui/react-headless-components-preview/link'; + +import type { LinkProps, LinkState } from './Link.types'; +import { useLinkStyles } from './useLinkStyles'; + +/** + * A Link navigates to another surface. Windmod Link: the headless link decorated with the + * Fluent visual contract (Tailwind v4 + CSS Modules). + */ +export const Link: ForwardRefComponent = React.forwardRef((props, ref) => { + // Look props belong to windmod — the headless hook neither accepts nor resolves them. + // Defaults mirror @fluentui/react-link's styled useLink. `as`, `href`, `disabled` and + // `disabledFocusable` are deliberately absent: the headless hook owns them. + const { appearance = 'default', inline = false, ...rest } = props; + + const state: LinkState = { + ...useLink(rest, ref), + appearance, + inline, + }; + + return renderLink(useLinkStyles(state)); + // Casting is required due to lack of distributive union to support union on @types/react +}) as ForwardRefComponent; + +Link.displayName = 'Link'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Link/Link.types.ts b/packages/react-components/react-windmod-preview/library/src/components/Link/Link.types.ts new file mode 100644 index 00000000000000..570dd9f395f00f --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Link/Link.types.ts @@ -0,0 +1,24 @@ +import type { + LinkProps as LinkHeadlessProps, + LinkState as LinkHeadlessState, +} from '@fluentui/react-headless-components-preview/link'; + +export type { LinkSlots } from '@fluentui/react-headless-components-preview/link'; + +/** Appearance of the Link. */ +export type LinkAppearance = 'default' | 'subtle'; + +/** + * Windmod Link props: the headless link plus the look props the headless surface deliberately + * omits (they exist purely to select styles). `disabled` and `disabledFocusable` are not among + * them — they stay headless props, and headless folds both into the state's `disabled`. + */ +export type LinkProps = LinkHeadlessProps & { + /** @default 'default' */ + appearance?: LinkAppearance; + /** @default false */ + inline?: boolean; +}; + +/** Windmod Link state: headless state plus the resolved look props. */ +export type LinkState = LinkHeadlessState & Required>; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Link/index.ts b/packages/react-components/react-windmod-preview/library/src/components/Link/index.ts new file mode 100644 index 00000000000000..d2938ecb21c491 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Link/index.ts @@ -0,0 +1,3 @@ +export { Link } from './Link'; +export { linkClassNames, useLinkStyles } from './useLinkStyles'; +export type { LinkAppearance, LinkProps, LinkSlots, LinkState } from './Link.types'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Link/useLinkStyles.ts b/packages/react-components/react-windmod-preview/library/src/components/Link/useLinkStyles.ts new file mode 100644 index 00000000000000..fb1025b16762a8 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Link/useLinkStyles.ts @@ -0,0 +1,42 @@ +import { clsx } from 'clsx'; + +import { componentMarkers } from '../../utils/groupMarker'; +import type { LinkState } from './Link.types'; + +import styles from './Link.module.css'; + +/** The only public classes — see componentMarkers; internals are hashed idents. */ +export const linkClassNames: { root: string } = { + root: componentMarkers('link'), +}; + +type LinkRootDataAttributes = { + 'data-appearance'?: LinkState['appearance']; + 'data-inline'?: true; +}; + +/** Applies the visual contract, returning new state. The headless hook already stamps + * data-disabled/-disabled-focusable, both derived from a `disabled` that is already the OR of + * `disabled` and `disabledFocusable`; these two are style-only. The element-type classes must + * read `state.root` after the headless hook has stripped `href` from a disabled anchor. */ +export const useLinkStyles = (state: LinkState): LinkState => { + const { appearance, inline } = state; + + const root: LinkState['root'] & LinkRootDataAttributes = { + ...state.root, + 'data-appearance': appearance, + 'data-inline': inline || undefined, + className: clsx( + linkClassNames.root, + styles.root, + state.root.as === 'a' && state.root.href && styles.href, + state.root.as === 'button' && styles.button, + state.root.className, + ), + }; + + return { + ...state, + root, + }; +}; diff --git a/packages/react-components/react-windmod-preview/library/src/index.ts b/packages/react-components/react-windmod-preview/library/src/index.ts index bbaecfa0593ba3..896a245bae3f84 100644 --- a/packages/react-components/react-windmod-preview/library/src/index.ts +++ b/packages/react-components/react-windmod-preview/library/src/index.ts @@ -7,6 +7,9 @@ export type { DividerAlignContent, DividerAppearance, DividerProps, DividerSlots export { Label, labelClassNames, renderLabel, useLabel, useLabelStyles } from './label'; export type { LabelProps, LabelSize, LabelSlots, LabelState, LabelWeight } from './label'; +export { Link, linkClassNames, renderLink, useLink, useLinkStyles } from './link'; +export type { LinkAppearance, LinkProps, LinkSlots, LinkState } from './link'; + export { renderTooltip, Tooltip, tooltipClassNames, useTooltip, useTooltipStyles } from './tooltip'; export type { OnVisibleChangeData, diff --git a/packages/react-components/react-windmod-preview/library/src/link.ts b/packages/react-components/react-windmod-preview/library/src/link.ts new file mode 100644 index 00000000000000..ce843ffd87b40c --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/link.ts @@ -0,0 +1,5 @@ +export { Link, linkClassNames, useLinkStyles } from './components/Link'; +export type { LinkAppearance, LinkProps, LinkSlots, LinkState } from './components/Link'; + +/** Headless building blocks, re-exported for consumers composing their own Link. */ +export { renderLink, useLink } from '@fluentui/react-headless-components-preview/link'; diff --git a/packages/react-components/react-windmod-preview/stories/src/Link/LinkDefault.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Link/LinkDefault.stories.tsx new file mode 100644 index 00000000000000..57e8581b90be49 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/Link/LinkDefault.stories.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { Link, ThemeProvider } from '@fluentui/react-windmod-preview'; + +import styles from '../compare.module.css'; + +const appearances = ['default', 'subtle'] as const; +const inlines = [false, true] as const; + +export const Default = (): React.ReactNode => ( + +
+ {appearances.map(appearance => + inlines.map(inline => ( +
+ + {appearance} + {inline ? ' inline' : ''} + + + Disabled + + + Disabled focusable + +
+ )), + )} +
+ Anchor + Button + Span +
+
+
+); diff --git a/packages/react-components/react-windmod-preview/stories/src/Link/LinkGriffelComparison.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Link/LinkGriffelComparison.stories.tsx new file mode 100644 index 00000000000000..6a3f0593d980a0 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/Link/LinkGriffelComparison.stories.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import { Link, ThemeProvider } from '@fluentui/react-windmod-preview'; +import { FluentProvider, Link as GriffelLink, webLightTheme } from '@fluentui/react-components'; + +import styles from '../compare.module.css'; + +type LookProps = { + appearance?: 'default' | 'subtle'; + inline?: boolean; + disabled?: boolean; + disabledFocusable?: boolean; + href?: string; + as?: 'a' | 'button' | 'span'; +}; + +/** + * Every windmod variant next to its Griffel-suite twin (inside a FluentProvider). + * The pair in each row must be pixel-identical. + */ +export const GriffelComparison = (): React.ReactNode => { + const variants: Array<{ label: string; props: LookProps }> = [ + { label: 'default', props: { href: 'https://example.com' } }, + { label: 'subtle', props: { href: 'https://example.com', appearance: 'subtle' } }, + { label: 'inline', props: { href: 'https://example.com', inline: true } }, + { label: 'subtle inline', props: { href: 'https://example.com', appearance: 'subtle', inline: true } }, + { label: 'disabled', props: { href: 'https://example.com', disabled: true } }, + { label: 'disabled focusable', props: { href: 'https://example.com', disabledFocusable: true } }, + { label: 'as button', props: {} }, + { label: 'as span', props: { as: 'span' } }, + { label: 'as anchor, no href', props: { as: 'a' } }, + ]; + + return ( +
+
Variant
+
Windmod
+
Griffel
+ {variants.map(({ label, props }) => ( + +
{label}
+
+ + Link + +
+
+ + Link + +
+
+ ))} +
+ ); +}; diff --git a/packages/react-components/react-windmod-preview/stories/src/Link/index.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Link/index.stories.tsx new file mode 100644 index 00000000000000..59db271e32fa3d --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/Link/index.stories.tsx @@ -0,0 +1,9 @@ +import { Link } from '@fluentui/react-windmod-preview'; + +export { Default } from './LinkDefault.stories'; +export { GriffelComparison } from './LinkGriffelComparison.stories'; + +export default { + title: 'Windmod/Link', + component: Link, +}; diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/LinkGriffel.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/LinkGriffel.stories.tsx new file mode 100644 index 00000000000000..d0943cf524675b --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/LinkGriffel.stories.tsx @@ -0,0 +1,10 @@ +import * as React from 'react'; +import { FluentProvider, Link as GriffelLink, webLightTheme } from '@fluentui/react-components'; + +import { LinkVrScene } from './LinkVrScene'; + +export const LinkGriffel = (): React.ReactNode => ( + + + +); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/LinkVrScene.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/LinkVrScene.tsx new file mode 100644 index 00000000000000..1476bf24d6a582 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/LinkVrScene.tsx @@ -0,0 +1,95 @@ +// eslint-disable-next-line import/no-extraneous-dependencies -- story-only helper outside the *.stories.tsx devDependencies exemption +import * as React from 'react'; + +const appearances = ['default', 'subtle'] as const; + +const roots = [ + { key: 'anchor', props: { href: 'https://example.com' } }, + { key: 'anchor-no-href', props: { as: 'a' as const } }, + { key: 'button', props: {} }, + { key: 'span', props: { as: 'span' as const } }, +]; + +const states = [ + { key: 'enabled', props: {} }, + { key: 'disabled', props: { disabled: true } }, + { key: 'disabled-focusable', props: { disabledFocusable: true } }, +]; + +const inlines = [false, true] as const; + +type LinkLike = React.ComponentType<{ + appearance?: (typeof appearances)[number]; + inline?: boolean; + disabled?: boolean; + disabledFocusable?: boolean; + href?: string; + as?: 'a' | 'button' | 'span'; + children?: React.ReactNode; +}>; + +const BASE_FONT_FAMILY = + "'Segoe UI', 'Segoe UI Web (West European)', -apple-system, BlinkMacSystemFont, Roboto, 'Helvetica Neue', sans-serif"; + +const cellStyle: React.CSSProperties = { display: 'inline-block', width: 170 }; + +/** + * fontFamily/fontSize/lineHeight/color are inherited by Link (it sets no line-height, and an + * anchor with an href takes `font-size: inherit`); FluentProvider supplies them on the Griffel + * side and ThemeProvider supplies nothing, so the scene must supply both. Rows are plain blocks, + * never flex: a flex parent would blockify the Link and hide its own `display: inline`. + */ +export const LinkVrScene = ({ Link }: { Link: LinkLike }): React.ReactNode => ( +
+ {roots.map(root => + appearances.map(appearance => ( +
+ {states.map(state => + inlines.map(inline => ( + + + {root.key} {appearance} + + + )), + )} +
+ )), + )} + + {/* The only place `font-size: inherit` on an anchor-with-href is observable. */} +
+ {roots.map(root => ( + + {root.key} + + ))} +
+ +
+ Running text around{' '} + + an inline link + + , then{' '} + + a subtle one + {' '} + and finally{' '} + + a disabled one + {' '} + to close the sentence. +
+
+); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/LinkWindmod.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/LinkWindmod.stories.tsx new file mode 100644 index 00000000000000..96cbee6bcbe310 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/LinkWindmod.stories.tsx @@ -0,0 +1,10 @@ +import * as React from 'react'; +import { Link, ThemeProvider } from '@fluentui/react-windmod-preview'; + +import { LinkVrScene } from './LinkVrScene'; + +export const LinkWindmod = (): React.ReactNode => ( + + + +); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx index fa37ab0ae82e32..78b4b6bf56eb1f 100644 --- a/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx +++ b/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx @@ -4,6 +4,8 @@ export { DividerWindmod } from './DividerWindmod.stories'; export { DividerGriffel } from './DividerGriffel.stories'; export { LabelWindmod } from './LabelWindmod.stories'; export { LabelGriffel } from './LabelGriffel.stories'; +export { LinkWindmod } from './LinkWindmod.stories'; +export { LinkGriffel } from './LinkGriffel.stories'; export { TooltipWindmod } from './TooltipWindmod.stories'; export { TooltipGriffel } from './TooltipGriffel.stories'; From 2ed26576c4c005745a88f774fcd17056433f672c Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Fri, 21 Aug 2026 15:14:11 -0700 Subject: [PATCH 029/246] refactor(windmod): split component-specific variants out of the theme catalog The theme keeps generic vocabulary only; component API values live in the package's own variants.css, exported as ./variants.css so consumers can compose group variants against these components. #theme now resolves to a package-local composition file so module.css authoring keeps its single @reference line. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- .../css/variants.css | 49 --------------- .../library/package.json | 6 +- .../library/src/theme.css | 7 +++ .../library/src/variants.css | 59 +++++++++++++++++++ 4 files changed, 70 insertions(+), 51 deletions(-) create mode 100644 packages/react-components/react-windmod-preview/library/src/theme.css create mode 100644 packages/react-components/react-windmod-preview/library/src/variants.css diff --git a/packages/react-components/react-tailwind-theme-preview/css/variants.css b/packages/react-components/react-tailwind-theme-preview/css/variants.css index a44ce7116efc31..99209e99d80fba 100644 --- a/packages/react-components/react-tailwind-theme-preview/css/variants.css +++ b/packages/react-components/react-tailwind-theme-preview/css/variants.css @@ -23,11 +23,6 @@ @custom-variant align-content-center (&:where([data-align-content='center'])); @custom-variant align-content-end (&:where([data-align-content='end'])); -/* surface edge position (Drawer) */ -@custom-variant position-start (&:where([data-position='start'])); -@custom-variant position-end (&:where([data-position='end'])); -@custom-variant position-bottom (&:where([data-position='bottom'])); - /* anchored-surface placement — data-placement on the positioned surface: above|below|before|after main axis, -start/-end/-top/-bottom alignment, suffix-less = centered */ @custom-variant placement-above (&:where([data-placement^='above'])); @custom-variant placement-below (&:where([data-placement^='below'])); @@ -42,17 +37,6 @@ @custom-variant placement-before-centered (&:where([data-placement='before'])); @custom-variant placement-after-centered (&:where([data-placement='after'])); -/* bundled-icon glyph variant, stamped by @fluentui/react-icons/headless bundleIcon */ -@custom-variant variant-filled (&:where([data-variant='filled'])); -@custom-variant variant-regular (&:where([data-variant='regular'])); - -/* scroll position of a scrollable body (Drawer) */ -@custom-variant scroll-state-none (&:where([data-scroll-state='none'])); -@custom-variant scroll-state-top (&:where([data-scroll-state='top'])); -@custom-variant scroll-state-middle (&:where([data-scroll-state='middle'])); -@custom-variant scroll-state-bottom (&:where([data-scroll-state='bottom'])); -@custom-variant not-scroll-state-none (&:where(:not([data-scroll-state='none']))); - /* generic states (native/ARIA + data- spellings of one state per entry) */ @custom-variant disabled (&:where([disabled], [data-disabled], :disabled)); @custom-variant enabled (&:where(:not([disabled], [data-disabled], :disabled))); @@ -71,13 +55,6 @@ @custom-variant indeterminate (&:where([data-indeterminate], :indeterminate)); @custom-variant not-indeterminate (&:where(:not([data-indeterminate], :indeterminate))); -/* active appearance (SwatchPicker) */ -@custom-variant with-active (&:where([data-active])); -@custom-variant active-inactive (&:where([data-active='inactive'])); -@custom-variant active-appearance-ring (&:where([data-active-appearance='ring'])); -@custom-variant active-appearance-shadow (&:where([data-active-appearance='shadow'])); -@custom-variant active-appearance-ring-shadow (&:where([data-active-appearance='ring-shadow'])); - /* size scale. Numeric data-size values get NO variants (size-16 would read as a * Tailwind utility) — coerce with typed attr(): calc(attr(data-size type()) * 1px) */ @custom-variant size-extra-tiny (&:where([data-size='extra-tiny'])); @@ -90,19 +67,11 @@ @custom-variant size-huge (&:where([data-size='huge'])); @custom-variant size-full (&:where([data-size='full'])); -/* thickness (ProgressBar) */ -@custom-variant thickness-medium (&:where([data-thickness='medium'])); -@custom-variant thickness-large (&:where([data-thickness='large'])); - /* icon slot states */ @custom-variant icon-only (&:where([data-icon-only])); @custom-variant with-icon (&:where([data-icon-position])); @custom-variant icon-before (&:where([data-icon-position='before'])); @custom-variant icon-after (&:where([data-icon-position='after'])); - -/* expand icon position (Accordion) */ -@custom-variant expand-icon-position-start (&:where([data-expand-icon-position='start'])); -@custom-variant expand-icon-position-end (&:where([data-expand-icon-position='end'])); @custom-variant icon (&:where([data-icon])); @custom-variant not-icon (&:where(:not([data-icon]))); @@ -120,21 +89,6 @@ @custom-variant layout-singleline (&:where([data-layout='singleline'])); @custom-variant layout-multiline (&:where([data-layout='multiline'])); -/* intent (MessageBar) */ -@custom-variant intent-info (&:where([data-intent='info'])); -@custom-variant intent-success (&:where([data-intent='success'])); -@custom-variant intent-warning (&:where([data-intent='warning'])); -@custom-variant intent-error (&:where([data-intent='error'])); - -/* appearance (Button) */ -@custom-variant appearance-subtle (&:where([data-appearance='subtle'])); -@custom-variant appearance-transparent (&:where([data-appearance='transparent'])); - -/* text position (Persona) */ -@custom-variant text-position-after (&:where([data-text-position='after'])); -@custom-variant text-position-before (&:where([data-text-position='before'])); -@custom-variant text-position-below (&:where([data-text-position='below'])); - /* per-component booleans */ @custom-variant inset (&:where([data-inset])); @custom-variant interactive (&:where([data-interactive])); @@ -145,9 +99,6 @@ @custom-variant has-actions (&:where([data-has-actions])); @custom-variant not-has-actions (&:where(:not([data-has-actions]))); -/* held spin repeat (SpinButton) — persists across the repeat interval, unlike :active */ -@custom-variant spin-active (&:where([data-spin-active])); - /* interaction */ @custom-variant hover (&:where(:hover)); @custom-variant focus (&:where(:focus)); diff --git a/packages/react-components/react-windmod-preview/library/package.json b/packages/react-components/react-windmod-preview/library/package.json index fc18ae3048514f..25a3efbc93633b 100644 --- a/packages/react-components/react-windmod-preview/library/package.json +++ b/packages/react-components/react-windmod-preview/library/package.json @@ -16,7 +16,8 @@ "dist/*.d.cts", "dist/styles.css", "lib", - "lib-commonjs" + "lib-commonjs", + "src/variants.css" ], "repository": { "type": "git", @@ -38,7 +39,7 @@ "react-dom": ">=16.14.0 <20.0.0" }, "imports": { - "#theme": "@fluentui/react-tailwind-theme-preview/css/index.css" + "#theme": "./src/theme.css" }, "exports": { ".": { @@ -112,6 +113,7 @@ } }, "./styles.css": "./dist/styles.css", + "./variants.css": "./src/variants.css", "./package.json": "./package.json" }, "beachball": { diff --git a/packages/react-components/react-windmod-preview/library/src/theme.css b/packages/react-components/react-windmod-preview/library/src/theme.css new file mode 100644 index 00000000000000..38c3f1e99ca36a --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/theme.css @@ -0,0 +1,7 @@ +/** + * The `#theme` reference target for this package's `*.module.css` files. Composes the shared + * theme (generic vocabulary, tokens, layer order) with this package's own component-specific + * variant catalog, so a module needs only one `@reference '#theme'` line to see both. + */ +@import '@fluentui/react-tailwind-theme-preview/css/index.css'; +@import './variants.css'; diff --git a/packages/react-components/react-windmod-preview/library/src/variants.css b/packages/react-components/react-windmod-preview/library/src/variants.css new file mode 100644 index 00000000000000..3868faf64c5290 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/variants.css @@ -0,0 +1,59 @@ +/** + * Component-specific variant catalog — component API values (appearance/intent/etc.), never + * meaningful without knowing the owning component's props. Generic vocabulary (state, structure, + * positioning/orientation, size-*) stays in the theme's `css/variants.css`; see that file's header + * for the shared rules this catalog also follows: canonical `&:where(…)` form, zero added + * specificity, group-composes automatically, no union entries — comma-separate variants at the + * call site (@variant hover, focus { … }). + * + * New component-specific entries go here, sectioned by the component that owns them. + */ + +/* Accordion */ +@custom-variant expand-icon-position-start (&:where([data-expand-icon-position='start'])); +@custom-variant expand-icon-position-end (&:where([data-expand-icon-position='end'])); + +/* Button */ +@custom-variant appearance-subtle (&:where([data-appearance='subtle'])); +@custom-variant appearance-transparent (&:where([data-appearance='transparent'])); + +/* bundled-icon glyph variant, stamped by @fluentui/react-icons/headless bundleIcon */ +@custom-variant variant-filled (&:where([data-variant='filled'])); +@custom-variant variant-regular (&:where([data-variant='regular'])); + +/* Drawer: surface edge position */ +@custom-variant position-start (&:where([data-position='start'])); +@custom-variant position-end (&:where([data-position='end'])); +@custom-variant position-bottom (&:where([data-position='bottom'])); + +/* Drawer: scroll position of a scrollable body */ +@custom-variant scroll-state-none (&:where([data-scroll-state='none'])); +@custom-variant scroll-state-top (&:where([data-scroll-state='top'])); +@custom-variant scroll-state-middle (&:where([data-scroll-state='middle'])); +@custom-variant scroll-state-bottom (&:where([data-scroll-state='bottom'])); +@custom-variant not-scroll-state-none (&:where(:not([data-scroll-state='none']))); + +/* MessageBar: intent */ +@custom-variant intent-info (&:where([data-intent='info'])); +@custom-variant intent-success (&:where([data-intent='success'])); +@custom-variant intent-warning (&:where([data-intent='warning'])); +@custom-variant intent-error (&:where([data-intent='error'])); + +/* Persona: text position */ +@custom-variant text-position-after (&:where([data-text-position='after'])); +@custom-variant text-position-before (&:where([data-text-position='before'])); +@custom-variant text-position-below (&:where([data-text-position='below'])); + +/* ProgressBar: thickness */ +@custom-variant thickness-medium (&:where([data-thickness='medium'])); +@custom-variant thickness-large (&:where([data-thickness='large'])); + +/* SpinButton: held spin repeat — persists across the repeat interval, unlike :active */ +@custom-variant spin-active (&:where([data-spin-active])); + +/* SwatchPicker: active appearance */ +@custom-variant with-active (&:where([data-active])); +@custom-variant active-inactive (&:where([data-active='inactive'])); +@custom-variant active-appearance-ring (&:where([data-active-appearance='ring'])); +@custom-variant active-appearance-shadow (&:where([data-active-appearance='shadow'])); +@custom-variant active-appearance-ring-shadow (&:where([data-active-appearance='ring-shadow'])); From b54054d712d1cc834e0ccf45870cfa404509dfff Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Fri, 21 Aug 2026 15:41:29 -0700 Subject: [PATCH 030/246] feat(windmod): add Badge Headless useBadge + Tailwind CSS-Modules styling, pixel-identical to the Griffel suite across the full appearance-color matrix, sizes, shapes and icon positions. Children presence gates on React.Children.toArray length, matching Griffel; icon styling reaches root state purely through group variants. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- .../library/etc/badge.api.md | 56 +++++ .../library/etc/react-windmod-preview.api.md | 45 ++++ .../library/package.json | 10 + .../library/src/badge.ts | 13 + .../src/components/Badge/Badge.module.css | 230 ++++++++++++++++++ .../src/components/Badge/Badge.test.tsx | 135 ++++++++++ .../library/src/components/Badge/Badge.tsx | 31 +++ .../src/components/Badge/Badge.types.ts | 36 +++ .../library/src/components/Badge/index.ts | 11 + .../src/components/Badge/useBadgeStyles.ts | 45 ++++ .../library/src/index.ts | 3 + .../src/Badge/BadgeDefault.stories.tsx | 53 ++++ .../Badge/BadgeGriffelComparison.stories.tsx | 55 +++++ .../stories/src/Badge/index.stories.tsx | 9 + .../stories/src/vr/BadgeGriffel.stories.tsx | 13 + .../stories/src/vr/BadgeVrScene.tsx | 96 ++++++++ .../stories/src/vr/BadgeWindmod.stories.tsx | 14 ++ .../stories/src/vr/index.stories.tsx | 2 + 18 files changed, 857 insertions(+) create mode 100644 packages/react-components/react-windmod-preview/library/etc/badge.api.md create mode 100644 packages/react-components/react-windmod-preview/library/src/badge.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Badge/Badge.module.css create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Badge/Badge.test.tsx create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Badge/Badge.tsx create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Badge/Badge.types.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Badge/index.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Badge/useBadgeStyles.ts create mode 100644 packages/react-components/react-windmod-preview/stories/src/Badge/BadgeDefault.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/Badge/BadgeGriffelComparison.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/Badge/index.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/BadgeGriffel.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/BadgeVrScene.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/BadgeWindmod.stories.tsx diff --git a/packages/react-components/react-windmod-preview/library/etc/badge.api.md b/packages/react-components/react-windmod-preview/library/etc/badge.api.md new file mode 100644 index 00000000000000..c1bef81900dac3 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/etc/badge.api.md @@ -0,0 +1,56 @@ +## API Report File for "@fluentui/react-windmod-preview" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { BadgeProps as BadgeProps_2 } from '@fluentui/react-headless-components-preview/badge'; +import { BadgeSlots } from '@fluentui/react-headless-components-preview/badge'; +import type { BadgeState as BadgeState_2 } from '@fluentui/react-headless-components-preview/badge'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { renderBadge } from '@fluentui/react-headless-components-preview/badge'; +import { useBadge } from '@fluentui/react-headless-components-preview/badge'; + +// @public +export const Badge: ForwardRefComponent; + +// @public +export type BadgeAppearance = 'filled' | 'ghost' | 'outline' | 'tint'; + +// @public +export const badgeClassNames: { + root: string; +}; + +// @public +export type BadgeColor = 'brand' | 'danger' | 'important' | 'informative' | 'severe' | 'subtle' | 'success' | 'warning'; + +// @public +export type BadgeProps = BadgeProps_2 & { + appearance?: BadgeAppearance; + color?: BadgeColor; + shape?: BadgeShape; + size?: BadgeSize; +}; + +// @public +export type BadgeShape = 'circular' | 'rounded' | 'square'; + +// @public +export type BadgeSize = 'tiny' | 'extra-small' | 'small' | 'medium' | 'large' | 'extra-large'; + +export { BadgeSlots } + +// @public +export type BadgeState = BadgeState_2 & Required>; + +export { renderBadge } + +export { useBadge } + +// @public +export const useBadgeStyles: (state: BadgeState) => BadgeState; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md b/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md index 5dd0ad0470099f..267be3d165cf48 100644 --- a/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md +++ b/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md @@ -4,6 +4,9 @@ ```ts +import type { BadgeProps as BadgeProps_2 } from '@fluentui/react-headless-components-preview/badge'; +import { BadgeSlots } from '@fluentui/react-headless-components-preview/badge'; +import type { BadgeState as BadgeState_2 } from '@fluentui/react-headless-components-preview/badge'; import type { ButtonProps as ButtonProps_2 } from '@fluentui/react-headless-components-preview/button'; import { ButtonSlots } from '@fluentui/react-headless-components-preview/button'; import type { ButtonState as ButtonState_2 } from '@fluentui/react-headless-components-preview/button'; @@ -21,6 +24,7 @@ import type { LinkState as LinkState_2 } from '@fluentui/react-headless-componen import { OnVisibleChangeData } from '@fluentui/react-headless-components-preview/tooltip'; import { Provider } from '@fluentui/react-headless-components-preview/provider'; import * as React_2 from 'react'; +import { renderBadge } from '@fluentui/react-headless-components-preview/badge'; import { renderButton } from '@fluentui/react-headless-components-preview/button'; import { renderDivider } from '@fluentui/react-headless-components-preview/divider'; import { renderLabel } from '@fluentui/react-headless-components-preview/label'; @@ -38,6 +42,7 @@ import type { TooltipProps as TooltipProps_2 } from '@fluentui/react-headless-co import { TooltipSlots } from '@fluentui/react-headless-components-preview/tooltip'; import type { TooltipState as TooltipState_2 } from '@fluentui/react-headless-components-preview/tooltip'; import { TooltipTriggerProps } from '@fluentui/react-headless-components-preview/tooltip'; +import { useBadge } from '@fluentui/react-headless-components-preview/badge'; import { useButton } from '@fluentui/react-headless-components-preview/button'; import { useDivider } from '@fluentui/react-headless-components-preview/divider'; import { useLabel } from '@fluentui/react-headless-components-preview/label'; @@ -47,6 +52,39 @@ import { useTooltip } from '@fluentui/react-headless-components-preview/tooltip' import { webDarkThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; import { webLightThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; +// @public +export const Badge: ForwardRefComponent; + +// @public +export type BadgeAppearance = 'filled' | 'ghost' | 'outline' | 'tint'; + +// @public +export const badgeClassNames: { + root: string; +}; + +// @public +export type BadgeColor = 'brand' | 'danger' | 'important' | 'informative' | 'severe' | 'subtle' | 'success' | 'warning'; + +// @public +export type BadgeProps = BadgeProps_2 & { + appearance?: BadgeAppearance; + color?: BadgeColor; + shape?: BadgeShape; + size?: BadgeSize; +}; + +// @public +export type BadgeShape = 'circular' | 'rounded' | 'square'; + +// @public +export type BadgeSize = 'tiny' | 'extra-small' | 'small' | 'medium' | 'large' | 'extra-large'; + +export { BadgeSlots } + +// @public +export type BadgeState = BadgeState_2 & Required>; + // @public export const Button: ForwardRefComponent; @@ -153,6 +191,8 @@ export { OnVisibleChangeData } export { Provider } +export { renderBadge } + export { renderButton } export { renderDivider } @@ -221,6 +261,11 @@ export type TooltipState = TooltipState_2 & { export { TooltipTriggerProps } +export { useBadge } + +// @public +export const useBadgeStyles: (state: BadgeState) => BadgeState; + export { useButton } // @public diff --git a/packages/react-components/react-windmod-preview/library/package.json b/packages/react-components/react-windmod-preview/library/package.json index 25a3efbc93633b..428e2729dd4488 100644 --- a/packages/react-components/react-windmod-preview/library/package.json +++ b/packages/react-components/react-windmod-preview/library/package.json @@ -52,6 +52,16 @@ "default": "./lib-commonjs/index.cjs" } }, + "./badge": { + "import": { + "types": "./dist/badge.d.ts", + "default": "./lib/badge.js" + }, + "require": { + "types": "./dist/badge.d.cts", + "default": "./lib-commonjs/badge.cjs" + } + }, "./button": { "import": { "types": "./dist/button.d.ts", diff --git a/packages/react-components/react-windmod-preview/library/src/badge.ts b/packages/react-components/react-windmod-preview/library/src/badge.ts new file mode 100644 index 00000000000000..b0ed6c8dcde179 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/badge.ts @@ -0,0 +1,13 @@ +export { Badge, badgeClassNames, useBadgeStyles } from './components/Badge'; +export type { + BadgeAppearance, + BadgeColor, + BadgeProps, + BadgeShape, + BadgeSize, + BadgeSlots, + BadgeState, +} from './components/Badge'; + +/** Headless building blocks, re-exported for consumers composing their own Badge. */ +export { renderBadge, useBadge } from '@fluentui/react-headless-components-preview/badge'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Badge/Badge.module.css b/packages/react-components/react-windmod-preview/library/src/components/Badge/Badge.module.css new file mode 100644 index 00000000000000..2085d818097cd7 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Badge/Badge.module.css @@ -0,0 +1,230 @@ +@reference '#theme'; + +/* The border rides the root's ::after so it never affects layout or padding. It carries no + module class, so it is reached with Tailwind's built-in after variant, which also supplies + `content: var(--tw-content)` — registered with initial-value "". `border-radius: inherit` + and `border-color: inherit` are how the shape and colour classes reach it. */ + +@layer fui.components.l1 { + .root { + @apply relative box-border inline-flex h-20 min-w-20 items-center justify-center rounded-circular border-transparent-stroke px-horizontal-s-nudge py-0 font-base text-base-200 leading-base-200 font-semibold; + + @variant after { + @apply absolute inset-0 rounded-[inherit] border-(length:--spacing-thin) border-solid border-inherit; + } + + @variant size-small, size-extra-small, size-tiny { + @apply text-base-100 leading-base-100; + } + + @variant size-tiny { + @apply size-6 min-w-auto p-0 text-[length:calc(4px*var(--base-scale))] leading-[calc(4px*var(--base-scale))]; + } + + @variant size-extra-small { + @apply size-10 min-w-auto p-0 text-[length:calc(6px*var(--base-scale))] leading-[calc(6px*var(--base-scale))]; + } + + @variant size-small { + @apply h-16 min-w-16 px-horizontal-xs; + } + + @variant size-large { + @apply h-24 min-w-24; + } + + @variant size-extra-large { + @apply h-32 min-w-32 px-horizontal-s; + } + } + + .rounded { + @apply rounded-medium; + + @variant size-small, size-extra-small, size-tiny { + @apply rounded-small; + } + } + + .square { + @apply rounded-none; + } + + .ghost { + @variant after { + @apply hidden; + } + } + + .outline { + @apply border-current; + } + + .filled-brand { + @apply bg-brand-background text-neutral-foreground-on-brand; + } + + .filled-danger { + @apply bg-palette-red-background-3 text-neutral-foreground-on-brand; + } + + .filled-important { + @apply bg-neutral-foreground-1 text-neutral-background-1; + } + + .filled-informative { + @apply bg-neutral-background-5 text-neutral-foreground-3; + } + + .filled-severe { + @apply bg-palette-dark-orange-background-3 text-neutral-foreground-on-brand; + } + + .filled-subtle { + @apply bg-neutral-background-1 text-neutral-foreground-1; + } + + .filled-success { + @apply bg-palette-green-background-3 text-neutral-foreground-on-brand; + } + + .filled-warning { + @apply bg-palette-yellow-background-3 text-neutral-foreground-1-static; + } + + .ghost-brand { + @apply text-brand-foreground-1; + } + + .ghost-danger { + @apply text-palette-red-foreground-3; + } + + .ghost-important { + @apply text-neutral-foreground-1; + } + + .ghost-informative { + @apply text-neutral-foreground-3; + } + + .ghost-severe { + @apply text-palette-dark-orange-foreground-3; + } + + .ghost-subtle { + @apply text-neutral-foreground-static-inverted; + } + + .ghost-success { + @apply text-palette-green-foreground-3; + } + + .ghost-warning { + @apply text-palette-yellow-foreground-2; + } + + .outline-brand { + @apply text-brand-foreground-1; + } + + .outline-danger { + @apply border-palette-red-border-2 text-palette-red-foreground-3; + } + + .outline-important { + @apply border-neutral-stroke-accessible text-neutral-foreground-3; + } + + .outline-informative { + @apply border-neutral-stroke-2 text-neutral-foreground-3; + } + + .outline-severe { + @apply text-palette-dark-orange-foreground-3; + } + + .outline-subtle { + @apply text-neutral-foreground-static-inverted; + } + + .outline-success { + @apply border-palette-green-border-2 text-palette-green-foreground-3; + } + + .outline-warning { + @apply text-palette-yellow-foreground-2; + } + + .tint-brand { + @apply border-brand-stroke-2 bg-brand-background-2 text-brand-foreground-2; + } + + .tint-danger { + @apply border-palette-red-border-1 bg-palette-red-background-1 text-palette-red-foreground-1; + } + + .tint-important { + @apply border-transparent-stroke bg-neutral-foreground-3 text-neutral-background-1; + } + + .tint-informative { + @apply border-neutral-stroke-2 bg-neutral-background-4 text-neutral-foreground-3; + } + + .tint-severe { + @apply border-palette-dark-orange-border-1 bg-palette-dark-orange-background-1 text-palette-dark-orange-foreground-1; + } + + .tint-subtle { + @apply border-neutral-stroke-2 bg-neutral-background-1 text-neutral-foreground-3; + } + + .tint-success { + @apply border-palette-green-border-1 bg-palette-green-background-1 text-palette-green-foreground-1; + } + + .tint-warning { + @apply border-palette-yellow-border-1 bg-palette-yellow-background-1 text-palette-yellow-foreground-1; + } + + .icon { + @apply -mx-horizontal-xxs my-0 flex text-[length:calc(12px*var(--base-scale))] leading-none; + + @variant group-not-empty/fui-badge { + @variant group-icon-before/fui-badge { + @apply me-horizontal-xs; + } + + @variant group-icon-after/fui-badge { + @apply ms-horizontal-xs; + } + + @variant group-size-extra-large/fui-badge { + @variant group-icon-before/fui-badge { + @apply me-horizontal-s-nudge; + } + + @variant group-icon-after/fui-badge { + @apply ms-horizontal-s-nudge; + } + } + } + + @variant group-size-tiny/fui-badge { + @apply text-[length:calc(6px*var(--base-scale))]; + } + + @variant group-size-extra-small/fui-badge { + @apply text-[length:calc(10px*var(--base-scale))]; + } + + @variant group-size-large/fui-badge { + @apply text-[length:calc(16px*var(--base-scale))]; + } + + @variant group-size-extra-large/fui-badge { + @apply text-[length:calc(20px*var(--base-scale))]; + } + } +} diff --git a/packages/react-components/react-windmod-preview/library/src/components/Badge/Badge.test.tsx b/packages/react-components/react-windmod-preview/library/src/components/Badge/Badge.test.tsx new file mode 100644 index 00000000000000..d486ace07795f0 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Badge/Badge.test.tsx @@ -0,0 +1,135 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; + +import { Badge } from './Badge'; +import { isConformant } from '../../testing/isConformant'; +import type { BadgeState } from './Badge.types'; +import { badgeClassNames, useBadgeStyles } from './useBadgeStyles'; + +import styles from './Badge.module.css'; + +describe('Badge', () => { + isConformant({ + Component: Badge, + displayName: 'Badge', + requiredProps: { children: 'Badge' }, + }); + + it('stamps data-size and the marker class', () => { + const { getByTestId } = render( + <> + Default + + Large + + , + ); + + expect(getByTestId('default').getAttribute('data-size')).toBe('medium'); + expect(getByTestId('large').getAttribute('data-size')).toBe('large'); + expect(getByTestId('default').className).toContain(badgeClassNames.root); + expect(getByTestId('default')).toHaveClass('fui-badge'); + expect(getByTestId('default')).toHaveClass('group/fui-badge'); + expect(getByTestId('default').classList[0]).toBe('fui-badge'); + }); + + it('stamps data-empty by counting rendered children, not by truthiness', () => { + const { getByTestId } = render( + <> + Content + {0} + {''} + + {null} + {false} + , + ); + + // 0 and '' render text and keep the icon spacing; null/false/absent do not. + expect(getByTestId('text').hasAttribute('data-empty')).toBe(false); + expect(getByTestId('zero').hasAttribute('data-empty')).toBe(false); + expect(getByTestId('emptyString').hasAttribute('data-empty')).toBe(false); + expect(getByTestId('none').hasAttribute('data-empty')).toBe(true); + expect(getByTestId('null').hasAttribute('data-empty')).toBe(true); + expect(getByTestId('false').hasAttribute('data-empty')).toBe(true); + }); + + it('leaves data-icon-position to the headless hook', () => { + const { getByTestId } = render( + <> + Badge + }> + Badge + + } iconPosition="after"> + Badge + + , + ); + + expect(getByTestId('noIcon').hasAttribute('data-icon-position')).toBe(false); + expect(getByTestId('before').getAttribute('data-icon-position')).toBe('before'); + expect(getByTestId('after').getAttribute('data-icon-position')).toBe('after'); + }); + + it('gives every appearance and color pair its own module class', () => { + const colors = ['brand', 'danger', 'important', 'informative', 'severe', 'subtle', 'success', 'warning'] as const; + const { getByTestId } = render( + <> + {colors.map(color => ( + + 8 + + ))} + , + ); + + // Appearance is held fixed so the one class that moves is the appearance-color pair. + const classLists = colors.map(color => getByTestId(color).className.split(' ')); + + expect(new Set(classLists.map(list => list.join(' '))).size).toBe(colors.length); + for (const list of classLists.slice(1)) { + expect(list).toHaveLength(classLists[0].length); + expect(list.filter(name => !classLists[0].includes(name))).toHaveLength(1); + } + }); + + it('looks up the root and icon classes by the keys the stylesheet authors', () => { + const { getByTestId } = render( + }> + 8 + , + ); + + // The css-module proxy answers every key, so these pin the lookup keys, not class existence. + const root = getByTestId('root'); + + expect(root).toHaveClass(styles.root); + expect(root).toHaveClass(styles.rounded); + expect(root).toHaveClass(styles.outline); + expect(root).toHaveClass(styles['outline-danger']); + expect(getByTestId('glyph').parentElement).toHaveClass(styles.icon); + }); + + it('does not mutate the state it is given', () => { + const state = { + appearance: 'filled', + color: 'brand', + components: { root: 'div', icon: 'span' }, + icon: { as: 'span', className: 'consumer-icon' }, + iconPosition: 'before', + root: { as: 'div', children: 'Badge', className: 'consumer' }, + shape: 'circular', + size: 'medium', + } as unknown as BadgeState; + + const styled = useBadgeStyles(state); + + expect(styled).not.toBe(state); + expect(state.root).not.toHaveProperty('data-size'); + expect(state.root.className).toBe('consumer'); + expect(state.icon?.className).toBe('consumer-icon'); + expect(styled.root.className).toContain('consumer'); + expect(styled.icon?.className).toContain('consumer-icon'); + }); +}); diff --git a/packages/react-components/react-windmod-preview/library/src/components/Badge/Badge.tsx b/packages/react-components/react-windmod-preview/library/src/components/Badge/Badge.tsx new file mode 100644 index 00000000000000..abc42d97c526c5 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Badge/Badge.tsx @@ -0,0 +1,31 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { renderBadge, useBadge } from '@fluentui/react-headless-components-preview/badge'; + +import type { BadgeProps, BadgeState } from './Badge.types'; +import { useBadgeStyles } from './useBadgeStyles'; + +/** + * A Badge is a visual decoration for UI elements. Windmod Badge: the headless badge + * decorated with the Fluent visual contract (Tailwind v4 + CSS Modules). + */ +export const Badge: ForwardRefComponent = React.forwardRef((props, ref) => { + // Look props belong to windmod — the headless hook neither accepts nor resolves them. + // Defaults mirror @fluentui/react-badge's styled useBadge. + const { appearance = 'filled', color = 'brand', shape = 'circular', size = 'medium', ...rest } = props; + + const state: BadgeState = { + ...useBadge(rest, ref), + appearance, + color, + shape, + size, + }; + + return renderBadge(useBadgeStyles(state)); + // Casting is required due to lack of distributive union to support union on @types/react +}) as ForwardRefComponent; + +Badge.displayName = 'Badge'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Badge/Badge.types.ts b/packages/react-components/react-windmod-preview/library/src/components/Badge/Badge.types.ts new file mode 100644 index 00000000000000..7b0cf7927345ee --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Badge/Badge.types.ts @@ -0,0 +1,36 @@ +import type { + BadgeProps as BadgeHeadlessProps, + BadgeState as BadgeHeadlessState, +} from '@fluentui/react-headless-components-preview/badge'; + +export type { BadgeSlots } from '@fluentui/react-headless-components-preview/badge'; + +/** Visual style of the Badge. `'filled'` is the base look. */ +export type BadgeAppearance = 'filled' | 'ghost' | 'outline' | 'tint'; + +/** Preset color of the Badge. */ +export type BadgeColor = 'brand' | 'danger' | 'important' | 'informative' | 'severe' | 'subtle' | 'success' | 'warning'; + +/** Corner treatment of the Badge. `'circular'` is the base look. */ +export type BadgeShape = 'circular' | 'rounded' | 'square'; + +/** Size of the Badge. */ +export type BadgeSize = 'tiny' | 'extra-small' | 'small' | 'medium' | 'large' | 'extra-large'; + +/** + * Windmod Badge props: the headless badge plus the look props the headless surface + * deliberately omits (they exist purely to select styles). + */ +export type BadgeProps = BadgeHeadlessProps & { + /** @default 'filled' */ + appearance?: BadgeAppearance; + /** @default 'brand' */ + color?: BadgeColor; + /** @default 'circular' */ + shape?: BadgeShape; + /** @default 'medium' */ + size?: BadgeSize; +}; + +/** Windmod Badge state: headless state plus the resolved look props. */ +export type BadgeState = BadgeHeadlessState & Required>; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Badge/index.ts b/packages/react-components/react-windmod-preview/library/src/components/Badge/index.ts new file mode 100644 index 00000000000000..51e01578c2d021 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Badge/index.ts @@ -0,0 +1,11 @@ +export { Badge } from './Badge'; +export { badgeClassNames, useBadgeStyles } from './useBadgeStyles'; +export type { + BadgeAppearance, + BadgeColor, + BadgeProps, + BadgeShape, + BadgeSize, + BadgeSlots, + BadgeState, +} from './Badge.types'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Badge/useBadgeStyles.ts b/packages/react-components/react-windmod-preview/library/src/components/Badge/useBadgeStyles.ts new file mode 100644 index 00000000000000..40c30810268e7d --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Badge/useBadgeStyles.ts @@ -0,0 +1,45 @@ +import * as React from 'react'; +import { clsx } from 'clsx'; + +import { componentMarkers } from '../../utils/groupMarker'; +import type { BadgeState } from './Badge.types'; + +import styles from './Badge.module.css'; + +/** The only public classes — see componentMarkers; internals are hashed idents. */ +export const badgeClassNames: { root: string } = { + root: componentMarkers('badge'), +}; + +type BadgeRootDataAttributes = { + 'data-size'?: BadgeState['size']; + 'data-empty'?: true; +}; + +/** Applies the visual contract, returning new state. The headless hook already stamps + * data-icon-position, and only when an icon slot is present. `data-empty` must count with + * React.Children.toArray: `0` and `''` are rendered content that keeps the icon spacing, + * while `[]` is not. `circular`, `filled` and `tint` are the base looks and carry no class. */ +export const useBadgeStyles = (state: BadgeState): BadgeState => { + const { appearance, color, shape, size } = state; + + const root: BadgeState['root'] & BadgeRootDataAttributes = { + ...state.root, + 'data-size': size, + 'data-empty': React.Children.toArray(state.root.children).length === 0 || undefined, + className: clsx( + badgeClassNames.root, + styles.root, + styles[shape], + styles[appearance], + styles[`${appearance}-${color}`], + state.root.className, + ), + }; + + return { + ...state, + root, + icon: state.icon && { ...state.icon, className: clsx(styles.icon, state.icon.className) }, + }; +}; diff --git a/packages/react-components/react-windmod-preview/library/src/index.ts b/packages/react-components/react-windmod-preview/library/src/index.ts index 896a245bae3f84..a75ce35758e19c 100644 --- a/packages/react-components/react-windmod-preview/library/src/index.ts +++ b/packages/react-components/react-windmod-preview/library/src/index.ts @@ -1,3 +1,6 @@ +export { Badge, badgeClassNames, renderBadge, useBadge, useBadgeStyles } from './badge'; +export type { BadgeAppearance, BadgeColor, BadgeProps, BadgeShape, BadgeSize, BadgeSlots, BadgeState } from './badge'; + export { Button, buttonClassNames, renderButton, useButton, useButtonStyles } from './button'; export type { ButtonAppearance, ButtonProps, ButtonShape, ButtonSize, ButtonSlots, ButtonState } from './button'; diff --git a/packages/react-components/react-windmod-preview/stories/src/Badge/BadgeDefault.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Badge/BadgeDefault.stories.tsx new file mode 100644 index 00000000000000..f3ad78804a118b --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/Badge/BadgeDefault.stories.tsx @@ -0,0 +1,53 @@ +import * as React from 'react'; +import { Badge, ThemeProvider } from '@fluentui/react-windmod-preview'; +import { CalendarMonthFilled, CalendarMonthRegular } from '@fluentui/react-icons'; +import { bundleIcon } from '@fluentui/react-icons/headless'; + +import styles from '../compare.module.css'; + +const CalendarMonth = bundleIcon(CalendarMonthFilled, CalendarMonthRegular); + +const appearances = ['filled', 'ghost', 'outline', 'tint'] as const; +const colors = ['brand', 'danger', 'important', 'informative', 'severe', 'subtle', 'success', 'warning'] as const; +const shapes = ['circular', 'rounded', 'square'] as const; +const sizes = ['tiny', 'extra-small', 'small', 'medium', 'large', 'extra-large'] as const; + +export const Default = (): React.ReactNode => ( + +
+ {appearances.map(appearance => ( +
+ {colors.map(color => ( + + {color} + + ))} +
+ ))} +
+ {sizes.map(size => ( + + 8 + + ))} +
+
+ {shapes.map(shape => ( + + {shape} + + ))} +
+
+ }>Icon before + } iconPosition="after"> + Icon after + + } /> + } size="extra-large"> + 8 + +
+
+
+); diff --git a/packages/react-components/react-windmod-preview/stories/src/Badge/BadgeGriffelComparison.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Badge/BadgeGriffelComparison.stories.tsx new file mode 100644 index 00000000000000..eab0f1be486705 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/Badge/BadgeGriffelComparison.stories.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import { Badge, ThemeProvider } from '@fluentui/react-windmod-preview'; +import { Badge as GriffelBadge, FluentProvider, webLightTheme } from '@fluentui/react-components'; + +import styles from '../compare.module.css'; + +type LookProps = { + appearance?: 'filled' | 'ghost' | 'outline' | 'tint'; + color?: 'brand' | 'danger' | 'important' | 'informative' | 'severe' | 'subtle' | 'success' | 'warning'; + shape?: 'circular' | 'rounded' | 'square'; + size?: 'tiny' | 'extra-small' | 'small' | 'medium' | 'large' | 'extra-large'; +}; + +/** + * Every windmod variant next to its Griffel-suite twin (inside a FluentProvider). + * The pair in each row must be pixel-identical. + */ +export const GriffelComparison = (): React.ReactNode => { + const variants: Array<{ label: string; props: LookProps }> = [ + { label: 'filled brand', props: {} }, + { label: 'ghost danger', props: { appearance: 'ghost', color: 'danger' } }, + { label: 'outline important', props: { appearance: 'outline', color: 'important' } }, + { label: 'tint success', props: { appearance: 'tint', color: 'success' } }, + { label: 'tint warning rounded', props: { appearance: 'tint', color: 'warning', shape: 'rounded' } }, + { label: 'outline severe square', props: { appearance: 'outline', color: 'severe', shape: 'square' } }, + { label: 'tiny', props: { size: 'tiny' } }, + { label: 'extra-small', props: { size: 'extra-small' } }, + { label: 'small rounded', props: { size: 'small', shape: 'rounded' } }, + { label: 'large', props: { size: 'large' } }, + { label: 'extra-large', props: { size: 'extra-large' } }, + ]; + + return ( +
+
Variant
+
Windmod
+
Griffel
+ {variants.map(({ label, props }) => ( + +
{label}
+
+ + 8 + +
+
+ + 8 + +
+
+ ))} +
+ ); +}; diff --git a/packages/react-components/react-windmod-preview/stories/src/Badge/index.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Badge/index.stories.tsx new file mode 100644 index 00000000000000..935d881f3c327e --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/Badge/index.stories.tsx @@ -0,0 +1,9 @@ +import { Badge } from '@fluentui/react-windmod-preview'; + +export { Default } from './BadgeDefault.stories'; +export { GriffelComparison } from './BadgeGriffelComparison.stories'; + +export default { + title: 'Windmod/Badge', + component: Badge, +}; diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/BadgeGriffel.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/BadgeGriffel.stories.tsx new file mode 100644 index 00000000000000..91d3c63fe74216 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/BadgeGriffel.stories.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import { Badge, FluentProvider, webLightTheme } from '@fluentui/react-components'; +import { bundleIcon, CalendarMonthFilled, CalendarMonthRegular } from '@fluentui/react-icons'; + +import { BadgeVrScene } from './BadgeVrScene'; + +const CalendarMonth = bundleIcon(CalendarMonthFilled, CalendarMonthRegular); + +export const BadgeGriffel = (): React.ReactNode => ( + + + +); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/BadgeVrScene.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/BadgeVrScene.tsx new file mode 100644 index 00000000000000..0845d6e92934cd --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/BadgeVrScene.tsx @@ -0,0 +1,96 @@ +// eslint-disable-next-line import/no-extraneous-dependencies -- story-only helper outside the *.stories.tsx devDependencies exemption +import * as React from 'react'; + +const appearances = ['filled', 'ghost', 'outline', 'tint'] as const; +const colors = ['brand', 'danger', 'important', 'informative', 'severe', 'subtle', 'success', 'warning'] as const; +const shapes = ['circular', 'rounded', 'square'] as const; +const sizes = ['tiny', 'extra-small', 'small', 'medium', 'large', 'extra-large'] as const; + +type BadgeLike = React.ComponentType<{ + appearance?: (typeof appearances)[number]; + color?: (typeof colors)[number]; + shape?: (typeof shapes)[number]; + size?: (typeof sizes)[number]; + icon?: React.ReactElement; + iconPosition?: 'before' | 'after'; + children?: React.ReactNode; +}>; + +const row: React.CSSProperties = { display: 'flex', gap: 8, alignItems: 'center' }; + +/** One scene, two implementations — the VR runner diffs the renders pixel for pixel. */ +export const BadgeVrScene = ({ Badge, Icon }: { Badge: BadgeLike; Icon: React.ComponentType }): React.ReactNode => ( +
+ {appearances.map(appearance => + colors.map(color => ( +
+ {sizes.map(size => ( + + 8 + + ))} +
+ )), + )} + {appearances.map(appearance => + shapes.map(shape => ( +
+ {sizes.map(size => ( + + 8 + + ))} +
+ )), + )} +
+ {sizes.map(size => ( + }> + 8 + + ))} +
+
+ {sizes.map(size => ( + } iconPosition="after"> + 8 + + ))} +
+
+ {sizes.map(size => ( + } /> + ))} +
+
+ {sizes.map(size => ( + } iconPosition="after" /> + ))} +
+ {/* Falsy-but-rendered children: the icon spacing survives 0 and '' — do not fold these + into && expressions, `false` would render a childless badge instead. */} +
+ }>{0} + } iconPosition="after"> + {0} + + }>{''} + } iconPosition="after"> + {''} + +
+ {/* Three of the four subtle looks are white or near-white; a wrong token there is only + observable against a dark ground. */} +
+ {appearances.map(appearance => ( +
+ {sizes.map(size => ( + + 8 + + ))} +
+ ))} +
+
+); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/BadgeWindmod.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/BadgeWindmod.stories.tsx new file mode 100644 index 00000000000000..328a4d0ca1b244 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/BadgeWindmod.stories.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import { Badge, ThemeProvider } from '@fluentui/react-windmod-preview'; +import { CalendarMonthFilled, CalendarMonthRegular } from '@fluentui/react-icons'; +import { bundleIcon } from '@fluentui/react-icons/headless'; + +import { BadgeVrScene } from './BadgeVrScene'; + +const CalendarMonth = bundleIcon(CalendarMonthFilled, CalendarMonthRegular); + +export const BadgeWindmod = (): React.ReactNode => ( + + + +); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx index 78b4b6bf56eb1f..a8756b7f890604 100644 --- a/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx +++ b/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx @@ -1,3 +1,5 @@ +export { BadgeWindmod } from './BadgeWindmod.stories'; +export { BadgeGriffel } from './BadgeGriffel.stories'; export { ButtonWindmod } from './ButtonWindmod.stories'; export { ButtonGriffel } from './ButtonGriffel.stories'; export { DividerWindmod } from './DividerWindmod.stories'; From c512815ac96f58c686e870cb4cfa1602947f03f8 Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Fri, 21 Aug 2026 16:40:13 -0700 Subject: [PATCH 031/246] feat(windmod): add Spinner Headless useSpinner + Tailwind CSS-Modules styling, pixel-identical to the Griffel suite in the frozen rest state across sizes, appearances and label positions. The CSS-Modules pipeline scopes the four keyframes; animation properties are authored as longhands so utility sorting cannot reset timing. RTL is a single scale flip on the spinner, an exact mirror of the upstream rotation treatment. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- .../library/etc/react-windmod-preview.api.md | 37 ++++ .../library/etc/spinner.api.md | 48 +++++ .../library/package.json | 10 + .../src/components/Spinner/Spinner.module.css | 195 ++++++++++++++++++ .../src/components/Spinner/Spinner.test.tsx | 147 +++++++++++++ .../src/components/Spinner/Spinner.tsx | 29 +++ .../src/components/Spinner/Spinner.types.ts | 26 +++ .../library/src/components/Spinner/index.ts | 3 + .../components/Spinner/useSpinnerStyles.ts | 42 ++++ .../library/src/index.ts | 3 + .../library/src/spinner.ts | 5 + .../library/src/variants.css | 3 + .../src/Spinner/SpinnerDefault.stories.tsx | 30 +++ .../SpinnerGriffelComparison.stories.tsx | 57 +++++ .../stories/src/Spinner/index.stories.tsx | 9 + .../stories/src/vr/SpinnerGriffel.stories.tsx | 10 + .../stories/src/vr/SpinnerVrScene.tsx | 51 +++++ .../stories/src/vr/SpinnerWindmod.stories.tsx | 10 + .../stories/src/vr/index.stories.tsx | 2 + 19 files changed, 717 insertions(+) create mode 100644 packages/react-components/react-windmod-preview/library/etc/spinner.api.md create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Spinner/Spinner.module.css create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Spinner/Spinner.test.tsx create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Spinner/Spinner.tsx create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Spinner/Spinner.types.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Spinner/index.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Spinner/useSpinnerStyles.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/spinner.ts create mode 100644 packages/react-components/react-windmod-preview/stories/src/Spinner/SpinnerDefault.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/Spinner/SpinnerGriffelComparison.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/Spinner/index.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/SpinnerGriffel.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/SpinnerVrScene.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/SpinnerWindmod.stories.tsx diff --git a/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md b/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md index 267be3d165cf48..83e3cc50462fb4 100644 --- a/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md +++ b/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md @@ -30,7 +30,11 @@ import { renderDivider } from '@fluentui/react-headless-components-preview/divid import { renderLabel } from '@fluentui/react-headless-components-preview/label'; import { renderLink } from '@fluentui/react-headless-components-preview/link'; import { renderProvider } from '@fluentui/react-headless-components-preview/provider'; +import { renderSpinner } from '@fluentui/react-headless-components-preview/spinner'; import { renderTooltip } from '@fluentui/react-headless-components-preview/tooltip'; +import type { SpinnerProps as SpinnerProps_2 } from '@fluentui/react-headless-components-preview/spinner'; +import { SpinnerSlots } from '@fluentui/react-headless-components-preview/spinner'; +import type { SpinnerState as SpinnerState_2 } from '@fluentui/react-headless-components-preview/spinner'; import { teamsDarkThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; import { teamsDarkV21ThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; import { teamsHighContrastThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; @@ -48,6 +52,7 @@ import { useDivider } from '@fluentui/react-headless-components-preview/divider' import { useLabel } from '@fluentui/react-headless-components-preview/label'; import { useLink } from '@fluentui/react-headless-components-preview/link'; import { useProvider } from '@fluentui/react-headless-components-preview/provider'; +import { useSpinner } from '@fluentui/react-headless-components-preview/spinner'; import { useTooltip } from '@fluentui/react-headless-components-preview/tooltip'; import { webDarkThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; import { webLightThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; @@ -203,8 +208,35 @@ export { renderLink } export { renderProvider } +export { renderSpinner } + export { renderTooltip } +// @public +export const Spinner: ForwardRefComponent; + +// @public +export type SpinnerAppearance = 'primary' | 'inverted'; + +// @public +export const spinnerClassNames: { + root: string; +}; + +// @public +export type SpinnerProps = SpinnerProps_2 & { + appearance?: SpinnerAppearance; + size?: SpinnerSize; +}; + +// @public +export type SpinnerSize = 'extra-tiny' | 'tiny' | 'extra-small' | 'small' | 'medium' | 'large' | 'extra-large' | 'huge'; + +export { SpinnerSlots } + +// @public +export type SpinnerState = SpinnerState_2 & Required>; + export { teamsDarkThemeClassName } export { teamsDarkV21ThemeClassName } @@ -288,6 +320,11 @@ export const useLinkStyles: (state: LinkState) => LinkState; export { useProvider } +export { useSpinner } + +// @public +export const useSpinnerStyles: (state: SpinnerState) => SpinnerState; + export { useTooltip } // @public diff --git a/packages/react-components/react-windmod-preview/library/etc/spinner.api.md b/packages/react-components/react-windmod-preview/library/etc/spinner.api.md new file mode 100644 index 00000000000000..38432255ba2c2f --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/etc/spinner.api.md @@ -0,0 +1,48 @@ +## API Report File for "@fluentui/react-windmod-preview" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { renderSpinner } from '@fluentui/react-headless-components-preview/spinner'; +import type { SpinnerProps as SpinnerProps_2 } from '@fluentui/react-headless-components-preview/spinner'; +import { SpinnerSlots } from '@fluentui/react-headless-components-preview/spinner'; +import type { SpinnerState as SpinnerState_2 } from '@fluentui/react-headless-components-preview/spinner'; +import { useSpinner } from '@fluentui/react-headless-components-preview/spinner'; + +export { renderSpinner } + +// @public +export const Spinner: ForwardRefComponent; + +// @public +export type SpinnerAppearance = 'primary' | 'inverted'; + +// @public +export const spinnerClassNames: { + root: string; +}; + +// @public +export type SpinnerProps = SpinnerProps_2 & { + appearance?: SpinnerAppearance; + size?: SpinnerSize; +}; + +// @public +export type SpinnerSize = 'extra-tiny' | 'tiny' | 'extra-small' | 'small' | 'medium' | 'large' | 'extra-large' | 'huge'; + +export { SpinnerSlots } + +// @public +export type SpinnerState = SpinnerState_2 & Required>; + +export { useSpinner } + +// @public +export const useSpinnerStyles: (state: SpinnerState) => SpinnerState; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-windmod-preview/library/package.json b/packages/react-components/react-windmod-preview/library/package.json index 428e2729dd4488..5d0d301cd4acf8 100644 --- a/packages/react-components/react-windmod-preview/library/package.json +++ b/packages/react-components/react-windmod-preview/library/package.json @@ -112,6 +112,16 @@ "default": "./lib-commonjs/provider.cjs" } }, + "./spinner": { + "import": { + "types": "./dist/spinner.d.ts", + "default": "./lib/spinner.js" + }, + "require": { + "types": "./dist/spinner.d.cts", + "default": "./lib-commonjs/spinner.cjs" + } + }, "./tooltip": { "import": { "types": "./dist/tooltip.d.ts", diff --git a/packages/react-components/react-windmod-preview/library/src/components/Spinner/Spinner.module.css b/packages/react-components/react-windmod-preview/library/src/components/Spinner/Spinner.module.css new file mode 100644 index 00000000000000..c7c35edab2fe46 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Spinner/Spinner.module.css @@ -0,0 +1,195 @@ +@reference '#theme'; + +/* Ring geometry lives entirely in masks: the spinner's radial mask cuts the disc down to a + stroke-width ring, and the tail's conic mask hides the first 105deg of the two conic-gradient + pseudo-elements behind it. The pseudos carry no module class, so Tailwind's before/after + variants reach them; those variants also supply `content: var(--tw-content)`, registered with + initial-value "". `animation: inherit` must stay ahead of the per-pseudo animation-name blocks. */ + +/* RTL is the plain horizontal mirror of LTR: mirroring the spinner also mirrors the tail, both + conic gradients and the sweep direction, which is what Griffel produces from its rtlTail entry + plus its automatic rotate() flipping. `scale` is the individual transform property, so the + `transform` keyframes cannot clobber it. */ + +@keyframes rotate { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +@keyframes tail-rotate { + 0% { + transform: rotate(-135deg); + } + 50% { + transform: rotate(0deg); + } + 100% { + transform: rotate(225deg); + } +} + +@keyframes tail-before-rotate { + 0% { + transform: rotate(0deg); + } + 50% { + transform: rotate(105deg); + } + 100% { + transform: rotate(0deg); + } +} + +@keyframes tail-after-rotate { + 0% { + transform: rotate(0deg); + } + 50% { + transform: rotate(225deg); + } + 100% { + transform: rotate(0deg); + } +} + +@layer fui.components.l1 { + .root { + @apply flex min-w-min items-center justify-center gap-8 overflow-hidden leading-[0]; + + @variant label-above, label-below { + @apply flex-col; + } + } + + .spinner { + @apply relative shrink-0 bg-brand-stroke-2-contrast text-brand-stroke-1 [animation-duration:1.5s] [animation-iteration-count:infinite] [animation-name:rotate] [animation-timing-function:linear]; + + -webkit-mask-image: radial-gradient( + closest-side, + transparent calc(100% - var(--fui-spinner-stroke-width) - 1px), + white calc(100% - var(--fui-spinner-stroke-width)) calc(100% - 1px), + transparent 100% + ); + mask-image: radial-gradient( + closest-side, + transparent calc(100% - var(--fui-spinner-stroke-width) - 1px), + white calc(100% - var(--fui-spinner-stroke-width)) calc(100% - 1px), + transparent 100% + ); + + @variant forced-colors { + @apply bg-[HighlightText] text-[Highlight] forced-color-adjust-none; + } + + @variant motion-reduce { + @apply [animation-duration:1.8s]; + } + + @variant group-size-extra-tiny/fui-spinner { + @apply size-16; + + --fui-spinner-stroke-width: var(--spacing-thick); + } + + @variant group-size-tiny/fui-spinner { + @apply size-20; + + --fui-spinner-stroke-width: var(--spacing-thick); + } + + @variant group-size-extra-small/fui-spinner { + @apply size-24; + + --fui-spinner-stroke-width: var(--spacing-thick); + } + + @variant group-size-small/fui-spinner { + @apply size-28; + + --fui-spinner-stroke-width: var(--spacing-thick); + } + + @variant group-size-medium/fui-spinner { + @apply size-32; + + --fui-spinner-stroke-width: var(--spacing-thicker); + } + + @variant group-size-large/fui-spinner { + @apply size-36; + + --fui-spinner-stroke-width: var(--spacing-thicker); + } + + @variant group-size-extra-large/fui-spinner { + @apply size-40; + + --fui-spinner-stroke-width: var(--spacing-thicker); + } + + @variant group-size-huge/fui-spinner { + @apply size-44; + + --fui-spinner-stroke-width: var(--spacing-thickest); + } + + @variant group-appearance-inverted/fui-spinner { + @apply bg-neutral-stroke-alpha-2 text-neutral-stroke-on-brand-2; + } + + @variant rtl { + @apply -scale-x-100; + } + } + + .tail { + @apply absolute block size-full [animation-duration:1.5s] [animation-iteration-count:infinite] [animation-name:tail-rotate] [animation-timing-function:var(--ease-easy-ease)]; + + -webkit-mask-image: conic-gradient(transparent 105deg, white 105deg); + mask-image: conic-gradient(transparent 105deg, white 105deg); + + @variant before, after { + @apply absolute block size-full [animation:inherit]; + + background-image: conic-gradient(currentcolor 135deg, transparent 135deg); + } + + @variant before { + @apply [animation-name:tail-before-rotate]; + } + + @variant after { + @apply [animation-name:tail-after-rotate]; + } + + @variant motion-reduce { + @apply [animation-iteration-count:0]; + + background-image: conic-gradient(transparent 120deg, currentcolor 360deg); + + @variant before, after { + @apply content-none; + } + } + } + + .label { + @apply font-base text-base-300 leading-base-300 font-regular text-neutral-foreground-1; + + @variant group-size-medium/fui-spinner, group-size-large/fui-spinner, group-size-extra-large/fui-spinner { + @apply text-base-400 leading-base-400 font-semibold; + } + + @variant group-size-huge/fui-spinner { + @apply text-base-500 leading-base-500 font-semibold; + } + + @variant group-appearance-inverted/fui-spinner { + @apply text-neutral-foreground-static-inverted; + } + } +} diff --git a/packages/react-components/react-windmod-preview/library/src/components/Spinner/Spinner.test.tsx b/packages/react-components/react-windmod-preview/library/src/components/Spinner/Spinner.test.tsx new file mode 100644 index 00000000000000..eb9a1e190023e9 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Spinner/Spinner.test.tsx @@ -0,0 +1,147 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; + +import { Spinner } from './Spinner'; +import { isConformant } from '../../testing/isConformant'; +import type { SpinnerState } from './Spinner.types'; +import { spinnerClassNames, useSpinnerStyles } from './useSpinnerStyles'; + +import styles from './Spinner.module.css'; + +describe('Spinner', () => { + isConformant({ + Component: Spinner, + displayName: 'Spinner', + requiredProps: { label: 'Spinner' }, + }); + + it('stamps the look props and the marker class', () => { + const { getByTestId } = render( + <> + + + , + ); + + expect(getByTestId('default').getAttribute('data-size')).toBe('medium'); + expect(getByTestId('default').getAttribute('data-appearance')).toBe('primary'); + expect(getByTestId('huge').getAttribute('data-size')).toBe('huge'); + expect(getByTestId('huge').getAttribute('data-appearance')).toBe('inverted'); + expect(getByTestId('default').className).toContain(spinnerClassNames.root); + expect(getByTestId('default')).toHaveClass('fui-spinner'); + expect(getByTestId('default')).toHaveClass('group/fui-spinner'); + expect(getByTestId('default').classList[0]).toBe('fui-spinner'); + }); + + it('leaves data-label-position to the headless hook, which stamps it with or without a label', () => { + const { getByTestId } = render( + <> + + + + , + ); + + // Always present, so it can never be read as a label-presence test. + expect(getByTestId('noLabel').getAttribute('data-label-position')).toBe('after'); + expect(getByTestId('default').getAttribute('data-label-position')).toBe('after'); + expect(getByTestId('above').getAttribute('data-label-position')).toBe('above'); + }); + + it('renders the spinner and tail slots, and orders the label by labelPosition', () => { + const { getByTestId } = render( + <> + + + + + + , + ); + + const bare = getByTestId('noLabel'); + + expect(bare.children).toHaveLength(1); + expect(bare.firstElementChild?.tagName).toBe('SPAN'); + expect(bare.firstElementChild?.children).toHaveLength(1); + expect(bare.firstElementChild?.firstElementChild?.tagName).toBe('SPAN'); + + expect(getByTestId('before').firstElementChild?.tagName).toBe('LABEL'); + expect(getByTestId('above').firstElementChild?.tagName).toBe('LABEL'); + expect(getByTestId('after').lastElementChild?.tagName).toBe('LABEL'); + expect(getByTestId('below').lastElementChild?.tagName).toBe('LABEL'); + }); + + it('keeps the headless accessibility contract intact', () => { + const { getByTestId } = render(); + + const root = getByTestId('root'); + + expect(root.getAttribute('role')).toBe('progressbar'); + expect(root.getAttribute('aria-labelledby')).toBe(root.querySelector('label')?.id); + }); + + it('looks up the four slot classes by the keys the stylesheet authors', () => { + const { getByTestId } = render(); + + // The css-module proxy answers every key, so these pin the lookup keys, not class existence. + const root = getByTestId('root'); + const spinner = root.querySelector('span'); + + expect(root).toHaveClass(styles.root); + expect(spinner).toHaveClass(styles.spinner); + expect(spinner?.firstElementChild).toHaveClass(styles.tail); + expect(root.querySelector('label')).toHaveClass(styles.label); + expect(root).not.toHaveClass(styles.spinner); + expect(spinner).not.toHaveClass(styles.root); + }); + + it('does not mutate the state it is given', () => { + const state = { + appearance: 'primary', + components: { root: 'div', spinner: 'span', spinnerTail: 'span', label: 'label' }, + delay: 0, + label: { as: 'label', children: 'Loading', className: 'consumer-label' }, + labelPosition: 'after', + root: { as: 'div', className: 'consumer' }, + shouldRenderSpinner: true, + size: 'medium', + spinner: { as: 'span', className: 'consumer-spinner' }, + spinnerTail: { as: 'span', className: 'consumer-tail' }, + } as unknown as SpinnerState; + + const styled = useSpinnerStyles(state); + + expect(styled).not.toBe(state); + expect(state.root).not.toHaveProperty('data-size'); + expect(state.root).not.toHaveProperty('data-appearance'); + expect(state.root.className).toBe('consumer'); + expect(state.spinner?.className).toBe('consumer-spinner'); + expect(state.spinnerTail?.className).toBe('consumer-tail'); + expect(state.label?.className).toBe('consumer-label'); + expect(styled.root.className).toContain('consumer'); + expect(styled.spinner?.className).toContain('consumer-spinner'); + expect(styled.spinnerTail?.className).toContain('consumer-tail'); + expect(styled.label?.className).toContain('consumer-label'); + }); + + it('passes absent slots through instead of decorating them', () => { + const state = { + appearance: 'primary', + components: { root: 'div', spinner: 'span', spinnerTail: 'span', label: 'label' }, + delay: 0, + labelPosition: 'after', + root: { as: 'div' }, + shouldRenderSpinner: true, + size: 'medium', + spinnerTail: { as: 'span' }, + } as unknown as SpinnerState; + + const styled = useSpinnerStyles(state); + + expect(styled.spinner).toBeUndefined(); + expect(styled.label).toBeUndefined(); + expect(styled.spinnerTail?.className).toBe(styles.tail); + expect(styled.root.className).toBe(`${spinnerClassNames.root} ${styles.root}`); + }); +}); diff --git a/packages/react-components/react-windmod-preview/library/src/components/Spinner/Spinner.tsx b/packages/react-components/react-windmod-preview/library/src/components/Spinner/Spinner.tsx new file mode 100644 index 00000000000000..a3a6a570c76923 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Spinner/Spinner.tsx @@ -0,0 +1,29 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { renderSpinner, useSpinner } from '@fluentui/react-headless-components-preview/spinner'; + +import type { SpinnerProps, SpinnerState } from './Spinner.types'; +import { useSpinnerStyles } from './useSpinnerStyles'; + +/** + * A Spinner is an animated loading indicator. Windmod Spinner: the headless spinner + * decorated with the Fluent visual contract (Tailwind v4 + CSS Modules). + */ +export const Spinner: ForwardRefComponent = React.forwardRef((props, ref) => { + // Look props belong to windmod — the headless hook neither accepts nor resolves them. + // Defaults mirror @fluentui/react-spinner's styled useSpinner. + const { appearance = 'primary', size = 'medium', ...rest } = props; + + const state: SpinnerState = { + ...useSpinner(rest, ref), + appearance, + size, + }; + + return renderSpinner(useSpinnerStyles(state)); + // Casting is required due to lack of distributive union to support union on @types/react +}) as ForwardRefComponent; + +Spinner.displayName = 'Spinner'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Spinner/Spinner.types.ts b/packages/react-components/react-windmod-preview/library/src/components/Spinner/Spinner.types.ts new file mode 100644 index 00000000000000..5e85b951b18cd0 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Spinner/Spinner.types.ts @@ -0,0 +1,26 @@ +import type { + SpinnerProps as SpinnerHeadlessProps, + SpinnerState as SpinnerHeadlessState, +} from '@fluentui/react-headless-components-preview/spinner'; + +export type { SpinnerSlots } from '@fluentui/react-headless-components-preview/spinner'; + +/** Visual style of the Spinner. `'primary'` is the base look. */ +export type SpinnerAppearance = 'primary' | 'inverted'; + +/** Size of the Spinner. */ +export type SpinnerSize = 'extra-tiny' | 'tiny' | 'extra-small' | 'small' | 'medium' | 'large' | 'extra-large' | 'huge'; + +/** + * Windmod Spinner props: the headless spinner plus the look props the headless surface + * deliberately omits (they exist purely to select styles). + */ +export type SpinnerProps = SpinnerHeadlessProps & { + /** @default 'primary' */ + appearance?: SpinnerAppearance; + /** @default 'medium' */ + size?: SpinnerSize; +}; + +/** Windmod Spinner state: headless state plus the resolved look props. */ +export type SpinnerState = SpinnerHeadlessState & Required>; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Spinner/index.ts b/packages/react-components/react-windmod-preview/library/src/components/Spinner/index.ts new file mode 100644 index 00000000000000..30a05289356183 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Spinner/index.ts @@ -0,0 +1,3 @@ +export { Spinner } from './Spinner'; +export { spinnerClassNames, useSpinnerStyles } from './useSpinnerStyles'; +export type { SpinnerAppearance, SpinnerProps, SpinnerSize, SpinnerSlots, SpinnerState } from './Spinner.types'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Spinner/useSpinnerStyles.ts b/packages/react-components/react-windmod-preview/library/src/components/Spinner/useSpinnerStyles.ts new file mode 100644 index 00000000000000..683965e597ebd1 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Spinner/useSpinnerStyles.ts @@ -0,0 +1,42 @@ +import { clsx } from 'clsx'; + +import { componentMarkers } from '../../utils/groupMarker'; +import type { SpinnerState } from './Spinner.types'; + +import styles from './Spinner.module.css'; + +/** The only public classes — see componentMarkers; internals are hashed idents. */ +export const spinnerClassNames: { root: string } = { + root: componentMarkers('spinner'), +}; + +type SpinnerRootDataAttributes = { + 'data-appearance'?: SpinnerState['appearance']; + 'data-size'?: SpinnerState['size']; +}; + +/** Applies the visual contract, returning new state. The headless hook already stamps + * data-label-position, on every Spinner whether or not a label slot is present, so it is + * never a label-presence test. `spinnerTail` is always resolved but still types as optional, + * so its guard is required. `primary` is the base look and carries no class. */ +export const useSpinnerStyles = (state: SpinnerState): SpinnerState => { + const { appearance, size } = state; + + const root: SpinnerState['root'] & SpinnerRootDataAttributes = { + ...state.root, + 'data-appearance': appearance, + 'data-size': size, + className: clsx(spinnerClassNames.root, styles.root, state.root.className), + }; + + return { + ...state, + root, + spinner: state.spinner && { ...state.spinner, className: clsx(styles.spinner, state.spinner.className) }, + spinnerTail: state.spinnerTail && { + ...state.spinnerTail, + className: clsx(styles.tail, state.spinnerTail.className), + }, + label: state.label && { ...state.label, className: clsx(styles.label, state.label.className) }, + }; +}; diff --git a/packages/react-components/react-windmod-preview/library/src/index.ts b/packages/react-components/react-windmod-preview/library/src/index.ts index a75ce35758e19c..59d5c8fe4c4699 100644 --- a/packages/react-components/react-windmod-preview/library/src/index.ts +++ b/packages/react-components/react-windmod-preview/library/src/index.ts @@ -13,6 +13,9 @@ export type { LabelProps, LabelSize, LabelSlots, LabelState, LabelWeight } from export { Link, linkClassNames, renderLink, useLink, useLinkStyles } from './link'; export type { LinkAppearance, LinkProps, LinkSlots, LinkState } from './link'; +export { renderSpinner, Spinner, spinnerClassNames, useSpinner, useSpinnerStyles } from './spinner'; +export type { SpinnerAppearance, SpinnerProps, SpinnerSize, SpinnerSlots, SpinnerState } from './spinner'; + export { renderTooltip, Tooltip, tooltipClassNames, useTooltip, useTooltipStyles } from './tooltip'; export type { OnVisibleChangeData, diff --git a/packages/react-components/react-windmod-preview/library/src/spinner.ts b/packages/react-components/react-windmod-preview/library/src/spinner.ts new file mode 100644 index 00000000000000..80383f8f6f771e --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/spinner.ts @@ -0,0 +1,5 @@ +export { Spinner, spinnerClassNames, useSpinnerStyles } from './components/Spinner'; +export type { SpinnerAppearance, SpinnerProps, SpinnerSize, SpinnerSlots, SpinnerState } from './components/Spinner'; + +/** Headless building blocks, re-exported for consumers composing their own Spinner. */ +export { renderSpinner, useSpinner } from '@fluentui/react-headless-components-preview/spinner'; diff --git a/packages/react-components/react-windmod-preview/library/src/variants.css b/packages/react-components/react-windmod-preview/library/src/variants.css index 3868faf64c5290..02e50a59992fd2 100644 --- a/packages/react-components/react-windmod-preview/library/src/variants.css +++ b/packages/react-components/react-windmod-preview/library/src/variants.css @@ -51,6 +51,9 @@ /* SpinButton: held spin repeat — persists across the repeat interval, unlike :active */ @custom-variant spin-active (&:where([data-spin-active])); +/* Spinner: appearance */ +@custom-variant appearance-inverted (&:where([data-appearance='inverted'])); + /* SwatchPicker: active appearance */ @custom-variant with-active (&:where([data-active])); @custom-variant active-inactive (&:where([data-active='inactive'])); diff --git a/packages/react-components/react-windmod-preview/stories/src/Spinner/SpinnerDefault.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Spinner/SpinnerDefault.stories.tsx new file mode 100644 index 00000000000000..57137d61283da3 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/Spinner/SpinnerDefault.stories.tsx @@ -0,0 +1,30 @@ +import * as React from 'react'; +import { Spinner, ThemeProvider } from '@fluentui/react-windmod-preview'; + +import styles from '../compare.module.css'; + +const sizes = ['extra-tiny', 'tiny', 'extra-small', 'small', 'medium', 'large', 'extra-large', 'huge'] as const; +const labelPositions = ['above', 'below', 'before', 'after'] as const; + +export const Default = (): React.ReactNode => ( + +
+
+ {sizes.map(size => ( + + ))} +
+
+ {labelPositions.map(labelPosition => ( + + ))} +
+ {/* Every inverted value is white or near-white — only a dark ground shows it. */} +
+ {sizes.map(size => ( + + ))} +
+
+
+); diff --git a/packages/react-components/react-windmod-preview/stories/src/Spinner/SpinnerGriffelComparison.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Spinner/SpinnerGriffelComparison.stories.tsx new file mode 100644 index 00000000000000..763ff8d3e34ffc --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/Spinner/SpinnerGriffelComparison.stories.tsx @@ -0,0 +1,57 @@ +import * as React from 'react'; +import { Spinner, ThemeProvider } from '@fluentui/react-windmod-preview'; +import { FluentProvider, Spinner as GriffelSpinner, webLightTheme } from '@fluentui/react-components'; + +import styles from '../compare.module.css'; + +type LookProps = { + appearance?: 'primary' | 'inverted'; + size?: 'extra-tiny' | 'tiny' | 'extra-small' | 'small' | 'medium' | 'large' | 'extra-large' | 'huge'; + label?: string; + labelPosition?: 'above' | 'below' | 'before' | 'after'; +}; + +/** + * Every windmod variant next to its Griffel-suite twin (inside a FluentProvider). + * The pair in each row must be pixel-identical. + */ +export const GriffelComparison = (): React.ReactNode => { + const variants: Array<{ label: string; props: LookProps }> = [ + { label: 'medium primary', props: {} }, + { label: 'extra-tiny', props: { size: 'extra-tiny' } }, + { label: 'tiny', props: { size: 'tiny' } }, + { label: 'extra-small', props: { size: 'extra-small' } }, + { label: 'small', props: { size: 'small' } }, + { label: 'large', props: { size: 'large' } }, + { label: 'extra-large', props: { size: 'extra-large' } }, + { label: 'huge', props: { size: 'huge' } }, + { label: 'label after', props: { label: 'Loading' } }, + { label: 'label before', props: { label: 'Loading', labelPosition: 'before' } }, + { label: 'label above', props: { label: 'Loading', labelPosition: 'above' } }, + { label: 'label below', props: { label: 'Loading', labelPosition: 'below' } }, + { label: 'huge with label', props: { size: 'huge', label: 'Loading' } }, + ]; + + return ( +
+
Variant
+
Windmod
+
Griffel
+ {variants.map(({ label, props }) => ( + +
{label}
+
+ + + +
+
+ + + +
+
+ ))} +
+ ); +}; diff --git a/packages/react-components/react-windmod-preview/stories/src/Spinner/index.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Spinner/index.stories.tsx new file mode 100644 index 00000000000000..7aafd72166e1cc --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/Spinner/index.stories.tsx @@ -0,0 +1,9 @@ +import { Spinner } from '@fluentui/react-windmod-preview'; + +export { Default } from './SpinnerDefault.stories'; +export { GriffelComparison } from './SpinnerGriffelComparison.stories'; + +export default { + title: 'Windmod/Spinner', + component: Spinner, +}; diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/SpinnerGriffel.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/SpinnerGriffel.stories.tsx new file mode 100644 index 00000000000000..6175b0a9d88155 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/SpinnerGriffel.stories.tsx @@ -0,0 +1,10 @@ +import * as React from 'react'; +import { FluentProvider, Spinner, webLightTheme } from '@fluentui/react-components'; + +import { SpinnerVrScene } from './SpinnerVrScene'; + +export const SpinnerGriffel = (): React.ReactNode => ( + + + +); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/SpinnerVrScene.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/SpinnerVrScene.tsx new file mode 100644 index 00000000000000..bdb87b0db4791c --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/SpinnerVrScene.tsx @@ -0,0 +1,51 @@ +// eslint-disable-next-line import/no-extraneous-dependencies -- story-only helper outside the *.stories.tsx devDependencies exemption +import * as React from 'react'; + +const sizes = ['extra-tiny', 'tiny', 'extra-small', 'small', 'medium', 'large', 'extra-large', 'huge'] as const; +const labelPositions = ['above', 'below', 'before', 'after'] as const; + +type SpinnerLike = React.ComponentType<{ + appearance?: 'primary' | 'inverted'; + size?: (typeof sizes)[number]; + label?: string; + labelPosition?: (typeof labelPositions)[number]; +}>; + +const row: React.CSSProperties = { display: 'flex', gap: 8, alignItems: 'center' }; + +/** The runner freezes animation before every capture, so both sides render the same rest + state: a full ring with the tail's 30deg arc. `delay` would gate the whole subtree behind + a timer and race the capture — it stays at its default. */ +const SpinnerRows = ({ Spinner, appearance }: { Spinner: SpinnerLike; appearance: 'primary' | 'inverted' }) => ( + <> +
+ {sizes.map(size => ( + + ))} +
+ {labelPositions.map(labelPosition => ( +
+ {sizes.map(size => ( + + ))} +
+ ))} + +); + +/** One scene, two implementations — the VR runner diffs the renders pixel for pixel. */ +export const SpinnerVrScene = ({ Spinner }: { Spinner: SpinnerLike }): React.ReactNode => ( +
+ + {/* Every inverted value is white or near-white; a wrong token there is only observable + against a dark ground. */} +
+ +
+ {/* The only place min-width: min-content and overflow: hidden are observable. */} +
+ + +
+
+); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/SpinnerWindmod.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/SpinnerWindmod.stories.tsx new file mode 100644 index 00000000000000..fd1b4d23946f3c --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/SpinnerWindmod.stories.tsx @@ -0,0 +1,10 @@ +import * as React from 'react'; +import { Spinner, ThemeProvider } from '@fluentui/react-windmod-preview'; + +import { SpinnerVrScene } from './SpinnerVrScene'; + +export const SpinnerWindmod = (): React.ReactNode => ( + + + +); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx index a8756b7f890604..dd94f02857361b 100644 --- a/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx +++ b/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx @@ -8,6 +8,8 @@ export { LabelWindmod } from './LabelWindmod.stories'; export { LabelGriffel } from './LabelGriffel.stories'; export { LinkWindmod } from './LinkWindmod.stories'; export { LinkGriffel } from './LinkGriffel.stories'; +export { SpinnerWindmod } from './SpinnerWindmod.stories'; +export { SpinnerGriffel } from './SpinnerGriffel.stories'; export { TooltipWindmod } from './TooltipWindmod.stories'; export { TooltipGriffel } from './TooltipGriffel.stories'; From 7f9ec0f3c007acae2e24893ed314312a5d08ad76 Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Fri, 21 Aug 2026 17:33:39 -0700 Subject: [PATCH 032/246] feat(windmod): add Skeleton and SkeletonItem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Headless useSkeleton/useSkeletonItem + Tailwind CSS-Modules styling, pixel-identical to the Griffel suite in the frozen rest state across sizes, shapes, appearances and animations. A package-local context carries group defaults to items — both the headless and local providers share one memoised value — and item size flows through a typed attr() coercion with a 16px fallback. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- .../library/etc/react-windmod-preview.api.md | 79 +++++++++++ .../library/etc/skeleton-item.api.md | 45 +++++++ .../library/etc/skeleton.api.md | 57 ++++++++ .../library/package.json | 20 +++ .../components/Skeleton/Skeleton.module.css | 7 + .../src/components/Skeleton/Skeleton.test.tsx | 84 ++++++++++++ .../src/components/Skeleton/Skeleton.tsx | 52 ++++++++ .../src/components/Skeleton/Skeleton.types.ts | 35 +++++ .../components/Skeleton/SkeletonContext.ts | 25 ++++ .../SkeletonItem/SkeletonItem.module.css | 103 +++++++++++++++ .../SkeletonItem/SkeletonItem.test.tsx | 124 ++++++++++++++++++ .../Skeleton/SkeletonItem/SkeletonItem.tsx | 42 ++++++ .../SkeletonItem/SkeletonItem.types.ts | 27 ++++ .../components/Skeleton/SkeletonItem/index.ts | 3 + .../SkeletonItem/useSkeletonItemStyles.ts | 42 ++++++ .../library/src/components/Skeleton/index.ts | 14 ++ .../components/Skeleton/useSkeletonStyles.ts | 24 ++++ .../library/src/index.ts | 27 ++++ .../library/src/skeleton-item.ts | 5 + .../library/src/skeleton.ts | 17 +++ .../library/src/variants.css | 6 + .../src/Skeleton/SkeletonDefault.stories.tsx | 42 ++++++ .../SkeletonGriffelComparison.stories.tsx | 79 +++++++++++ .../stories/src/Skeleton/index.stories.tsx | 9 ++ .../src/vr/SkeletonGriffel.stories.tsx | 10 ++ .../stories/src/vr/SkeletonVrScene.tsx | 119 +++++++++++++++++ .../src/vr/SkeletonWindmod.stories.tsx | 10 ++ .../stories/src/vr/index.stories.tsx | 2 + 28 files changed, 1109 insertions(+) create mode 100644 packages/react-components/react-windmod-preview/library/etc/skeleton-item.api.md create mode 100644 packages/react-components/react-windmod-preview/library/etc/skeleton.api.md create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Skeleton/Skeleton.module.css create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Skeleton/Skeleton.test.tsx create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Skeleton/Skeleton.tsx create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Skeleton/Skeleton.types.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Skeleton/SkeletonContext.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Skeleton/SkeletonItem/SkeletonItem.module.css create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Skeleton/SkeletonItem/SkeletonItem.test.tsx create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Skeleton/SkeletonItem/SkeletonItem.tsx create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Skeleton/SkeletonItem/SkeletonItem.types.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Skeleton/SkeletonItem/index.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Skeleton/SkeletonItem/useSkeletonItemStyles.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Skeleton/index.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Skeleton/useSkeletonStyles.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/skeleton-item.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/skeleton.ts create mode 100644 packages/react-components/react-windmod-preview/stories/src/Skeleton/SkeletonDefault.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/Skeleton/SkeletonGriffelComparison.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/Skeleton/index.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/SkeletonGriffel.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/SkeletonVrScene.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/SkeletonWindmod.stories.tsx diff --git a/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md b/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md index 83e3cc50462fb4..4bd5179d10d7ae 100644 --- a/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md +++ b/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md @@ -30,8 +30,16 @@ import { renderDivider } from '@fluentui/react-headless-components-preview/divid import { renderLabel } from '@fluentui/react-headless-components-preview/label'; import { renderLink } from '@fluentui/react-headless-components-preview/link'; import { renderProvider } from '@fluentui/react-headless-components-preview/provider'; +import { renderSkeleton } from '@fluentui/react-headless-components-preview/skeleton'; +import { renderSkeletonItem } from '@fluentui/react-headless-components-preview/skeleton'; import { renderSpinner } from '@fluentui/react-headless-components-preview/spinner'; import { renderTooltip } from '@fluentui/react-headless-components-preview/tooltip'; +import type { SkeletonItemProps as SkeletonItemProps_2 } from '@fluentui/react-headless-components-preview/skeleton'; +import { SkeletonItemSlots } from '@fluentui/react-headless-components-preview/skeleton'; +import type { SkeletonItemState as SkeletonItemState_2 } from '@fluentui/react-headless-components-preview/skeleton'; +import type { SkeletonProps as SkeletonProps_2 } from '@fluentui/react-headless-components-preview/skeleton'; +import { SkeletonSlots } from '@fluentui/react-headless-components-preview/skeleton'; +import type { SkeletonState as SkeletonState_2 } from '@fluentui/react-headless-components-preview/skeleton'; import type { SpinnerProps as SpinnerProps_2 } from '@fluentui/react-headless-components-preview/spinner'; import { SpinnerSlots } from '@fluentui/react-headless-components-preview/spinner'; import type { SpinnerState as SpinnerState_2 } from '@fluentui/react-headless-components-preview/spinner'; @@ -52,6 +60,9 @@ import { useDivider } from '@fluentui/react-headless-components-preview/divider' import { useLabel } from '@fluentui/react-headless-components-preview/label'; import { useLink } from '@fluentui/react-headless-components-preview/link'; import { useProvider } from '@fluentui/react-headless-components-preview/provider'; +import { useSkeleton } from '@fluentui/react-headless-components-preview/skeleton'; +import { useSkeletonContextValues } from '@fluentui/react-headless-components-preview/skeleton'; +import { useSkeletonItem } from '@fluentui/react-headless-components-preview/skeleton'; import { useSpinner } from '@fluentui/react-headless-components-preview/spinner'; import { useTooltip } from '@fluentui/react-headless-components-preview/tooltip'; import { webDarkThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; @@ -208,10 +219,66 @@ export { renderLink } export { renderProvider } +export { renderSkeleton } + +export { renderSkeletonItem } + export { renderSpinner } export { renderTooltip } +// @public +export const Skeleton: ForwardRefComponent; + +// @public +export type SkeletonAnimation = 'wave' | 'pulse'; + +// @public +export type SkeletonAppearance = 'opaque' | 'translucent'; + +// @public +export const skeletonClassNames: { + root: string; +}; + +// @public +export const SkeletonItem: ForwardRefComponent; + +// @public +export const skeletonItemClassNames: { + root: string; +}; + +// @public +export type SkeletonItemProps = SkeletonItemProps_2 & { + animation?: SkeletonAnimation; + appearance?: SkeletonAppearance; + shape?: SkeletonItemShape; + size?: SkeletonItemSize; +}; + +// @public +export type SkeletonItemShape = 'circle' | 'rectangle' | 'square'; + +// @public +export type SkeletonItemSize = NonNullable; + +export { SkeletonItemSlots } + +// @public +export type SkeletonItemState = SkeletonItemState_2 & Required>; + +// @public +export type SkeletonProps = SkeletonProps_2 & { + animation?: SkeletonAnimation; + appearance?: SkeletonAppearance; +}; + +export { SkeletonSlots } + +// @public +export type SkeletonState = SkeletonState_2 & Required> & Pick; + // @public export const Spinner: ForwardRefComponent; @@ -320,6 +387,18 @@ export const useLinkStyles: (state: LinkState) => LinkState; export { useProvider } +export { useSkeleton } + +export { useSkeletonContextValues } + +export { useSkeletonItem } + +// @public +export const useSkeletonItemStyles: (state: SkeletonItemState) => SkeletonItemState; + +// @public +export const useSkeletonStyles: (state: SkeletonState) => SkeletonState; + export { useSpinner } // @public diff --git a/packages/react-components/react-windmod-preview/library/etc/skeleton-item.api.md b/packages/react-components/react-windmod-preview/library/etc/skeleton-item.api.md new file mode 100644 index 00000000000000..a4968cf0797b0e --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/etc/skeleton-item.api.md @@ -0,0 +1,45 @@ +## API Report File for "@fluentui/react-windmod-preview" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { renderSkeletonItem } from '@fluentui/react-headless-components-preview/skeleton'; +import type { SkeletonItemProps as SkeletonItemProps_2 } from '@fluentui/react-headless-components-preview/skeleton'; +import { SkeletonItemSlots } from '@fluentui/react-headless-components-preview/skeleton'; +import type { SkeletonItemState as SkeletonItemState_2 } from '@fluentui/react-headless-components-preview/skeleton'; +import type { SkeletonProps } from '@fluentui/react-headless-components-preview/skeleton'; +import { useSkeletonItem } from '@fluentui/react-headless-components-preview/skeleton'; + +export { renderSkeletonItem } + +// @public +export const SkeletonItem: ForwardRefComponent; + +// @public +export const skeletonItemClassNames: { + root: string; +}; + +// @public +export type SkeletonItemProps = SkeletonItemProps_2 & { + animation?: SkeletonAnimation; + appearance?: SkeletonAppearance; + shape?: SkeletonItemShape; + size?: SkeletonItemSize; +}; + +export { SkeletonItemSlots } + +// @public +export type SkeletonItemState = SkeletonItemState_2 & Required>; + +export { useSkeletonItem } + +// @public +export const useSkeletonItemStyles: (state: SkeletonItemState) => SkeletonItemState; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-windmod-preview/library/etc/skeleton.api.md b/packages/react-components/react-windmod-preview/library/etc/skeleton.api.md new file mode 100644 index 00000000000000..61ff0062f4cf60 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/etc/skeleton.api.md @@ -0,0 +1,57 @@ +## API Report File for "@fluentui/react-windmod-preview" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { renderSkeleton } from '@fluentui/react-headless-components-preview/skeleton'; +import type { SkeletonProps as SkeletonProps_2 } from '@fluentui/react-headless-components-preview/skeleton'; +import { SkeletonSlots } from '@fluentui/react-headless-components-preview/skeleton'; +import type { SkeletonState as SkeletonState_2 } from '@fluentui/react-headless-components-preview/skeleton'; +import { useSkeleton } from '@fluentui/react-headless-components-preview/skeleton'; +import { useSkeletonContextValues } from '@fluentui/react-headless-components-preview/skeleton'; + +export { renderSkeleton } + +// @public +export const Skeleton: ForwardRefComponent; + +// @public +export type SkeletonAnimation = 'wave' | 'pulse'; + +// @public +export type SkeletonAppearance = 'opaque' | 'translucent'; + +// @public +export const skeletonClassNames: { + root: string; +}; + +// @public +export type SkeletonItemShape = 'circle' | 'rectangle' | 'square'; + +// @public +export type SkeletonItemSize = NonNullable; + +// @public +export type SkeletonProps = SkeletonProps_2 & { + animation?: SkeletonAnimation; + appearance?: SkeletonAppearance; +}; + +export { SkeletonSlots } + +// @public +export type SkeletonState = SkeletonState_2 & Required> & Pick; + +export { useSkeleton } + +export { useSkeletonContextValues } + +// @public +export const useSkeletonStyles: (state: SkeletonState) => SkeletonState; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-windmod-preview/library/package.json b/packages/react-components/react-windmod-preview/library/package.json index 5d0d301cd4acf8..3220c4387c999c 100644 --- a/packages/react-components/react-windmod-preview/library/package.json +++ b/packages/react-components/react-windmod-preview/library/package.json @@ -112,6 +112,26 @@ "default": "./lib-commonjs/provider.cjs" } }, + "./skeleton": { + "import": { + "types": "./dist/skeleton.d.ts", + "default": "./lib/skeleton.js" + }, + "require": { + "types": "./dist/skeleton.d.cts", + "default": "./lib-commonjs/skeleton.cjs" + } + }, + "./skeleton-item": { + "import": { + "types": "./dist/skeleton-item.d.ts", + "default": "./lib/skeleton-item.js" + }, + "require": { + "types": "./dist/skeleton-item.d.cts", + "default": "./lib-commonjs/skeleton-item.cjs" + } + }, "./spinner": { "import": { "types": "./dist/spinner.d.ts", diff --git a/packages/react-components/react-windmod-preview/library/src/components/Skeleton/Skeleton.module.css b/packages/react-components/react-windmod-preview/library/src/components/Skeleton/Skeleton.module.css new file mode 100644 index 00000000000000..f63393dd3f97f8 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Skeleton/Skeleton.module.css @@ -0,0 +1,7 @@ +@reference '#theme'; + +@layer fui.components.l1 { + .block { + @apply block; + } +} diff --git a/packages/react-components/react-windmod-preview/library/src/components/Skeleton/Skeleton.test.tsx b/packages/react-components/react-windmod-preview/library/src/components/Skeleton/Skeleton.test.tsx new file mode 100644 index 00000000000000..a22cbdbf7a5b18 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Skeleton/Skeleton.test.tsx @@ -0,0 +1,84 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; + +import { Skeleton } from './Skeleton'; +import { isConformant } from '../../testing/isConformant'; +import type { SkeletonState } from './Skeleton.types'; +import { skeletonClassNames, useSkeletonStyles } from './useSkeletonStyles'; + +import styles from './Skeleton.module.css'; + +describe('Skeleton', () => { + isConformant({ + Component: Skeleton, + displayName: 'Skeleton', + requiredProps: { children: 'Skeleton' }, + }); + + it('stamps the marker class', () => { + const { getByTestId } = render(Skeleton); + + const root = getByTestId('root'); + + expect(root.className).toContain(skeletonClassNames.root); + expect(root).toHaveClass('fui-skeleton'); + expect(root).toHaveClass('group/fui-skeleton'); + expect(root.classList[0]).toBe('fui-skeleton'); + }); + + it('stamps no data attribute — the group resolves in JS, not in CSS', () => { + const { getByTestId } = render( + + Skeleton + , + ); + + const root = getByTestId('root'); + + expect(root.hasAttribute('data-animation')).toBe(false); + expect(root.hasAttribute('data-appearance')).toBe(false); + expect(root.hasAttribute('data-size')).toBe(false); + expect(root.hasAttribute('data-shape')).toBe(false); + }); + + it('keeps the headless accessibility contract intact', () => { + const { getByTestId } = render(Skeleton); + + const root = getByTestId('root'); + + expect(root.getAttribute('role')).toBe('progressbar'); + expect(root.getAttribute('aria-busy')).toBe('true'); + }); + + it('adds the block class only when the root renders as a span', () => { + const { getByTestId } = render( + <> + Skeleton + + Skeleton + + , + ); + + // The css-module proxy answers every key, so these pin the lookup keys, not class existence. + expect(getByTestId('span').tagName).toBe('SPAN'); + expect(getByTestId('span')).toHaveClass(styles.block); + expect(getByTestId('div')).not.toHaveClass(styles.block); + }); + + it('does not mutate the state it is given', () => { + const state = { + animation: 'wave', + appearance: 'opaque', + components: { root: 'div' }, + root: { as: 'span', children: 'Skeleton', className: 'consumer' }, + } as unknown as SkeletonState; + + const styled = useSkeletonStyles(state); + + expect(styled).not.toBe(state); + expect(state.root.className).toBe('consumer'); + expect(styled.root.className).toContain('consumer'); + expect(styled.root.className).toContain(skeletonClassNames.root); + }); +}); diff --git a/packages/react-components/react-windmod-preview/library/src/components/Skeleton/Skeleton.tsx b/packages/react-components/react-windmod-preview/library/src/components/Skeleton/Skeleton.tsx new file mode 100644 index 00000000000000..c832edae0cc471 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Skeleton/Skeleton.tsx @@ -0,0 +1,52 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { + renderSkeleton, + useSkeleton, + useSkeletonContextValues, +} from '@fluentui/react-headless-components-preview/skeleton'; + +import type { SkeletonProps, SkeletonState } from './Skeleton.types'; +import { SkeletonContextProvider, useSkeletonContext } from './SkeletonContext'; +import { useSkeletonStyles } from './useSkeletonStyles'; + +/** + * A Skeleton is a loading placeholder that groups SkeletonItems. Windmod Skeleton: the headless + * skeleton decorated with the Fluent visual contract (Tailwind v4 + CSS Modules). + */ +export const Skeleton: ForwardRefComponent = React.forwardRef((props, ref) => { + const { animation: contextAnimation, appearance: contextAppearance } = useSkeletonContext(); + // `size` and `shape` carry no context fallback: a nested Skeleton without them resets them + // for its subtree, while `animation` and `appearance` pass through. + const { + animation = contextAnimation ?? 'wave', + appearance = contextAppearance ?? 'opaque', + size, + shape, + ...rest + } = props; + + const state: SkeletonState = { + ...useSkeleton(rest, ref as React.Ref), + animation, + appearance, + size, + shape, + }; + + const styled = useSkeletonStyles(state); + const contextValues = useSkeletonContextValues(styled); + + // renderSkeleton installs the headless group provider; this wrapper populates the windmod one + // the windmod SkeletonItem reads. Both providers share the one memoised value object. + return ( + + {renderSkeleton(styled, contextValues)} + + ); + // Casting is required due to lack of distributive union to support union on @types/react +}) as ForwardRefComponent; + +Skeleton.displayName = 'Skeleton'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Skeleton/Skeleton.types.ts b/packages/react-components/react-windmod-preview/library/src/components/Skeleton/Skeleton.types.ts new file mode 100644 index 00000000000000..2d76d277f46289 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Skeleton/Skeleton.types.ts @@ -0,0 +1,35 @@ +import type { + SkeletonProps as SkeletonHeadlessProps, + SkeletonState as SkeletonHeadlessState, +} from '@fluentui/react-headless-components-preview/skeleton'; + +export type { SkeletonSlots } from '@fluentui/react-headless-components-preview/skeleton'; + +/** Animation of a Skeleton and the items it groups. `'wave'` is the base look. */ +export type SkeletonAnimation = 'wave' | 'pulse'; + +/** Visual style of a Skeleton and the items it groups. `'opaque'` is the base look. */ +export type SkeletonAppearance = 'opaque' | 'translucent'; + +/** Shape of a SkeletonItem. `'rectangle'` is the base look. */ +export type SkeletonItemShape = 'circle' | 'rectangle' | 'square'; + +/** Pixel size of a SkeletonItem, restricted to the recommended set. */ +export type SkeletonItemSize = NonNullable; + +/** + * Windmod Skeleton props: the headless skeleton plus the look props the headless surface + * deliberately omits (they exist purely to select styles). `size` and `shape` are already on + * the headless props — they are group values read by SkeletonItem, not styles of the group. + */ +export type SkeletonProps = SkeletonHeadlessProps & { + /** @default 'wave' */ + animation?: SkeletonAnimation; + /** @default 'opaque' */ + appearance?: SkeletonAppearance; +}; + +/** Windmod Skeleton state: headless state plus the resolved look props and the group values. */ +export type SkeletonState = SkeletonHeadlessState & + Required> & + Pick; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Skeleton/SkeletonContext.ts b/packages/react-components/react-windmod-preview/library/src/components/Skeleton/SkeletonContext.ts new file mode 100644 index 00000000000000..26fea9779b456e --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Skeleton/SkeletonContext.ts @@ -0,0 +1,25 @@ +'use client'; + +import * as React from 'react'; + +import type { SkeletonAnimation, SkeletonAppearance, SkeletonItemShape, SkeletonItemSize } from './Skeleton.types'; + +/** Group values a Skeleton publishes to the SkeletonItems below it. */ +export type SkeletonContextValue = { + animation?: SkeletonAnimation; + appearance?: SkeletonAppearance; + size?: SkeletonItemSize; + shape?: SkeletonItemShape; +}; + +const SkeletonContext = React.createContext(undefined); +const skeletonContextDefaultValue: SkeletonContextValue = {}; + +export const SkeletonContextProvider = SkeletonContext.Provider; + +/** + * The headless surface exports the provider half of the skeleton group context but not the + * reader, so this module supplies one. It is internal — no barrel re-exports it. + */ +export const useSkeletonContext = (): SkeletonContextValue => + React.useContext(SkeletonContext) ?? skeletonContextDefaultValue; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Skeleton/SkeletonItem/SkeletonItem.module.css b/packages/react-components/react-windmod-preview/library/src/components/Skeleton/SkeletonItem/SkeletonItem.module.css new file mode 100644 index 00000000000000..0bc1474c3643d9 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Skeleton/SkeletonItem/SkeletonItem.module.css @@ -0,0 +1,103 @@ +@reference '#theme'; + +/* The sweep offset must be written on `transform`, not on the individual `translate` property: + the wave keyframes declare only a `to` step, so the implicit `from` is the element's own + `transform`. Routing the offset elsewhere would start the sweep from `none` and halve it. */ + +/* Block order carries the cascade. The reduced-motion query is last so it beats the pulse + block's animation-duration; the forced-colors block is first, inside the resting `::after`, + so the pulse block's background-color beats it. */ + +@keyframes wave { + to { + transform: translate(100%); + } +} + +@keyframes pulse { + 0% { + opacity: 1; + } + + 50% { + opacity: 0.4; + } + + 100% { + opacity: 1; + } +} + +@layer fui.components.l1 { + .root { + @apply relative h-(--fui-skeleton-item-size) w-full overflow-hidden rounded-[4px] bg-neutral-stencil-1; + + --fui-skeleton-item-size: calc(attr(data-size type(), 16) * 1px); + + @variant after { + @apply absolute inset-0 block [transform:translate(-100%)] [animation-duration:3s] [animation-iteration-count:infinite] [animation-name:wave] [animation-timing-function:ease-in-out]; + + background-image: linear-gradient( + to right, + var(--color-neutral-stencil-1) 0%, + var(--color-neutral-stencil-2) 50%, + var(--color-neutral-stencil-1) 100% + ); + + @variant forced-colors { + @apply bg-[WindowText]; + } + + @variant rtl { + @apply -scale-x-100; + } + } + + @variant animation-pulse { + @apply bg-transparent; + + @variant after { + @apply transform-none bg-neutral-stencil-1 bg-none [animation-duration:1s] [animation-name:pulse]; + } + } + + @variant appearance-translucent { + @apply bg-neutral-stencil-1-alpha; + + @variant after { + background-image: linear-gradient( + to right, + transparent 0%, + var(--color-neutral-stencil-1-alpha) 50%, + transparent 100% + ); + } + + @variant animation-pulse { + @apply bg-transparent; + + @variant after { + @apply bg-neutral-stencil-1-alpha; + } + } + } + + @variant shape-square { + @apply size-(--fui-skeleton-item-size) rounded-none; + } + + @variant shape-circle { + @apply size-(--fui-skeleton-item-size) rounded-[50%]; + } + + @media screen and (prefers-reduced-motion: reduce) { + @variant after { + @apply [animation-duration:0.01ms] [animation-iteration-count:1]; + } + } + } + + .block { + @apply block; + } +} diff --git a/packages/react-components/react-windmod-preview/library/src/components/Skeleton/SkeletonItem/SkeletonItem.test.tsx b/packages/react-components/react-windmod-preview/library/src/components/Skeleton/SkeletonItem/SkeletonItem.test.tsx new file mode 100644 index 00000000000000..dd095b926b3a12 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Skeleton/SkeletonItem/SkeletonItem.test.tsx @@ -0,0 +1,124 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; + +import { isConformant } from '../../../testing/isConformant'; +import { Skeleton } from '../Skeleton'; +import { SkeletonItem } from './SkeletonItem'; +import type { SkeletonItemState } from './SkeletonItem.types'; +import { skeletonItemClassNames, useSkeletonItemStyles } from './useSkeletonItemStyles'; + +import styles from './SkeletonItem.module.css'; + +describe('SkeletonItem', () => { + isConformant({ + Component: SkeletonItem, + displayName: 'SkeletonItem', + }); + + it('stamps the four look props and the marker class', () => { + const { getByTestId } = render( + <> + + + , + ); + + const fallback = getByTestId('default'); + + expect(fallback.getAttribute('data-animation')).toBe('wave'); + expect(fallback.getAttribute('data-appearance')).toBe('opaque'); + expect(fallback.getAttribute('data-shape')).toBe('rectangle'); + expect(fallback.getAttribute('data-size')).toBe('16'); + expect(fallback.className).toContain(skeletonItemClassNames.root); + expect(fallback).toHaveClass('fui-skeleton-item'); + expect(fallback).toHaveClass('group/fui-skeleton-item'); + expect(fallback.classList[0]).toBe('fui-skeleton-item'); + + const custom = getByTestId('custom'); + + expect(custom.getAttribute('data-animation')).toBe('pulse'); + expect(custom.getAttribute('data-appearance')).toBe('translucent'); + expect(custom.getAttribute('data-shape')).toBe('circle'); + expect(custom.getAttribute('data-size')).toBe('48'); + }); + + it('resolves each dimension against the group, then lets an own prop win', () => { + const { getByTestId } = render( + + + + + + + + , + ); + + const inherit = getByTestId('inherit'); + + expect(inherit.getAttribute('data-animation')).toBe('pulse'); + expect(inherit.getAttribute('data-appearance')).toBe('translucent'); + expect(inherit.getAttribute('data-size')).toBe('48'); + expect(inherit.getAttribute('data-shape')).toBe('circle'); + + const override = getByTestId('override'); + + expect(override.getAttribute('data-animation')).toBe('wave'); + expect(override.getAttribute('data-appearance')).toBe('translucent'); + expect(override.getAttribute('data-size')).toBe('92'); + expect(override.getAttribute('data-shape')).toBe('circle'); + + const overrideLook = getByTestId('override-look'); + + expect(overrideLook.getAttribute('data-animation')).toBe('pulse'); + expect(overrideLook.getAttribute('data-appearance')).toBe('opaque'); + expect(overrideLook.getAttribute('data-size')).toBe('48'); + expect(overrideLook.getAttribute('data-shape')).toBe('square'); + + // A nested Skeleton passes animation and appearance through but resets size and shape. + const nested = getByTestId('nested'); + + expect(nested.getAttribute('data-animation')).toBe('pulse'); + expect(nested.getAttribute('data-appearance')).toBe('translucent'); + expect(nested.getAttribute('data-size')).toBe('16'); + expect(nested.getAttribute('data-shape')).toBe('rectangle'); + }); + + it('merges the root, block and consumer classes on a span root', () => { + const { getByTestId } = render( + <> + + + , + ); + + // The css-module proxy answers every key, so these pin the lookup keys, not class existence. + const span = getByTestId('span'); + + expect(span.tagName).toBe('SPAN'); + expect(span).toHaveClass(styles.root); + expect(span).toHaveClass(styles.block); + expect(span).toHaveClass('consumer'); + expect(getByTestId('div')).not.toHaveClass(styles.block); + }); + + it('does not mutate the state it is given', () => { + const state = { + animation: 'wave', + appearance: 'opaque', + components: { root: 'div' }, + root: { as: 'div', className: 'consumer' }, + shape: 'rectangle', + size: 16, + } as unknown as SkeletonItemState; + + const styled = useSkeletonItemStyles(state); + + expect(styled).not.toBe(state); + expect(state.root).not.toHaveProperty('data-size'); + expect(state.root).not.toHaveProperty('data-shape'); + expect(state.root.className).toBe('consumer'); + expect(styled.root.className).toContain('consumer'); + expect(styled.root.className).toContain(skeletonItemClassNames.root); + }); +}); diff --git a/packages/react-components/react-windmod-preview/library/src/components/Skeleton/SkeletonItem/SkeletonItem.tsx b/packages/react-components/react-windmod-preview/library/src/components/Skeleton/SkeletonItem/SkeletonItem.tsx new file mode 100644 index 00000000000000..1f265dc2de1ea8 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Skeleton/SkeletonItem/SkeletonItem.tsx @@ -0,0 +1,42 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { renderSkeletonItem, useSkeletonItem } from '@fluentui/react-headless-components-preview/skeleton'; + +import { useSkeletonContext } from '../SkeletonContext'; +import type { SkeletonItemProps, SkeletonItemState } from './SkeletonItem.types'; +import { useSkeletonItemStyles } from './useSkeletonItemStyles'; + +/** + * A SkeletonItem is a single loading placeholder. Windmod SkeletonItem: the headless skeleton + * item decorated with the Fluent visual contract (Tailwind v4 + CSS Modules). + */ +export const SkeletonItem: ForwardRefComponent = React.forwardRef((props, ref) => { + const { + animation: contextAnimation, + appearance: contextAppearance, + size: contextSize, + shape: contextShape, + } = useSkeletonContext(); + const { + animation = contextAnimation ?? 'wave', + appearance = contextAppearance ?? 'opaque', + size = contextSize ?? 16, + shape = contextShape ?? 'rectangle', + ...rest + } = props; + + const state: SkeletonItemState = { + ...useSkeletonItem(rest, ref as React.Ref), + animation, + appearance, + size, + shape, + }; + + return renderSkeletonItem(useSkeletonItemStyles(state)); + // Casting is required due to lack of distributive union to support union on @types/react +}) as ForwardRefComponent; + +SkeletonItem.displayName = 'SkeletonItem'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Skeleton/SkeletonItem/SkeletonItem.types.ts b/packages/react-components/react-windmod-preview/library/src/components/Skeleton/SkeletonItem/SkeletonItem.types.ts new file mode 100644 index 00000000000000..28c7de61d38201 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Skeleton/SkeletonItem/SkeletonItem.types.ts @@ -0,0 +1,27 @@ +import type { + SkeletonItemProps as SkeletonItemHeadlessProps, + SkeletonItemState as SkeletonItemHeadlessState, +} from '@fluentui/react-headless-components-preview/skeleton'; + +import type { SkeletonAnimation, SkeletonAppearance, SkeletonItemShape, SkeletonItemSize } from '../Skeleton.types'; + +export type { SkeletonItemSlots } from '@fluentui/react-headless-components-preview/skeleton'; + +/** + * Windmod SkeletonItem props: the headless skeleton item plus the look props the headless + * surface deliberately omits (they exist purely to select styles). + */ +export type SkeletonItemProps = SkeletonItemHeadlessProps & { + /** @default 'wave' */ + animation?: SkeletonAnimation; + /** @default 'opaque' */ + appearance?: SkeletonAppearance; + /** @default 'rectangle' */ + shape?: SkeletonItemShape; + /** @default 16 */ + size?: SkeletonItemSize; +}; + +/** Windmod SkeletonItem state: headless state plus the resolved look props. */ +export type SkeletonItemState = SkeletonItemHeadlessState & + Required>; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Skeleton/SkeletonItem/index.ts b/packages/react-components/react-windmod-preview/library/src/components/Skeleton/SkeletonItem/index.ts new file mode 100644 index 00000000000000..96149c23198347 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Skeleton/SkeletonItem/index.ts @@ -0,0 +1,3 @@ +export { SkeletonItem } from './SkeletonItem'; +export { skeletonItemClassNames, useSkeletonItemStyles } from './useSkeletonItemStyles'; +export type { SkeletonItemProps, SkeletonItemSlots, SkeletonItemState } from './SkeletonItem.types'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Skeleton/SkeletonItem/useSkeletonItemStyles.ts b/packages/react-components/react-windmod-preview/library/src/components/Skeleton/SkeletonItem/useSkeletonItemStyles.ts new file mode 100644 index 00000000000000..9a7ea7ae35b835 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Skeleton/SkeletonItem/useSkeletonItemStyles.ts @@ -0,0 +1,42 @@ +import { clsx } from 'clsx'; + +import { componentMarkers } from '../../../utils/groupMarker'; +import type { SkeletonItemState } from './SkeletonItem.types'; + +import styles from './SkeletonItem.module.css'; + +/** The only public classes — see componentMarkers; internals are hashed idents. */ +export const skeletonItemClassNames: { root: string } = { + root: componentMarkers('skeleton-item'), +}; + +type SkeletonItemRootDataAttributes = { + 'data-animation'?: SkeletonItemState['animation']; + 'data-appearance'?: SkeletonItemState['appearance']; + 'data-shape'?: SkeletonItemState['shape']; + 'data-size'?: SkeletonItemState['size']; +}; + +/** + * Applies the visual contract, returning new state. `data-size` stays a number because the + * stylesheet coerces it with `attr(data-size type(), 16)`. + */ +export const useSkeletonItemStyles = (state: SkeletonItemState): SkeletonItemState => { + const { animation, appearance, shape, size } = state; + + const root: SkeletonItemState['root'] & SkeletonItemRootDataAttributes = { + ...state.root, + 'data-animation': animation, + 'data-appearance': appearance, + 'data-shape': shape, + 'data-size': size, + className: clsx( + skeletonItemClassNames.root, + styles.root, + state.root.as === 'span' && styles.block, + state.root.className, + ), + }; + + return { ...state, root }; +}; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Skeleton/index.ts b/packages/react-components/react-windmod-preview/library/src/components/Skeleton/index.ts new file mode 100644 index 00000000000000..ea592f9b495426 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Skeleton/index.ts @@ -0,0 +1,14 @@ +export { Skeleton } from './Skeleton'; +export { skeletonClassNames, useSkeletonStyles } from './useSkeletonStyles'; +export type { + SkeletonAnimation, + SkeletonAppearance, + SkeletonItemShape, + SkeletonItemSize, + SkeletonProps, + SkeletonSlots, + SkeletonState, +} from './Skeleton.types'; + +export { SkeletonItem, skeletonItemClassNames, useSkeletonItemStyles } from './SkeletonItem'; +export type { SkeletonItemProps, SkeletonItemSlots, SkeletonItemState } from './SkeletonItem'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Skeleton/useSkeletonStyles.ts b/packages/react-components/react-windmod-preview/library/src/components/Skeleton/useSkeletonStyles.ts new file mode 100644 index 00000000000000..9013d3a16c63e2 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Skeleton/useSkeletonStyles.ts @@ -0,0 +1,24 @@ +import { clsx } from 'clsx'; + +import { componentMarkers } from '../../utils/groupMarker'; +import type { SkeletonState } from './Skeleton.types'; + +import styles from './Skeleton.module.css'; + +/** The only public classes — see componentMarkers; internals are hashed idents. */ +export const skeletonClassNames: { root: string } = { + root: componentMarkers('skeleton'), +}; + +/** + * Applies the visual contract, returning new state. The group's animation, appearance, size and + * shape are resolved in JS and reach the items through context, so nothing in CSS selects on the + * group and no data attribute is stamped here. + */ +export const useSkeletonStyles = (state: SkeletonState): SkeletonState => ({ + ...state, + root: { + ...state.root, + className: clsx(skeletonClassNames.root, state.root.as === 'span' && styles.block, state.root.className), + }, +}); diff --git a/packages/react-components/react-windmod-preview/library/src/index.ts b/packages/react-components/react-windmod-preview/library/src/index.ts index 59d5c8fe4c4699..be294241546020 100644 --- a/packages/react-components/react-windmod-preview/library/src/index.ts +++ b/packages/react-components/react-windmod-preview/library/src/index.ts @@ -13,6 +13,33 @@ export type { LabelProps, LabelSize, LabelSlots, LabelState, LabelWeight } from export { Link, linkClassNames, renderLink, useLink, useLinkStyles } from './link'; export type { LinkAppearance, LinkProps, LinkSlots, LinkState } from './link'; +export { + renderSkeleton, + Skeleton, + skeletonClassNames, + useSkeleton, + useSkeletonContextValues, + useSkeletonStyles, +} from './skeleton'; +export type { + SkeletonAnimation, + SkeletonAppearance, + SkeletonItemShape, + SkeletonItemSize, + SkeletonProps, + SkeletonSlots, + SkeletonState, +} from './skeleton'; + +export { + renderSkeletonItem, + SkeletonItem, + skeletonItemClassNames, + useSkeletonItem, + useSkeletonItemStyles, +} from './skeleton-item'; +export type { SkeletonItemProps, SkeletonItemSlots, SkeletonItemState } from './skeleton-item'; + export { renderSpinner, Spinner, spinnerClassNames, useSpinner, useSpinnerStyles } from './spinner'; export type { SpinnerAppearance, SpinnerProps, SpinnerSize, SpinnerSlots, SpinnerState } from './spinner'; diff --git a/packages/react-components/react-windmod-preview/library/src/skeleton-item.ts b/packages/react-components/react-windmod-preview/library/src/skeleton-item.ts new file mode 100644 index 00000000000000..e5d9a0c0605e3a --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/skeleton-item.ts @@ -0,0 +1,5 @@ +export { SkeletonItem, skeletonItemClassNames, useSkeletonItemStyles } from './components/Skeleton/SkeletonItem'; +export type { SkeletonItemProps, SkeletonItemSlots, SkeletonItemState } from './components/Skeleton/SkeletonItem'; + +/** Headless building blocks, re-exported for consumers composing their own SkeletonItem. */ +export { renderSkeletonItem, useSkeletonItem } from '@fluentui/react-headless-components-preview/skeleton'; diff --git a/packages/react-components/react-windmod-preview/library/src/skeleton.ts b/packages/react-components/react-windmod-preview/library/src/skeleton.ts new file mode 100644 index 00000000000000..e89bdc1bc1c683 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/skeleton.ts @@ -0,0 +1,17 @@ +export { Skeleton, skeletonClassNames, useSkeletonStyles } from './components/Skeleton'; +export type { + SkeletonAnimation, + SkeletonAppearance, + SkeletonItemShape, + SkeletonItemSize, + SkeletonProps, + SkeletonSlots, + SkeletonState, +} from './components/Skeleton'; + +/** Headless building blocks, re-exported for consumers composing their own Skeleton. */ +export { + renderSkeleton, + useSkeleton, + useSkeletonContextValues, +} from '@fluentui/react-headless-components-preview/skeleton'; diff --git a/packages/react-components/react-windmod-preview/library/src/variants.css b/packages/react-components/react-windmod-preview/library/src/variants.css index 02e50a59992fd2..a64c892e886d81 100644 --- a/packages/react-components/react-windmod-preview/library/src/variants.css +++ b/packages/react-components/react-windmod-preview/library/src/variants.css @@ -48,6 +48,12 @@ @custom-variant thickness-medium (&:where([data-thickness='medium'])); @custom-variant thickness-large (&:where([data-thickness='large'])); +/* Skeleton: animation, appearance, item shape */ +@custom-variant animation-pulse (&:where([data-animation='pulse'])); +@custom-variant appearance-translucent (&:where([data-appearance='translucent'])); +@custom-variant shape-circle (&:where([data-shape='circle'])); +@custom-variant shape-square (&:where([data-shape='square'])); + /* SpinButton: held spin repeat — persists across the repeat interval, unlike :active */ @custom-variant spin-active (&:where([data-spin-active])); diff --git a/packages/react-components/react-windmod-preview/stories/src/Skeleton/SkeletonDefault.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Skeleton/SkeletonDefault.stories.tsx new file mode 100644 index 00000000000000..95b8b2983d0d7a --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/Skeleton/SkeletonDefault.stories.tsx @@ -0,0 +1,42 @@ +import * as React from 'react'; +import { Skeleton, SkeletonItem, ThemeProvider } from '@fluentui/react-windmod-preview'; + +import styles from '../compare.module.css'; + +const shapes = ['rectangle', 'square', 'circle'] as const; + +export const Default = (): React.ReactNode => ( + +
+ +
+ + + +
+
+
+ {shapes.map(shape => ( + + ))} +
+ +
+ + + +
+
+ {/* translucent composites to the opaque colour over white — only a mid-grey ground shows it. */} +
+ +
+ + + +
+
+
+
+
+); diff --git a/packages/react-components/react-windmod-preview/stories/src/Skeleton/SkeletonGriffelComparison.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Skeleton/SkeletonGriffelComparison.stories.tsx new file mode 100644 index 00000000000000..25a1f3bc175ede --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/Skeleton/SkeletonGriffelComparison.stories.tsx @@ -0,0 +1,79 @@ +import * as React from 'react'; +import { Skeleton, SkeletonItem, ThemeProvider } from '@fluentui/react-windmod-preview'; +import { + FluentProvider, + Skeleton as GriffelSkeleton, + SkeletonItem as GriffelSkeletonItem, + webLightTheme, +} from '@fluentui/react-components'; + +import styles from '../compare.module.css'; + +type GroupProps = { + animation?: 'wave' | 'pulse'; + appearance?: 'opaque' | 'translucent'; + size?: 16 | 24 | 32 | 48; + shape?: 'circle' | 'rectangle' | 'square'; +}; + +type ItemProps = GroupProps; + +/** + * Every windmod variant next to its Griffel-suite twin (inside a FluentProvider). + * The pair in each row must be pixel-identical. + */ +export const GriffelComparison = (): React.ReactNode => { + const variants: Array<{ label: string; group: GroupProps; items: ItemProps[] }> = [ + { label: 'default', group: {}, items: [{}, {}] }, + { label: 'pulse', group: { animation: 'pulse' }, items: [{}, {}] }, + { label: 'translucent', group: { appearance: 'translucent' }, items: [{}, {}] }, + { + label: 'pulse translucent', + group: { animation: 'pulse', appearance: 'translucent' }, + items: [{}, {}], + }, + { label: 'square group', group: { shape: 'square', size: 32 }, items: [{}, {}] }, + { label: 'circle group', group: { shape: 'circle', size: 48 }, items: [{}, {}] }, + { label: 'sizes', group: {}, items: [{ size: 16 }, { size: 24 }, { size: 48 }] }, + { + label: 'item overrides group', + group: { animation: 'pulse', shape: 'circle', size: 32 }, + items: [{}, { shape: 'rectangle' }, { animation: 'wave', size: 16 }], + }, + ]; + + return ( +
+
Variant
+
Windmod
+
Griffel
+ {variants.map(({ label, group, items }) => ( + +
{label}
+
+ + +
+ {items.map((item, index) => ( + + ))} +
+
+
+
+
+ + +
+ {items.map((item, index) => ( + + ))} +
+
+
+
+
+ ))} +
+ ); +}; diff --git a/packages/react-components/react-windmod-preview/stories/src/Skeleton/index.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Skeleton/index.stories.tsx new file mode 100644 index 00000000000000..62f445ee57bd1c --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/Skeleton/index.stories.tsx @@ -0,0 +1,9 @@ +import { Skeleton } from '@fluentui/react-windmod-preview'; + +export { Default } from './SkeletonDefault.stories'; +export { GriffelComparison } from './SkeletonGriffelComparison.stories'; + +export default { + title: 'Windmod/Skeleton', + component: Skeleton, +}; diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/SkeletonGriffel.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/SkeletonGriffel.stories.tsx new file mode 100644 index 00000000000000..7424774f74e5b7 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/SkeletonGriffel.stories.tsx @@ -0,0 +1,10 @@ +import * as React from 'react'; +import { FluentProvider, Skeleton, SkeletonItem, webLightTheme } from '@fluentui/react-components'; + +import { SkeletonVrScene } from './SkeletonVrScene'; + +export const SkeletonGriffel = (): React.ReactNode => ( + + + +); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/SkeletonVrScene.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/SkeletonVrScene.tsx new file mode 100644 index 00000000000000..af67a5a7d1780d --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/SkeletonVrScene.tsx @@ -0,0 +1,119 @@ +// eslint-disable-next-line import/no-extraneous-dependencies -- story-only helper outside the *.stories.tsx devDependencies exemption +import * as React from 'react'; + +const sizes = [8, 12, 14, 16, 20, 22, 24, 28, 32, 36, 40, 48, 52, 56, 64, 72, 92, 96, 120, 128] as const; +const animations = ['wave', 'pulse'] as const; +const appearances = ['opaque', 'translucent'] as const; + +type SkeletonLike = React.ComponentType<{ + animation?: (typeof animations)[number]; + appearance?: (typeof appearances)[number]; + size?: (typeof sizes)[number]; + shape?: 'circle' | 'rectangle' | 'square'; + as?: 'div' | 'span'; + style?: React.CSSProperties; + children?: React.ReactNode; +}>; +type SkeletonItemLike = React.ComponentType, 'children'>>; + +const column: React.CSSProperties = { display: 'flex', flexDirection: 'column', gap: 16 }; +const wrap: React.CSSProperties = { width: 640, display: 'flex', flexWrap: 'wrap', gap: 8 }; + +/* translucent over white composites to the same hex as opaque, so the appearance dimension is + only discriminable against a mid-grey ground. */ +const strip: React.CSSProperties = { background: '#8a8a8a', padding: 12, ...column }; + +/** One scene, two implementations — the VR runner diffs the renders pixel for pixel. */ +export const SkeletonVrScene = ({ + Skeleton, + SkeletonItem, +}: { + Skeleton: SkeletonLike; + SkeletonItem: SkeletonItemLike; +}): React.ReactNode => ( +
+
+ {sizes.map(size => ( + + ))} +
+
+ {sizes.map(size => ( + + ))} +
+
+ {sizes.map(size => ( + + ))} +
+ +
+ {animations.map(animation => + appearances.map(appearance => ( + + + + + + )), + )} +
+ +
+ + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + +
+ +
+ + + + + + +
+ + {/* The only place display: block is observable — an inline style would mask it. */} + + + + + +
+ + + +
+
+); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/SkeletonWindmod.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/SkeletonWindmod.stories.tsx new file mode 100644 index 00000000000000..0afe8fb1f18c66 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/SkeletonWindmod.stories.tsx @@ -0,0 +1,10 @@ +import * as React from 'react'; +import { Skeleton, SkeletonItem, ThemeProvider } from '@fluentui/react-windmod-preview'; + +import { SkeletonVrScene } from './SkeletonVrScene'; + +export const SkeletonWindmod = (): React.ReactNode => ( + + + +); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx index dd94f02857361b..728ba5553f1e81 100644 --- a/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx +++ b/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx @@ -8,6 +8,8 @@ export { LabelWindmod } from './LabelWindmod.stories'; export { LabelGriffel } from './LabelGriffel.stories'; export { LinkWindmod } from './LinkWindmod.stories'; export { LinkGriffel } from './LinkGriffel.stories'; +export { SkeletonWindmod } from './SkeletonWindmod.stories'; +export { SkeletonGriffel } from './SkeletonGriffel.stories'; export { SpinnerWindmod } from './SpinnerWindmod.stories'; export { SpinnerGriffel } from './SpinnerGriffel.stories'; export { TooltipWindmod } from './TooltipWindmod.stories'; From 773583313a7321dcd9ee28e55ad8549e9a1350a3 Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Fri, 21 Aug 2026 18:34:08 -0700 Subject: [PATCH 033/246] feat(windmod): add Image Headless useImage + Tailwind CSS-Modules styling, pixel-identical to the Griffel suite across fit modes, shapes, borders and shadows. The fit-fill sizing branch follows Griffel's null-tolerant explicit-size gate; the VR scene decodes inline SVG sources through a story-level gate so captures never race image loading. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- .../library/etc/image.api.md | 52 ++++++ .../library/etc/react-windmod-preview.api.md | 41 +++++ .../library/package.json | 10 ++ .../src/components/Image/Image.module.css | 51 ++++++ .../src/components/Image/Image.test.tsx | 154 +++++++++++++++++ .../library/src/components/Image/Image.tsx | 32 ++++ .../src/components/Image/Image.types.ts | 33 ++++ .../library/src/components/Image/index.ts | 3 + .../src/components/Image/useImageStyles.ts | 41 +++++ .../library/src/image.ts | 5 + .../library/src/index.ts | 3 + .../src/Image/ImageDefault.stories.tsx | 44 +++++ .../Image/ImageGriffelComparison.stories.tsx | 68 ++++++++ .../stories/src/Image/index.stories.tsx | 9 + .../stories/src/vr/ImageGriffel.stories.tsx | 19 +++ .../stories/src/vr/ImageVrScene.tsx | 156 ++++++++++++++++++ .../stories/src/vr/ImageWindmod.stories.tsx | 20 +++ .../stories/src/vr/index.stories.tsx | 2 + 18 files changed, 743 insertions(+) create mode 100644 packages/react-components/react-windmod-preview/library/etc/image.api.md create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Image/Image.module.css create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Image/Image.test.tsx create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Image/Image.tsx create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Image/Image.types.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Image/index.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Image/useImageStyles.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/image.ts create mode 100644 packages/react-components/react-windmod-preview/stories/src/Image/ImageDefault.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/Image/ImageGriffelComparison.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/Image/index.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/ImageGriffel.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/ImageVrScene.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/ImageWindmod.stories.tsx diff --git a/packages/react-components/react-windmod-preview/library/etc/image.api.md b/packages/react-components/react-windmod-preview/library/etc/image.api.md new file mode 100644 index 00000000000000..33c308fb98d733 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/etc/image.api.md @@ -0,0 +1,52 @@ +## API Report File for "@fluentui/react-windmod-preview" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { ImageProps as ImageProps_2 } from '@fluentui/react-headless-components-preview/image'; +import { ImageSlots } from '@fluentui/react-headless-components-preview/image'; +import type { ImageState as ImageState_2 } from '@fluentui/react-headless-components-preview/image'; +import { renderImage } from '@fluentui/react-headless-components-preview/image'; +import { useImage } from '@fluentui/react-headless-components-preview/image'; + +// @public +const Image_2: ForwardRefComponent; +export { Image_2 as Image } + +// @public +export const imageClassNames: { + root: string; +}; + +// @public +export type ImageFit = 'none' | 'center' | 'contain' | 'cover' | 'default'; + +// @public +export type ImageProps = ImageProps_2 & { + block?: boolean; + bordered?: boolean; + fit?: ImageFit; + shadow?: boolean; + shape?: ImageShape; +}; + +// @public +export type ImageShape = 'square' | 'circular' | 'rounded'; + +export { ImageSlots } + +// @public +export type ImageState = ImageState_2 & Required>; + +export { renderImage } + +export { useImage } + +// @public +export const useImageStyles: (state: ImageState) => ImageState; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md b/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md index 4bd5179d10d7ae..e44dd50d63b7ca 100644 --- a/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md +++ b/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md @@ -14,6 +14,9 @@ import type { DividerProps as DividerProps_2 } from '@fluentui/react-headless-co import { DividerSlots } from '@fluentui/react-headless-components-preview/divider'; import type { DividerState as DividerState_2 } from '@fluentui/react-headless-components-preview/divider'; import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { ImageProps as ImageProps_2 } from '@fluentui/react-headless-components-preview/image'; +import { ImageSlots } from '@fluentui/react-headless-components-preview/image'; +import type { ImageState as ImageState_2 } from '@fluentui/react-headless-components-preview/image'; import type { JSXElement } from '@fluentui/react-utilities'; import type { LabelProps as LabelProps_2 } from '@fluentui/react-headless-components-preview/label'; import { LabelSlots } from '@fluentui/react-headless-components-preview/label'; @@ -27,6 +30,7 @@ import * as React_2 from 'react'; import { renderBadge } from '@fluentui/react-headless-components-preview/badge'; import { renderButton } from '@fluentui/react-headless-components-preview/button'; import { renderDivider } from '@fluentui/react-headless-components-preview/divider'; +import { renderImage } from '@fluentui/react-headless-components-preview/image'; import { renderLabel } from '@fluentui/react-headless-components-preview/label'; import { renderLink } from '@fluentui/react-headless-components-preview/link'; import { renderProvider } from '@fluentui/react-headless-components-preview/provider'; @@ -57,6 +61,7 @@ import { TooltipTriggerProps } from '@fluentui/react-headless-components-preview import { useBadge } from '@fluentui/react-headless-components-preview/badge'; import { useButton } from '@fluentui/react-headless-components-preview/button'; import { useDivider } from '@fluentui/react-headless-components-preview/divider'; +import { useImage } from '@fluentui/react-headless-components-preview/image'; import { useLabel } from '@fluentui/react-headless-components-preview/label'; import { useLink } from '@fluentui/react-headless-components-preview/link'; import { useProvider } from '@fluentui/react-headless-components-preview/provider'; @@ -156,6 +161,35 @@ export { DividerSlots } // @public export type DividerState = DividerState_2 & Required>; +// @public +const Image_2: ForwardRefComponent; +export { Image_2 as Image } + +// @public +export const imageClassNames: { + root: string; +}; + +// @public +export type ImageFit = 'none' | 'center' | 'contain' | 'cover' | 'default'; + +// @public +export type ImageProps = ImageProps_2 & { + block?: boolean; + bordered?: boolean; + fit?: ImageFit; + shadow?: boolean; + shape?: ImageShape; +}; + +// @public +export type ImageShape = 'square' | 'circular' | 'rounded'; + +export { ImageSlots } + +// @public +export type ImageState = ImageState_2 & Required>; + // @public export const Label: ForwardRefComponent; @@ -213,6 +247,8 @@ export { renderButton } export { renderDivider } +export { renderImage } + export { renderLabel } export { renderLink } @@ -375,6 +411,11 @@ export { useDivider } // @public export const useDividerStyles: (state: DividerState) => DividerState; +export { useImage } + +// @public +export const useImageStyles: (state: ImageState) => ImageState; + export { useLabel } // @public diff --git a/packages/react-components/react-windmod-preview/library/package.json b/packages/react-components/react-windmod-preview/library/package.json index 3220c4387c999c..3c6ec36c7be317 100644 --- a/packages/react-components/react-windmod-preview/library/package.json +++ b/packages/react-components/react-windmod-preview/library/package.json @@ -82,6 +82,16 @@ "default": "./lib-commonjs/divider.cjs" } }, + "./image": { + "import": { + "types": "./dist/image.d.ts", + "default": "./lib/image.js" + }, + "require": { + "types": "./dist/image.d.cts", + "default": "./lib-commonjs/image.cjs" + } + }, "./label": { "import": { "types": "./dist/label.d.ts", diff --git a/packages/react-components/react-windmod-preview/library/src/components/Image/Image.module.css b/packages/react-components/react-windmod-preview/library/src/components/Image/Image.module.css new file mode 100644 index 00000000000000..b293019faed867 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Image/Image.module.css @@ -0,0 +1,51 @@ +@reference '#theme'; + +/* Block order carries the cascade: `.circular` and `.rounded` override `.root`'s border-radius + at equal specificity. `.root` declares border-color alone — a border only draws once + `.bordered` supplies the width and style. */ + +@layer fui.components.l1 { + .root { + @apply box-border inline-block rounded-none border-neutral-stroke-1; + } + + .block { + @apply w-full; + } + + .bordered { + @apply border-(length:--spacing-thin) border-solid; + } + + .shadow { + @apply shadow-4; + } + + .center { + @apply object-none object-center; + } + + .contain { + @apply object-contain object-center; + } + + .cover { + @apply object-cover object-center; + } + + .none { + @apply object-none object-left-top; + } + + .fit-fill { + @apply size-full; + } + + .circular { + @apply rounded-circular; + } + + .rounded { + @apply rounded-medium; + } +} diff --git a/packages/react-components/react-windmod-preview/library/src/components/Image/Image.test.tsx b/packages/react-components/react-windmod-preview/library/src/components/Image/Image.test.tsx new file mode 100644 index 00000000000000..4519f1726c48bd --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Image/Image.test.tsx @@ -0,0 +1,154 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; + +import { Image } from './Image'; +import { isConformant } from '../../testing/isConformant'; +import type { ImageState } from './Image.types'; +import { imageClassNames, useImageStyles } from './useImageStyles'; + +import styles from './Image.module.css'; + +const src = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='; + +describe('Image', () => { + isConformant({ + Component: Image, + displayName: 'Image', + requiredProps: { alt: '', src }, + }); + + it('stamps the marker class', () => { + const { getByTestId } = render(); + + const root = getByTestId('root'); + + expect(root.className).toContain(imageClassNames.root); + expect(root).toHaveClass('fui-image'); + expect(root).toHaveClass('group/fui-image'); + expect(root.classList[0]).toBe('fui-image'); + }); + + it('stamps no data attribute — every look prop resolves to a class', () => { + const { getByTestId } = render( + , + ); + + const root = getByTestId('root'); + + expect(root.hasAttribute('data-block')).toBe(false); + expect(root.hasAttribute('data-bordered')).toBe(false); + expect(root.hasAttribute('data-fit')).toBe(false); + expect(root.hasAttribute('data-shadow')).toBe(false); + expect(root.hasAttribute('data-shape')).toBe(false); + }); + + it('renders an img and passes the intrinsic attributes through', () => { + const { getByTestId } = render(Alt); + + const root = getByTestId('root'); + + expect(root.tagName).toBe('IMG'); + expect(root.getAttribute('src')).toBe(src); + expect(root.getAttribute('alt')).toBe('Alt'); + expect(root.getAttribute('width')).toBe('40'); + expect(root.getAttribute('height')).toBe('20'); + }); + + it('adds the boolean look classes only when the prop is on', () => { + // The css-module proxy answers every key, so these pin the lookup keys, not class existence. + const { getByTestId } = render( + <> + + + , + ); + + expect(getByTestId('plain')).not.toHaveClass(styles.block); + expect(getByTestId('plain')).not.toHaveClass(styles.bordered); + expect(getByTestId('plain')).not.toHaveClass(styles.shadow); + expect(getByTestId('decorated')).toHaveClass(styles.block); + expect(getByTestId('decorated')).toHaveClass(styles.bordered); + expect(getByTestId('decorated')).toHaveClass(styles.shadow); + }); + + it('adds one shape class at a time', () => { + const { getByTestId } = render( + <> + + + , + ); + + expect(getByTestId('circular')).toHaveClass(styles.circular); + expect(getByTestId('circular')).not.toHaveClass(styles.rounded); + expect(getByTestId('rounded')).toHaveClass(styles.rounded); + expect(getByTestId('rounded')).not.toHaveClass(styles.circular); + }); + + it('looks up the root and fit classes by the keys the stylesheet authors', () => { + const { getByTestId } = render( + <> + + + + + , + ); + + expect(getByTestId('center')).toHaveClass(styles.root); + expect(getByTestId('center')).toHaveClass(styles.center); + expect(getByTestId('contain')).toHaveClass(styles.contain); + expect(getByTestId('cover')).toHaveClass(styles.cover); + expect(getByTestId('cover')).not.toHaveClass(styles.contain); + expect(getByTestId('none')).toHaveClass(styles.none); + }); + + it('applies fit-fill only for a non-default fit with no explicit size', () => { + const { getByTestId } = render( + <> + + + + + + , + ); + + expect(getByTestId('cover')).toHaveClass(styles['fit-fill']); + expect(getByTestId('default')).not.toHaveClass(styles['fit-fill']); + expect(getByTestId('width')).not.toHaveClass(styles['fit-fill']); + expect(getByTestId('height')).not.toHaveClass(styles['fit-fill']); + expect(getByTestId('both')).not.toHaveClass(styles['fit-fill']); + }); + + it('counts a zero size as explicit and a null size as absent', () => { + const { getByTestId } = render( + <> + + + , + ); + + expect(getByTestId('zero')).not.toHaveClass(styles['fit-fill']); + expect(getByTestId('null')).toHaveClass(styles['fit-fill']); + }); + + it('does not mutate the state it is given', () => { + const state = { + block: false, + bordered: true, + components: { root: 'img' }, + fit: 'cover', + root: { as: 'img', alt: '', className: 'consumer', src }, + shadow: true, + shape: 'circular', + } as unknown as ImageState; + + const styled = useImageStyles(state); + + expect(styled).not.toBe(state); + expect(state.root.className).toBe('consumer'); + expect(styled.root.className).toContain('consumer'); + expect(styled.root.className).toContain(imageClassNames.root); + }); +}); diff --git a/packages/react-components/react-windmod-preview/library/src/components/Image/Image.tsx b/packages/react-components/react-windmod-preview/library/src/components/Image/Image.tsx new file mode 100644 index 00000000000000..c0ceb5133e203b --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Image/Image.tsx @@ -0,0 +1,32 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { renderImage, useImage } from '@fluentui/react-headless-components-preview/image'; + +import type { ImageProps, ImageState } from './Image.types'; +import { useImageStyles } from './useImageStyles'; + +/** + * An Image displays a picture, icon or other visual content. Windmod Image: the headless + * image decorated with the Fluent visual contract (Tailwind v4 + CSS Modules). + */ +export const Image: ForwardRefComponent = React.forwardRef((props, ref) => { + // Look props belong to windmod — the headless hook neither accepts nor resolves them. + // Defaults mirror @fluentui/react-image's styled useImage. + const { block = false, bordered = false, fit = 'default', shadow = false, shape = 'square', ...rest } = props; + + const state: ImageState = { + ...useImage(rest, ref), + block, + bordered, + fit, + shadow, + shape, + }; + + return renderImage(useImageStyles(state)); + // Casting is required due to lack of distributive union to support union on @types/react +}) as ForwardRefComponent; + +Image.displayName = 'Image'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Image/Image.types.ts b/packages/react-components/react-windmod-preview/library/src/components/Image/Image.types.ts new file mode 100644 index 00000000000000..894ad19cac3038 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Image/Image.types.ts @@ -0,0 +1,33 @@ +import type { + ImageProps as ImageHeadlessProps, + ImageState as ImageHeadlessState, +} from '@fluentui/react-headless-components-preview/image'; + +export type { ImageSlots } from '@fluentui/react-headless-components-preview/image'; + +/** How an Image is resized to fit its container. `'default'` is the base look. */ +export type ImageFit = 'none' | 'center' | 'contain' | 'cover' | 'default'; + +/** Corner treatment of the Image. `'square'` is the base look. */ +export type ImageShape = 'square' | 'circular' | 'rounded'; + +/** + * Windmod Image props: the headless image plus the look props the headless surface + * deliberately omits (they exist purely to select styles). + */ +export type ImageProps = ImageHeadlessProps & { + /** @default false */ + block?: boolean; + /** @default false */ + bordered?: boolean; + /** @default 'default' */ + fit?: ImageFit; + /** @default false */ + shadow?: boolean; + /** @default 'square' */ + shape?: ImageShape; +}; + +/** Windmod Image state: headless state plus the resolved look props. */ +export type ImageState = ImageHeadlessState & + Required>; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Image/index.ts b/packages/react-components/react-windmod-preview/library/src/components/Image/index.ts new file mode 100644 index 00000000000000..b2565243da5139 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Image/index.ts @@ -0,0 +1,3 @@ +export { Image } from './Image'; +export { imageClassNames, useImageStyles } from './useImageStyles'; +export type { ImageFit, ImageProps, ImageShape, ImageSlots, ImageState } from './Image.types'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Image/useImageStyles.ts b/packages/react-components/react-windmod-preview/library/src/components/Image/useImageStyles.ts new file mode 100644 index 00000000000000..6e69686985c3c8 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Image/useImageStyles.ts @@ -0,0 +1,41 @@ +import { clsx } from 'clsx'; + +import { componentMarkers } from '../../utils/groupMarker'; +import type { ImageState } from './Image.types'; + +import styles from './Image.module.css'; + +/** The only public classes — see componentMarkers; internals are hashed idents. */ +export const imageClassNames: { root: string } = { + root: componentMarkers('image'), +}; + +/** + * Applies the visual contract, returning new state. `fit-fill` exists because an img with no + * width/height attribute sizes to its intrinsic dimensions, leaving object-fit nothing to do; + * an explicit width or height means the consumer has taken over sizing. `default` fit and + * `square` shape are the base look and carry no class. + */ +export const useImageStyles = (state: ImageState): ImageState => { + const { block, bordered, fit, shadow, shape } = state; + const { height, width } = state.root; + const hasExplicitSize = (height !== undefined && height !== null) || (width !== undefined && width !== null); + + return { + ...state, + root: { + ...state.root, + className: clsx( + imageClassNames.root, + styles.root, + block && styles.block, + bordered && styles.bordered, + shadow && styles.shadow, + styles[fit], + fit !== 'default' && !hasExplicitSize && styles['fit-fill'], + styles[shape], + state.root.className, + ), + }, + }; +}; diff --git a/packages/react-components/react-windmod-preview/library/src/image.ts b/packages/react-components/react-windmod-preview/library/src/image.ts new file mode 100644 index 00000000000000..08d5532931cf55 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/image.ts @@ -0,0 +1,5 @@ +export { Image, imageClassNames, useImageStyles } from './components/Image'; +export type { ImageFit, ImageProps, ImageShape, ImageSlots, ImageState } from './components/Image'; + +/** Headless building blocks, re-exported for consumers composing their own Image. */ +export { renderImage, useImage } from '@fluentui/react-headless-components-preview/image'; diff --git a/packages/react-components/react-windmod-preview/library/src/index.ts b/packages/react-components/react-windmod-preview/library/src/index.ts index be294241546020..2ea24e3989c449 100644 --- a/packages/react-components/react-windmod-preview/library/src/index.ts +++ b/packages/react-components/react-windmod-preview/library/src/index.ts @@ -7,6 +7,9 @@ export type { ButtonAppearance, ButtonProps, ButtonShape, ButtonSize, ButtonSlot export { Divider, dividerClassNames, renderDivider, useDivider, useDividerStyles } from './divider'; export type { DividerAlignContent, DividerAppearance, DividerProps, DividerSlots, DividerState } from './divider'; +export { Image, imageClassNames, renderImage, useImage, useImageStyles } from './image'; +export type { ImageFit, ImageProps, ImageShape, ImageSlots, ImageState } from './image'; + export { Label, labelClassNames, renderLabel, useLabel, useLabelStyles } from './label'; export type { LabelProps, LabelSize, LabelSlots, LabelState, LabelWeight } from './label'; diff --git a/packages/react-components/react-windmod-preview/stories/src/Image/ImageDefault.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Image/ImageDefault.stories.tsx new file mode 100644 index 00000000000000..3f26efa4b92fbc --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/Image/ImageDefault.stories.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { Image, ThemeProvider } from '@fluentui/react-windmod-preview'; + +import styles from '../compare.module.css'; + +/* A self-contained source: the storybook build serves no outbound network. Explicit width and + height on the svg root give it the intrinsic size object-fit needs. */ +const src = `data:image/svg+xml,${encodeURIComponent( + '' + + '' + + '' + + '' + + '', +)}`; + +const fits = ['default', 'none', 'center', 'contain', 'cover'] as const; +const shapes = ['square', 'circular', 'rounded'] as const; + +export const Default = (): React.ReactNode => ( + +
+
+ {fits.map(fit => ( +
+ +
+ ))} +
+
+ {shapes.map(shape => ( + + ))} +
+
+ + + +
+
+ +
+
+
+); diff --git a/packages/react-components/react-windmod-preview/stories/src/Image/ImageGriffelComparison.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Image/ImageGriffelComparison.stories.tsx new file mode 100644 index 00000000000000..dc9330efb241f4 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/Image/ImageGriffelComparison.stories.tsx @@ -0,0 +1,68 @@ +import * as React from 'react'; +import { Image, ThemeProvider } from '@fluentui/react-windmod-preview'; +import { FluentProvider, Image as GriffelImage, webLightTheme } from '@fluentui/react-components'; + +import styles from '../compare.module.css'; + +// See ImageDefault.stories for why the source is an inline data URI. +const src = `data:image/svg+xml,${encodeURIComponent( + '' + + '' + + '' + + '' + + '', +)}`; + +type LookProps = { + block?: boolean; + bordered?: boolean; + fit?: 'none' | 'center' | 'contain' | 'cover' | 'default'; + shadow?: boolean; + shape?: 'square' | 'circular' | 'rounded'; + width?: number; + height?: number; +}; + +/** + * Every windmod variant next to its Griffel-suite twin (inside a FluentProvider). + * The pair in each row must be pixel-identical. + */ +export const GriffelComparison = (): React.ReactNode => { + const variants: Array<{ label: string; props: LookProps }> = [ + { label: 'default', props: {} }, + { label: 'bordered', props: { bordered: true } }, + { label: 'shadow', props: { shadow: true } }, + { label: 'circular', props: { shape: 'circular', width: 64, height: 64 } }, + { label: 'rounded', props: { shape: 'rounded', width: 64, height: 64 } }, + { label: 'fit none', props: { fit: 'none' } }, + { label: 'fit center', props: { fit: 'center' } }, + { label: 'fit contain', props: { fit: 'contain' } }, + { label: 'fit cover', props: { fit: 'cover' } }, + { label: 'fit cover sized', props: { fit: 'cover', width: 80, height: 80 } }, + { label: 'block', props: { block: true } }, + { label: 'bordered shadow circular', props: { bordered: true, shadow: true, shape: 'circular' } }, + ]; + + return ( +
+
Variant
+
Windmod
+
Griffel
+ {variants.map(({ label, props }) => ( + +
{label}
+
+ + + +
+
+ + + +
+
+ ))} +
+ ); +}; diff --git a/packages/react-components/react-windmod-preview/stories/src/Image/index.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Image/index.stories.tsx new file mode 100644 index 00000000000000..2379c23d513429 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/Image/index.stories.tsx @@ -0,0 +1,9 @@ +import { Image } from '@fluentui/react-windmod-preview'; + +export { Default } from './ImageDefault.stories'; +export { GriffelComparison } from './ImageGriffelComparison.stories'; + +export default { + title: 'Windmod/Image', + component: Image, +}; diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/ImageGriffel.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/ImageGriffel.stories.tsx new file mode 100644 index 00000000000000..53980ba8466904 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/ImageGriffel.stories.tsx @@ -0,0 +1,19 @@ +import * as React from 'react'; +import { FluentProvider, Image, webLightTheme } from '@fluentui/react-components'; + +import { ImageVrScene, useImageSourcesDecoded } from './ImageVrScene'; + +export const ImageGriffel = (): React.ReactNode => { + // See ImageWindmod.stories — the gate must sit above the provider. + const decoded = useImageSourcesDecoded(); + + if (!decoded) { + return null; + } + + return ( + + + + ); +}; diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/ImageVrScene.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/ImageVrScene.tsx new file mode 100644 index 00000000000000..46558e5a9acf4e --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/ImageVrScene.tsx @@ -0,0 +1,156 @@ +// eslint-disable-next-line import/no-extraneous-dependencies -- story-only helper outside the *.stories.tsx devDependencies exemption +import * as React from 'react'; + +/* Explicit width/height on the svg root are required: without them the element has no intrinsic + size and an img falls back to 300x150, which silently defeats every object-fit: none cell. + encodeURIComponent is required too — the `#` of a hex colour is not a literal in a data URI. */ +const svg = (w: number, h: number) => + `data:image/svg+xml,${encodeURIComponent( + `` + + `` + + `` + + `` + + ``, + )}`; + +const srcWide = svg(100, 20); +const srcTall = svg(20, 100); +const srcSmall = svg(32, 20); + +export const sources = [srcWide, srcTall, srcSmall]; + +/** run.mjs screenshots two frames after mount without waiting for images; rendering nothing + until every source has decoded makes that wait unnecessary. The probe element shares the + document's image cache with the rendered ones, so they paint on their first layout. */ +export const useImageSourcesDecoded = (): boolean => { + const [decoded, setDecoded] = React.useState(false); + + React.useEffect(() => { + let cancelled = false; + + Promise.all( + sources.map(src => { + // `Image` is the component under test in both story files — never `new Image()`. + const el = document.createElement('img'); + el.src = src; + return el.decode + ? el.decode() + : new Promise(resolve => { + el.onload = () => resolve(); + el.onerror = () => resolve(); + }); + }), + ) + .catch(() => undefined) + .then(() => { + if (!cancelled) { + setDecoded(true); + } + }); + + return () => { + cancelled = true; + }; + }, []); + + return decoded; +}; + +const fits = ['default', 'none', 'center', 'contain', 'cover'] as const; +const shapes = ['square', 'circular', 'rounded'] as const; + +type ImageLike = React.ComponentType<{ + src: string; + alt: string; + block?: boolean; + bordered?: boolean; + fit?: (typeof fits)[number]; + shadow?: boolean; + shape?: (typeof shapes)[number]; + width?: number; + height?: number; +}>; + +/* FluentProvider inherits body1 typography onto its subtree while ThemeProvider inherits + nothing; an inline-block img sits on a line-box baseline, so the strut would shift the + Griffel side. Pinning font-size and line-height to 0 removes the strut and collapses the + whitespace the inline-block adjacency row depends on. */ +const frame: React.CSSProperties = { + display: 'inline-flex', + flexDirection: 'column', + gap: 24, + padding: 24, + background: '#ffffff', + fontSize: 0, + lineHeight: 0, + textAlign: 'left', +}; + +const cell: React.CSSProperties = { width: 120, height: 80, overflow: 'hidden' }; +const row: React.CSSProperties = { display: 'flex', gap: 24 }; +const looseRow: React.CSSProperties = { display: 'flex', gap: 32, alignItems: 'flex-start' }; + +/** One scene, two implementations — the VR runner diffs the renders pixel for pixel. */ +export const ImageVrScene = ({ Image }: { Image: ImageLike }): React.ReactNode => ( +
+ {sources.map(src => ( +
+ {fits.map(fit => ( +
+ +
+ ))} +
+ ))} + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ {shapes.map(shape => ( + + + + + ))} +
+ +
+ + + +
+ +
+
+ +
+
+ +
+
+ + {/* The only place display: inline-block is observable; the two images must be adjacent + with no whitespace text node between them. */} +
+ + +
+ +
+ +
+
+); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/ImageWindmod.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/ImageWindmod.stories.tsx new file mode 100644 index 00000000000000..a12903b2e7a891 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/ImageWindmod.stories.tsx @@ -0,0 +1,20 @@ +import * as React from 'react'; +import { Image, ThemeProvider } from '@fluentui/react-windmod-preview'; + +import { ImageVrScene, useImageSourcesDecoded } from './ImageVrScene'; + +export const ImageWindmod = (): React.ReactNode => { + // ThemeProvider renders a real element, so the gate must sit above it: the runner starts + // capturing as soon as #storybook-root has any child. + const decoded = useImageSourcesDecoded(); + + if (!decoded) { + return null; + } + + return ( + + + + ); +}; diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx index 728ba5553f1e81..1d7dc5ca892dfb 100644 --- a/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx +++ b/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx @@ -4,6 +4,8 @@ export { ButtonWindmod } from './ButtonWindmod.stories'; export { ButtonGriffel } from './ButtonGriffel.stories'; export { DividerWindmod } from './DividerWindmod.stories'; export { DividerGriffel } from './DividerGriffel.stories'; +export { ImageWindmod } from './ImageWindmod.stories'; +export { ImageGriffel } from './ImageGriffel.stories'; export { LabelWindmod } from './LabelWindmod.stories'; export { LabelGriffel } from './LabelGriffel.stories'; export { LinkWindmod } from './LinkWindmod.stories'; From 2ecdea4d2b6d88b7cea31541e5bf488235782302 Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Fri, 21 Aug 2026 19:47:17 -0700 Subject: [PATCH 034/246] feat(windmod): add ProgressBar Headless useProgressBar + Tailwind CSS-Modules styling, pixel-identical to the Griffel suite. The headless hook owns the determinate width inline style; the bar reaches indeterminate state through its group variant, and the VR scene freezes both CSS and WAAPI animations at fixed times via commitStyles so the diff proves duration, easing and iteration count. Removes the unused thickness catalog entries; VR scene fixtures gain use-client directives and scoped lint pragmas. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- .../library/etc/progress-bar.api.md | 52 +++++ .../library/etc/react-windmod-preview.api.md | 41 ++++ .../library/package.json | 10 + .../ProgressBar/ProgressBar.module.css | 100 +++++++++ .../ProgressBar/ProgressBar.test.tsx | 204 ++++++++++++++++++ .../components/ProgressBar/ProgressBar.tsx | 31 +++ .../ProgressBar/ProgressBar.types.ts | 32 +++ .../src/components/ProgressBar/index.ts | 10 + .../ProgressBar/useProgressBarStyles.ts | 41 ++++ .../library/src/index.ts | 16 ++ .../library/src/progress-bar.ts | 12 ++ .../library/src/variants.css | 4 - .../ProgressBarDefault.stories.tsx | 44 ++++ .../ProgressBarGriffelComparison.stories.tsx | 57 +++++ .../stories/src/ProgressBar/index.stories.tsx | 9 + .../stories/src/vr/ImageVrScene.tsx | 3 + .../src/vr/ProgressBarGriffel.stories.tsx | 10 + .../stories/src/vr/ProgressBarVrScene.tsx | 117 ++++++++++ .../src/vr/ProgressBarWindmod.stories.tsx | 10 + .../stories/src/vr/index.stories.tsx | 2 + 20 files changed, 801 insertions(+), 4 deletions(-) create mode 100644 packages/react-components/react-windmod-preview/library/etc/progress-bar.api.md create mode 100644 packages/react-components/react-windmod-preview/library/src/components/ProgressBar/ProgressBar.module.css create mode 100644 packages/react-components/react-windmod-preview/library/src/components/ProgressBar/ProgressBar.test.tsx create mode 100644 packages/react-components/react-windmod-preview/library/src/components/ProgressBar/ProgressBar.tsx create mode 100644 packages/react-components/react-windmod-preview/library/src/components/ProgressBar/ProgressBar.types.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/ProgressBar/index.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/ProgressBar/useProgressBarStyles.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/progress-bar.ts create mode 100644 packages/react-components/react-windmod-preview/stories/src/ProgressBar/ProgressBarDefault.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/ProgressBar/ProgressBarGriffelComparison.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/ProgressBar/index.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/ProgressBarGriffel.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/ProgressBarVrScene.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/ProgressBarWindmod.stories.tsx diff --git a/packages/react-components/react-windmod-preview/library/etc/progress-bar.api.md b/packages/react-components/react-windmod-preview/library/etc/progress-bar.api.md new file mode 100644 index 00000000000000..dce8b6fb58b342 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/etc/progress-bar.api.md @@ -0,0 +1,52 @@ +## API Report File for "@fluentui/react-windmod-preview" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { ProgressBarProps as ProgressBarProps_2 } from '@fluentui/react-headless-components-preview/progress-bar'; +import { ProgressBarSlots } from '@fluentui/react-headless-components-preview/progress-bar'; +import type { ProgressBarState as ProgressBarState_2 } from '@fluentui/react-headless-components-preview/progress-bar'; +import { renderProgressBar } from '@fluentui/react-headless-components-preview/progress-bar'; +import { useProgressBar } from '@fluentui/react-headless-components-preview/progress-bar'; + +// @public +export const ProgressBar: ForwardRefComponent; + +// @public +export const progressBarClassNames: { + root: string; +}; + +// @public +export type ProgressBarColor = 'brand' | 'success' | 'warning' | 'error'; + +// @public +export type ProgressBarProps = ProgressBarProps_2 & { + color?: ProgressBarColor; + shape?: ProgressBarShape; + thickness?: ProgressBarThickness; +}; + +// @public +export type ProgressBarShape = 'rounded' | 'square'; + +export { ProgressBarSlots } + +// @public +export type ProgressBarState = ProgressBarState_2 & Required>; + +// @public +export type ProgressBarThickness = 'medium' | 'large'; + +export { renderProgressBar } + +export { useProgressBar } + +// @public +export const useProgressBarStyles: (state: ProgressBarState) => ProgressBarState; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md b/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md index e44dd50d63b7ca..f6ca13cb7d0861 100644 --- a/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md +++ b/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md @@ -25,6 +25,9 @@ import type { LinkProps as LinkProps_2 } from '@fluentui/react-headless-componen import { LinkSlots } from '@fluentui/react-headless-components-preview/link'; import type { LinkState as LinkState_2 } from '@fluentui/react-headless-components-preview/link'; import { OnVisibleChangeData } from '@fluentui/react-headless-components-preview/tooltip'; +import type { ProgressBarProps as ProgressBarProps_2 } from '@fluentui/react-headless-components-preview/progress-bar'; +import { ProgressBarSlots } from '@fluentui/react-headless-components-preview/progress-bar'; +import type { ProgressBarState as ProgressBarState_2 } from '@fluentui/react-headless-components-preview/progress-bar'; import { Provider } from '@fluentui/react-headless-components-preview/provider'; import * as React_2 from 'react'; import { renderBadge } from '@fluentui/react-headless-components-preview/badge'; @@ -33,6 +36,7 @@ import { renderDivider } from '@fluentui/react-headless-components-preview/divid import { renderImage } from '@fluentui/react-headless-components-preview/image'; import { renderLabel } from '@fluentui/react-headless-components-preview/label'; import { renderLink } from '@fluentui/react-headless-components-preview/link'; +import { renderProgressBar } from '@fluentui/react-headless-components-preview/progress-bar'; import { renderProvider } from '@fluentui/react-headless-components-preview/provider'; import { renderSkeleton } from '@fluentui/react-headless-components-preview/skeleton'; import { renderSkeletonItem } from '@fluentui/react-headless-components-preview/skeleton'; @@ -64,6 +68,7 @@ import { useDivider } from '@fluentui/react-headless-components-preview/divider' import { useImage } from '@fluentui/react-headless-components-preview/image'; import { useLabel } from '@fluentui/react-headless-components-preview/label'; import { useLink } from '@fluentui/react-headless-components-preview/link'; +import { useProgressBar } from '@fluentui/react-headless-components-preview/progress-bar'; import { useProvider } from '@fluentui/react-headless-components-preview/provider'; import { useSkeleton } from '@fluentui/react-headless-components-preview/skeleton'; import { useSkeletonContextValues } from '@fluentui/react-headless-components-preview/skeleton'; @@ -239,6 +244,35 @@ export type LinkState = LinkState_2 & Required; + +// @public +export const progressBarClassNames: { + root: string; +}; + +// @public +export type ProgressBarColor = 'brand' | 'success' | 'warning' | 'error'; + +// @public +export type ProgressBarProps = ProgressBarProps_2 & { + color?: ProgressBarColor; + shape?: ProgressBarShape; + thickness?: ProgressBarThickness; +}; + +// @public +export type ProgressBarShape = 'rounded' | 'square'; + +export { ProgressBarSlots } + +// @public +export type ProgressBarState = ProgressBarState_2 & Required>; + +// @public +export type ProgressBarThickness = 'medium' | 'large'; + export { Provider } export { renderBadge } @@ -253,6 +287,8 @@ export { renderLabel } export { renderLink } +export { renderProgressBar } + export { renderProvider } export { renderSkeleton } @@ -426,6 +462,11 @@ export { useLink } // @public export const useLinkStyles: (state: LinkState) => LinkState; +export { useProgressBar } + +// @public +export const useProgressBarStyles: (state: ProgressBarState) => ProgressBarState; + export { useProvider } export { useSkeleton } diff --git a/packages/react-components/react-windmod-preview/library/package.json b/packages/react-components/react-windmod-preview/library/package.json index 3c6ec36c7be317..0064e4aab8ec36 100644 --- a/packages/react-components/react-windmod-preview/library/package.json +++ b/packages/react-components/react-windmod-preview/library/package.json @@ -112,6 +112,16 @@ "default": "./lib-commonjs/link.cjs" } }, + "./progress-bar": { + "import": { + "types": "./dist/progress-bar.d.ts", + "default": "./lib/progress-bar.js" + }, + "require": { + "types": "./dist/progress-bar.d.cts", + "default": "./lib-commonjs/progress-bar.cjs" + } + }, "./provider": { "import": { "types": "./dist/provider.d.ts", diff --git a/packages/react-components/react-windmod-preview/library/src/components/ProgressBar/ProgressBar.module.css b/packages/react-components/react-windmod-preview/library/src/components/ProgressBar/ProgressBar.module.css new file mode 100644 index 00000000000000..e413dec1761c1a --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/ProgressBar/ProgressBar.module.css @@ -0,0 +1,100 @@ +@reference '#theme'; + +/* The keyframes declare both endpoints so the element's underlying `translate` stays `none`: + the reduced-motion branch swaps to the opacity keyframes and must leave no residual offset. */ + +/* `.bar` is authored last in this layer so its forced-colors override outranks the plain + background-color the colour classes set at equal specificity. */ + +@keyframes indeterminate-slide { + 0% { + translate: -100%; + } + + 100% { + translate: 300%; + } +} + +@keyframes indeterminate-pulse { + 0% { + opacity: 0.2; + } + + 50% { + opacity: 1; + } + + 100% { + opacity: 0.2; + } +} + +@layer fui.components.l1 { + .root { + @apply block w-full overflow-hidden bg-neutral-background-6; + + @variant forced-colors { + @apply bg-[CanvasText]; + } + } + + .rounded { + @apply rounded-medium; + } + + .square { + @apply rounded-none; + } + + .medium { + @apply h-2; + } + + .large { + @apply h-4; + } + + .brand { + @apply bg-compound-brand-background; + } + + .error { + @apply bg-palette-red-background-3; + } + + .warning { + @apply bg-palette-dark-orange-background-3; + } + + .success { + @apply bg-palette-green-background-3; + } + + .non-zero-determinate { + @apply transition-[width] duration-300 ease-[ease]; + } + + .bar { + @apply h-full rounded-[inherit]; + + @variant forced-colors { + @apply bg-[Highlight]; + } + + @variant group-indeterminate/fui-progress-bar { + @apply relative max-w-[33%] [animation-duration:3s] [animation-iteration-count:infinite] [animation-name:indeterminate-slide] [animation-timing-function:linear]; + + background-image: linear-gradient( + to right, + var(--color-neutral-background-6) 0%, + var(--color-transparent-background) 50%, + var(--color-neutral-background-6) 100% + ); + + @media screen and (prefers-reduced-motion: reduce) { + @apply max-w-full [animation-name:indeterminate-pulse]; + } + } + } +} diff --git a/packages/react-components/react-windmod-preview/library/src/components/ProgressBar/ProgressBar.test.tsx b/packages/react-components/react-windmod-preview/library/src/components/ProgressBar/ProgressBar.test.tsx new file mode 100644 index 00000000000000..20a09b22d68a9b --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/ProgressBar/ProgressBar.test.tsx @@ -0,0 +1,204 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; + +import { isConformant } from '../../testing/isConformant'; +import { ProgressBar } from './ProgressBar'; +import type { ProgressBarState } from './ProgressBar.types'; +import { progressBarClassNames, useProgressBarStyles } from './useProgressBarStyles'; + +import styles from './ProgressBar.module.css'; + +const barOf = (root: HTMLElement): Element => { + const bar = root.firstElementChild; + + if (!bar) { + throw new Error('ProgressBar rendered no bar slot'); + } + + return bar; +}; + +describe('ProgressBar', () => { + isConformant({ + Component: ProgressBar, + displayName: 'ProgressBar', + }); + + it('stamps the marker class', () => { + const { getByTestId } = render(); + + const root = getByTestId('root'); + + expect(root.className).toContain(progressBarClassNames.root); + expect(root).toHaveClass('fui-progress-bar'); + expect(root).toHaveClass('group/fui-progress-bar'); + expect(root.classList[0]).toBe('fui-progress-bar'); + }); + + it('stamps no data attribute of its own — every look prop resolves to a class', () => { + const { getByTestId } = render( + , + ); + + const root = getByTestId('root'); + + expect(root.hasAttribute('data-color')).toBe(false); + expect(root.hasAttribute('data-shape')).toBe(false); + expect(root.hasAttribute('data-thickness')).toBe(false); + }); + + it('leaves data-indeterminate to the headless hook', () => { + const { getByTestId } = render( + <> + + + , + ); + + expect(getByTestId('indeterminate').getAttribute('data-indeterminate')).toBe(''); + expect(getByTestId('determinate').hasAttribute('data-indeterminate')).toBe(false); + }); + + it('adds one shape class and one thickness class at a time', () => { + // The css-module proxy answers every key, so these pin the lookup keys, not class existence. + const { getByTestId } = render( + <> + + + , + ); + + expect(getByTestId('default')).toHaveClass(styles.root); + expect(getByTestId('default')).toHaveClass(styles.rounded); + expect(getByTestId('default')).toHaveClass(styles.medium); + expect(getByTestId('default')).not.toHaveClass(styles.square); + expect(getByTestId('default')).not.toHaveClass(styles.large); + expect(getByTestId('square-large')).toHaveClass(styles.square); + expect(getByTestId('square-large')).toHaveClass(styles.large); + expect(getByTestId('square-large')).not.toHaveClass(styles.rounded); + expect(getByTestId('square-large')).not.toHaveClass(styles.medium); + }); + + it('stamps the bar class on the bar slot', () => { + const { getByTestId } = render( + <> + + + , + ); + + expect(barOf(getByTestId('determinate'))).toHaveClass(styles.bar); + expect(barOf(getByTestId('indeterminate'))).toHaveClass(styles.bar); + }); + + it('colours a determinate bar and always renders an indeterminate one brand', () => { + const { getByTestId } = render( + <> + + + + , + ); + + expect(barOf(getByTestId('determinate-default'))).toHaveClass(styles.brand); + expect(barOf(getByTestId('determinate-error'))).toHaveClass(styles.error); + expect(barOf(getByTestId('determinate-error'))).not.toHaveClass(styles.brand); + expect(barOf(getByTestId('indeterminate-error'))).toHaveClass(styles.brand); + expect(barOf(getByTestId('indeterminate-error'))).not.toHaveClass(styles.error); + }); + + it('transitions the width only above the zero threshold', () => { + const { getByTestId } = render( + <> + + + + + + , + ); + + expect(barOf(getByTestId('half'))).toHaveClass(styles['non-zero-determinate']); + expect(barOf(getByTestId('zero'))).not.toHaveClass(styles['non-zero-determinate']); + expect(barOf(getByTestId('at-threshold'))).not.toHaveClass(styles['non-zero-determinate']); + expect(barOf(getByTestId('above-threshold'))).toHaveClass(styles['non-zero-determinate']); + expect(barOf(getByTestId('indeterminate'))).not.toHaveClass(styles['non-zero-determinate']); + }); + + it('leaves the bar carrying the headless-computed inline width', () => { + const { getByTestId } = render( + <> + + + + , + ); + + expect(barOf(getByTestId('half'))).toHaveStyle({ width: '50%' }); + expect(barOf(getByTestId('ratio'))).toHaveStyle({ width: '42%' }); + expect((barOf(getByTestId('indeterminate')) as HTMLElement).style.width).toBe(''); + }); + + it('passes the headless ARIA contract through', () => { + const { getByTestId } = render( + <> + + + , + ); + + const determinate = getByTestId('determinate'); + + expect(determinate.getAttribute('role')).toBe('progressbar'); + expect(determinate.getAttribute('aria-valuenow')).toBe('0.5'); + expect(determinate.getAttribute('aria-valuemin')).toBe('0'); + expect(determinate.getAttribute('aria-valuemax')).toBe('1'); + + const indeterminate = getByTestId('indeterminate'); + + expect(indeterminate.getAttribute('role')).toBe('progressbar'); + expect(indeterminate.hasAttribute('aria-valuenow')).toBe(false); + expect(indeterminate.hasAttribute('aria-valuemin')).toBe(false); + expect(indeterminate.hasAttribute('aria-valuemax')).toBe(false); + }); + + it('does not mutate the state it is given', () => { + const state = { + color: 'error', + components: { root: 'div', bar: 'div' }, + max: 1, + root: { as: 'div', className: 'consumer' }, + bar: { as: 'div', className: 'consumer-bar' }, + shape: 'square', + thickness: 'large', + value: 0.5, + } as unknown as ProgressBarState; + + const styled = useProgressBarStyles(state); + + expect(styled).not.toBe(state); + expect(state.root.className).toBe('consumer'); + expect(state.bar.className).toBe('consumer-bar'); + expect(styled.root.className).toContain('consumer'); + expect(styled.root.className).toContain(progressBarClassNames.root); + expect(styled.bar.className).toContain('consumer-bar'); + }); + + it('leaves the bar inline style untouched', () => { + const state = { + color: 'brand', + components: { root: 'div', bar: 'div' }, + max: 1, + root: { as: 'div' }, + bar: { as: 'div', style: { width: '50%' } }, + shape: 'rounded', + thickness: 'medium', + value: 0.5, + } as unknown as ProgressBarState; + + const styled = useProgressBarStyles(state); + + expect(styled.bar.style).toBe(state.bar.style); + expect(styled.bar.style).toEqual({ width: '50%' }); + }); +}); diff --git a/packages/react-components/react-windmod-preview/library/src/components/ProgressBar/ProgressBar.tsx b/packages/react-components/react-windmod-preview/library/src/components/ProgressBar/ProgressBar.tsx new file mode 100644 index 00000000000000..3f1925cb0b5073 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/ProgressBar/ProgressBar.tsx @@ -0,0 +1,31 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { renderProgressBar, useProgressBar } from '@fluentui/react-headless-components-preview/progress-bar'; + +import type { ProgressBarProps, ProgressBarState } from './ProgressBar.types'; +import { useProgressBarStyles } from './useProgressBarStyles'; + +/** + * A ProgressBar shows the completion of a task. Windmod ProgressBar: the headless progress bar + * decorated with the Fluent visual contract (Tailwind v4 + CSS Modules). + */ +export const ProgressBar: ForwardRefComponent = React.forwardRef((props, ref) => { + // Look props belong to windmod — the headless hook neither accepts nor resolves them. + // Defaults mirror @fluentui/react-progress's styled useProgressBar, except `color`, whose + // Griffel default is derived from the Field validation state headless does not expose. + const { color = 'brand', shape = 'rounded', thickness = 'medium', ...rest } = props; + + const state: ProgressBarState = { + ...useProgressBar(rest, ref), + color, + shape, + thickness, + }; + + return renderProgressBar(useProgressBarStyles(state)); + // Casting is required due to lack of distributive union to support union on @types/react +}) as ForwardRefComponent; + +ProgressBar.displayName = 'ProgressBar'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/ProgressBar/ProgressBar.types.ts b/packages/react-components/react-windmod-preview/library/src/components/ProgressBar/ProgressBar.types.ts new file mode 100644 index 00000000000000..082b29795318c8 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/ProgressBar/ProgressBar.types.ts @@ -0,0 +1,32 @@ +import type { + ProgressBarProps as ProgressBarHeadlessProps, + ProgressBarState as ProgressBarHeadlessState, +} from '@fluentui/react-headless-components-preview/progress-bar'; + +export type { ProgressBarSlots } from '@fluentui/react-headless-components-preview/progress-bar'; + +/** Fill colour of a determinate ProgressBar. An indeterminate bar always renders `'brand'`. */ +export type ProgressBarColor = 'brand' | 'success' | 'warning' | 'error'; + +/** Corner treatment of the ProgressBar track. */ +export type ProgressBarShape = 'rounded' | 'square'; + +/** Height of the ProgressBar track. */ +export type ProgressBarThickness = 'medium' | 'large'; + +/** + * Windmod ProgressBar props: the headless progress bar plus the look props the headless surface + * deliberately omits (they exist purely to select styles). + */ +export type ProgressBarProps = ProgressBarHeadlessProps & { + /** @default 'brand' */ + color?: ProgressBarColor; + /** @default 'rounded' */ + shape?: ProgressBarShape; + /** @default 'medium' */ + thickness?: ProgressBarThickness; +}; + +/** Windmod ProgressBar state: headless state plus the resolved look props. */ +export type ProgressBarState = ProgressBarHeadlessState & + Required>; diff --git a/packages/react-components/react-windmod-preview/library/src/components/ProgressBar/index.ts b/packages/react-components/react-windmod-preview/library/src/components/ProgressBar/index.ts new file mode 100644 index 00000000000000..feb6e0a8b4318f --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/ProgressBar/index.ts @@ -0,0 +1,10 @@ +export { ProgressBar } from './ProgressBar'; +export { progressBarClassNames, useProgressBarStyles } from './useProgressBarStyles'; +export type { + ProgressBarColor, + ProgressBarProps, + ProgressBarShape, + ProgressBarSlots, + ProgressBarState, + ProgressBarThickness, +} from './ProgressBar.types'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/ProgressBar/useProgressBarStyles.ts b/packages/react-components/react-windmod-preview/library/src/components/ProgressBar/useProgressBarStyles.ts new file mode 100644 index 00000000000000..7c255257140b3a --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/ProgressBar/useProgressBarStyles.ts @@ -0,0 +1,41 @@ +import { clsx } from 'clsx'; + +import { componentMarkers } from '../../utils/groupMarker'; +import type { ProgressBarState } from './ProgressBar.types'; + +import styles from './ProgressBar.module.css'; + +/** The only public classes — see componentMarkers; internals are hashed idents. */ +export const progressBarClassNames: { root: string } = { + root: componentMarkers('progress-bar'), +}; + +/** Below this `value` the width change is not transitioned, so a reset to zero does not + * animate. The comparison is against `value` itself, never its ratio to `max`. */ +const ZERO_THRESHOLD = 0.01; + +/** + * Applies the visual contract, returning new state. The determinate bar's inline width and the + * root's data-indeterminate are both stamped by the headless hook; the bar's indeterminate block + * selects on that attribute through the marker group. Colour is ignored while indeterminate. + */ +export const useProgressBarStyles = (state: ProgressBarState): ProgressBarState => { + const { color, shape, thickness, value } = state; + + return { + ...state, + root: { + ...state.root, + className: clsx(progressBarClassNames.root, styles.root, styles[shape], styles[thickness], state.root.className), + }, + bar: state.bar && { + ...state.bar, + className: clsx( + styles.bar, + styles[value === undefined ? 'brand' : color], + value !== undefined && value > ZERO_THRESHOLD && styles['non-zero-determinate'], + state.bar.className, + ), + }, + }; +}; diff --git a/packages/react-components/react-windmod-preview/library/src/index.ts b/packages/react-components/react-windmod-preview/library/src/index.ts index 2ea24e3989c449..621b5f22e9d7aa 100644 --- a/packages/react-components/react-windmod-preview/library/src/index.ts +++ b/packages/react-components/react-windmod-preview/library/src/index.ts @@ -16,6 +16,22 @@ export type { LabelProps, LabelSize, LabelSlots, LabelState, LabelWeight } from export { Link, linkClassNames, renderLink, useLink, useLinkStyles } from './link'; export type { LinkAppearance, LinkProps, LinkSlots, LinkState } from './link'; +export { + ProgressBar, + progressBarClassNames, + renderProgressBar, + useProgressBar, + useProgressBarStyles, +} from './progress-bar'; +export type { + ProgressBarColor, + ProgressBarProps, + ProgressBarShape, + ProgressBarSlots, + ProgressBarState, + ProgressBarThickness, +} from './progress-bar'; + export { renderSkeleton, Skeleton, diff --git a/packages/react-components/react-windmod-preview/library/src/progress-bar.ts b/packages/react-components/react-windmod-preview/library/src/progress-bar.ts new file mode 100644 index 00000000000000..206287894e164a --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/progress-bar.ts @@ -0,0 +1,12 @@ +export { ProgressBar, progressBarClassNames, useProgressBarStyles } from './components/ProgressBar'; +export type { + ProgressBarColor, + ProgressBarProps, + ProgressBarShape, + ProgressBarSlots, + ProgressBarState, + ProgressBarThickness, +} from './components/ProgressBar'; + +/** Headless building blocks, re-exported for consumers composing their own ProgressBar. */ +export { renderProgressBar, useProgressBar } from '@fluentui/react-headless-components-preview/progress-bar'; diff --git a/packages/react-components/react-windmod-preview/library/src/variants.css b/packages/react-components/react-windmod-preview/library/src/variants.css index a64c892e886d81..72f40994bcd211 100644 --- a/packages/react-components/react-windmod-preview/library/src/variants.css +++ b/packages/react-components/react-windmod-preview/library/src/variants.css @@ -44,10 +44,6 @@ @custom-variant text-position-before (&:where([data-text-position='before'])); @custom-variant text-position-below (&:where([data-text-position='below'])); -/* ProgressBar: thickness */ -@custom-variant thickness-medium (&:where([data-thickness='medium'])); -@custom-variant thickness-large (&:where([data-thickness='large'])); - /* Skeleton: animation, appearance, item shape */ @custom-variant animation-pulse (&:where([data-animation='pulse'])); @custom-variant appearance-translucent (&:where([data-appearance='translucent'])); diff --git a/packages/react-components/react-windmod-preview/stories/src/ProgressBar/ProgressBarDefault.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/ProgressBar/ProgressBarDefault.stories.tsx new file mode 100644 index 00000000000000..ea7f23c09d041f --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/ProgressBar/ProgressBarDefault.stories.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import { ProgressBar, ThemeProvider } from '@fluentui/react-windmod-preview'; + +import styles from '../compare.module.css'; + +const values = [0, 0.25, 0.5, 0.75, 1] as const; +const thicknesses = ['medium', 'large'] as const; +const shapes = ['rounded', 'square'] as const; +const colors = ['brand', 'success', 'warning', 'error'] as const; + +const track: React.CSSProperties = { width: 200 }; + +export const Default = (): React.ReactNode => ( + +
+
+ {values.map(value => ( +
+ +
+ ))} +
+
+ {thicknesses.map(thickness => + shapes.map(shape => ( +
+ +
+ )), + )} +
+
+ {colors.map(color => ( +
+ +
+ ))} +
+
+ +
+
+
+); diff --git a/packages/react-components/react-windmod-preview/stories/src/ProgressBar/ProgressBarGriffelComparison.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/ProgressBar/ProgressBarGriffelComparison.stories.tsx new file mode 100644 index 00000000000000..031a68e2f2074c --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/ProgressBar/ProgressBarGriffelComparison.stories.tsx @@ -0,0 +1,57 @@ +import * as React from 'react'; +import { ProgressBar, ThemeProvider } from '@fluentui/react-windmod-preview'; +import { FluentProvider, ProgressBar as GriffelProgressBar, webLightTheme } from '@fluentui/react-components'; + +import styles from '../compare.module.css'; + +type LookProps = { + color?: 'brand' | 'success' | 'warning' | 'error'; + max?: number; + shape?: 'rounded' | 'square'; + thickness?: 'medium' | 'large'; + value?: number; +}; + +/** + * Every windmod variant next to its Griffel-suite twin (inside a FluentProvider). + * The pair in each row must be pixel-identical. + */ +export const GriffelComparison = (): React.ReactNode => { + const variants: Array<{ label: string; props: LookProps }> = [ + { label: 'indeterminate', props: {} }, + { label: 'value 0', props: { value: 0 } }, + { label: 'value 0.5', props: { value: 0.5 } }, + { label: 'value 1', props: { value: 1 } }, + { label: 'value 42 of 100', props: { value: 42, max: 100 } }, + { label: 'square', props: { value: 0.5, shape: 'square' } }, + { label: 'large', props: { value: 0.5, thickness: 'large' } }, + { label: 'square large', props: { value: 0.5, shape: 'square', thickness: 'large' } }, + { label: 'success', props: { value: 0.6, color: 'success' } }, + { label: 'warning', props: { value: 0.6, color: 'warning' } }, + { label: 'error', props: { value: 0.6, color: 'error' } }, + { label: 'indeterminate large', props: { thickness: 'large' } }, + ]; + + return ( +
+
Variant
+
Windmod
+
Griffel
+ {variants.map(({ label, props }) => ( + +
{label}
+
+ + + +
+
+ + + +
+
+ ))} +
+ ); +}; diff --git a/packages/react-components/react-windmod-preview/stories/src/ProgressBar/index.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/ProgressBar/index.stories.tsx new file mode 100644 index 00000000000000..cd95ba91bd38ad --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/ProgressBar/index.stories.tsx @@ -0,0 +1,9 @@ +import { ProgressBar } from '@fluentui/react-windmod-preview'; + +export { Default } from './ProgressBarDefault.stories'; +export { GriffelComparison } from './ProgressBarGriffelComparison.stories'; + +export default { + title: 'Windmod/ProgressBar', + component: ProgressBar, +}; diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/ImageVrScene.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/ImageVrScene.tsx index 46558e5a9acf4e..d84544db29ad58 100644 --- a/packages/react-components/react-windmod-preview/stories/src/vr/ImageVrScene.tsx +++ b/packages/react-components/react-windmod-preview/stories/src/vr/ImageVrScene.tsx @@ -1,3 +1,5 @@ +'use client'; + // eslint-disable-next-line import/no-extraneous-dependencies -- story-only helper outside the *.stories.tsx devDependencies exemption import * as React from 'react'; @@ -31,6 +33,7 @@ export const useImageSourcesDecoded = (): boolean => { Promise.all( sources.map(src => { // `Image` is the component under test in both story files — never `new Image()`. + // eslint-disable-next-line @nx/workspace-no-restricted-globals -- capture-browser-only fixture; no provider in scope const el = document.createElement('img'); el.src = src; return el.decode diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/ProgressBarGriffel.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/ProgressBarGriffel.stories.tsx new file mode 100644 index 00000000000000..c7c86a08ce4475 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/ProgressBarGriffel.stories.tsx @@ -0,0 +1,10 @@ +import * as React from 'react'; +import { FluentProvider, ProgressBar, webLightTheme } from '@fluentui/react-components'; + +import { ProgressBarVrScene } from './ProgressBarVrScene'; + +export const ProgressBarGriffel = (): React.ReactNode => ( + + + +); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/ProgressBarVrScene.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/ProgressBarVrScene.tsx new file mode 100644 index 00000000000000..f39d0a53c7d51f --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/ProgressBarVrScene.tsx @@ -0,0 +1,117 @@ +'use client'; + +// eslint-disable-next-line import/no-extraneous-dependencies -- story-only helper outside the *.stories.tsx devDependencies exemption +import * as React from 'react'; + +type ProgressBarLike = React.ComponentType<{ + value?: number; + max?: number; + color?: 'brand' | 'success' | 'warning' | 'error'; + shape?: 'rounded' | 'square'; + thickness?: 'medium' | 'large'; +}>; + +const colors = ['brand', 'success', 'warning', 'error'] as const; +const thicknesses = ['medium', 'large'] as const; +const shapes = ['rounded', 'square'] as const; + +const frame: React.CSSProperties = { + display: 'inline-flex', + flexDirection: 'column', + gap: 24, + padding: 24, + background: '#ffffff', + fontSize: 0, + lineHeight: 0, +}; +const track: React.CSSProperties = { width: 240 }; + +/* run.mjs's determinism sheet stops CSS animations but not the Web Animations API ones the + Griffel indeterminate bar uses; committing each animation's value at a fixed time and + cancelling it leaves both implementations on the same static frame. Adding state to this + scene would let React reconcile the inline style commitStyles() wrote and undo the freeze. */ +const useFrozenAnimations = (ref: React.RefObject): void => { + // eslint-disable-next-line no-restricted-properties -- capture-browser-only fixture; SSR never renders it + React.useLayoutEffect(() => { + const freeze = () => { + ref.current?.querySelectorAll('[data-vr-freeze]').forEach(host => { + const at = Number(host.dataset.vrFreeze); + host.getAnimations({ subtree: true }).forEach(animation => { + animation.currentTime = at; + animation.commitStyles(); + animation.cancel(); + }); + }); + }; + + freeze(); + // eslint-disable-next-line @nx/workspace-no-restricted-globals -- capture-browser-only fixture; no provider in scope + const handle = requestAnimationFrame(freeze); + // eslint-disable-next-line @nx/workspace-no-restricted-globals -- capture-browser-only fixture; no provider in scope + return () => cancelAnimationFrame(handle); + }, [ref]); +}; + +/** One scene, two implementations — the VR runner diffs the renders pixel for pixel. */ +export const ProgressBarVrScene = ({ ProgressBar }: { ProgressBar: ProgressBarLike }): React.ReactNode => { + const sceneRef = React.useRef(null); + + useFrozenAnimations(sceneRef); + + return ( +
+ {[0, 0.005, 0.25, 0.5, 0.75, 1].map(value => ( +
+ +
+ ))} + +
+ +
+
+ +
+ + {thicknesses.map(thickness => + shapes.map(shape => ( +
+ +
+ )), + )} + + {colors.map(color => ( +
+ +
+ ))} + + {colors.map(color => ( +
+ +
+ ))} + + {[0, 750, 1500, 2250, 4500].map(at => ( +
+ +
+ ))} + + {thicknesses.map(thickness => + shapes.map(shape => ( +
+ +
+ )), + )} + + {colors.map(color => ( +
+ +
+ ))} +
+ ); +}; diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/ProgressBarWindmod.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/ProgressBarWindmod.stories.tsx new file mode 100644 index 00000000000000..b016f96edbf283 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/ProgressBarWindmod.stories.tsx @@ -0,0 +1,10 @@ +import * as React from 'react'; +import { ProgressBar, ThemeProvider } from '@fluentui/react-windmod-preview'; + +import { ProgressBarVrScene } from './ProgressBarVrScene'; + +export const ProgressBarWindmod = (): React.ReactNode => ( + + + +); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx index 1d7dc5ca892dfb..b8b4af2e63b77e 100644 --- a/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx +++ b/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx @@ -10,6 +10,8 @@ export { LabelWindmod } from './LabelWindmod.stories'; export { LabelGriffel } from './LabelGriffel.stories'; export { LinkWindmod } from './LinkWindmod.stories'; export { LinkGriffel } from './LinkGriffel.stories'; +export { ProgressBarWindmod } from './ProgressBarWindmod.stories'; +export { ProgressBarGriffel } from './ProgressBarGriffel.stories'; export { SkeletonWindmod } from './SkeletonWindmod.stories'; export { SkeletonGriffel } from './SkeletonGriffel.stories'; export { SpinnerWindmod } from './SpinnerWindmod.stories'; From 640dd9915d603120776bd3e8792fb4e12f15f7b9 Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Fri, 21 Aug 2026 20:21:38 -0700 Subject: [PATCH 035/246] docs(windmod): fix typos inherited from the spec template Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- .../react-windmod-preview/library/docs/Spec.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-components/react-windmod-preview/library/docs/Spec.md b/packages/react-components/react-windmod-preview/library/docs/Spec.md index 72820f4ea8052b..d2663916218dc9 100644 --- a/packages/react-components/react-windmod-preview/library/docs/Spec.md +++ b/packages/react-components/react-windmod-preview/library/docs/Spec.md @@ -52,10 +52,10 @@ _Explain how the component will behave in use, including:_ Base accessibility information is included in the design document. After the spec is filled and review, outcomes from it need to be communicated to design and incorporated in the design document. -- Decide whether to use **native element** or folow **ARIA** and provide reasons +- Decide whether to use **native element** or follow **ARIA** and provide reasons - Identify the **[ARIA](https://www.w3.org/TR/wai-aria-practices-1.2/) pattern** and, if the component is listed there, follow its specification as possible. - Identify accessibility **variants**, the `role` ([ARIA roles](https://www.w3.org/TR/wai-aria-1.1/#role_definitions)) of the component, its `slots` and `aria-*` props. -- Describe the **keyboard navigation**: Tab Oder and Arrow Key Navigation. Describe any other keyboard **shortcuts** used +- Describe the **keyboard navigation**: Tab Order and Arrow Key Navigation. Describe any other keyboard **shortcuts** used - Specify texts for **state change announcements** - [ARIA live regions ](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions) (number of available items in dropdown, error messages, confirmations, ...) - Identify UI parts that appear on **hover or focus** and specify keyboard and screen reader interaction with them From ae032f3412ee2c1d533402f7e0beb683f3412777 Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Fri, 21 Aug 2026 20:47:02 -0700 Subject: [PATCH 036/246] feat(windmod): add RatingDisplay and RatingItem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Headless useRatingDisplay/useRatingItem + Tailwind CSS-Modules styling, pixel-identical to the Griffel suite across values, half fills, colors, sizes and compact mode. Star glyphs come from the headless icon factories — the library's first icon dependency — and a package-local context carries color and size to items. The half-fill overlay replicates the upstream clip geometry exactly. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- .../library/etc/rating-display.api.md | 51 ++++ .../library/etc/rating-item.api.md | 42 +++ .../library/etc/react-windmod-preview.api.md | 71 +++++ .../library/package.json | 21 ++ .../RatingDisplay/RatingDisplay.module.css | 32 +++ .../RatingDisplay/RatingDisplay.test.tsx | 251 ++++++++++++++++++ .../RatingDisplay/RatingDisplay.tsx | 56 ++++ .../RatingDisplay/RatingDisplay.types.ts | 27 ++ .../RatingItem/RatingItem.module.css | 70 +++++ .../RatingItem/RatingItem.test.tsx | 111 ++++++++ .../RatingDisplay/RatingItem/RatingItem.tsx | 29 ++ .../RatingItem/RatingItem.types.ts | 22 ++ .../RatingDisplay/RatingItem/index.ts | 3 + .../RatingItem/useRatingItemStyles.ts | 48 ++++ .../RatingDisplay/RatingItemContext.ts | 24 ++ .../src/components/RatingDisplay/index.ts | 12 + .../RatingDisplay/useRatingDisplayStyles.ts | 36 +++ .../library/src/index.ts | 19 ++ .../library/src/rating-display.ts | 15 ++ .../library/src/rating-item.ts | 5 + .../RatingDisplayDefault.stories.tsx | 32 +++ ...RatingDisplayGriffelComparison.stories.tsx | 61 +++++ .../src/RatingDisplay/index.stories.tsx | 9 + .../src/vr/RatingDisplayGriffel.stories.tsx | 11 + .../stories/src/vr/RatingDisplayVrScene.tsx | 113 ++++++++ .../src/vr/RatingDisplayWindmod.stories.tsx | 11 + .../stories/src/vr/index.stories.tsx | 2 + 27 files changed, 1184 insertions(+) create mode 100644 packages/react-components/react-windmod-preview/library/etc/rating-display.api.md create mode 100644 packages/react-components/react-windmod-preview/library/etc/rating-item.api.md create mode 100644 packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingDisplay.module.css create mode 100644 packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingDisplay.test.tsx create mode 100644 packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingDisplay.tsx create mode 100644 packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingDisplay.types.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingItem/RatingItem.module.css create mode 100644 packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingItem/RatingItem.test.tsx create mode 100644 packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingItem/RatingItem.tsx create mode 100644 packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingItem/RatingItem.types.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingItem/index.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingItem/useRatingItemStyles.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingItemContext.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/index.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/useRatingDisplayStyles.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/rating-display.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/rating-item.ts create mode 100644 packages/react-components/react-windmod-preview/stories/src/RatingDisplay/RatingDisplayDefault.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/RatingDisplay/RatingDisplayGriffelComparison.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/RatingDisplay/index.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/RatingDisplayGriffel.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/RatingDisplayVrScene.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/RatingDisplayWindmod.stories.tsx diff --git a/packages/react-components/react-windmod-preview/library/etc/rating-display.api.md b/packages/react-components/react-windmod-preview/library/etc/rating-display.api.md new file mode 100644 index 00000000000000..5cb75cfd10b73c --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/etc/rating-display.api.md @@ -0,0 +1,51 @@ +## API Report File for "@fluentui/react-windmod-preview" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { RatingDisplayProps as RatingDisplayProps_2 } from '@fluentui/react-headless-components-preview/rating-display'; +import { RatingDisplaySlots } from '@fluentui/react-headless-components-preview/rating-display'; +import type { RatingDisplayState as RatingDisplayState_2 } from '@fluentui/react-headless-components-preview/rating-display'; +import { renderRatingDisplay } from '@fluentui/react-headless-components-preview/rating-display'; +import { useRatingDisplay } from '@fluentui/react-headless-components-preview/rating-display'; +import { useRatingDisplayContextValues } from '@fluentui/react-headless-components-preview/rating-display'; + +// @public +export const RatingDisplay: ForwardRefComponent; + +// @public +export const ratingDisplayClassNames: { + root: string; +}; + +// @public +export type RatingDisplayColor = 'brand' | 'marigold' | 'neutral'; + +// @public +export type RatingDisplayProps = RatingDisplayProps_2 & { + color?: RatingDisplayColor; + size?: RatingDisplaySize; +}; + +// @public +export type RatingDisplaySize = 'small' | 'medium' | 'large' | 'extra-large'; + +export { RatingDisplaySlots } + +// @public +export type RatingDisplayState = RatingDisplayState_2 & Required>; + +export { renderRatingDisplay } + +export { useRatingDisplay } + +export { useRatingDisplayContextValues } + +// @public +export const useRatingDisplayStyles: (state: RatingDisplayState) => RatingDisplayState; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-windmod-preview/library/etc/rating-item.api.md b/packages/react-components/react-windmod-preview/library/etc/rating-item.api.md new file mode 100644 index 00000000000000..c0c26af05ef3c2 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/etc/rating-item.api.md @@ -0,0 +1,42 @@ +## API Report File for "@fluentui/react-windmod-preview" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { RatingItemProps as RatingItemProps_2 } from '@fluentui/react-headless-components-preview/rating'; +import { RatingItemSlots } from '@fluentui/react-headless-components-preview/rating'; +import type { RatingItemState as RatingItemState_2 } from '@fluentui/react-headless-components-preview/rating'; +import { renderRatingItem } from '@fluentui/react-headless-components-preview/rating'; +import { useRatingItem } from '@fluentui/react-headless-components-preview/rating'; + +// @public +export const RatingItem: ForwardRefComponent; + +// @public +export const ratingItemClassNames: { + root: string; +}; + +// @public +export type RatingItemProps = RatingItemProps_2 & { + color?: RatingDisplayColor; + size?: RatingDisplaySize; +}; + +export { RatingItemSlots } + +// @public +export type RatingItemState = RatingItemState_2 & Required>; + +export { renderRatingItem } + +export { useRatingItem } + +// @public +export const useRatingItemStyles: (state: RatingItemState) => RatingItemState; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md b/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md index f6ca13cb7d0861..acfcb425d72330 100644 --- a/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md +++ b/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md @@ -29,6 +29,12 @@ import type { ProgressBarProps as ProgressBarProps_2 } from '@fluentui/react-hea import { ProgressBarSlots } from '@fluentui/react-headless-components-preview/progress-bar'; import type { ProgressBarState as ProgressBarState_2 } from '@fluentui/react-headless-components-preview/progress-bar'; import { Provider } from '@fluentui/react-headless-components-preview/provider'; +import type { RatingDisplayProps as RatingDisplayProps_2 } from '@fluentui/react-headless-components-preview/rating-display'; +import { RatingDisplaySlots } from '@fluentui/react-headless-components-preview/rating-display'; +import type { RatingDisplayState as RatingDisplayState_2 } from '@fluentui/react-headless-components-preview/rating-display'; +import type { RatingItemProps as RatingItemProps_2 } from '@fluentui/react-headless-components-preview/rating'; +import { RatingItemSlots } from '@fluentui/react-headless-components-preview/rating'; +import type { RatingItemState as RatingItemState_2 } from '@fluentui/react-headless-components-preview/rating'; import * as React_2 from 'react'; import { renderBadge } from '@fluentui/react-headless-components-preview/badge'; import { renderButton } from '@fluentui/react-headless-components-preview/button'; @@ -38,6 +44,8 @@ import { renderLabel } from '@fluentui/react-headless-components-preview/label'; import { renderLink } from '@fluentui/react-headless-components-preview/link'; import { renderProgressBar } from '@fluentui/react-headless-components-preview/progress-bar'; import { renderProvider } from '@fluentui/react-headless-components-preview/provider'; +import { renderRatingDisplay } from '@fluentui/react-headless-components-preview/rating-display'; +import { renderRatingItem } from '@fluentui/react-headless-components-preview/rating'; import { renderSkeleton } from '@fluentui/react-headless-components-preview/skeleton'; import { renderSkeletonItem } from '@fluentui/react-headless-components-preview/skeleton'; import { renderSpinner } from '@fluentui/react-headless-components-preview/spinner'; @@ -70,6 +78,9 @@ import { useLabel } from '@fluentui/react-headless-components-preview/label'; import { useLink } from '@fluentui/react-headless-components-preview/link'; import { useProgressBar } from '@fluentui/react-headless-components-preview/progress-bar'; import { useProvider } from '@fluentui/react-headless-components-preview/provider'; +import { useRatingDisplay } from '@fluentui/react-headless-components-preview/rating-display'; +import { useRatingDisplayContextValues } from '@fluentui/react-headless-components-preview/rating-display'; +import { useRatingItem } from '@fluentui/react-headless-components-preview/rating'; import { useSkeleton } from '@fluentui/react-headless-components-preview/skeleton'; import { useSkeletonContextValues } from '@fluentui/react-headless-components-preview/skeleton'; import { useSkeletonItem } from '@fluentui/react-headless-components-preview/skeleton'; @@ -275,6 +286,50 @@ export type ProgressBarThickness = 'medium' | 'large'; export { Provider } +// @public +export const RatingDisplay: ForwardRefComponent; + +// @public +export const ratingDisplayClassNames: { + root: string; +}; + +// @public +export type RatingDisplayColor = 'brand' | 'marigold' | 'neutral'; + +// @public +export type RatingDisplayProps = RatingDisplayProps_2 & { + color?: RatingDisplayColor; + size?: RatingDisplaySize; +}; + +// @public +export type RatingDisplaySize = 'small' | 'medium' | 'large' | 'extra-large'; + +export { RatingDisplaySlots } + +// @public +export type RatingDisplayState = RatingDisplayState_2 & Required>; + +// @public +export const RatingItem: ForwardRefComponent; + +// @public +export const ratingItemClassNames: { + root: string; +}; + +// @public +export type RatingItemProps = RatingItemProps_2 & { + color?: RatingDisplayColor; + size?: RatingDisplaySize; +}; + +export { RatingItemSlots } + +// @public +export type RatingItemState = RatingItemState_2 & Required>; + export { renderBadge } export { renderButton } @@ -291,6 +346,10 @@ export { renderProgressBar } export { renderProvider } +export { renderRatingDisplay } + +export { renderRatingItem } + export { renderSkeleton } export { renderSkeletonItem } @@ -469,6 +528,18 @@ export const useProgressBarStyles: (state: ProgressBarState) => ProgressBarState export { useProvider } +export { useRatingDisplay } + +export { useRatingDisplayContextValues } + +// @public +export const useRatingDisplayStyles: (state: RatingDisplayState) => RatingDisplayState; + +export { useRatingItem } + +// @public +export const useRatingItemStyles: (state: RatingItemState) => RatingItemState; + export { useSkeleton } export { useSkeletonContextValues } diff --git a/packages/react-components/react-windmod-preview/library/package.json b/packages/react-components/react-windmod-preview/library/package.json index 0064e4aab8ec36..37e21f00af9ff4 100644 --- a/packages/react-components/react-windmod-preview/library/package.json +++ b/packages/react-components/react-windmod-preview/library/package.json @@ -26,6 +26,7 @@ "license": "MIT", "dependencies": { "@fluentui/react-headless-components-preview": "^0.2.5", + "@fluentui/react-icons": "^2.0.245", "@fluentui/react-shared-contexts": "^9.26.3", "@fluentui/react-tailwind-theme-preview": "^9.0.0-alpha.0", "@fluentui/react-utilities": "^9.26.6", @@ -132,6 +133,26 @@ "default": "./lib-commonjs/provider.cjs" } }, + "./rating-display": { + "import": { + "types": "./dist/rating-display.d.ts", + "default": "./lib/rating-display.js" + }, + "require": { + "types": "./dist/rating-display.d.cts", + "default": "./lib-commonjs/rating-display.cjs" + } + }, + "./rating-item": { + "import": { + "types": "./dist/rating-item.d.ts", + "default": "./lib/rating-item.js" + }, + "require": { + "types": "./dist/rating-item.d.cts", + "default": "./lib-commonjs/rating-item.cjs" + } + }, "./skeleton": { "import": { "types": "./dist/skeleton.d.ts", diff --git a/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingDisplay.module.css b/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingDisplay.module.css new file mode 100644 index 00000000000000..6816ad9062866c --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingDisplay.module.css @@ -0,0 +1,32 @@ +@reference '#theme'; + +/* Block order carries the cascade: `.large` and `.extra-large` override `.label`'s caption1 + font metrics and inline-start margin at equal specificity. */ + +@layer fui.components.l1 { + .root { + @apply flex flex-wrap items-center; + } + + .label { + @apply ms-horizontal-xs font-base text-base-200 leading-base-200 font-regular text-neutral-foreground-1; + } + + .strong { + @apply font-semibold; + } + + .large { + @apply ms-horizontal-s-nudge text-base-300 leading-base-300; + } + + .extra-large { + @apply ms-horizontal-s text-base-400 leading-base-400; + } + + .divider { + @variant before { + @apply content-['·_']; + } + } +} diff --git a/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingDisplay.test.tsx b/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingDisplay.test.tsx new file mode 100644 index 00000000000000..8af52228d3a5d6 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingDisplay.test.tsx @@ -0,0 +1,251 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; + +import { isConformant } from '../../testing/isConformant'; +import { RatingDisplay } from './RatingDisplay'; +import type { RatingDisplayState } from './RatingDisplay.types'; +import { ratingDisplayClassNames, useRatingDisplayStyles } from './useRatingDisplayStyles'; + +import itemStyles from './RatingItem/RatingItem.module.css'; +import styles from './RatingDisplay.module.css'; + +const items = (root: HTMLElement) => root.querySelectorAll('.fui-rating-item'); +const labels = (root: HTMLElement) => root.querySelectorAll(`.${styles.label}`); + +describe('RatingDisplay', () => { + isConformant({ + Component: RatingDisplay, + displayName: 'RatingDisplay', + // With no slot rendered the headless base hook joins an empty aria-labelledby. + requiredProps: { value: 4 }, + }); + + it('stamps the marker class', () => { + const { getByTestId } = render(); + + const root = getByTestId('root'); + + expect(root.className).toContain(ratingDisplayClassNames.root); + expect(root).toHaveClass('fui-rating-display'); + expect(root).toHaveClass('group/fui-rating-display'); + expect(root).toHaveClass(styles.root); + expect(root.classList[0]).toBe('fui-rating-display'); + }); + + it('stamps no data attribute — every look prop resolves to a class', () => { + const { getByTestId } = render(); + + const root = getByTestId('root'); + + expect(root.hasAttribute('data-color')).toBe(false); + expect(root.hasAttribute('data-size')).toBe(false); + expect(root.hasAttribute('data-compact')).toBe(false); + expect(items(root)[0].hasAttribute('data-appearance')).toBe(false); + }); + + it('renders one item per max, and a single item when compact', () => { + const { getByTestId } = render( + <> + + + + + , + ); + + expect(items(getByTestId('default'))).toHaveLength(5); + expect(items(getByTestId('three'))).toHaveLength(3); + expect(items(getByTestId('ten'))).toHaveLength(10); + expect(items(getByTestId('compact'))).toHaveLength(1); + }); + + it('lets consumer children replace the generated items', () => { + const { getByTestId } = render( + + + , + ); + + const root = getByTestId('root'); + + expect(items(root)).toHaveLength(0); + expect(getByTestId('own')).toBeTruthy(); + }); + + it('keeps the headless accessibility contract intact', () => { + const { getByTestId } = render(); + + const root = getByTestId('root'); + + expect(root.getAttribute('role')).toBe('img'); + + const labelledBy = root.getAttribute('aria-labelledby')!.split(' '); + + expect(labelledBy).toHaveLength(2); + + labelledBy.forEach(id => { + const label = root.querySelector(`#${id}`); + + expect(label).not.toBeNull(); + expect(label!.tagName).toBe('SPAN'); + expect(label!.getAttribute('aria-hidden')).toBe('true'); + }); + + items(root).forEach(item => expect(item.getAttribute('aria-hidden')).toBe('true')); + }); + + it('routes the icon prop into both indicator slots', () => { + const Custom = (props: React.SVGProps) => ; + + const { getByTestId } = render(); + + const item = items(getByTestId('root'))[0]; + + expect(item.querySelectorAll(`.${itemStyles.indicator}`)).toHaveLength(2); + expect(item.querySelectorAll('[data-custom]')).toHaveLength(2); + }); + + it('renders the value and count text only when the props are given', () => { + const { getByTestId } = render( + <> + + + + + , + ); + + // The css-module proxy answers every key, so these pin the lookup keys, not class existence. + expect(labels(getByTestId('bare'))).toHaveLength(0); + + const value = labels(getByTestId('value')); + + expect(value).toHaveLength(1); + expect(value[0].textContent).toBe('3.5'); + expect(value[0]).toHaveClass(styles.strong); + expect(value[0]).not.toHaveClass(styles.divider); + + const count = labels(getByTestId('count')); + + expect(count).toHaveLength(1); + expect(count[0].textContent).toBe((1160).toLocaleString()); + expect(count[0]).not.toHaveClass(styles.strong); + expect(count[0]).not.toHaveClass(styles.divider); + + const both = labels(getByTestId('both')); + + expect(both).toHaveLength(2); + expect(both[0]).toHaveClass(styles.strong); + expect(both[1]).toHaveClass(styles.divider); + expect(both[1]).not.toHaveClass(styles.strong); + }); + + it('applies the label size classes to both the value and count text, only for large and extra-large', () => { + const { getByTestId } = render( + <> + + + + + , + ); + + ['small', 'medium'].forEach(id => { + labels(getByTestId(id)).forEach(label => { + expect(label).not.toHaveClass(styles.large); + expect(label).not.toHaveClass(styles['extra-large']); + }); + }); + + labels(getByTestId('large')).forEach(label => expect(label).toHaveClass(styles.large)); + labels(getByTestId('extra-large')).forEach(label => expect(label).toHaveClass(styles['extra-large'])); + }); + + it('overlays both indicators at a half value, and one at either extreme', () => { + // Fill is `Math.round(value * 2) / 2` against each item's own value, so the boundaries sit + // at the quarter points: 0.25 rounds up to a half fill, 0.75 up to a full one. + const fills: Array<[string, number, 'empty' | 'half' | 'full']> = [ + ['zero', 0, 'empty'], + ['low', 0.2, 'empty'], + ['quarter', 0.25, 'half'], + ['half', 0.5, 'half'], + ['high', 0.7, 'half'], + ['three-quarter', 0.75, 'full'], + ['one', 1, 'full'], + ]; + + const { getByTestId } = render( + <> + {fills.map(([id, value]) => ( + + ))} + , + ); + + fills.forEach(([id, , fill]) => { + const item = items(getByTestId(id))[0]; + const indicators = item.querySelectorAll(`.${itemStyles.indicator}`); + + expect(indicators).toHaveLength(fill === 'half' ? 2 : 1); + + if (fill === 'half') { + // renderRatingItem paints the unselected glyph first; the selected one clips on top. + expect(indicators[0]).toHaveClass(itemStyles['upper-half']); + expect(indicators[1]).toHaveClass(itemStyles['lower-half']); + } else { + expect(indicators[0]).not.toHaveClass(itemStyles['upper-half']); + expect(indicators[0]).not.toHaveClass(itemStyles['lower-half']); + + if (fill === 'full') { + expect(indicators[0]).not.toHaveClass(itemStyles['unselected-neutral']); + } else { + expect(indicators[0]).toHaveClass(itemStyles['unselected-neutral']); + } + } + }); + }); + + it('forces a full fill on the single compact item', () => { + const { getByTestId } = render(); + + const item = items(getByTestId('root'))[0]; + const indicators = item.querySelectorAll(`.${itemStyles.indicator}`); + + expect(indicators).toHaveLength(1); + expect(indicators[0]).not.toHaveClass(itemStyles['unselected-neutral']); + expect(item.getAttribute('aria-hidden')).toBe('true'); + }); + + it('publishes its colour and size to the items it builds', () => { + const { getByTestId } = render(); + + const item = items(getByTestId('root'))[0]; + + expect(item).toHaveClass(itemStyles.large); + expect(item.querySelector(`.${itemStyles.indicator}`)).toHaveClass(itemStyles['selected-brand']); + }); + + it('does not mutate the state it is given', () => { + const state = { + color: 'neutral', + compact: false, + components: { root: 'div', valueText: 'span', countText: 'span' }, + countText: { children: '10', className: 'count' }, + max: 5, + root: { as: 'div', className: 'consumer' }, + size: 'large', + valueText: { children: 4, className: 'value' }, + } as unknown as RatingDisplayState; + + const styled = useRatingDisplayStyles(state); + + expect(styled).not.toBe(state); + expect(state.root.className).toBe('consumer'); + expect(state.valueText!.className).toBe('value'); + expect(state.countText!.className).toBe('count'); + expect(styled.root.className).toContain('consumer'); + expect(styled.root.className).toContain(ratingDisplayClassNames.root); + expect(styled.valueText!.className).toContain('value'); + expect(styled.countText!.className).toContain('count'); + }); +}); diff --git a/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingDisplay.tsx b/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingDisplay.tsx new file mode 100644 index 00000000000000..484c0c3aa1f91c --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingDisplay.tsx @@ -0,0 +1,56 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { + renderRatingDisplay, + useRatingDisplay, + useRatingDisplayContextValues, +} from '@fluentui/react-headless-components-preview/rating-display'; +import { StarFilled } from '@fluentui/react-icons/headless/svg/star'; + +import type { RatingDisplayProps, RatingDisplayState } from './RatingDisplay.types'; +import { RatingItem } from './RatingItem'; +import { RatingItemContextProvider } from './RatingItemContext'; +import { useRatingDisplayStyles } from './useRatingDisplayStyles'; + +/** + * A RatingDisplay shows a read-only star rating. Windmod RatingDisplay: the headless rating + * display decorated with the Fluent visual contract (Tailwind v4 + CSS Modules). + */ +export const RatingDisplay: ForwardRefComponent = React.forwardRef((props, ref) => { + // The headless surface defaults `icon` to a bare span so it needs no icon dependency; windmod + // restores the Fluent default alongside the look props the headless hook never accepts. + const { color = 'neutral', compact = false, icon = StarFilled, max = 5, size = 'medium', ...rest } = props; + + // The headless hook defaults these children to its own unstyled RatingItem, so windmod + // supplies the styled ones ahead of it; a consumer's own children still win. + const children = React.useMemo( + () => + compact ? ( + + ) : ( + Array.from(Array(max), (_, index) => ) + ), + [compact, max], + ); + + const state: RatingDisplayState = { + ...useRatingDisplay({ children, compact, icon, max, ...rest }, ref as React.Ref), + color, + size, + }; + + const styled = useRatingDisplayStyles(state); + const contextValues = useRatingDisplayContextValues(styled); + const itemContext = React.useMemo(() => ({ color, size }), [color, size]); + + return ( + + {renderRatingDisplay(styled, contextValues)} + + ); + // Casting is required due to lack of distributive union to support union on @types/react +}) as ForwardRefComponent; + +RatingDisplay.displayName = 'RatingDisplay'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingDisplay.types.ts b/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingDisplay.types.ts new file mode 100644 index 00000000000000..1a3f1d636ca94a --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingDisplay.types.ts @@ -0,0 +1,27 @@ +import type { + RatingDisplayProps as RatingDisplayHeadlessProps, + RatingDisplayState as RatingDisplayHeadlessState, +} from '@fluentui/react-headless-components-preview/rating-display'; + +export type { RatingDisplaySlots } from '@fluentui/react-headless-components-preview/rating-display'; + +/** Colour of the rating items. `'neutral'` is the base look. */ +export type RatingDisplayColor = 'brand' | 'marigold' | 'neutral'; + +/** Size of the rating items and the value/count text. */ +export type RatingDisplaySize = 'small' | 'medium' | 'large' | 'extra-large'; + +/** + * Windmod RatingDisplay props: the headless rating display plus the look props the headless + * surface deliberately omits (they exist purely to select styles). `icon` is a headless prop + * whose default the headless surface strips to avoid an icon dependency. + */ +export type RatingDisplayProps = RatingDisplayHeadlessProps & { + /** @default 'neutral' */ + color?: RatingDisplayColor; + /** @default 'medium' */ + size?: RatingDisplaySize; +}; + +/** Windmod RatingDisplay state: headless state plus the resolved look props. */ +export type RatingDisplayState = RatingDisplayHeadlessState & Required>; diff --git a/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingItem/RatingItem.module.css b/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingItem/RatingItem.module.css new file mode 100644 index 00000000000000..378005dfbc8f1d --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingItem/RatingItem.module.css @@ -0,0 +1,70 @@ +@reference '#theme'; + +/* Block order carries the cascade: the colour classes override `.indicator`'s resting colour, + and `.unselected` is authored last so its forced-colors override outranks them. */ + +@layer fui.components.l1 { + .root { + @apply relative; + } + + .small { + @apply size-12 text-[length:calc(12px*var(--base-scale))]; + } + + .medium { + @apply size-16 text-[length:calc(16px*var(--base-scale))]; + } + + .large { + @apply size-20 text-[length:calc(20px*var(--base-scale))]; + } + + .extra-large { + @apply size-28 text-[length:calc(28px*var(--base-scale))]; + } + + .indicator { + @apply pointer-events-none absolute inset-0 flex overflow-hidden fill-current text-neutral-foreground-1; + } + + .selected-brand { + @apply text-brand-foreground-1; + } + + .selected-marigold { + @apply text-palette-marigold-border-active; + } + + .unselected-neutral { + @apply text-neutral-background-6; + } + + .unselected-brand { + @apply text-brand-background-2; + } + + .unselected-marigold { + @apply text-palette-marigold-background-2; + } + + .unselected { + @apply stroke-transparent-stroke; + + @variant forced-colors { + @apply stroke-[CanvasText] text-[Canvas]; + } + } + + .lower-half { + @apply end-1/2; + + & > svg { + @apply flex-none; + } + } + + .upper-half { + @apply start-1/2 -ms-[50%]; + } +} diff --git a/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingItem/RatingItem.test.tsx b/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingItem/RatingItem.test.tsx new file mode 100644 index 00000000000000..2fe763b0e6c284 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingItem/RatingItem.test.tsx @@ -0,0 +1,111 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; + +import { isConformant } from '../../../testing/isConformant'; +import { RatingDisplay } from '../RatingDisplay'; +import { RatingItem } from './RatingItem'; +import type { RatingItemState } from './RatingItem.types'; +import { ratingItemClassNames, useRatingItemStyles } from './useRatingItemStyles'; + +import styles from './RatingItem.module.css'; + +const indicators = (root: HTMLElement) => root.querySelectorAll(`.${styles.indicator}`); + +describe('RatingItem', () => { + isConformant({ + Component: RatingItem, + displayName: 'RatingItem', + requiredProps: { value: 1 }, + }); + + it('stamps the marker class', () => { + const { getByTestId } = render(); + + const root = getByTestId('root'); + + expect(root.className).toContain(ratingItemClassNames.root); + expect(root).toHaveClass('fui-rating-item'); + expect(root).toHaveClass('group/fui-rating-item'); + expect(root.classList[0]).toBe('fui-rating-item'); + }); + + it('stamps no data attribute and renders no input outside an interactive rating', () => { + const { getByTestId } = render(); + + const root = getByTestId('root'); + + expect(root.hasAttribute('data-appearance')).toBe(false); + expect(root.hasAttribute('data-color')).toBe(false); + expect(root.hasAttribute('data-size')).toBe(false); + expect(root.querySelector('input')).toBeNull(); + }); + + it('renders only the unselected indicator with no rating display above it', () => { + // Outside a provider the item reads the base package's default context, whose `value` is + // undefined — so the fill width is 0 and the selected indicator never renders. + const { getByTestId } = render(); + + const root = getByTestId('root'); + + expect(indicators(root)).toHaveLength(1); + expect(indicators(root)[0]).toHaveClass(styles['unselected-neutral']); + expect(indicators(root)[0]).toHaveClass(styles.unselected); + expect(root).toHaveClass(styles.root); + expect(root).toHaveClass(styles.medium); + }); + + it('resolves colour and size from the owning RatingDisplay, then lets an own prop win', () => { + // The css-module proxy answers every key, so these pin the lookup keys, not class existence. + const { getByTestId } = render( + + + + + , + ); + + const inherit = getByTestId('inherit'); + + expect(inherit).toHaveClass(styles.large); + expect(indicators(inherit)[0]).toHaveClass(styles['selected-brand']); + + const override = getByTestId('override'); + + expect(override).toHaveClass(styles.small); + expect(override).not.toHaveClass(styles.large); + expect(indicators(override)[0]).toHaveClass(styles['selected-marigold']); + + const unselected = getByTestId('unselected'); + + expect(indicators(unselected)[0]).toHaveClass(styles['unselected-brand']); + expect(indicators(unselected)[0]).toHaveClass(styles.unselected); + }); + + it('does not mutate the state it is given', () => { + const state = { + appearance: 'filled', + color: 'neutral', + components: { root: 'span', selectedIcon: 'div', unselectedIcon: 'div' }, + iconFillWidth: 0.5, + root: { as: 'span', className: 'consumer' }, + selectedIcon: { className: 'consumer-selected' }, + size: 'medium', + step: 0.5, + unselectedIcon: { className: 'consumer-unselected' }, + value: 1, + } as unknown as RatingItemState; + + const styled = useRatingItemStyles(state); + + expect(styled).not.toBe(state); + expect(state.root.className).toBe('consumer'); + expect(state.selectedIcon!.className).toBe('consumer-selected'); + expect(state.unselectedIcon!.className).toBe('consumer-unselected'); + expect(styled.root.className).toContain('consumer'); + expect(styled.root.className).toContain(ratingItemClassNames.root); + expect(styled.selectedIcon!.className).toContain('consumer-selected'); + expect(styled.selectedIcon!.className).toContain(styles['lower-half']); + expect(styled.unselectedIcon!.className).toContain('consumer-unselected'); + expect(styled.unselectedIcon!.className).toContain(styles['upper-half']); + }); +}); diff --git a/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingItem/RatingItem.tsx b/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingItem/RatingItem.tsx new file mode 100644 index 00000000000000..b4a42acb116884 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingItem/RatingItem.tsx @@ -0,0 +1,29 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { renderRatingItem, useRatingItem } from '@fluentui/react-headless-components-preview/rating'; + +import { useRatingItemContext } from '../RatingItemContext'; +import type { RatingItemProps, RatingItemState } from './RatingItem.types'; +import { useRatingItemStyles } from './useRatingItemStyles'; + +/** + * A RatingItem is one star of a rating. Windmod RatingItem: the headless rating item decorated + * with the Fluent visual contract (Tailwind v4 + CSS Modules). + */ +export const RatingItem: ForwardRefComponent = React.forwardRef((props, ref) => { + const { color: contextColor, size: contextSize } = useRatingItemContext(); + const { color = contextColor ?? 'neutral', size = contextSize ?? 'medium', ...rest } = props; + + const state: RatingItemState = { + ...useRatingItem(rest, ref as React.Ref), + color, + size, + }; + + return renderRatingItem(useRatingItemStyles(state)); + // Casting is required due to lack of distributive union to support union on @types/react +}) as ForwardRefComponent; + +RatingItem.displayName = 'RatingItem'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingItem/RatingItem.types.ts b/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingItem/RatingItem.types.ts new file mode 100644 index 00000000000000..33d21ad48f1175 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingItem/RatingItem.types.ts @@ -0,0 +1,22 @@ +import type { + RatingItemProps as RatingItemHeadlessProps, + RatingItemState as RatingItemHeadlessState, +} from '@fluentui/react-headless-components-preview/rating'; + +import type { RatingDisplayColor, RatingDisplaySize } from '../RatingDisplay.types'; + +export type { RatingItemSlots } from '@fluentui/react-headless-components-preview/rating'; + +/** + * Windmod RatingItem props: the headless rating item plus the look props the headless surface + * deliberately omits. Both fall back to the owning RatingDisplay's values. + */ +export type RatingItemProps = RatingItemHeadlessProps & { + /** @default 'neutral' */ + color?: RatingDisplayColor; + /** @default 'medium' */ + size?: RatingDisplaySize; +}; + +/** Windmod RatingItem state: headless state plus the resolved look props. */ +export type RatingItemState = RatingItemHeadlessState & Required>; diff --git a/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingItem/index.ts b/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingItem/index.ts new file mode 100644 index 00000000000000..d2864a92fb53ed --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingItem/index.ts @@ -0,0 +1,3 @@ +export { RatingItem } from './RatingItem'; +export { ratingItemClassNames, useRatingItemStyles } from './useRatingItemStyles'; +export type { RatingItemProps, RatingItemSlots, RatingItemState } from './RatingItem.types'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingItem/useRatingItemStyles.ts b/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingItem/useRatingItemStyles.ts new file mode 100644 index 00000000000000..d920a3a3b42c09 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingItem/useRatingItemStyles.ts @@ -0,0 +1,48 @@ +import { clsx } from 'clsx'; + +import { componentMarkers } from '../../../utils/groupMarker'; +import type { RatingItemState } from './RatingItem.types'; + +import styles from './RatingItem.module.css'; + +/** The only public classes — see componentMarkers; internals are hashed idents. */ +export const ratingItemClassNames: { root: string } = { + root: componentMarkers('rating-item'), +}; + +/** + * Applies the visual contract, returning new state. A half fill renders both indicators: the + * unselected glyph at full width underneath, the selected one clipped to the inline-start half + * on top. `neutral` is the selected indicator's base look and carries no colour class. + */ +export const useRatingItemStyles = (state: RatingItemState): RatingItemState => { + const { color, iconFillWidth, size } = state; + const isHalf = iconFillWidth === 0.5; + + return { + ...state, + root: { + ...state.root, + className: clsx(ratingItemClassNames.root, styles.root, styles[size], state.root.className), + }, + selectedIcon: state.selectedIcon && { + ...state.selectedIcon, + className: clsx( + styles.indicator, + color !== 'neutral' && styles[`selected-${color}`], + isHalf && styles['lower-half'], + state.selectedIcon.className, + ), + }, + unselectedIcon: state.unselectedIcon && { + ...state.unselectedIcon, + className: clsx( + styles.indicator, + styles[`unselected-${color}`], + styles.unselected, + isHalf && styles['upper-half'], + state.unselectedIcon.className, + ), + }, + }; +}; diff --git a/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingItemContext.ts b/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingItemContext.ts new file mode 100644 index 00000000000000..3c25ae2282194f --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/RatingItemContext.ts @@ -0,0 +1,24 @@ +'use client'; + +import * as React from 'react'; + +import type { RatingDisplayColor, RatingDisplaySize } from './RatingDisplay.types'; + +/** Look values a RatingDisplay publishes to the RatingItems below it. */ +export type RatingItemContextValue = { + color?: RatingDisplayColor; + size?: RatingDisplaySize; +}; + +const RatingItemContext = React.createContext(undefined); +const ratingItemContextDefaultValue: RatingItemContextValue = {}; + +export const RatingItemContextProvider = RatingItemContext.Provider; + +/** + * The headless surface installs the rating item context inside `renderRatingDisplay` and exports + * no reader, and its value carries none of the windmod look props, so this module supplies both. + * It is internal — no barrel re-exports it. + */ +export const useRatingItemContext = (): RatingItemContextValue => + React.useContext(RatingItemContext) ?? ratingItemContextDefaultValue; diff --git a/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/index.ts b/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/index.ts new file mode 100644 index 00000000000000..0cb97bd54d04e1 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/index.ts @@ -0,0 +1,12 @@ +export { RatingDisplay } from './RatingDisplay'; +export { ratingDisplayClassNames, useRatingDisplayStyles } from './useRatingDisplayStyles'; +export type { + RatingDisplayColor, + RatingDisplayProps, + RatingDisplaySize, + RatingDisplaySlots, + RatingDisplayState, +} from './RatingDisplay.types'; + +export { RatingItem, ratingItemClassNames, useRatingItemStyles } from './RatingItem'; +export type { RatingItemProps, RatingItemSlots, RatingItemState } from './RatingItem'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/useRatingDisplayStyles.ts b/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/useRatingDisplayStyles.ts new file mode 100644 index 00000000000000..913327f121f415 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/RatingDisplay/useRatingDisplayStyles.ts @@ -0,0 +1,36 @@ +import { clsx } from 'clsx'; + +import { componentMarkers } from '../../utils/groupMarker'; +import type { RatingDisplayState } from './RatingDisplay.types'; + +import styles from './RatingDisplay.module.css'; + +/** The only public classes — see componentMarkers; internals are hashed idents. */ +export const ratingDisplayClassNames: { root: string } = { + root: componentMarkers('rating-display'), +}; + +/** + * Applies the visual contract, returning new state. `small` and `medium` share the caption1 base + * and carry no size class. The count text's leading separator exists only next to a value text. + */ +export const useRatingDisplayStyles = (state: RatingDisplayState): RatingDisplayState => { + const { size } = state; + const sizeClass = (size === 'large' || size === 'extra-large') && styles[size]; + + return { + ...state, + root: { + ...state.root, + className: clsx(ratingDisplayClassNames.root, styles.root, state.root.className), + }, + valueText: state.valueText && { + ...state.valueText, + className: clsx(styles.label, styles.strong, sizeClass, state.valueText.className), + }, + countText: state.countText && { + ...state.countText, + className: clsx(styles.label, sizeClass, state.valueText && styles.divider, state.countText.className), + }, + }; +}; diff --git a/packages/react-components/react-windmod-preview/library/src/index.ts b/packages/react-components/react-windmod-preview/library/src/index.ts index 621b5f22e9d7aa..fc0ce018740950 100644 --- a/packages/react-components/react-windmod-preview/library/src/index.ts +++ b/packages/react-components/react-windmod-preview/library/src/index.ts @@ -32,6 +32,25 @@ export type { ProgressBarThickness, } from './progress-bar'; +export { + RatingDisplay, + ratingDisplayClassNames, + renderRatingDisplay, + useRatingDisplay, + useRatingDisplayContextValues, + useRatingDisplayStyles, +} from './rating-display'; +export type { + RatingDisplayColor, + RatingDisplayProps, + RatingDisplaySize, + RatingDisplaySlots, + RatingDisplayState, +} from './rating-display'; + +export { RatingItem, ratingItemClassNames, renderRatingItem, useRatingItem, useRatingItemStyles } from './rating-item'; +export type { RatingItemProps, RatingItemSlots, RatingItemState } from './rating-item'; + export { renderSkeleton, Skeleton, diff --git a/packages/react-components/react-windmod-preview/library/src/rating-display.ts b/packages/react-components/react-windmod-preview/library/src/rating-display.ts new file mode 100644 index 00000000000000..9f1f67a7760901 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/rating-display.ts @@ -0,0 +1,15 @@ +export { RatingDisplay, ratingDisplayClassNames, useRatingDisplayStyles } from './components/RatingDisplay'; +export type { + RatingDisplayColor, + RatingDisplayProps, + RatingDisplaySize, + RatingDisplaySlots, + RatingDisplayState, +} from './components/RatingDisplay'; + +/** Headless building blocks, re-exported for consumers composing their own RatingDisplay. */ +export { + renderRatingDisplay, + useRatingDisplay, + useRatingDisplayContextValues, +} from '@fluentui/react-headless-components-preview/rating-display'; diff --git a/packages/react-components/react-windmod-preview/library/src/rating-item.ts b/packages/react-components/react-windmod-preview/library/src/rating-item.ts new file mode 100644 index 00000000000000..42abe47ca8c0c9 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/rating-item.ts @@ -0,0 +1,5 @@ +export { RatingItem, ratingItemClassNames, useRatingItemStyles } from './components/RatingDisplay/RatingItem'; +export type { RatingItemProps, RatingItemSlots, RatingItemState } from './components/RatingDisplay/RatingItem'; + +/** Headless building blocks, re-exported for consumers composing their own RatingItem. */ +export { renderRatingItem, useRatingItem } from '@fluentui/react-headless-components-preview/rating'; diff --git a/packages/react-components/react-windmod-preview/stories/src/RatingDisplay/RatingDisplayDefault.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/RatingDisplay/RatingDisplayDefault.stories.tsx new file mode 100644 index 00000000000000..edaaeab8be9fb8 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/RatingDisplay/RatingDisplayDefault.stories.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import { RatingDisplay, ThemeProvider } from '@fluentui/react-windmod-preview'; +import { CircleFilled } from '@fluentui/react-icons/headless/svg/circle'; + +import styles from '../compare.module.css'; + +const colors = ['neutral', 'brand', 'marigold'] as const; +const sizes = ['small', 'medium', 'large', 'extra-large'] as const; + +export const Default = (): React.ReactNode => ( + +
+ {colors.map(color => ( +
+ {sizes.map(size => ( + + ))} +
+ ))} +
+ {[0, 0.5, 1, 2.5, 4, 5].map(value => ( + + ))} +
+
+ + + +
+
+
+); diff --git a/packages/react-components/react-windmod-preview/stories/src/RatingDisplay/RatingDisplayGriffelComparison.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/RatingDisplay/RatingDisplayGriffelComparison.stories.tsx new file mode 100644 index 00000000000000..2b422ee8024c9d --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/RatingDisplay/RatingDisplayGriffelComparison.stories.tsx @@ -0,0 +1,61 @@ +import * as React from 'react'; +import { RatingDisplay, ThemeProvider } from '@fluentui/react-windmod-preview'; +import { FluentProvider, RatingDisplay as GriffelRatingDisplay, webLightTheme } from '@fluentui/react-components'; +import { CircleFilled } from '@fluentui/react-icons/headless/svg/circle'; +import { CircleFilled as GriffelCircleFilled } from '@fluentui/react-icons'; + +import styles from '../compare.module.css'; + +type LookProps = { + color?: 'brand' | 'marigold' | 'neutral'; + compact?: boolean; + count?: number; + max?: number; + size?: 'small' | 'medium' | 'large' | 'extra-large'; + value?: number; +}; + +/** + * Every windmod variant next to its Griffel-suite twin (inside a FluentProvider). + * The pair in each row must be pixel-identical. + */ +export const GriffelComparison = (): React.ReactNode => { + const variants: Array<{ label: string; props: LookProps; icon?: boolean }> = [ + { label: 'default', props: {} }, + { label: 'value 3.5', props: { value: 3.5 } }, + { label: 'value 0', props: { value: 0 } }, + { label: 'value 5', props: { value: 5 } }, + { label: 'brand', props: { color: 'brand', value: 3.5 } }, + { label: 'marigold', props: { color: 'marigold', value: 3.5 } }, + { label: 'small', props: { size: 'small', value: 3.5, count: 1160 } }, + { label: 'large', props: { size: 'large', value: 3.5, count: 1160 } }, + { label: 'extra-large', props: { size: 'extra-large', value: 3.5, count: 1160 } }, + { label: 'compact', props: { compact: true, value: 4.5, count: 1160 } }, + { label: 'max 10', props: { max: 10, value: 7 } }, + { label: 'count only', props: { count: 1234567 } }, + { label: 'custom icon', props: { color: 'marigold', value: 2.5 }, icon: true }, + ]; + + return ( +
+
Variant
+
Windmod
+
Griffel
+ {variants.map(({ label, props, icon }) => ( + +
{label}
+
+ + + +
+
+ + + +
+
+ ))} +
+ ); +}; diff --git a/packages/react-components/react-windmod-preview/stories/src/RatingDisplay/index.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/RatingDisplay/index.stories.tsx new file mode 100644 index 00000000000000..a2abe06459447c --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/RatingDisplay/index.stories.tsx @@ -0,0 +1,9 @@ +import { RatingDisplay } from '@fluentui/react-windmod-preview'; + +export { Default } from './RatingDisplayDefault.stories'; +export { GriffelComparison } from './RatingDisplayGriffelComparison.stories'; + +export default { + title: 'Windmod/RatingDisplay', + component: RatingDisplay, +}; diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/RatingDisplayGriffel.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/RatingDisplayGriffel.stories.tsx new file mode 100644 index 00000000000000..5f54291e9cfb40 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/RatingDisplayGriffel.stories.tsx @@ -0,0 +1,11 @@ +import * as React from 'react'; +import { FluentProvider, RatingDisplay, webLightTheme } from '@fluentui/react-components'; +import { CircleFilled } from '@fluentui/react-icons'; + +import { RatingDisplayVrScene } from './RatingDisplayVrScene'; + +export const RatingDisplayGriffel = (): React.ReactNode => ( + + + +); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/RatingDisplayVrScene.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/RatingDisplayVrScene.tsx new file mode 100644 index 00000000000000..049b57459fad57 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/RatingDisplayVrScene.tsx @@ -0,0 +1,113 @@ +// eslint-disable-next-line import/no-extraneous-dependencies -- story-only helper outside the *.stories.tsx devDependencies exemption +import * as React from 'react'; + +const colors = ['neutral', 'brand', 'marigold'] as const; +const sizes = ['small', 'medium', 'large', 'extra-large'] as const; + +/* The fill rounds to the nearest half, so the interesting boundaries are the quarter points. */ +const values = [0, 0.2, 0.25, 0.5, 0.7, 1, 1.5, 2.4, 2.5, 2.6, 3.7, 3.9, 4, 5] as const; +const maxes = [1, 2, 3, 7, 10] as const; +const counts = [0, 7, 1160, 1234567] as const; + +type RatingDisplayLike = React.ComponentType<{ + color?: (typeof colors)[number]; + compact?: boolean; + count?: number; + icon?: React.ElementType; + max?: number; + size?: (typeof sizes)[number]; + value?: number; + valueText?: { children?: React.ReactNode }; + countText?: { children?: React.ReactNode }; +}>; + +/* FluentProvider applies body1 typography, a colour and a text alignment to its whole subtree + while the windmod ThemeProvider is display:contents and applies none of it; pinning all three + identically on both sides keeps the inherited values out of the diff. */ +const frame: React.CSSProperties = { + display: 'inline-flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: 24, + padding: 24, + background: '#ffffff', + fontSize: 0, + lineHeight: 0, + textAlign: 'left', +}; +const row: React.CSSProperties = { display: 'flex', alignItems: 'flex-start', gap: 16 }; +const column: React.CSSProperties = { display: 'flex', flexDirection: 'column', gap: 8 }; + +/** One scene, two implementations — the VR runner diffs the renders pixel for pixel. */ +export const RatingDisplayVrScene = ({ + RatingDisplay, + Icon, +}: { + RatingDisplay: RatingDisplayLike; + Icon: React.ElementType; +}): React.ReactNode => ( +
+ {sizes.map(size => ( +
+ {colors.map(color => ( + + ))} +
+ ))} + +
+ {values.map(value => ( + + ))} +
+ +
+ {colors.map(color => ( + + ))} +
+ +
+ {maxes.map(max => ( + + ))} +
+ +
+ {counts.map(count => ( + + ))} + {counts.map(count => ( + + ))} +
+ + {sizes.map(size => ( +
+ {colors.map(color => ( + + ))} +
+ ))} + + +
+ {colors.map(color => ( + + ))} +
+ +
+ + +
+ +
+ +
+ + {colors.map(color => ( + + ))} +
+); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/RatingDisplayWindmod.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/RatingDisplayWindmod.stories.tsx new file mode 100644 index 00000000000000..7d7409f450875c --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/RatingDisplayWindmod.stories.tsx @@ -0,0 +1,11 @@ +import * as React from 'react'; +import { RatingDisplay, ThemeProvider } from '@fluentui/react-windmod-preview'; +import { CircleFilled } from '@fluentui/react-icons/headless/svg/circle'; + +import { RatingDisplayVrScene } from './RatingDisplayVrScene'; + +export const RatingDisplayWindmod = (): React.ReactNode => ( + + + +); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx index b8b4af2e63b77e..a9fd509c95d79c 100644 --- a/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx +++ b/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx @@ -12,6 +12,8 @@ export { LinkWindmod } from './LinkWindmod.stories'; export { LinkGriffel } from './LinkGriffel.stories'; export { ProgressBarWindmod } from './ProgressBarWindmod.stories'; export { ProgressBarGriffel } from './ProgressBarGriffel.stories'; +export { RatingDisplayWindmod } from './RatingDisplayWindmod.stories'; +export { RatingDisplayGriffel } from './RatingDisplayGriffel.stories'; export { SkeletonWindmod } from './SkeletonWindmod.stories'; export { SkeletonGriffel } from './SkeletonGriffel.stories'; export { SpinnerWindmod } from './SpinnerWindmod.stories'; From 3c8bd4d5c20032f8ee28382140e31c6fff08986b Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Fri, 21 Aug 2026 22:02:06 -0700 Subject: [PATCH 037/246] feat(windmod): add ToggleButton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First Button-family composition: useToggleButtonStyles composes useButtonStyles, the root carries both marker pairs, and toggle overrides ride fui.components.l2 over Button's l1. Glyph display swaps live in an l1 block — importance inverts layer order, so only the icons stylesheet's own layer can arbitrate visibility. Checked visuals are pixel-identical to the Griffel suite. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- .../library/etc/compound-button.api.md | 43 +++ .../library/etc/react-windmod-preview.api.md | 64 ++++ .../library/etc/toggle-button.api.md | 43 +++ .../library/package.json | 20 ++ .../CompoundButton/CompoundButton.module.css | 121 ++++++++ .../CompoundButton/CompoundButton.test.tsx | 277 ++++++++++++++++++ .../CompoundButton/CompoundButton.tsx | 30 ++ .../CompoundButton/CompoundButton.types.ts | 25 ++ .../src/components/CompoundButton/index.ts | 3 + .../CompoundButton/useCompoundButtonStyles.ts | 51 ++++ .../ToggleButton/ToggleButton.module.css | 246 ++++++++++++++++ .../ToggleButton/ToggleButton.test.tsx | 258 ++++++++++++++++ .../components/ToggleButton/ToggleButton.tsx | 30 ++ .../ToggleButton/ToggleButton.types.ts | 25 ++ .../src/components/ToggleButton/index.ts | 3 + .../ToggleButton/useToggleButtonStyles.ts | 42 +++ .../library/src/compound-button.ts | 5 + .../library/src/index.ts | 18 ++ .../library/src/toggle-button.ts | 5 + .../library/src/variants.css | 5 + .../CompoundButtonDefault.stories.tsx | 53 ++++ ...ompoundButtonGriffelComparison.stories.tsx | 102 +++++++ .../src/CompoundButton/index.stories.tsx | 9 + .../ToggleButtonDefault.stories.tsx | 52 ++++ .../ToggleButtonGriffelComparison.stories.tsx | 95 ++++++ .../src/ToggleButton/index.stories.tsx | 9 + .../src/vr/CompoundButtonGriffel.stories.tsx | 13 + .../stories/src/vr/CompoundButtonVrScene.tsx | 89 ++++++ .../src/vr/CompoundButtonWindmod.stories.tsx | 14 + .../src/vr/ToggleButtonGriffel.stories.tsx | 13 + .../stories/src/vr/ToggleButtonVrScene.tsx | 82 ++++++ .../src/vr/ToggleButtonWindmod.stories.tsx | 14 + .../stories/src/vr/index.stories.tsx | 4 + 33 files changed, 1863 insertions(+) create mode 100644 packages/react-components/react-windmod-preview/library/etc/compound-button.api.md create mode 100644 packages/react-components/react-windmod-preview/library/etc/toggle-button.api.md create mode 100644 packages/react-components/react-windmod-preview/library/src/components/CompoundButton/CompoundButton.module.css create mode 100644 packages/react-components/react-windmod-preview/library/src/components/CompoundButton/CompoundButton.test.tsx create mode 100644 packages/react-components/react-windmod-preview/library/src/components/CompoundButton/CompoundButton.tsx create mode 100644 packages/react-components/react-windmod-preview/library/src/components/CompoundButton/CompoundButton.types.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/CompoundButton/index.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/CompoundButton/useCompoundButtonStyles.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/ToggleButton/ToggleButton.module.css create mode 100644 packages/react-components/react-windmod-preview/library/src/components/ToggleButton/ToggleButton.test.tsx create mode 100644 packages/react-components/react-windmod-preview/library/src/components/ToggleButton/ToggleButton.tsx create mode 100644 packages/react-components/react-windmod-preview/library/src/components/ToggleButton/ToggleButton.types.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/ToggleButton/index.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/ToggleButton/useToggleButtonStyles.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/compound-button.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/toggle-button.ts create mode 100644 packages/react-components/react-windmod-preview/stories/src/CompoundButton/CompoundButtonDefault.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/CompoundButton/CompoundButtonGriffelComparison.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/CompoundButton/index.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/ToggleButton/ToggleButtonDefault.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/ToggleButton/ToggleButtonGriffelComparison.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/ToggleButton/index.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/CompoundButtonGriffel.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/CompoundButtonVrScene.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/CompoundButtonWindmod.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/ToggleButtonGriffel.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/ToggleButtonVrScene.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/ToggleButtonWindmod.stories.tsx diff --git a/packages/react-components/react-windmod-preview/library/etc/compound-button.api.md b/packages/react-components/react-windmod-preview/library/etc/compound-button.api.md new file mode 100644 index 00000000000000..5b7121ceae7703 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/etc/compound-button.api.md @@ -0,0 +1,43 @@ +## API Report File for "@fluentui/react-windmod-preview" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { CompoundButtonProps as CompoundButtonProps_2 } from '@fluentui/react-headless-components-preview/compound-button'; +import { CompoundButtonSlots } from '@fluentui/react-headless-components-preview/compound-button'; +import type { CompoundButtonState as CompoundButtonState_2 } from '@fluentui/react-headless-components-preview/compound-button'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { renderCompoundButton } from '@fluentui/react-headless-components-preview/compound-button'; +import { useCompoundButton } from '@fluentui/react-headless-components-preview/compound-button'; + +// @public +export const CompoundButton: ForwardRefComponent; + +// @public +export const compoundButtonClassNames: { + root: string; +}; + +// @public +export type CompoundButtonProps = CompoundButtonProps_2 & { + appearance?: ButtonAppearance; + shape?: ButtonShape; + size?: ButtonSize; +}; + +export { CompoundButtonSlots } + +// @public +export type CompoundButtonState = CompoundButtonState_2 & Required>; + +export { renderCompoundButton } + +export { useCompoundButton } + +// @public +export const useCompoundButtonStyles: (state: CompoundButtonState) => CompoundButtonState; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md b/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md index acfcb425d72330..f7c3289186561f 100644 --- a/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md +++ b/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md @@ -10,6 +10,9 @@ import type { BadgeState as BadgeState_2 } from '@fluentui/react-headless-compon import type { ButtonProps as ButtonProps_2 } from '@fluentui/react-headless-components-preview/button'; import { ButtonSlots } from '@fluentui/react-headless-components-preview/button'; import type { ButtonState as ButtonState_2 } from '@fluentui/react-headless-components-preview/button'; +import type { CompoundButtonProps as CompoundButtonProps_2 } from '@fluentui/react-headless-components-preview/compound-button'; +import { CompoundButtonSlots } from '@fluentui/react-headless-components-preview/compound-button'; +import type { CompoundButtonState as CompoundButtonState_2 } from '@fluentui/react-headless-components-preview/compound-button'; import type { DividerProps as DividerProps_2 } from '@fluentui/react-headless-components-preview/divider'; import { DividerSlots } from '@fluentui/react-headless-components-preview/divider'; import type { DividerState as DividerState_2 } from '@fluentui/react-headless-components-preview/divider'; @@ -38,6 +41,7 @@ import type { RatingItemState as RatingItemState_2 } from '@fluentui/react-headl import * as React_2 from 'react'; import { renderBadge } from '@fluentui/react-headless-components-preview/badge'; import { renderButton } from '@fluentui/react-headless-components-preview/button'; +import { renderCompoundButton } from '@fluentui/react-headless-components-preview/compound-button'; import { renderDivider } from '@fluentui/react-headless-components-preview/divider'; import { renderImage } from '@fluentui/react-headless-components-preview/image'; import { renderLabel } from '@fluentui/react-headless-components-preview/label'; @@ -49,6 +53,7 @@ import { renderRatingItem } from '@fluentui/react-headless-components-preview/ra import { renderSkeleton } from '@fluentui/react-headless-components-preview/skeleton'; import { renderSkeletonItem } from '@fluentui/react-headless-components-preview/skeleton'; import { renderSpinner } from '@fluentui/react-headless-components-preview/spinner'; +import { renderToggleButton } from '@fluentui/react-headless-components-preview/toggle-button'; import { renderTooltip } from '@fluentui/react-headless-components-preview/tooltip'; import type { SkeletonItemProps as SkeletonItemProps_2 } from '@fluentui/react-headless-components-preview/skeleton'; import { SkeletonItemSlots } from '@fluentui/react-headless-components-preview/skeleton'; @@ -66,12 +71,16 @@ import { teamsLightThemeClassName } from '@fluentui/react-tailwind-theme-preview import { teamsLightV21ThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; import { ThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; import { themeClassNames } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; +import type { ToggleButtonProps as ToggleButtonProps_2 } from '@fluentui/react-headless-components-preview/toggle-button'; +import { ToggleButtonSlots } from '@fluentui/react-headless-components-preview/toggle-button'; +import type { ToggleButtonState as ToggleButtonState_2 } from '@fluentui/react-headless-components-preview/toggle-button'; import type { TooltipProps as TooltipProps_2 } from '@fluentui/react-headless-components-preview/tooltip'; import { TooltipSlots } from '@fluentui/react-headless-components-preview/tooltip'; import type { TooltipState as TooltipState_2 } from '@fluentui/react-headless-components-preview/tooltip'; import { TooltipTriggerProps } from '@fluentui/react-headless-components-preview/tooltip'; import { useBadge } from '@fluentui/react-headless-components-preview/badge'; import { useButton } from '@fluentui/react-headless-components-preview/button'; +import { useCompoundButton } from '@fluentui/react-headless-components-preview/compound-button'; import { useDivider } from '@fluentui/react-headless-components-preview/divider'; import { useImage } from '@fluentui/react-headless-components-preview/image'; import { useLabel } from '@fluentui/react-headless-components-preview/label'; @@ -85,6 +94,7 @@ import { useSkeleton } from '@fluentui/react-headless-components-preview/skeleto import { useSkeletonContextValues } from '@fluentui/react-headless-components-preview/skeleton'; import { useSkeletonItem } from '@fluentui/react-headless-components-preview/skeleton'; import { useSpinner } from '@fluentui/react-headless-components-preview/spinner'; +import { useToggleButton } from '@fluentui/react-headless-components-preview/toggle-button'; import { useTooltip } from '@fluentui/react-headless-components-preview/tooltip'; import { webDarkThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; import { webLightThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; @@ -151,6 +161,26 @@ export { ButtonSlots } // @public export type ButtonState = ButtonState_2 & Required>; +// @public +export const CompoundButton: ForwardRefComponent; + +// @public +export const compoundButtonClassNames: { + root: string; +}; + +// @public +export type CompoundButtonProps = CompoundButtonProps_2 & { + appearance?: ButtonAppearance; + shape?: ButtonShape; + size?: ButtonSize; +}; + +export { CompoundButtonSlots } + +// @public +export type CompoundButtonState = CompoundButtonState_2 & Required>; + // @public export const Divider: ForwardRefComponent; @@ -334,6 +364,8 @@ export { renderBadge } export { renderButton } +export { renderCompoundButton } + export { renderDivider } export { renderImage } @@ -356,6 +388,8 @@ export { renderSkeletonItem } export { renderSpinner } +export { renderToggleButton } + export { renderTooltip } // @public @@ -463,6 +497,26 @@ export type ThemeProviderProps = React_2.HTMLAttributes & { targetDocument?: Document; }; +// @public +export const ToggleButton: ForwardRefComponent; + +// @public +export const toggleButtonClassNames: { + root: string; +}; + +// @public +export type ToggleButtonProps = ToggleButtonProps_2 & { + appearance?: ButtonAppearance; + shape?: ButtonShape; + size?: ButtonSize; +}; + +export { ToggleButtonSlots } + +// @public +export type ToggleButtonState = ToggleButtonState_2 & Required>; + // @public export const Tooltip: { (props: TooltipProps): JSXElement; @@ -501,6 +555,11 @@ export { useButton } // @public export const useButtonStyles: (state: ButtonState) => ButtonState; +export { useCompoundButton } + +// @public +export const useCompoundButtonStyles: (state: CompoundButtonState) => CompoundButtonState; + export { useDivider } // @public @@ -557,6 +616,11 @@ export { useSpinner } // @public export const useSpinnerStyles: (state: SpinnerState) => SpinnerState; +export { useToggleButton } + +// @public +export const useToggleButtonStyles: (state: ToggleButtonState) => ToggleButtonState; + export { useTooltip } // @public diff --git a/packages/react-components/react-windmod-preview/library/etc/toggle-button.api.md b/packages/react-components/react-windmod-preview/library/etc/toggle-button.api.md new file mode 100644 index 00000000000000..1f5c36dfd48359 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/etc/toggle-button.api.md @@ -0,0 +1,43 @@ +## API Report File for "@fluentui/react-windmod-preview" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { renderToggleButton } from '@fluentui/react-headless-components-preview/toggle-button'; +import type { ToggleButtonProps as ToggleButtonProps_2 } from '@fluentui/react-headless-components-preview/toggle-button'; +import { ToggleButtonSlots } from '@fluentui/react-headless-components-preview/toggle-button'; +import type { ToggleButtonState as ToggleButtonState_2 } from '@fluentui/react-headless-components-preview/toggle-button'; +import { useToggleButton } from '@fluentui/react-headless-components-preview/toggle-button'; + +export { renderToggleButton } + +// @public +export const ToggleButton: ForwardRefComponent; + +// @public +export const toggleButtonClassNames: { + root: string; +}; + +// @public +export type ToggleButtonProps = ToggleButtonProps_2 & { + appearance?: ButtonAppearance; + shape?: ButtonShape; + size?: ButtonSize; +}; + +export { ToggleButtonSlots } + +// @public +export type ToggleButtonState = ToggleButtonState_2 & Required>; + +export { useToggleButton } + +// @public +export const useToggleButtonStyles: (state: ToggleButtonState) => ToggleButtonState; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-windmod-preview/library/package.json b/packages/react-components/react-windmod-preview/library/package.json index 37e21f00af9ff4..e13193b0482245 100644 --- a/packages/react-components/react-windmod-preview/library/package.json +++ b/packages/react-components/react-windmod-preview/library/package.json @@ -73,6 +73,16 @@ "default": "./lib-commonjs/button.cjs" } }, + "./compound-button": { + "import": { + "types": "./dist/compound-button.d.ts", + "default": "./lib/compound-button.js" + }, + "require": { + "types": "./dist/compound-button.d.cts", + "default": "./lib-commonjs/compound-button.cjs" + } + }, "./divider": { "import": { "types": "./dist/divider.d.ts", @@ -183,6 +193,16 @@ "default": "./lib-commonjs/spinner.cjs" } }, + "./toggle-button": { + "import": { + "types": "./dist/toggle-button.d.ts", + "default": "./lib/toggle-button.js" + }, + "require": { + "types": "./dist/toggle-button.d.cts", + "default": "./lib-commonjs/toggle-button.cjs" + } + }, "./tooltip": { "import": { "types": "./dist/tooltip.d.ts", diff --git a/packages/react-components/react-windmod-preview/library/src/components/CompoundButton/CompoundButton.module.css b/packages/react-components/react-windmod-preview/library/src/components/CompoundButton/CompoundButton.module.css new file mode 100644 index 00000000000000..22c6bc3a8f4ea1 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/CompoundButton/CompoundButton.module.css @@ -0,0 +1,121 @@ +@reference '#theme'; + +@layer fui.components.l2 { + .root { + @apply h-auto px-horizontal-m pt-14 pb-horizontal-l text-base-300 leading-base-300; + + @variant size-small { + @apply px-horizontal-s pt-horizontal-s pb-horizontal-m-nudge; + } + + @variant size-large { + @apply px-horizontal-l pt-18 pb-horizontal-xl text-base-400 leading-base-400; + } + + @variant icon-only { + @variant size-small { + @apply max-w-48 min-w-48 p-horizontal-xs; + } + + @variant size-medium { + @apply max-w-52 min-w-52 p-horizontal-s-nudge; + } + + @variant size-large { + @apply max-w-56 min-w-56 p-horizontal-s; + } + } + } + + .icon { + @apply size-40 text-[length:calc(40px*var(--base-scale))]; + + @variant group-not-empty/fui-compound-button { + @variant group-icon-before/fui-compound-button { + @apply me-horizontal-m; + } + + @variant group-icon-after/fui-compound-button { + @apply ms-horizontal-m; + } + } + } + + .contentContainer { + @apply flex flex-col text-start; + } + + .secondaryContent { + @apply text-base-200 leading-[100%] font-regular text-neutral-foreground-2; + + @variant group-size-large/fui-compound-button { + @apply text-base-300; + } + + @variant group-hover/fui-compound-button { + @apply text-neutral-foreground-2-hover; + } + + @variant group-hover-active/fui-compound-button { + @apply text-neutral-foreground-2-pressed; + } + + @variant forced-colors { + @variant group-hover/fui-compound-button { + @apply text-[Highlight]; + } + + @variant group-hover-active/fui-compound-button { + @apply text-[Highlight]; + } + } + + @variant group-appearance-primary/fui-compound-button { + @apply text-neutral-foreground-on-brand; + + @variant forced-colors { + @apply text-[HighlightText]; + + /* Griffel reaches this from the root with a :hover compound that outranks the + appearance rule by specificity; here position alone carries it. */ + @variant group-hover/fui-compound-button { + @apply text-[Highlight]; + } + + @variant group-hover-active/fui-compound-button { + @apply text-[Highlight]; + } + } + } + + @variant group-appearance-subtle/fui-compound-button { + @variant forced-colors { + @variant group-hover/fui-compound-button { + @apply text-[Canvas]; + } + + @variant group-hover-active/fui-compound-button { + @apply text-[Canvas]; + } + } + } + + @variant group-appearance-transparent/fui-compound-button { + @variant group-hover/fui-compound-button { + @apply text-neutral-foreground-2-brand-hover; + } + + @variant group-hover-active/fui-compound-button { + @apply text-neutral-foreground-2-brand-pressed; + } + } + + @variant group-disabled/fui-compound-button, group-disabled-focusable/fui-compound-button { + @apply text-neutral-foreground-disabled; + + @variant forced-colors { + @apply text-[GrayText]; + } + } + } +} diff --git a/packages/react-components/react-windmod-preview/library/src/components/CompoundButton/CompoundButton.test.tsx b/packages/react-components/react-windmod-preview/library/src/components/CompoundButton/CompoundButton.test.tsx new file mode 100644 index 00000000000000..cb5d517fd1e44f --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/CompoundButton/CompoundButton.test.tsx @@ -0,0 +1,277 @@ +import * as React from 'react'; +import { render } from '@testing-library/react'; + +import { isConformant } from '../../testing/isConformant'; +import { CompoundButton } from './CompoundButton'; +import type { CompoundButtonState } from './CompoundButton.types'; +import { compoundButtonClassNames, useCompoundButtonStyles } from './useCompoundButtonStyles'; + +import styles from './CompoundButton.module.css'; + +// The jest css-module proxy drops the component and hash segments, so Button's `root` and +// CompoundButton's `root` are the same string — only the occurrence count distinguishes them. +const occurrences = (className: string, target: string): number => + className.split(' ').filter(name => name === target).length; + +const iconOf = (root: HTMLElement): HTMLElement => { + const icon = root.querySelector(`span.${styles.icon}`); + + if (!icon) { + throw new Error('CompoundButton rendered no icon slot'); + } + + return icon; +}; + +const containerOf = (root: HTMLElement): HTMLElement | null => + root.querySelector(`span.${styles.contentContainer}`); + +const Glyph = (): React.ReactElement => ; + +describe('CompoundButton', () => { + isConformant({ + Component: CompoundButton, + displayName: 'CompoundButton', + }); + + it('stamps both marker pairs, its own first', () => { + const { getByTestId } = render(Compound); + + const root = getByTestId('root'); + + expect(root.className).toContain(compoundButtonClassNames.root); + expect(root).toHaveClass('fui-compound-button'); + expect(root).toHaveClass('group/fui-compound-button'); + expect(root).toHaveClass('fui-button'); + expect(root).toHaveClass('group/fui-button'); + expect(root.classList[0]).toBe('fui-compound-button'); + }); + + it('carries the root class of both stylesheets', () => { + const { getByTestId } = render(Compound); + + expect(occurrences(getByTestId('root').className, styles.root)).toBe(2); + }); + + it('carries the icon class of both stylesheets', () => { + const { getByTestId } = render( + }> + Compound + , + ); + + expect(occurrences(iconOf(getByTestId('root')).className, styles.icon)).toBe(2); + }); + + it('styles the content container and the secondary content', () => { + const { getByTestId } = render( + + Compound + , + ); + + const container = containerOf(getByTestId('root')); + + expect(container).not.toBeNull(); + expect(container).toHaveClass(styles.contentContainer); + expect(container!.querySelector(`span.${styles.secondaryContent}`)).not.toBeNull(); + }); + + it('renders children then secondaryContent inside the container, and no container when icon only', () => { + const { getByTestId } = render( + <> + + Compound + + } aria-label="Compound" /> + , + ); + + const container = containerOf(getByTestId('labelled')); + + expect(container).not.toBeNull(); + expect(container!.textContent).toBe('CompoundSecond line'); + expect(container!.lastElementChild).toHaveClass(styles.secondaryContent); + expect(container!.lastElementChild!.textContent).toBe('Second line'); + + expect(containerOf(getByTestId('icon-only'))).toBeNull(); + }); + + it('renders no secondaryContent span when the consumer supplies none', () => { + const { getByTestId } = render(Compound); + + expect(getByTestId('root').querySelector(`span.${styles.secondaryContent}`)).toBeNull(); + }); + + it('stamps data-icon-position only when an icon is rendered', () => { + const { getByTestId } = render( + <> + Compound + }> + Compound + + } iconPosition="after"> + Compound + + , + ); + + expect(getByTestId('plain').hasAttribute('data-icon-position')).toBe(false); + expect(getByTestId('before').getAttribute('data-icon-position')).toBe('before'); + expect(getByTestId('after').getAttribute('data-icon-position')).toBe('after'); + }); + + it('keeps the data attributes useButtonStyles resolves', () => { + const { getByTestId } = render( + <> + + Compound + + } aria-label="Compound" /> + , + ); + + const labelled = getByTestId('labelled'); + + expect(labelled.getAttribute('data-appearance')).toBe('primary'); + expect(labelled.getAttribute('data-size')).toBe('large'); + expect(labelled.hasAttribute('data-empty')).toBe(false); + + expect(getByTestId('icon-only').hasAttribute('data-empty')).toBe(true); + }); + + it('leaves data-icon-only and data-has-secondary-content to the headless hook', () => { + const { getByTestId } = render( + <> + Compound + + Compound + + } aria-label="Compound" /> + , + ); + + const plain = getByTestId('plain'); + + expect(plain.hasAttribute('data-icon-only')).toBe(false); + expect(plain.hasAttribute('data-has-secondary-content')).toBe(false); + + expect(getByTestId('secondary').getAttribute('data-has-secondary-content')).toBe(''); + + const iconOnly = getByTestId('icon-only'); + + expect(iconOnly.getAttribute('data-icon-only')).toBe(''); + expect(iconOnly.hasAttribute('data-has-secondary-content')).toBe(false); + }); + + it('keeps consumer props on every slot it restyles', () => { + const { getByTestId } = render( + , id: 'consumer-icon' }} + contentContainer={{ id: 'consumer-container', className: 'consumer-container' }} + secondaryContent={{ children: 'Second line', id: 'consumer-secondary', className: 'consumer-secondary' }} + > + Compound + , + ); + + const root = getByTestId('root'); + + expect(root).toHaveClass('consumer'); + expect(occurrences(root.className, 'consumer')).toBe(1); + + expect(iconOf(root).id).toBe('consumer-icon'); + + const container = containerOf(root); + + expect(container!.id).toBe('consumer-container'); + expect(container).toHaveClass('consumer-container'); + expect(occurrences(container!.className, 'consumer-container')).toBe(1); + + const secondary = root.querySelector(`span.${styles.secondaryContent}`); + + expect(secondary!.id).toBe('consumer-secondary'); + expect(secondary).toHaveClass('consumer-secondary'); + expect(occurrences(secondary!.className, 'consumer-secondary')).toBe(1); + }); + + it('passes disabled and disabledFocusable through to the root', () => { + const { getByTestId } = render( + <> + + Compound + + + Compound + + , + ); + + const disabled = getByTestId('disabled'); + + expect(disabled.getAttribute('data-disabled')).toBe(''); + expect(disabled).toBeDisabled(); + + const disabledFocusable = getByTestId('disabled-focusable'); + + expect(disabledFocusable.getAttribute('data-disabled-focusable')).toBe(''); + expect(disabledFocusable.getAttribute('aria-disabled')).toBe('true'); + }); + + it('does not mutate the state it is given', () => { + const state = { + appearance: 'primary', + components: { root: 'button', icon: 'span', contentContainer: 'span', secondaryContent: 'span' }, + contentContainer: { as: 'span', className: 'consumer-container' }, + icon: { className: 'consumer-icon' }, + iconPosition: 'after', + root: { as: 'button', className: 'consumer', 'data-icon-only': '' }, + secondaryContent: { as: 'span', className: 'consumer-secondary' }, + shape: 'circular', + size: 'large', + } as unknown as CompoundButtonState; + + const styled = useCompoundButtonStyles(state); + + expect(styled).not.toBe(state); + expect(styled.root).not.toBe(state.root); + expect(styled.contentContainer).not.toBe(state.contentContainer); + expect(styled.secondaryContent).not.toBe(state.secondaryContent); + + expect(state.root.className).toBe('consumer'); + expect(state.root).not.toHaveProperty('data-icon-position'); + expect(state.root).not.toHaveProperty('data-appearance'); + expect(state.icon!.className).toBe('consumer-icon'); + expect(state.contentContainer.className).toBe('consumer-container'); + expect(state.secondaryContent!.className).toBe('consumer-secondary'); + + expect(styled.root.className).toContain('consumer'); + expect(styled.root.className).toContain(compoundButtonClassNames.root); + expect(styled.root['data-icon-only']).toBe(''); + expect((styled.root as { 'data-icon-position'?: string })['data-icon-position']).toBe('after'); + expect(occurrences(styled.icon!.className!, styles.icon)).toBe(2); + expect(styled.contentContainer.className).toContain(styles.contentContainer); + expect(styled.contentContainer.className).toContain('consumer-container'); + expect(styled.secondaryContent!.className).toContain(styles.secondaryContent); + expect(styled.secondaryContent!.className).toContain('consumer-secondary'); + }); + + it('adds no icon or secondaryContent slot when the consumer supplies neither', () => { + const state = { + appearance: 'secondary', + components: { root: 'button', icon: 'span', contentContainer: 'span', secondaryContent: 'span' }, + contentContainer: { as: 'span' }, + root: { as: 'button' }, + shape: 'rounded', + size: 'medium', + } as unknown as CompoundButtonState; + + const styled = useCompoundButtonStyles(state); + + expect(styled.icon).toBeUndefined(); + expect(styled.secondaryContent).toBeUndefined(); + expect((styled.root as { 'data-icon-position'?: string })['data-icon-position']).toBeUndefined(); + }); +}); diff --git a/packages/react-components/react-windmod-preview/library/src/components/CompoundButton/CompoundButton.tsx b/packages/react-components/react-windmod-preview/library/src/components/CompoundButton/CompoundButton.tsx new file mode 100644 index 00000000000000..9621dbd30b3754 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/CompoundButton/CompoundButton.tsx @@ -0,0 +1,30 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { renderCompoundButton, useCompoundButton } from '@fluentui/react-headless-components-preview/compound-button'; + +import type { CompoundButtonProps, CompoundButtonState } from './CompoundButton.types'; +import { useCompoundButtonStyles } from './useCompoundButtonStyles'; + +/** + * A CompoundButton pairs an action with a second line describing it. Windmod CompoundButton: the + * headless compound button decorated with the Fluent visual contract (Tailwind v4 + CSS Modules). + */ +export const CompoundButton: ForwardRefComponent = React.forwardRef((props, ref) => { + // Look props belong to windmod — the headless hook neither accepts nor resolves them. + // Defaults mirror @fluentui/react-button's styled useCompoundButton. + const { appearance = 'secondary', shape = 'rounded', size = 'medium', ...rest } = props; + + const state: CompoundButtonState = { + ...useCompoundButton(rest, ref), + appearance, + shape, + size, + }; + + return renderCompoundButton(useCompoundButtonStyles(state)); + // Casting is required due to lack of distributive union to support union on @types/react +}) as ForwardRefComponent; + +CompoundButton.displayName = 'CompoundButton'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/CompoundButton/CompoundButton.types.ts b/packages/react-components/react-windmod-preview/library/src/components/CompoundButton/CompoundButton.types.ts new file mode 100644 index 00000000000000..4337f1b9729178 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/CompoundButton/CompoundButton.types.ts @@ -0,0 +1,25 @@ +import type { + CompoundButtonProps as CompoundButtonHeadlessProps, + CompoundButtonState as CompoundButtonHeadlessState, +} from '@fluentui/react-headless-components-preview/compound-button'; + +import type { ButtonAppearance, ButtonShape, ButtonSize } from '../Button/Button.types'; + +export type { CompoundButtonSlots } from '@fluentui/react-headless-components-preview/compound-button'; + +/** + * Windmod CompoundButton props: the headless compound button plus the look props the headless + * surface deliberately omits (they exist purely to select styles). + */ +export type CompoundButtonProps = CompoundButtonHeadlessProps & { + /** @default 'secondary' */ + appearance?: ButtonAppearance; + /** @default 'rounded' */ + shape?: ButtonShape; + /** @default 'medium' */ + size?: ButtonSize; +}; + +/** Windmod CompoundButton state: headless state plus the resolved look props. */ +export type CompoundButtonState = CompoundButtonHeadlessState & + Required>; diff --git a/packages/react-components/react-windmod-preview/library/src/components/CompoundButton/index.ts b/packages/react-components/react-windmod-preview/library/src/components/CompoundButton/index.ts new file mode 100644 index 00000000000000..5f3b48e9c8c597 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/CompoundButton/index.ts @@ -0,0 +1,3 @@ +export { CompoundButton } from './CompoundButton'; +export { compoundButtonClassNames, useCompoundButtonStyles } from './useCompoundButtonStyles'; +export type { CompoundButtonProps, CompoundButtonSlots, CompoundButtonState } from './CompoundButton.types'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/CompoundButton/useCompoundButtonStyles.ts b/packages/react-components/react-windmod-preview/library/src/components/CompoundButton/useCompoundButtonStyles.ts new file mode 100644 index 00000000000000..2ac2afb6778ce7 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/CompoundButton/useCompoundButtonStyles.ts @@ -0,0 +1,51 @@ +'use client'; + +import { clsx } from 'clsx'; + +import { componentMarkers } from '../../utils/groupMarker'; +import { useButtonStyles } from '../Button/useButtonStyles'; +import type { CompoundButtonState } from './CompoundButton.types'; + +import styles from './CompoundButton.module.css'; + +/** The only public classes — see componentMarkers; internals are hashed idents. */ +export const compoundButtonClassNames: { root: string } = { + root: componentMarkers('compound-button'), +}; + +type CompoundButtonRootDataAttributes = { + 'data-icon-position'?: CompoundButtonState['iconPosition']; +}; + +/** + * Applies the visual contract on top of Button's, returning new state. The headless hook stamps + * data-disabled/-disabled-focusable/-icon-only/-has-secondary-content and useButtonStyles stamps + * data-appearance/-size/-empty; data-icon-position is the one attribute the headless compound + * hook omits that Button's and this component's icon spacing both select on. + * + * Button's icon rules select through `group/fui-button`, so the root must keep Button's marker + * pair alongside its own. + */ +export const useCompoundButtonStyles = (state: CompoundButtonState): CompoundButtonState => { + const { root: buttonRoot, icon: buttonIcon } = useButtonStyles(state); + + const root: CompoundButtonState['root'] & CompoundButtonRootDataAttributes = { + ...buttonRoot, + 'data-icon-position': state.icon ? state.iconPosition : undefined, + className: clsx(compoundButtonClassNames.root, styles.root, buttonRoot.className), + }; + + return { + ...state, + root, + icon: buttonIcon && { ...buttonIcon, className: clsx(styles.icon, buttonIcon.className) }, + contentContainer: { + ...state.contentContainer, + className: clsx(styles.contentContainer, state.contentContainer.className), + }, + secondaryContent: state.secondaryContent && { + ...state.secondaryContent, + className: clsx(styles.secondaryContent, state.secondaryContent.className), + }, + }; +}; diff --git a/packages/react-components/react-windmod-preview/library/src/components/ToggleButton/ToggleButton.module.css b/packages/react-components/react-windmod-preview/library/src/components/ToggleButton/ToggleButton.module.css new file mode 100644 index 00000000000000..76fa762810d544 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/ToggleButton/ToggleButton.module.css @@ -0,0 +1,246 @@ +@reference '#theme'; + +/* The icons package pins `[data-fui-icon-hidden] { display: none !important }` into + fui.components.l1, and importance inverts cascade-layer order: an important declaration in + l2 loses to one in l1 regardless of specificity. Revealing a hidden glyph is therefore only + expressible at l1, so the glyph swaps live here while every other rule stays in l2. */ +@layer fui.components.l1 { + .icon { + @variant group-checked/fui-toggle-button { + & * { + @variant variant-filled { + @apply inline!; + } + + @variant variant-regular { + @apply hidden!; + } + } + } + + @variant group-disabled/fui-toggle-button, group-disabled-focusable/fui-toggle-button { + & * { + @variant group-hover/fui-toggle-button { + @variant variant-filled { + @apply hidden!; + } + + @variant variant-regular { + @apply inline!; + } + } + + @variant group-hover-active/fui-toggle-button { + @variant variant-filled { + @apply hidden!; + } + + @variant variant-regular { + @apply inline!; + } + } + } + } + } +} + +@layer fui.components.l2 { + .root { + @variant appearance-primary { + @variant forced-colors { + @apply border-[ButtonBorder] bg-[ButtonFace] text-[ButtonText] forced-color-adjust-auto; + } + + @variant disabled, disabled-focusable { + @variant forced-colors { + @apply border-[GrayText] text-[GrayText]; + + @variant focus { + @apply border-[GrayText]; + } + } + } + } + + @variant checked { + @apply border-(length:--spacing-thin) border-neutral-stroke-1 bg-neutral-background-1-selected text-neutral-foreground-1-selected; + + @variant hover { + @apply border-neutral-stroke-1-hover bg-neutral-background-1-hover text-neutral-foreground-1-hover; + } + + @variant hover-active { + @apply border-neutral-stroke-1-pressed bg-neutral-background-1-pressed text-neutral-foreground-1-pressed; + } + + @variant forced-colors { + @apply border-[Highlight] bg-[Highlight] text-[HighlightText] forced-color-adjust-none; + + @variant hover { + @apply border-[Highlight] bg-[HighlightText] text-[Highlight]; + } + + @variant hover-active { + @apply border-[Highlight] bg-[HighlightText] text-[Highlight]; + } + + @variant focus { + @apply border border-solid border-[HighlightText] outline-[color:Highlight]; + } + } + + @variant appearance-outline { + @apply border-(length:--spacing-thicker) border-neutral-stroke-1 bg-transparent-background-selected; + + @variant hover { + @apply bg-transparent-background-hover; + } + + @variant hover-active { + @apply bg-transparent-background-pressed; + } + } + + @variant appearance-primary { + @apply border-transparent bg-brand-background-selected text-neutral-foreground-on-brand; + + @variant hover { + @apply border-transparent bg-brand-background-hover text-neutral-foreground-on-brand; + } + + @variant hover-active { + @apply border-transparent bg-brand-background-pressed text-neutral-foreground-on-brand; + } + } + + @variant appearance-subtle { + @apply border-transparent bg-subtle-background-selected text-neutral-foreground-2-selected; + + @variant hover { + @apply border-transparent bg-subtle-background-hover text-neutral-foreground-2-hover; + } + + @variant hover-active { + @apply border-transparent bg-subtle-background-pressed text-neutral-foreground-2-pressed; + } + } + + @variant appearance-transparent { + @apply border-transparent bg-transparent-background-selected text-neutral-foreground-2-brand-selected; + + @variant hover { + @apply border-transparent bg-transparent-background-hover text-neutral-foreground-2-brand-hover; + } + + @variant hover-active { + @apply border-transparent bg-transparent-background-pressed text-neutral-foreground-2-brand-pressed; + } + } + + @variant accessible { + @apply border-transparent bg-brand-background text-neutral-foreground-on-brand; + + @variant hover { + @apply border-transparent bg-brand-background-hover text-neutral-foreground-on-brand; + } + + @variant hover-active { + @apply border-transparent bg-brand-background-pressed text-neutral-foreground-on-brand; + } + + @variant appearance-outline { + @apply border-(length:--spacing-thin); + } + + @variant appearance-primary { + @apply outline-(length:--spacing-thin) -outline-offset-(--spacing-thicker) outline-neutral-foreground-on-brand outline-solid; + + @variant focus-visible { + @apply outline-(length:--spacing-thin) -outline-offset-(--spacing-thickest) outline-neutral-foreground-on-brand outline-solid; + } + } + } + } + + @variant disabled, disabled-focusable { + @apply border-neutral-stroke-disabled bg-neutral-background-disabled text-neutral-foreground-disabled; + + @variant hover { + @apply border-neutral-stroke-disabled bg-neutral-background-disabled text-neutral-foreground-disabled; + } + + @variant hover-active { + @apply border-neutral-stroke-disabled bg-neutral-background-disabled text-neutral-foreground-disabled; + } + + @variant appearance-primary { + @apply border-transparent; + + @variant hover { + @apply border-transparent; + } + + @variant hover-active { + @apply border-transparent; + } + } + + @variant appearance-subtle, appearance-transparent { + @apply border-transparent bg-transparent-background; + + @variant hover { + @apply border-transparent bg-transparent-background-hover; + } + + @variant hover-active { + @apply border-transparent bg-transparent-background-pressed; + } + } + } + + /* Griffel selects the focus indicator with an attribute on top of the class, so it + outranks every recolour above by specificity; here position alone carries that. */ + @variant focus-visible { + border-color: var(--fui-focus-ring-color, var(--color-stroke-focus-2)); + border-width: var(--fui-focus-ring-width, 1px); + + @variant checked { + @variant appearance-outline { + @apply border-neutral-stroke-1; + } + } + } + } + + .icon { + @variant forced-colors { + @apply forced-color-adjust-auto; + } + + @variant group-checked/fui-toggle-button { + @variant group-appearance-subtle/fui-toggle-button, group-appearance-transparent/fui-toggle-button { + @apply text-neutral-foreground-2-brand-selected; + } + + @variant group-accessible/fui-toggle-button { + @apply text-neutral-foreground-on-brand; + } + } + + /* The subtle pressed icon colour is a token of its own; the hover colour is the same + value as the selected colour in every shipped theme. */ + @variant group-appearance-subtle/fui-toggle-button { + @variant group-hover-active/fui-toggle-button { + @apply text-neutral-foreground-2-brand-pressed; + } + } + + @variant group-disabled/fui-toggle-button, group-disabled-focusable/fui-toggle-button { + @apply text-neutral-foreground-disabled; + + @variant forced-colors { + @apply text-[GrayText]; + } + } + } +} diff --git a/packages/react-components/react-windmod-preview/library/src/components/ToggleButton/ToggleButton.test.tsx b/packages/react-components/react-windmod-preview/library/src/components/ToggleButton/ToggleButton.test.tsx new file mode 100644 index 00000000000000..3824912ac3bf6d --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/ToggleButton/ToggleButton.test.tsx @@ -0,0 +1,258 @@ +import * as React from 'react'; +import { fireEvent, render } from '@testing-library/react'; + +import { isConformant } from '../../testing/isConformant'; +import { ToggleButton } from './ToggleButton'; +import type { ToggleButtonState } from './ToggleButton.types'; +import { toggleButtonClassNames, useToggleButtonStyles } from './useToggleButtonStyles'; + +import styles from './ToggleButton.module.css'; + +// The jest css-module proxy drops the component and hash segments, so Button's `root` and +// ToggleButton's `root` are the same string — only the occurrence count distinguishes them. +const occurrences = (className: string, target: string): number => + className.split(' ').filter(name => name === target).length; + +const iconOf = (root: HTMLElement): HTMLElement => { + const icon = root.querySelector('span'); + + if (!icon) { + throw new Error('ToggleButton rendered no icon slot'); + } + + return icon; +}; + +const Glyph = (): React.ReactElement => ; + +describe('ToggleButton', () => { + isConformant({ + Component: ToggleButton, + displayName: 'ToggleButton', + }); + + it('stamps both marker pairs, its own first', () => { + const { getByTestId } = render(Toggle); + + const root = getByTestId('root'); + + expect(root.className).toContain(toggleButtonClassNames.root); + expect(root).toHaveClass('fui-toggle-button'); + expect(root).toHaveClass('group/fui-toggle-button'); + expect(root).toHaveClass('fui-button'); + expect(root).toHaveClass('group/fui-button'); + expect(root.classList[0]).toBe('fui-toggle-button'); + }); + + it('carries the root class of both stylesheets', () => { + const { getByTestId } = render(Toggle); + + expect(occurrences(getByTestId('root').className, styles.root)).toBe(2); + }); + + it('keeps a consumer className on the root', () => { + const { getByTestId } = render( + + Toggle + , + ); + + const root = getByTestId('root'); + + expect(root).toHaveClass('consumer'); + expect(occurrences(root.className, 'consumer')).toBe(1); + }); + + it('carries the icon class of both stylesheets', () => { + const { getByTestId } = render( + }> + Toggle + , + ); + + expect(occurrences(iconOf(getByTestId('root')).className, styles.icon)).toBe(2); + }); + + it('keeps the data attributes useButtonStyles resolves', () => { + const { getByTestId } = render( + <> + + Toggle + + } aria-label="Toggle" /> + , + ); + + const labelled = getByTestId('labelled'); + + expect(labelled.getAttribute('data-appearance')).toBe('primary'); + expect(labelled.getAttribute('data-size')).toBe('large'); + expect(labelled.hasAttribute('data-empty')).toBe(false); + + expect(getByTestId('icon-only').hasAttribute('data-empty')).toBe(true); + }); + + it('stamps data-accessible only when isAccessible is set', () => { + const { getByTestId } = render( + <> + Toggle + + Toggle + + , + ); + + expect(getByTestId('plain').hasAttribute('data-accessible')).toBe(false); + expect(getByTestId('accessible').hasAttribute('data-accessible')).toBe(true); + }); + + it('keeps data-appearance and data-size when checked', () => { + const { getByTestId } = render( + + Toggle + , + ); + + const root = getByTestId('root'); + + expect(root.getAttribute('data-appearance')).toBe('primary'); + expect(root.getAttribute('data-size')).toBe('large'); + }); + + it('carries the icon class of both stylesheets when checked and disabled', () => { + const { getByTestId } = render( + } checked disabled> + Toggle + , + ); + + expect(occurrences(iconOf(getByTestId('root')).className, styles.icon)).toBe(2); + }); + + it('spells the toggled state as aria-checked under role="checkbox"', () => { + const { getByTestId } = render( + + Toggle + , + ); + + const root = getByTestId('root'); + + expect(root.getAttribute('aria-checked')).toBe('true'); + expect(root.hasAttribute('aria-pressed')).toBe(false); + }); + + it('leaves data-checked and aria-pressed to the headless hook', () => { + const { getByTestId } = render( + <> + Toggle + + Toggle + + , + ); + + const unchecked = getByTestId('unchecked'); + + expect(unchecked.hasAttribute('data-checked')).toBe(false); + expect(unchecked.getAttribute('aria-pressed')).toBe('false'); + + const checked = getByTestId('checked'); + + expect(checked.getAttribute('data-checked')).toBe(''); + expect(checked.getAttribute('aria-pressed')).toBe('true'); + }); + + it('toggles an uncontrolled defaultChecked button on click', () => { + const { getByTestId } = render( + + Toggle + , + ); + + const root = getByTestId('root'); + + expect(root.getAttribute('aria-pressed')).toBe('true'); + expect(root.getAttribute('data-checked')).toBe(''); + + fireEvent.click(root); + + expect(root.getAttribute('aria-pressed')).toBe('false'); + expect(root.hasAttribute('data-checked')).toBe(false); + }); + + it('never toggles a controlled button on its own', () => { + const onClick = jest.fn(); + const { getByTestId } = render( + + Toggle + , + ); + + const root = getByTestId('root'); + + fireEvent.click(root); + + expect(onClick).toHaveBeenCalledTimes(1); + expect(root.getAttribute('aria-pressed')).toBe('true'); + expect(root.getAttribute('data-checked')).toBe(''); + }); + + it('does not toggle while disabled or disabledFocusable', () => { + const { getByTestId } = render( + <> + + Toggle + + + Toggle + + , + ); + + fireEvent.click(getByTestId('disabled')); + fireEvent.click(getByTestId('disabled-focusable')); + + expect(getByTestId('disabled').getAttribute('aria-pressed')).toBe('true'); + expect(getByTestId('disabled-focusable').getAttribute('aria-pressed')).toBe('true'); + }); + + it('does not mutate the state it is given', () => { + const state = { + appearance: 'primary', + checked: true, + components: { root: 'button', icon: 'span' }, + icon: { className: 'consumer-icon' }, + isAccessible: true, + root: { as: 'button', className: 'consumer', 'data-checked': '' }, + shape: 'circular', + size: 'large', + } as unknown as ToggleButtonState; + + const styled = useToggleButtonStyles(state); + + expect(styled).not.toBe(state); + expect(state.root.className).toBe('consumer'); + expect(state.root).not.toHaveProperty('data-accessible'); + expect(state.icon!.className).toBe('consumer-icon'); + expect(styled.checked).toBe(true); + expect(styled.isAccessible).toBe(true); + expect(styled.root.className).toContain('consumer'); + expect(styled.root.className).toContain(toggleButtonClassNames.root); + expect(styled.root['data-checked']).toBe(''); + expect(styled.icon!.className).toContain('consumer-icon'); + expect(occurrences(styled.icon!.className!, styles.icon)).toBe(2); + }); + + it('renders no icon slot when the consumer supplies none', () => { + const state = { + appearance: 'secondary', + components: { root: 'button', icon: 'span' }, + root: { as: 'button' }, + shape: 'rounded', + size: 'medium', + } as unknown as ToggleButtonState; + + expect(useToggleButtonStyles(state).icon).toBeUndefined(); + }); +}); diff --git a/packages/react-components/react-windmod-preview/library/src/components/ToggleButton/ToggleButton.tsx b/packages/react-components/react-windmod-preview/library/src/components/ToggleButton/ToggleButton.tsx new file mode 100644 index 00000000000000..576eca7b9b6cfd --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/ToggleButton/ToggleButton.tsx @@ -0,0 +1,30 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { renderToggleButton, useToggleButton } from '@fluentui/react-headless-components-preview/toggle-button'; + +import type { ToggleButtonProps, ToggleButtonState } from './ToggleButton.types'; +import { useToggleButtonStyles } from './useToggleButtonStyles'; + +/** + * A ToggleButton lets people switch a setting on and off. Windmod ToggleButton: the headless + * toggle button decorated with the Fluent visual contract (Tailwind v4 + CSS Modules). + */ +export const ToggleButton: ForwardRefComponent = React.forwardRef((props, ref) => { + // Look props belong to windmod — the headless hook neither accepts nor resolves them. + // Defaults mirror @fluentui/react-button's styled useToggleButton. + const { appearance = 'secondary', shape = 'rounded', size = 'medium', ...rest } = props; + + const state: ToggleButtonState = { + ...useToggleButton(rest, ref), + appearance, + shape, + size, + }; + + return renderToggleButton(useToggleButtonStyles(state)); + // Casting is required due to lack of distributive union to support union on @types/react +}) as ForwardRefComponent; + +ToggleButton.displayName = 'ToggleButton'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/ToggleButton/ToggleButton.types.ts b/packages/react-components/react-windmod-preview/library/src/components/ToggleButton/ToggleButton.types.ts new file mode 100644 index 00000000000000..05eed7a45faad6 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/ToggleButton/ToggleButton.types.ts @@ -0,0 +1,25 @@ +import type { + ToggleButtonProps as ToggleButtonHeadlessProps, + ToggleButtonState as ToggleButtonHeadlessState, +} from '@fluentui/react-headless-components-preview/toggle-button'; + +import type { ButtonAppearance, ButtonShape, ButtonSize } from '../Button/Button.types'; + +export type { ToggleButtonSlots } from '@fluentui/react-headless-components-preview/toggle-button'; + +/** + * Windmod ToggleButton props: the headless toggle button plus the look props the headless + * surface deliberately omits (they exist purely to select styles). + */ +export type ToggleButtonProps = ToggleButtonHeadlessProps & { + /** @default 'secondary' */ + appearance?: ButtonAppearance; + /** @default 'rounded' */ + shape?: ButtonShape; + /** @default 'medium' */ + size?: ButtonSize; +}; + +/** Windmod ToggleButton state: headless state plus the resolved look props. */ +export type ToggleButtonState = ToggleButtonHeadlessState & + Required>; diff --git a/packages/react-components/react-windmod-preview/library/src/components/ToggleButton/index.ts b/packages/react-components/react-windmod-preview/library/src/components/ToggleButton/index.ts new file mode 100644 index 00000000000000..ac27b0b8ffec9d --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/ToggleButton/index.ts @@ -0,0 +1,3 @@ +export { ToggleButton } from './ToggleButton'; +export { toggleButtonClassNames, useToggleButtonStyles } from './useToggleButtonStyles'; +export type { ToggleButtonProps, ToggleButtonSlots, ToggleButtonState } from './ToggleButton.types'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/ToggleButton/useToggleButtonStyles.ts b/packages/react-components/react-windmod-preview/library/src/components/ToggleButton/useToggleButtonStyles.ts new file mode 100644 index 00000000000000..8510cc77db1f02 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/ToggleButton/useToggleButtonStyles.ts @@ -0,0 +1,42 @@ +'use client'; + +import { clsx } from 'clsx'; + +import { componentMarkers } from '../../utils/groupMarker'; +import { useButtonStyles } from '../Button/useButtonStyles'; +import type { ToggleButtonState } from './ToggleButton.types'; + +import styles from './ToggleButton.module.css'; + +/** The only public classes — see componentMarkers; internals are hashed idents. */ +export const toggleButtonClassNames: { root: string } = { + root: componentMarkers('toggle-button'), +}; + +type ToggleButtonRootDataAttributes = { + 'data-accessible'?: true; +}; + +/** + * Applies the visual contract on top of Button's, returning new state. The headless hook + * already stamps data-checked/-disabled/-disabled-focusable/-icon-only/-icon-position and + * useButtonStyles stamps data-appearance/-size/-empty; data-accessible is style-only. + * + * Button's icon rules select through `group/fui-button`, so the root must keep Button's + * marker pair alongside its own. + */ +export const useToggleButtonStyles = (state: ToggleButtonState): ToggleButtonState => { + const { root: buttonRoot, icon: buttonIcon } = useButtonStyles(state); + + const root: ToggleButtonState['root'] & ToggleButtonRootDataAttributes = { + ...buttonRoot, + 'data-accessible': state.isAccessible || undefined, + className: clsx(toggleButtonClassNames.root, styles.root, buttonRoot.className), + }; + + return { + ...state, + root, + icon: buttonIcon && { ...buttonIcon, className: clsx(styles.icon, buttonIcon.className) }, + }; +}; diff --git a/packages/react-components/react-windmod-preview/library/src/compound-button.ts b/packages/react-components/react-windmod-preview/library/src/compound-button.ts new file mode 100644 index 00000000000000..6ddb44d5e116a6 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/compound-button.ts @@ -0,0 +1,5 @@ +export { CompoundButton, compoundButtonClassNames, useCompoundButtonStyles } from './components/CompoundButton'; +export type { CompoundButtonProps, CompoundButtonSlots, CompoundButtonState } from './components/CompoundButton'; + +/** Headless building blocks, re-exported for consumers composing their own CompoundButton. */ +export { renderCompoundButton, useCompoundButton } from '@fluentui/react-headless-components-preview/compound-button'; diff --git a/packages/react-components/react-windmod-preview/library/src/index.ts b/packages/react-components/react-windmod-preview/library/src/index.ts index fc0ce018740950..d6065dfd59ce00 100644 --- a/packages/react-components/react-windmod-preview/library/src/index.ts +++ b/packages/react-components/react-windmod-preview/library/src/index.ts @@ -4,6 +4,15 @@ export type { BadgeAppearance, BadgeColor, BadgeProps, BadgeShape, BadgeSize, Ba export { Button, buttonClassNames, renderButton, useButton, useButtonStyles } from './button'; export type { ButtonAppearance, ButtonProps, ButtonShape, ButtonSize, ButtonSlots, ButtonState } from './button'; +export { + CompoundButton, + compoundButtonClassNames, + renderCompoundButton, + useCompoundButton, + useCompoundButtonStyles, +} from './compound-button'; +export type { CompoundButtonProps, CompoundButtonSlots, CompoundButtonState } from './compound-button'; + export { Divider, dividerClassNames, renderDivider, useDivider, useDividerStyles } from './divider'; export type { DividerAlignContent, DividerAppearance, DividerProps, DividerSlots, DividerState } from './divider'; @@ -81,6 +90,15 @@ export type { SkeletonItemProps, SkeletonItemSlots, SkeletonItemState } from './ export { renderSpinner, Spinner, spinnerClassNames, useSpinner, useSpinnerStyles } from './spinner'; export type { SpinnerAppearance, SpinnerProps, SpinnerSize, SpinnerSlots, SpinnerState } from './spinner'; +export { + renderToggleButton, + ToggleButton, + toggleButtonClassNames, + useToggleButton, + useToggleButtonStyles, +} from './toggle-button'; +export type { ToggleButtonProps, ToggleButtonSlots, ToggleButtonState } from './toggle-button'; + export { renderTooltip, Tooltip, tooltipClassNames, useTooltip, useTooltipStyles } from './tooltip'; export type { OnVisibleChangeData, diff --git a/packages/react-components/react-windmod-preview/library/src/toggle-button.ts b/packages/react-components/react-windmod-preview/library/src/toggle-button.ts new file mode 100644 index 00000000000000..021748bca4ffc8 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/toggle-button.ts @@ -0,0 +1,5 @@ +export { ToggleButton, toggleButtonClassNames, useToggleButtonStyles } from './components/ToggleButton'; +export type { ToggleButtonProps, ToggleButtonSlots, ToggleButtonState } from './components/ToggleButton'; + +/** Headless building blocks, re-exported for consumers composing their own ToggleButton. */ +export { renderToggleButton, useToggleButton } from '@fluentui/react-headless-components-preview/toggle-button'; diff --git a/packages/react-components/react-windmod-preview/library/src/variants.css b/packages/react-components/react-windmod-preview/library/src/variants.css index 72f40994bcd211..b5c54046e5c26a 100644 --- a/packages/react-components/react-windmod-preview/library/src/variants.css +++ b/packages/react-components/react-windmod-preview/library/src/variants.css @@ -14,6 +14,8 @@ @custom-variant expand-icon-position-end (&:where([data-expand-icon-position='end'])); /* Button */ +@custom-variant appearance-primary (&:where([data-appearance='primary'])); +@custom-variant appearance-outline (&:where([data-appearance='outline'])); @custom-variant appearance-subtle (&:where([data-appearance='subtle'])); @custom-variant appearance-transparent (&:where([data-appearance='transparent'])); @@ -62,3 +64,6 @@ @custom-variant active-appearance-ring (&:where([data-active-appearance='ring'])); @custom-variant active-appearance-shadow (&:where([data-active-appearance='shadow'])); @custom-variant active-appearance-ring-shadow (&:where([data-active-appearance='ring-shadow'])); + +/* ToggleButton: opt-in high-contrast selected look */ +@custom-variant accessible (&:where([data-accessible])); diff --git a/packages/react-components/react-windmod-preview/stories/src/CompoundButton/CompoundButtonDefault.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/CompoundButton/CompoundButtonDefault.stories.tsx new file mode 100644 index 00000000000000..15d8e6cfaa30ec --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/CompoundButton/CompoundButtonDefault.stories.tsx @@ -0,0 +1,53 @@ +import * as React from 'react'; +import { CompoundButton, ThemeProvider } from '@fluentui/react-windmod-preview'; +import { CalendarMonthFilled, CalendarMonthRegular } from '@fluentui/react-icons'; +import { bundleIcon } from '@fluentui/react-icons/headless'; + +import styles from '../compare.module.css'; + +const CalendarMonth = bundleIcon(CalendarMonthFilled, CalendarMonthRegular); + +const appearances = ['secondary', 'primary', 'outline', 'subtle', 'transparent'] as const; +const sizes = ['small', 'medium', 'large'] as const; +const shapes = ['rounded', 'circular', 'square'] as const; + +export const Default = (): React.ReactNode => ( + +
+ {appearances.map(appearance => ( +
+ {sizes.map(size => ( + + {appearance} {size} + + ))} + } secondaryContent="Secondary content"> + With icon + + } + iconPosition="after" + secondaryContent="Secondary content" + > + Icon after + + } aria-label="Calendar" /> + + Disabled + + + Disabled focusable + +
+ ))} +
+ {shapes.map(shape => ( + + {shape} + + ))} +
+
+
+); diff --git a/packages/react-components/react-windmod-preview/stories/src/CompoundButton/CompoundButtonGriffelComparison.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/CompoundButton/CompoundButtonGriffelComparison.stories.tsx new file mode 100644 index 00000000000000..cfb23c9da18a54 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/CompoundButton/CompoundButtonGriffelComparison.stories.tsx @@ -0,0 +1,102 @@ +import * as React from 'react'; +import { CompoundButton, ThemeProvider } from '@fluentui/react-windmod-preview'; +import { CompoundButton as GriffelCompoundButton, FluentProvider, webLightTheme } from '@fluentui/react-components'; +import { bundleIcon as griffelBundleIcon, CalendarMonthFilled, CalendarMonthRegular } from '@fluentui/react-icons'; +import { bundleIcon } from '@fluentui/react-icons/headless'; + +import styles from '../compare.module.css'; + +// Each side bundles with its own mechanism: headless (data-variant) vs Griffel classic. +const CalendarMonth = bundleIcon(CalendarMonthFilled, CalendarMonthRegular); +const GriffelCalendarMonth = griffelBundleIcon(CalendarMonthFilled, CalendarMonthRegular); + +const appearances = ['secondary', 'primary', 'outline', 'subtle', 'transparent'] as const; +const sizes = ['small', 'medium', 'large'] as const; +const shapes = ['rounded', 'circular', 'square'] as const; + +type LookProps = { + appearance?: (typeof appearances)[number]; + size?: (typeof sizes)[number]; + shape?: (typeof shapes)[number]; + disabled?: boolean; + disabledFocusable?: boolean; +}; + +/** + * Every windmod variant next to its Griffel-suite twin (inside a FluentProvider). + * The pair in each row must be pixel-identical — this is the pilot's review surface. + */ +export const GriffelComparison = (): React.ReactNode => { + const variants: Array<{ label: string; props: LookProps }> = [ + ...appearances.map(appearance => ({ label: appearance, props: { appearance } })), + ...sizes.map(size => ({ label: size, props: { size } })), + ...shapes.map(shape => ({ label: shape, props: { shape } })), + { label: 'disabled', props: { disabled: true } }, + { label: 'disabledFocusable', props: { disabledFocusable: true } }, + { label: 'primary disabled', props: { appearance: 'primary', disabled: true } }, + ]; + + return ( +
+
Variant
+
Windmod
+
Griffel
+ {variants.map(({ label, props }) => ( + +
{label}
+
+ + + Compound + + +
+
+ + + Compound + + +
+
+ ))} +
with icon
+
+ + } secondaryContent="Secondary content"> + Compound + + +
+
+ + } secondaryContent="Secondary content"> + Compound + + +
+
icon only
+
+ + } aria-label="Calendar" /> + +
+
+ + } aria-label="Calendar" /> + +
+
no secondary content
+
+ + Compound + +
+
+ + Compound + +
+
+ ); +}; diff --git a/packages/react-components/react-windmod-preview/stories/src/CompoundButton/index.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/CompoundButton/index.stories.tsx new file mode 100644 index 00000000000000..68220b501ef9b2 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/CompoundButton/index.stories.tsx @@ -0,0 +1,9 @@ +import { CompoundButton } from '@fluentui/react-windmod-preview'; + +export { Default } from './CompoundButtonDefault.stories'; +export { GriffelComparison } from './CompoundButtonGriffelComparison.stories'; + +export default { + title: 'Windmod/CompoundButton', + component: CompoundButton, +}; diff --git a/packages/react-components/react-windmod-preview/stories/src/ToggleButton/ToggleButtonDefault.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/ToggleButton/ToggleButtonDefault.stories.tsx new file mode 100644 index 00000000000000..d8b43f48c579eb --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/ToggleButton/ToggleButtonDefault.stories.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import { ThemeProvider, ToggleButton } from '@fluentui/react-windmod-preview'; +import { CalendarMonthFilled, CalendarMonthRegular } from '@fluentui/react-icons'; +import { bundleIcon } from '@fluentui/react-icons/headless'; + +import styles from '../compare.module.css'; + +// Bundled pair: a checked ToggleButton swaps regular → filled via data-variant. +const CalendarMonth = bundleIcon(CalendarMonthFilled, CalendarMonthRegular); + +const appearances = ['secondary', 'primary', 'outline', 'subtle', 'transparent'] as const; +const sizes = ['small', 'medium', 'large'] as const; +const shapes = ['rounded', 'circular', 'square'] as const; + +export const Default = (): React.ReactNode => ( + +
+ {appearances.map(appearance => ( +
+ {sizes.map(size => ( + + {appearance} {size} + + ))} + + Checked + + + Checked accessible + + }> + With icon + + } aria-label="Calendar" /> + + Disabled + + + Disabled focusable + +
+ ))} +
+ {shapes.map(shape => ( + + {shape} + + ))} +
+
+
+); diff --git a/packages/react-components/react-windmod-preview/stories/src/ToggleButton/ToggleButtonGriffelComparison.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/ToggleButton/ToggleButtonGriffelComparison.stories.tsx new file mode 100644 index 00000000000000..eeed98a013055b --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/ToggleButton/ToggleButtonGriffelComparison.stories.tsx @@ -0,0 +1,95 @@ +import * as React from 'react'; +import { ThemeProvider, ToggleButton } from '@fluentui/react-windmod-preview'; +import { FluentProvider, ToggleButton as GriffelToggleButton, webLightTheme } from '@fluentui/react-components'; +import { bundleIcon as griffelBundleIcon, CalendarMonthFilled, CalendarMonthRegular } from '@fluentui/react-icons'; +import { bundleIcon } from '@fluentui/react-icons/headless'; + +import styles from '../compare.module.css'; + +// Each side bundles with its own mechanism: headless (data-variant) vs Griffel classic. +const CalendarMonth = bundleIcon(CalendarMonthFilled, CalendarMonthRegular); +const GriffelCalendarMonth = griffelBundleIcon(CalendarMonthFilled, CalendarMonthRegular); + +const appearances = ['secondary', 'primary', 'outline', 'subtle', 'transparent'] as const; +const sizes = ['small', 'medium', 'large'] as const; +const shapes = ['rounded', 'circular', 'square'] as const; + +type LookProps = { + appearance?: (typeof appearances)[number]; + size?: (typeof sizes)[number]; + shape?: (typeof shapes)[number]; + checked?: boolean; + isAccessible?: boolean; + disabled?: boolean; + disabledFocusable?: boolean; +}; + +/** + * Every windmod variant next to its Griffel-suite twin (inside a FluentProvider). + * The pair in each row must be pixel-identical — this is the pilot's review surface. + */ +export const GriffelComparison = (): React.ReactNode => { + const variants: Array<{ label: string; props: LookProps }> = [ + ...appearances.map(appearance => ({ label: appearance, props: { appearance, checked: true } })), + ...appearances.map(appearance => ({ + label: `${appearance} accessible`, + props: { appearance, checked: true, isAccessible: true }, + })), + ...sizes.map(size => ({ label: size, props: { size, checked: true } })), + ...shapes.map(shape => ({ label: shape, props: { shape, checked: true } })), + { label: 'unchecked', props: {} }, + { label: 'checked disabled', props: { checked: true, disabled: true } }, + { label: 'checked disabledFocusable', props: { checked: true, disabledFocusable: true } }, + { label: 'outline disabled', props: { appearance: 'outline', disabled: true } }, + { label: 'primary checked disabled', props: { appearance: 'primary', checked: true, disabled: true } }, + ]; + + return ( +
+
Variant
+
Windmod
+
Griffel
+ {variants.map(({ label, props }) => ( + +
{label}
+
+ + Toggle + +
+
+ + Toggle + +
+
+ ))} +
checked with icon
+
+ + }> + Toggle + + +
+
+ + }> + Toggle + + +
+
checked icon only
+
+ + } aria-label="Calendar" /> + +
+
+ + } aria-label="Calendar" /> + +
+
+ ); +}; diff --git a/packages/react-components/react-windmod-preview/stories/src/ToggleButton/index.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/ToggleButton/index.stories.tsx new file mode 100644 index 00000000000000..cb73c3dd3d01f8 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/ToggleButton/index.stories.tsx @@ -0,0 +1,9 @@ +import { ToggleButton } from '@fluentui/react-windmod-preview'; + +export { Default } from './ToggleButtonDefault.stories'; +export { GriffelComparison } from './ToggleButtonGriffelComparison.stories'; + +export default { + title: 'Windmod/ToggleButton', + component: ToggleButton, +}; diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/CompoundButtonGriffel.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/CompoundButtonGriffel.stories.tsx new file mode 100644 index 00000000000000..2ef84afcf544e1 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/CompoundButtonGriffel.stories.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import { CompoundButton, FluentProvider, webLightTheme } from '@fluentui/react-components'; +import { bundleIcon, CalendarMonthFilled, CalendarMonthRegular } from '@fluentui/react-icons'; + +import { CompoundButtonVrScene } from './CompoundButtonVrScene'; + +const CalendarMonth = bundleIcon(CalendarMonthFilled, CalendarMonthRegular); + +export const CompoundButtonGriffel = (): React.ReactNode => ( + + + +); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/CompoundButtonVrScene.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/CompoundButtonVrScene.tsx new file mode 100644 index 00000000000000..575a750ee23507 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/CompoundButtonVrScene.tsx @@ -0,0 +1,89 @@ +// eslint-disable-next-line import/no-extraneous-dependencies -- story-only helper outside the *.stories.tsx devDependencies exemption +import * as React from 'react'; + +const appearances = ['secondary', 'primary', 'outline', 'subtle', 'transparent'] as const; +const sizes = ['small', 'medium', 'large'] as const; +const shapes = ['rounded', 'circular', 'square'] as const; + +type CompoundButtonLike = React.ComponentType<{ + appearance?: (typeof appearances)[number]; + size?: (typeof sizes)[number]; + shape?: (typeof shapes)[number]; + disabled?: boolean; + disabledFocusable?: boolean; + icon?: React.ReactElement; + iconPosition?: 'before' | 'after'; + secondaryContent?: string; + 'aria-label'?: string; + children?: React.ReactNode; +}>; + +const secondary = 'Secondary content'; + +/** One scene, two implementations — the VR runner diffs the renders pixel for pixel. */ +export const CompoundButtonVrScene = ({ + CompoundButton, + Icon, +}: { + CompoundButton: CompoundButtonLike; + Icon: React.ComponentType; +}): React.ReactNode => ( +
+ {appearances.map(appearance => + [true, false].map(withSecondary => { + const secondaryContent = withSecondary ? secondary : undefined; + + return ( +
+ {sizes.map(size => ( + + Compound + + ))} + } secondaryContent={secondaryContent}> + Icon + + } secondaryContent={secondaryContent}> + Icon + + } secondaryContent={secondaryContent}> + Icon + + } + iconPosition="after" + secondaryContent={secondaryContent} + > + Icon + + {withSecondary ? ( + } secondaryContent={secondary} /> + ) : ( + } aria-label="icon" /> + )} + + Disabled + + + Disabled + +
+ ); + }), + )} +
+ {shapes.map(shape => ( + + Shape + + ))} + {shapes.map(shape => ( + } aria-label="icon" /> + ))} +
+
+); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/CompoundButtonWindmod.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/CompoundButtonWindmod.stories.tsx new file mode 100644 index 00000000000000..c0035bcb3a1e85 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/CompoundButtonWindmod.stories.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import { CompoundButton, ThemeProvider } from '@fluentui/react-windmod-preview'; +import { CalendarMonthFilled, CalendarMonthRegular } from '@fluentui/react-icons'; +import { bundleIcon } from '@fluentui/react-icons/headless'; + +import { CompoundButtonVrScene } from './CompoundButtonVrScene'; + +const CalendarMonth = bundleIcon(CalendarMonthFilled, CalendarMonthRegular); + +export const CompoundButtonWindmod = (): React.ReactNode => ( + + + +); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/ToggleButtonGriffel.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/ToggleButtonGriffel.stories.tsx new file mode 100644 index 00000000000000..7bb197a7484f22 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/ToggleButtonGriffel.stories.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import { FluentProvider, ToggleButton, webLightTheme } from '@fluentui/react-components'; +import { bundleIcon, CalendarMonthFilled, CalendarMonthRegular } from '@fluentui/react-icons'; + +import { ToggleButtonVrScene } from './ToggleButtonVrScene'; + +const CalendarMonth = bundleIcon(CalendarMonthFilled, CalendarMonthRegular); + +export const ToggleButtonGriffel = (): React.ReactNode => ( + + + +); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/ToggleButtonVrScene.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/ToggleButtonVrScene.tsx new file mode 100644 index 00000000000000..911e244f119316 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/ToggleButtonVrScene.tsx @@ -0,0 +1,82 @@ +// eslint-disable-next-line import/no-extraneous-dependencies -- story-only helper outside the *.stories.tsx devDependencies exemption +import * as React from 'react'; + +const appearances = ['secondary', 'primary', 'outline', 'subtle', 'transparent'] as const; +const sizes = ['small', 'medium', 'large'] as const; +const shapes = ['rounded', 'circular', 'square'] as const; + +type ToggleButtonLike = React.ComponentType<{ + appearance?: (typeof appearances)[number]; + size?: (typeof sizes)[number]; + shape?: (typeof shapes)[number]; + checked?: boolean; + isAccessible?: boolean; + disabled?: boolean; + disabledFocusable?: boolean; + icon?: React.ReactElement; + iconPosition?: 'before' | 'after'; + 'aria-label'?: string; + children?: React.ReactNode; +}>; + +/** One scene, two implementations — the VR runner diffs the renders pixel for pixel. */ +export const ToggleButtonVrScene = ({ + ToggleButton, + Icon, +}: { + ToggleButton: ToggleButtonLike; + Icon: React.ComponentType; +}): React.ReactNode => ( +
+ {appearances.map(appearance => + [false, true].map(checked => ( +
+ {sizes.map(size => ( + + Toggle + + ))} + }> + Icon + + } iconPosition="after"> + Icon + + } aria-label="icon" /> + + Disabled + + + Disabled + +
+ )), + )} + {appearances.map(appearance => ( +
+ {sizes.map(size => ( + + Toggle + + ))} + } aria-label="icon" /> + + Disabled + + + Disabled + +
+ ))} +
+ {shapes.map(shape => ( + + Shape + + ))} + {shapes.map(shape => ( + } aria-label="icon" /> + ))} +
+
+); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/ToggleButtonWindmod.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/ToggleButtonWindmod.stories.tsx new file mode 100644 index 00000000000000..a96126030469df --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/ToggleButtonWindmod.stories.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import { ThemeProvider, ToggleButton } from '@fluentui/react-windmod-preview'; +import { CalendarMonthFilled, CalendarMonthRegular } from '@fluentui/react-icons'; +import { bundleIcon } from '@fluentui/react-icons/headless'; + +import { ToggleButtonVrScene } from './ToggleButtonVrScene'; + +const CalendarMonth = bundleIcon(CalendarMonthFilled, CalendarMonthRegular); + +export const ToggleButtonWindmod = (): React.ReactNode => ( + + + +); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx index a9fd509c95d79c..c5d0c2a76e6a94 100644 --- a/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx +++ b/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx @@ -2,6 +2,8 @@ export { BadgeWindmod } from './BadgeWindmod.stories'; export { BadgeGriffel } from './BadgeGriffel.stories'; export { ButtonWindmod } from './ButtonWindmod.stories'; export { ButtonGriffel } from './ButtonGriffel.stories'; +export { CompoundButtonWindmod } from './CompoundButtonWindmod.stories'; +export { CompoundButtonGriffel } from './CompoundButtonGriffel.stories'; export { DividerWindmod } from './DividerWindmod.stories'; export { DividerGriffel } from './DividerGriffel.stories'; export { ImageWindmod } from './ImageWindmod.stories'; @@ -18,6 +20,8 @@ export { SkeletonWindmod } from './SkeletonWindmod.stories'; export { SkeletonGriffel } from './SkeletonGriffel.stories'; export { SpinnerWindmod } from './SpinnerWindmod.stories'; export { SpinnerGriffel } from './SpinnerGriffel.stories'; +export { ToggleButtonWindmod } from './ToggleButtonWindmod.stories'; +export { ToggleButtonGriffel } from './ToggleButtonGriffel.stories'; export { TooltipWindmod } from './TooltipWindmod.stories'; export { TooltipGriffel } from './TooltipGriffel.stories'; From 6baf4c3a13ef57ea98b3b2b92054064e049e7ab9 Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Fri, 21 Aug 2026 22:32:11 -0700 Subject: [PATCH 038/246] feat(windmod): finalize CompoundButton Completes the component whose initial files were swept into the ToggleButton commit: the review-stage forced-colors repair for transparent hover (Griffel hoists media buckets past hover buckets), the look-prop default and slot-props tests, and the VR scene registration. Pixel-identical to the Griffel suite across the 106-cell appearance/content/size/icon matrix. The styles hook stamps data-icon-position to close a headless omission. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- .../CompoundButton/CompoundButton.module.css | 12 ++++++ .../CompoundButton/CompoundButton.test.tsx | 38 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/packages/react-components/react-windmod-preview/library/src/components/CompoundButton/CompoundButton.module.css b/packages/react-components/react-windmod-preview/library/src/components/CompoundButton/CompoundButton.module.css index 22c6bc3a8f4ea1..af724f6e644bf4 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/CompoundButton/CompoundButton.module.css +++ b/packages/react-components/react-windmod-preview/library/src/components/CompoundButton/CompoundButton.module.css @@ -108,6 +108,18 @@ @variant group-hover-active/fui-compound-button { @apply text-neutral-foreground-2-brand-pressed; } + + /* Griffel orders every forced-colors rule after every :hover rule regardless of source + position, so the high-contrast hover colour outranks this appearance there. */ + @variant forced-colors { + @variant group-hover/fui-compound-button { + @apply text-[Highlight]; + } + + @variant group-hover-active/fui-compound-button { + @apply text-[Highlight]; + } + } } @variant group-disabled/fui-compound-button, group-disabled-focusable/fui-compound-button { diff --git a/packages/react-components/react-windmod-preview/library/src/components/CompoundButton/CompoundButton.test.tsx b/packages/react-components/react-windmod-preview/library/src/components/CompoundButton/CompoundButton.test.tsx index cb5d517fd1e44f..4029f43f45f4f4 100644 --- a/packages/react-components/react-windmod-preview/library/src/components/CompoundButton/CompoundButton.test.tsx +++ b/packages/react-components/react-windmod-preview/library/src/components/CompoundButton/CompoundButton.test.tsx @@ -97,6 +97,25 @@ describe('CompoundButton', () => { expect(containerOf(getByTestId('icon-only'))).toBeNull(); }); + it('keeps the content container when an icon and secondaryContent arrive without children', () => { + const { getByTestId } = render( + } secondaryContent="Second line" />, + ); + + const root = getByTestId('root'); + + expect(root.hasAttribute('data-icon-only')).toBe(false); + expect(root.getAttribute('data-has-secondary-content')).toBe(''); + expect(root.hasAttribute('data-empty')).toBe(true); + expect(root.getAttribute('data-icon-position')).toBe('before'); + + const container = containerOf(root); + + expect(container).not.toBeNull(); + expect(container!.textContent).toBe('Second line'); + expect(container!.firstElementChild).toHaveClass(styles.secondaryContent); + }); + it('renders no secondaryContent span when the consumer supplies none', () => { const { getByTestId } = render(Compound); @@ -140,6 +159,25 @@ describe('CompoundButton', () => { expect(getByTestId('icon-only').hasAttribute('data-empty')).toBe(true); }); + it('resolves the look-prop defaults and passes an explicit shape through', () => { + const { getByTestId } = render( + <> + Compound + + Compound + + , + ); + + const byDefault = getByTestId('default'); + + expect(byDefault.getAttribute('data-appearance')).toBe('secondary'); + expect(byDefault.getAttribute('data-size')).toBe('medium'); + expect(byDefault).not.toHaveClass(styles.circular); + + expect(getByTestId('circular')).toHaveClass(styles.circular); + }); + it('leaves data-icon-only and data-has-secondary-content to the headless hook', () => { const { getByTestId } = render( <> From b387a157735313b488ce94a88e61b5adc71df234 Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Sat, 22 Aug 2026 00:43:06 -0700 Subject: [PATCH 039/246] feat(windmod): add Input Headless useInput + Tailwind CSS-Modules styling, pixel-identical to the Griffel suite across appearances, sizes, states and content slots. Block order carries the cascade, mirroring the upstream merge list; the invalid gate computes in JS with the upstream predicate; the styles hook stamps the content-slot attributes the headless hook omits. Also activates the previously dead export-map conformance test for every component and fixes a focus-only phantom border on the underline pseudo-element. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- .../library/etc/input.api.md | 48 +++ .../library/etc/react-windmod-preview.api.md | 37 +++ .../library/package.json | 10 + .../src/components/Input/Input.module.css | 229 +++++++++++++ .../src/components/Input/Input.test.tsx | 305 ++++++++++++++++++ .../library/src/components/Input/Input.tsx | 30 ++ .../src/components/Input/Input.types.ts | 26 ++ .../library/src/components/Input/index.ts | 3 + .../src/components/Input/useInputStyles.ts | 64 ++++ .../library/src/index.ts | 3 + .../library/src/input.ts | 5 + .../library/src/testing/isConformant.ts | 4 +- .../src/Input/InputDefault.stories.tsx | 48 +++ .../Input/InputGriffelComparison.stories.tsx | 71 ++++ .../stories/src/Input/index.stories.tsx | 9 + .../stories/src/vr/InputGriffel.stories.tsx | 13 + .../stories/src/vr/InputVrScene.tsx | 74 +++++ .../stories/src/vr/InputWindmod.stories.tsx | 14 + .../stories/src/vr/index.stories.tsx | 2 + 19 files changed, 994 insertions(+), 1 deletion(-) create mode 100644 packages/react-components/react-windmod-preview/library/etc/input.api.md create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Input/Input.module.css create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Input/Input.test.tsx create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Input/Input.tsx create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Input/Input.types.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Input/index.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Input/useInputStyles.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/input.ts create mode 100644 packages/react-components/react-windmod-preview/stories/src/Input/InputDefault.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/Input/InputGriffelComparison.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/Input/index.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/InputGriffel.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/InputVrScene.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/InputWindmod.stories.tsx diff --git a/packages/react-components/react-windmod-preview/library/etc/input.api.md b/packages/react-components/react-windmod-preview/library/etc/input.api.md new file mode 100644 index 00000000000000..09e3eae35a17fb --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/etc/input.api.md @@ -0,0 +1,48 @@ +## API Report File for "@fluentui/react-windmod-preview" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import type { InputProps as InputProps_2 } from '@fluentui/react-headless-components-preview/input'; +import { InputSlots } from '@fluentui/react-headless-components-preview/input'; +import type { InputState as InputState_2 } from '@fluentui/react-headless-components-preview/input'; +import { renderInput } from '@fluentui/react-headless-components-preview/input'; +import { useInput } from '@fluentui/react-headless-components-preview/input'; + +// @public +export const Input: ForwardRefComponent; + +// @public +export type InputAppearance = 'outline' | 'underline' | 'filled-darker' | 'filled-lighter'; + +// @public +export const inputClassNames: { + root: string; +}; + +// @public +export type InputProps = InputProps_2 & { + appearance?: InputAppearance; + size?: InputSize; +}; + +// @public +export type InputSize = 'small' | 'medium' | 'large'; + +export { InputSlots } + +// @public +export type InputState = InputState_2 & Required>; + +export { renderInput } + +export { useInput } + +// @public +export const useInputStyles: (state: InputState) => InputState; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md b/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md index f7c3289186561f..2b6fd81f06120b 100644 --- a/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md +++ b/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md @@ -20,6 +20,9 @@ import type { ForwardRefComponent } from '@fluentui/react-utilities'; import type { ImageProps as ImageProps_2 } from '@fluentui/react-headless-components-preview/image'; import { ImageSlots } from '@fluentui/react-headless-components-preview/image'; import type { ImageState as ImageState_2 } from '@fluentui/react-headless-components-preview/image'; +import type { InputProps as InputProps_2 } from '@fluentui/react-headless-components-preview/input'; +import { InputSlots } from '@fluentui/react-headless-components-preview/input'; +import type { InputState as InputState_2 } from '@fluentui/react-headless-components-preview/input'; import type { JSXElement } from '@fluentui/react-utilities'; import type { LabelProps as LabelProps_2 } from '@fluentui/react-headless-components-preview/label'; import { LabelSlots } from '@fluentui/react-headless-components-preview/label'; @@ -44,6 +47,7 @@ import { renderButton } from '@fluentui/react-headless-components-preview/button import { renderCompoundButton } from '@fluentui/react-headless-components-preview/compound-button'; import { renderDivider } from '@fluentui/react-headless-components-preview/divider'; import { renderImage } from '@fluentui/react-headless-components-preview/image'; +import { renderInput } from '@fluentui/react-headless-components-preview/input'; import { renderLabel } from '@fluentui/react-headless-components-preview/label'; import { renderLink } from '@fluentui/react-headless-components-preview/link'; import { renderProgressBar } from '@fluentui/react-headless-components-preview/progress-bar'; @@ -83,6 +87,7 @@ import { useButton } from '@fluentui/react-headless-components-preview/button'; import { useCompoundButton } from '@fluentui/react-headless-components-preview/compound-button'; import { useDivider } from '@fluentui/react-headless-components-preview/divider'; import { useImage } from '@fluentui/react-headless-components-preview/image'; +import { useInput } from '@fluentui/react-headless-components-preview/input'; import { useLabel } from '@fluentui/react-headless-components-preview/label'; import { useLink } from '@fluentui/react-headless-components-preview/link'; import { useProgressBar } from '@fluentui/react-headless-components-preview/progress-bar'; @@ -236,6 +241,31 @@ export { ImageSlots } // @public export type ImageState = ImageState_2 & Required>; +// @public +export const Input: ForwardRefComponent; + +// @public +export type InputAppearance = 'outline' | 'underline' | 'filled-darker' | 'filled-lighter'; + +// @public +export const inputClassNames: { + root: string; +}; + +// @public +export type InputProps = InputProps_2 & { + appearance?: InputAppearance; + size?: InputSize; +}; + +// @public +export type InputSize = 'small' | 'medium' | 'large'; + +export { InputSlots } + +// @public +export type InputState = InputState_2 & Required>; + // @public export const Label: ForwardRefComponent; @@ -370,6 +400,8 @@ export { renderDivider } export { renderImage } +export { renderInput } + export { renderLabel } export { renderLink } @@ -570,6 +602,11 @@ export { useImage } // @public export const useImageStyles: (state: ImageState) => ImageState; +export { useInput } + +// @public +export const useInputStyles: (state: InputState) => InputState; + export { useLabel } // @public diff --git a/packages/react-components/react-windmod-preview/library/package.json b/packages/react-components/react-windmod-preview/library/package.json index e13193b0482245..925fd1f8351709 100644 --- a/packages/react-components/react-windmod-preview/library/package.json +++ b/packages/react-components/react-windmod-preview/library/package.json @@ -103,6 +103,16 @@ "default": "./lib-commonjs/image.cjs" } }, + "./input": { + "import": { + "types": "./dist/input.d.ts", + "default": "./lib/input.js" + }, + "require": { + "types": "./dist/input.d.cts", + "default": "./lib-commonjs/input.cjs" + } + }, "./label": { "import": { "types": "./dist/label.d.ts", diff --git a/packages/react-components/react-windmod-preview/library/src/components/Input/Input.module.css b/packages/react-components/react-windmod-preview/library/src/components/Input/Input.module.css new file mode 100644 index 00000000000000..c510ae91bbcb2d --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Input/Input.module.css @@ -0,0 +1,229 @@ +@reference '#theme'; + +@layer fui.components.l1 { + /* Root-modifier block order is the cascade: every selector below is :where()-flat, so source + order alone arbitrates. .filled before .filledInteractive, .invalid after every appearance + block, .disabled last. */ + .root { + @apply relative box-border inline-flex min-h-32 flex-nowrap items-center gap-horizontal-xxs rounded-medium border-(length:--spacing-thin) border-solid border-neutral-stroke-1 border-b-neutral-stroke-accessible bg-neutral-background-1 align-middle font-base text-base-300 leading-base-300 font-regular; + + @variant after { + @apply absolute -inset-x-1 -bottom-1 box-border rounded-b-medium border-b-2 border-b-compound-brand-stroke content-['']; + + height: max(calc(var(--spacing) * 2), var(--radius-medium)); + clip-path: inset(calc(100% - var(--spacing) * 2) 0 0 0); + transform: scaleX(0); + transition-property: transform; + transition-duration: var(--duration-ultra-fast); + + @media screen and (prefers-reduced-motion: reduce) { + transition-duration: 0.01ms; + } + } + + @variant size-small { + @apply min-h-24 text-base-200 leading-base-200; + + @variant content-before { + @apply ps-horizontal-s-nudge; + } + + @variant content-after { + @apply pe-horizontal-s-nudge; + } + } + + @variant size-medium { + @variant content-before { + @apply ps-horizontal-m-nudge; + } + + @variant content-after { + @apply pe-horizontal-m-nudge; + } + } + + @variant size-large { + @apply min-h-40 gap-horizontal-s-nudge text-base-400 leading-base-400; + + @variant content-before { + @apply ps-horizontal-m; + } + + @variant content-after { + @apply pe-horizontal-m; + } + } + + @variant focus-within { + @apply outline-2 outline-transparent outline-solid; + + @variant after { + transform: scaleX(1); + transition-property: transform; + transition-duration: var(--duration-normal); + + @media screen and (prefers-reduced-motion: reduce) { + transition-duration: 0.01ms; + } + } + + @variant active { + @variant after { + @apply border-b-compound-brand-stroke-pressed; + } + } + } + } + + .underline { + @apply rounded-none border-x-0 border-t-0 bg-transparent-background; + + @variant after { + @apply inset-x-0; + } + } + + .filledDarker { + @apply bg-neutral-background-3; + } + + .filledLighter { + @apply bg-neutral-background-1; + } + + .filled { + @apply border-transparent-stroke; + } + + .outlineInteractive { + @variant hover { + @apply border-neutral-stroke-1-hover border-b-neutral-stroke-accessible-hover; + } + + @variant active, focus-within { + @apply border-neutral-stroke-1-pressed border-b-neutral-stroke-accessible-pressed; + } + } + + .underlineInteractive { + @variant after { + @apply rounded-none; + } + + @variant hover { + @apply border-b-neutral-stroke-accessible-hover; + } + + @variant active, focus-within { + @apply border-b-neutral-stroke-accessible-pressed; + } + } + + .filledInteractive { + @variant hover, focus-within { + @apply border-transparent-stroke-interactive; + } + } + + .invalid { + @variant not-focus-within { + @apply border-palette-red-border-2; + } + } + + .disabled { + @apply cursor-not-allowed border-neutral-stroke-disabled bg-transparent-background; + + @variant forced-colors { + @apply border-[GrayText]; + } + + @variant after { + content: unset; + } + + @variant focus-within { + @apply outline-none; + } + } + + .input { + @apply box-border min-w-0 grow self-stretch border-none bg-transparent px-horizontal-m py-0 text-neutral-foreground-1 outline-none; + + font-family: inherit; + font-size: inherit; + font-weight: inherit; + line-height: inherit; + + @variant placeholder { + @apply text-neutral-foreground-4 opacity-100; + } + + @variant group-size-small/fui-input { + @apply px-horizontal-s; + + @variant group-content-before/fui-input { + @apply ps-horizontal-xxs; + } + + @variant group-content-after/fui-input { + @apply pe-horizontal-xxs; + } + } + + @variant group-size-medium/fui-input { + @variant group-content-before/fui-input { + @apply ps-horizontal-xxs; + } + + @variant group-content-after/fui-input { + @apply pe-horizontal-xxs; + } + } + + @variant group-size-large/fui-input { + @apply px-18; + + @variant group-content-before/fui-input { + @apply ps-horizontal-s-nudge; + } + + @variant group-content-after/fui-input { + @apply pe-horizontal-s-nudge; + } + } + + @variant group-disabled/fui-input { + @apply cursor-not-allowed bg-transparent-background text-neutral-foreground-disabled; + + @variant placeholder { + @apply text-neutral-foreground-disabled; + } + } + } + + .content { + @apply box-border flex text-neutral-foreground-3; + + & > svg { + @apply text-[length:calc(20px*var(--base-scale))]; + } + + @variant group-disabled/fui-input { + @apply text-neutral-foreground-disabled; + } + + @variant group-size-small/fui-input { + & > svg { + @apply text-[length:calc(16px*var(--base-scale))]; + } + } + + @variant group-size-large/fui-input { + & > svg { + @apply text-[length:calc(24px*var(--base-scale))]; + } + } + } +} diff --git a/packages/react-components/react-windmod-preview/library/src/components/Input/Input.test.tsx b/packages/react-components/react-windmod-preview/library/src/components/Input/Input.test.tsx new file mode 100644 index 00000000000000..1ec0e420c7500d --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Input/Input.test.tsx @@ -0,0 +1,305 @@ +import * as React from 'react'; +import { fireEvent, render } from '@testing-library/react'; + +import { isConformant } from '../../testing/isConformant'; +import { Input } from './Input'; +import type { InputState } from './Input.types'; +import { inputClassNames, useInputStyles } from './useInputStyles'; + +import styles from './Input.module.css'; + +const appearances = ['outline', 'underline', 'filled-darker', 'filled-lighter'] as const; +const sizes = ['small', 'medium', 'large'] as const; + +const parts = (root: HTMLElement) => ({ + root, + input: root.querySelector('input')!, + content: root.querySelectorAll(`.${styles.content}`), +}); + +// `input` is the primary slot, so every native prop — data-testid included — lands on the +// ; the root is only reachable through the container. +const renderInput = (props: React.ComponentProps = {}) => { + const { container } = render(); + + return parts(container.firstElementChild as HTMLElement); +}; + +describe('Input', () => { + isConformant({ + Component: Input, + displayName: 'Input', + primarySlot: 'input', + }); + + it('stamps the marker pair on the root', () => { + const { root } = renderInput(); + + expect(root).toHaveClass('fui-input'); + expect(root).toHaveClass('group/fui-input'); + expect(root.classList[0]).toBe('fui-input'); + expect(inputClassNames.root).toBe('fui-input group/fui-input'); + }); + + it('applies one module class per slot', () => { + const { root, input, content } = renderInput({ contentBefore: , contentAfter: }); + + expect(root).toHaveClass(styles.root); + expect(input).toHaveClass(styles.input); + expect(input).not.toHaveClass(styles.root); + expect(content).toHaveLength(2); + content.forEach(span => { + expect(span).toHaveClass(styles.content); + expect(span).not.toHaveClass(styles.input); + }); + }); + + it('maps every appearance onto its own class set', () => { + const expected: Record<(typeof appearances)[number], string[]> = { + outline: [styles.outlineInteractive], + underline: [styles.underline, styles.underlineInteractive], + 'filled-darker': [styles.filledDarker, styles.filled, styles.filledInteractive], + 'filled-lighter': [styles.filledLighter, styles.filled, styles.filledInteractive], + }; + + const all = [ + styles.underline, + styles.underlineInteractive, + styles.filledDarker, + styles.filledLighter, + styles.filled, + styles.filledInteractive, + styles.outlineInteractive, + ]; + + appearances.forEach(appearance => { + sizes.forEach(size => { + const { root } = renderInput({ appearance, size }); + + expect(root.getAttribute('data-size')).toBe(size); + expected[appearance].forEach(className => expect(root).toHaveClass(className)); + all + .filter(className => !expected[appearance].includes(className)) + .forEach(className => expect(root).not.toHaveClass(className)); + }); + }); + }); + + it('defaults to the outline appearance at the medium size', () => { + const { root } = renderInput(); + + expect(root.getAttribute('data-size')).toBe('medium'); + expect(root).toHaveClass(styles.outlineInteractive); + expect(root).not.toHaveClass(styles.underline); + expect(root).not.toHaveClass(styles.filled); + expect(root).not.toHaveClass(styles.filledDarker); + expect(root).not.toHaveClass(styles.filledLighter); + }); + + it('drops every interactive class when disabled', () => { + appearances.forEach(appearance => { + const { root } = renderInput({ appearance, disabled: true }); + + expect(root).toHaveClass(styles.disabled); + expect(root).not.toHaveClass(styles.outlineInteractive); + expect(root).not.toHaveClass(styles.underlineInteractive); + expect(root).not.toHaveClass(styles.filledInteractive); + }); + }); + + it('keeps the resting appearance classes when disabled', () => { + expect(renderInput({ appearance: 'underline', disabled: true }).root).toHaveClass(styles.underline); + expect(renderInput({ appearance: 'filled-darker', disabled: true }).root).toHaveClass(styles.filledDarker); + expect(renderInput({ appearance: 'filled-darker', disabled: true }).root).toHaveClass(styles.filled); + }); + + it('gates the invalid look on aria-invalid resolving to true, not on the headless attribute', () => { + const truthy = renderInput({ 'aria-invalid': true }).root; + + expect(truthy).toHaveClass(styles.invalid); + expect(truthy.getAttribute('data-invalid')).toBe(''); + + const stringTrue = renderInput({ 'aria-invalid': 'true' }).root; + + expect(stringTrue).toHaveClass(styles.invalid); + expect(stringTrue.getAttribute('data-invalid')).toBe(''); + + const grammar = renderInput({ 'aria-invalid': 'grammar' }).root; + + expect(grammar).not.toHaveClass(styles.invalid); + expect(grammar.getAttribute('data-invalid')).toBe('grammar'); + + const off = renderInput({ 'aria-invalid': false }).root; + + expect(off).not.toHaveClass(styles.invalid); + expect(off.hasAttribute('data-invalid')).toBe(false); + + const absent = renderInput().root; + + expect(absent).not.toHaveClass(styles.invalid); + expect(absent.hasAttribute('data-invalid')).toBe(false); + }); + + it('gives the disabled look precedence over the invalid one', () => { + const { root } = renderInput({ 'aria-invalid': true, disabled: true }); + + expect(root).toHaveClass(styles.disabled); + expect(root).not.toHaveClass(styles.invalid); + expect(root.getAttribute('data-disabled')).toBe(''); + }); + + it('stamps data-disabled only while disabled', () => { + expect(renderInput().root.hasAttribute('data-disabled')).toBe(false); + expect(renderInput({ disabled: true }).root.getAttribute('data-disabled')).toBe(''); + }); + + it('stamps data-size on the root alone', () => { + sizes.forEach(size => { + const { root, input, content } = renderInput({ size, contentBefore: , contentAfter: }); + + expect(root.getAttribute('data-size')).toBe(size); + expect(input.hasAttribute('data-size')).toBe(false); + content.forEach(span => expect(span.hasAttribute('data-size')).toBe(false)); + }); + }); + + it('stamps a content attribute per present content slot', () => { + const bare = renderInput().root; + + expect(bare.hasAttribute('data-content-before')).toBe(false); + expect(bare.hasAttribute('data-content-after')).toBe(false); + + const before = renderInput({ contentBefore: }).root; + + expect(before.getAttribute('data-content-before')).toBe('true'); + expect(before.hasAttribute('data-content-after')).toBe(false); + + const after = renderInput({ contentAfter: }).root; + + expect(after.hasAttribute('data-content-before')).toBe(false); + expect(after.getAttribute('data-content-after')).toBe('true'); + + const both = renderInput({ contentBefore: , contentAfter: }).root; + + expect(both.getAttribute('data-content-before')).toBe('true'); + expect(both.getAttribute('data-content-after')).toBe('true'); + }); + + it('renders no content span for a null slot', () => { + const { root, content } = renderInput({ contentBefore: null, contentAfter: null }); + + expect(content).toHaveLength(0); + expect(root.hasAttribute('data-content-before')).toBe(false); + expect(root.hasAttribute('data-content-after')).toBe(false); + }); + + it('keeps the resolved slot props of a content shorthand', () => { + const { content } = renderInput({ + contentBefore: { children: , className: 'x', id: 'before' }, + }); + + expect(content).toHaveLength(1); + expect(content[0]).toHaveClass(styles.content); + expect(content[0]).toHaveClass('x'); + expect(content[0].id).toBe('before'); + expect(content[0].querySelector('[data-glyph]')).not.toBeNull(); + }); + + it('renders the content slots around the input', () => { + const { root, input } = renderInput({ contentBefore: , contentAfter: }); + const children = Array.from(root.children); + + expect(children).toHaveLength(3); + expect(children[0].querySelector('[data-before]')).not.toBeNull(); + expect(children[1]).toBe(input); + expect(children[2].querySelector('[data-after]')).not.toBeNull(); + }); + + it('passes native props through to the input', () => { + const { root, input } = renderInput({ + 'data-native': '', + placeholder: 'ph', + required: true, + type: 'password', + } as React.ComponentProps); + + expect(input.getAttribute('data-native')).toBe(''); + expect(input.placeholder).toBe('ph'); + expect(input.required).toBe(true); + expect(input.type).toBe('password'); + expect(root.hasAttribute('data-native')).toBe(false); + expect(renderInput().input.type).toBe('text'); + }); + + it('passes the uncontrolled value machinery through untouched', () => { + const onChange = jest.fn(); + const { input } = renderInput({ defaultValue: 'a', onChange }); + + expect(input.value).toBe('a'); + + fireEvent.change(input, { target: { value: 'ab' } }); + + expect(input.value).toBe('ab'); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][1]).toEqual({ value: 'ab' }); + }); + + it('passes the controlled value machinery through untouched', () => { + const onChange = jest.fn(); + const { input } = renderInput({ value: 'a', onChange }); + + expect(input.value).toBe('a'); + + fireEvent.change(input, { target: { value: 'ab' } }); + + expect(input.value).toBe('a'); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange.mock.calls[0][1]).toEqual({ value: 'ab' }); + }); + + it('keeps a consumer className on the root exactly once', () => { + const { root, input } = renderInput({ className: 'consumer' }); + + // classList is an ordered set, so a duplicated token is only visible in the raw attribute. + expect( + root + .getAttribute('class')! + .split(/\s+/) + .filter(name => name === 'consumer'), + ).toHaveLength(1); + expect(root).toHaveClass(styles.root); + expect(input).not.toHaveClass('consumer'); + }); + + it('does not mutate the state it is given', () => { + const state = { + appearance: 'filled-darker', + components: { root: 'span', input: 'input', contentBefore: 'span', contentAfter: 'span' }, + contentAfter: { className: 'after' }, + contentBefore: { className: 'before' }, + input: { 'aria-invalid': true, className: 'native', disabled: false }, + root: { className: 'consumer' }, + size: 'large', + } as unknown as InputState; + + const styled = useInputStyles(state); + + expect(styled).not.toBe(state); + expect(styled.root).not.toBe(state.root); + expect(styled.input).not.toBe(state.input); + expect(styled.contentBefore).not.toBe(state.contentBefore); + expect(styled.contentAfter).not.toBe(state.contentAfter); + + expect(state.root.className).toBe('consumer'); + expect(state.input.className).toBe('native'); + expect(state.contentBefore!.className).toBe('before'); + expect(state.contentAfter!.className).toBe('after'); + expect('data-size' in state.root).toBe(false); + expect('data-content-before' in state.root).toBe(false); + + expect(styled.root.className).toContain('consumer'); + expect(styled.input.className).toContain('native'); + expect(styled.contentBefore!.className).toContain('before'); + expect(styled.contentAfter!.className).toContain('after'); + }); +}); diff --git a/packages/react-components/react-windmod-preview/library/src/components/Input/Input.tsx b/packages/react-components/react-windmod-preview/library/src/components/Input/Input.tsx new file mode 100644 index 00000000000000..6a42379a7a996a --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Input/Input.tsx @@ -0,0 +1,30 @@ +'use client'; + +import * as React from 'react'; +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { renderInput, useInput } from '@fluentui/react-headless-components-preview/input'; + +import type { InputProps, InputState } from './Input.types'; +import { useInputStyles } from './useInputStyles'; + +/** + * An Input lets people enter and edit a single line of text. Windmod Input: the headless input + * decorated with the Fluent visual contract (Tailwind v4 + CSS Modules). + */ +export const Input: ForwardRefComponent = React.forwardRef((props, ref) => { + // Look props belong to windmod — the headless hook neither accepts nor resolves them. + // Defaults mirror @fluentui/react-input's styled useInput, minus its Field-context and + // overrides-context fallbacks, which windmod ships no counterpart for. + const { appearance = 'outline', size = 'medium', ...rest } = props; + + const state: InputState = { + ...useInput(rest, ref), + appearance, + size, + }; + + return renderInput(useInputStyles(state)); + // Casting is required due to lack of distributive union to support union on @types/react +}) as ForwardRefComponent; + +Input.displayName = 'Input'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Input/Input.types.ts b/packages/react-components/react-windmod-preview/library/src/components/Input/Input.types.ts new file mode 100644 index 00000000000000..285eaa18258f75 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Input/Input.types.ts @@ -0,0 +1,26 @@ +import type { + InputProps as InputHeadlessProps, + InputState as InputHeadlessState, +} from '@fluentui/react-headless-components-preview/input'; + +export type { InputSlots } from '@fluentui/react-headless-components-preview/input'; + +/** Colours and borders of the Input. `'outline'` is the base look. */ +export type InputAppearance = 'outline' | 'underline' | 'filled-darker' | 'filled-lighter'; + +/** Size of the Input — changes its height, font size and horizontal padding. */ +export type InputSize = 'small' | 'medium' | 'large'; + +/** + * Windmod Input props: the headless input plus the look props the headless surface + * deliberately omits (they exist purely to select styles). + */ +export type InputProps = InputHeadlessProps & { + /** @default 'outline' */ + appearance?: InputAppearance; + /** @default 'medium' */ + size?: InputSize; +}; + +/** Windmod Input state: headless state plus the resolved look props. */ +export type InputState = InputHeadlessState & Required>; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Input/index.ts b/packages/react-components/react-windmod-preview/library/src/components/Input/index.ts new file mode 100644 index 00000000000000..66e4dfd9ab5f37 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Input/index.ts @@ -0,0 +1,3 @@ +export { Input } from './Input'; +export { inputClassNames, useInputStyles } from './useInputStyles'; +export type { InputAppearance, InputProps, InputSize, InputSlots, InputState } from './Input.types'; diff --git a/packages/react-components/react-windmod-preview/library/src/components/Input/useInputStyles.ts b/packages/react-components/react-windmod-preview/library/src/components/Input/useInputStyles.ts new file mode 100644 index 00000000000000..9ff8bead42b958 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Input/useInputStyles.ts @@ -0,0 +1,64 @@ +import { clsx } from 'clsx'; + +import { componentMarkers } from '../../utils/groupMarker'; +import type { InputState } from './Input.types'; + +import styles from './Input.module.css'; + +/** The only public classes — see componentMarkers; internals are hashed idents. */ +export const inputClassNames: { root: string } = { + root: componentMarkers('input'), +}; + +type InputRootDataAttributes = { + 'data-size'?: InputState['size']; + 'data-content-before'?: true; + 'data-content-after'?: true; +}; + +/** Applies the visual contract, returning new state. The headless hook already stamps + * data-disabled and data-invalid; data-invalid is deliberately unused, because it is present for + * every non-boolean aria-invalid token while the invalid look belongs only to `aria-invalid` true. + * The root's appearance/invalid/disabled looks are module classes because their cascade is carried + * by block order in Input.module.css. */ +export const useInputStyles = (state: InputState): InputState => { + const { appearance, size } = state; + const disabled = state.input.disabled; + const invalid = `${state.input['aria-invalid']}` === 'true'; + const filled = appearance.startsWith('filled'); + + const root: InputState['root'] & InputRootDataAttributes = { + ...state.root, + 'data-size': size, + 'data-content-before': !!state.contentBefore || undefined, + 'data-content-after': !!state.contentAfter || undefined, + className: clsx( + inputClassNames.root, + styles.root, + appearance === 'underline' && styles.underline, + appearance === 'filled-darker' && styles.filledDarker, + appearance === 'filled-lighter' && styles.filledLighter, + filled && styles.filled, + !disabled && appearance === 'outline' && styles.outlineInteractive, + !disabled && appearance === 'underline' && styles.underlineInteractive, + !disabled && filled && styles.filledInteractive, + !disabled && invalid && styles.invalid, + disabled && styles.disabled, + state.root.className, + ), + }; + + return { + ...state, + root, + input: { ...state.input, className: clsx(styles.input, state.input.className) }, + contentBefore: state.contentBefore && { + ...state.contentBefore, + className: clsx(styles.content, state.contentBefore.className), + }, + contentAfter: state.contentAfter && { + ...state.contentAfter, + className: clsx(styles.content, state.contentAfter.className), + }, + }; +}; diff --git a/packages/react-components/react-windmod-preview/library/src/index.ts b/packages/react-components/react-windmod-preview/library/src/index.ts index d6065dfd59ce00..e2b640652372e1 100644 --- a/packages/react-components/react-windmod-preview/library/src/index.ts +++ b/packages/react-components/react-windmod-preview/library/src/index.ts @@ -19,6 +19,9 @@ export type { DividerAlignContent, DividerAppearance, DividerProps, DividerSlots export { Image, imageClassNames, renderImage, useImage, useImageStyles } from './image'; export type { ImageFit, ImageProps, ImageShape, ImageSlots, ImageState } from './image'; +export { Input, inputClassNames, renderInput, useInput, useInputStyles } from './input'; +export type { InputAppearance, InputProps, InputSize, InputSlots, InputState } from './input'; + export { Label, labelClassNames, renderLabel, useLabel, useLabelStyles } from './label'; export type { LabelProps, LabelSize, LabelSlots, LabelState, LabelWeight } from './label'; diff --git a/packages/react-components/react-windmod-preview/library/src/input.ts b/packages/react-components/react-windmod-preview/library/src/input.ts new file mode 100644 index 00000000000000..13108694036e24 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/input.ts @@ -0,0 +1,5 @@ +export { Input, inputClassNames, useInputStyles } from './components/Input'; +export type { InputAppearance, InputProps, InputSize, InputSlots, InputState } from './components/Input'; + +/** Headless building blocks, re-exported for consumers composing their own Input. */ +export { renderInput, useInput } from '@fluentui/react-headless-components-preview/input'; diff --git a/packages/react-components/react-windmod-preview/library/src/testing/isConformant.ts b/packages/react-components/react-windmod-preview/library/src/testing/isConformant.ts index 0b5374b286cb21..6dcaa1383cc9d4 100644 --- a/packages/react-components/react-windmod-preview/library/src/testing/isConformant.ts +++ b/packages/react-components/react-windmod-preview/library/src/testing/isConformant.ts @@ -31,7 +31,9 @@ export function isConformant( // The component must have an export map entry in package.json for proper module // resolution and type definitions (this package's dual ESM/CJS shape). - 'export-map-entry-exists': () => { + // The unused parameter is load-bearing: react-conformance schedules an extra test only + // when its callback has arity 1. + 'export-map-entry-exists': (_testInfo: unknown) => { const packageJSON = require('../../package.json'); it('component has export map entry in package.json', () => { diff --git a/packages/react-components/react-windmod-preview/stories/src/Input/InputDefault.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Input/InputDefault.stories.tsx new file mode 100644 index 00000000000000..fc670c2ccd19b9 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/Input/InputDefault.stories.tsx @@ -0,0 +1,48 @@ +import * as React from 'react'; +import { Input, ThemeProvider } from '@fluentui/react-windmod-preview'; +import { CalendarMonthFilled, CalendarMonthRegular } from '@fluentui/react-icons'; +import { bundleIcon } from '@fluentui/react-icons/headless'; + +import styles from '../compare.module.css'; + +const CalendarMonth = bundleIcon(CalendarMonthFilled, CalendarMonthRegular); + +const appearances = ['outline', 'underline', 'filled-darker', 'filled-lighter'] as const; +const sizes = ['small', 'medium', 'large'] as const; + +export const Default = (): React.ReactNode => ( + +
+
+ {appearances.map(appearance => ( + + ))} +
+
+ {sizes.map(size => ( + + ))} +
+
+ } defaultValue="Content before" /> + } defaultValue="Content after" /> + } contentAfter={} defaultValue="Both" /> +
+
+ {sizes.map(size => ( + + ))} +
+
+ {appearances.map(appearance => ( + + ))} +
+
+ {appearances.map(appearance => ( + + ))} +
+
+
+); diff --git a/packages/react-components/react-windmod-preview/stories/src/Input/InputGriffelComparison.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Input/InputGriffelComparison.stories.tsx new file mode 100644 index 00000000000000..bf67272265301e --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/Input/InputGriffelComparison.stories.tsx @@ -0,0 +1,71 @@ +import * as React from 'react'; +import { Input, ThemeProvider } from '@fluentui/react-windmod-preview'; +import { FluentProvider, Input as GriffelInput, webLightTheme } from '@fluentui/react-components'; +import { bundleIcon as griffelBundleIcon, CalendarMonthFilled, CalendarMonthRegular } from '@fluentui/react-icons'; +import { bundleIcon } from '@fluentui/react-icons/headless'; + +import styles from '../compare.module.css'; + +// Each side bundles with its own mechanism: headless (data-variant) vs Griffel classic. +const CalendarMonth = bundleIcon(CalendarMonthFilled, CalendarMonthRegular); +const GriffelCalendarMonth = griffelBundleIcon(CalendarMonthFilled, CalendarMonthRegular); + +type LookProps = { + appearance?: 'outline' | 'underline' | 'filled-darker' | 'filled-lighter'; + size?: 'small' | 'medium' | 'large'; + disabled?: boolean; + placeholder?: string; + defaultValue?: string; + 'aria-invalid'?: boolean; +}; + +const slots = (Icon: React.ComponentType, before: boolean, after: boolean) => ({ + contentBefore: before ? : undefined, + contentAfter: after ? : undefined, +}); + +/** + * Every windmod variant next to its Griffel-suite twin (inside a FluentProvider). + * The pair in each row must be pixel-identical. + */ +export const GriffelComparison = (): React.ReactNode => { + const variants: Array<{ label: string; props: LookProps; before?: boolean; after?: boolean }> = [ + { label: 'outline', props: { defaultValue: 'Text' } }, + { label: 'underline', props: { appearance: 'underline', defaultValue: 'Text' } }, + { label: 'filled-darker', props: { appearance: 'filled-darker', defaultValue: 'Text' } }, + { label: 'filled-lighter', props: { appearance: 'filled-lighter', defaultValue: 'Text' } }, + { label: 'small', props: { size: 'small', defaultValue: 'Text' } }, + { label: 'large', props: { size: 'large', defaultValue: 'Text' } }, + { label: 'placeholder', props: { placeholder: 'Placeholder' } }, + { label: 'invalid', props: { 'aria-invalid': true, defaultValue: 'Text' } }, + { label: 'invalid underline', props: { appearance: 'underline', 'aria-invalid': true, defaultValue: 'Text' } }, + { label: 'disabled', props: { disabled: true, defaultValue: 'Text' } }, + { label: 'disabled filled-darker', props: { appearance: 'filled-darker', disabled: true, placeholder: 'Ph' } }, + { label: 'content before', props: { defaultValue: 'Text' }, before: true }, + { label: 'content after', props: { defaultValue: 'Text' }, after: true }, + { label: 'both, large', props: { size: 'large', defaultValue: 'Text' }, before: true, after: true }, + ]; + + return ( +
+
Variant
+
Windmod
+
Griffel
+ {variants.map(({ label, props, before = false, after = false }) => ( + +
{label}
+
+ + + +
+
+ + + +
+
+ ))} +
+ ); +}; diff --git a/packages/react-components/react-windmod-preview/stories/src/Input/index.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/Input/index.stories.tsx new file mode 100644 index 00000000000000..773ae67d1073fc --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/Input/index.stories.tsx @@ -0,0 +1,9 @@ +import { Input } from '@fluentui/react-windmod-preview'; + +export { Default } from './InputDefault.stories'; +export { GriffelComparison } from './InputGriffelComparison.stories'; + +export default { + title: 'Windmod/Input', + component: Input, +}; diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/InputGriffel.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/InputGriffel.stories.tsx new file mode 100644 index 00000000000000..0a7138296f9766 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/InputGriffel.stories.tsx @@ -0,0 +1,13 @@ +import * as React from 'react'; +import { FluentProvider, Input, webLightTheme } from '@fluentui/react-components'; +import { bundleIcon, CalendarMonthFilled, CalendarMonthRegular } from '@fluentui/react-icons'; + +import { InputVrScene } from './InputVrScene'; + +const CalendarMonth = bundleIcon(CalendarMonthFilled, CalendarMonthRegular); + +export const InputGriffel = (): React.ReactNode => ( + + + +); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/InputVrScene.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/InputVrScene.tsx new file mode 100644 index 00000000000000..4867d742feed00 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/InputVrScene.tsx @@ -0,0 +1,74 @@ +// eslint-disable-next-line import/no-extraneous-dependencies -- story-only helper outside the *.stories.tsx devDependencies exemption +import * as React from 'react'; + +const appearances = ['outline', 'underline', 'filled-darker', 'filled-lighter'] as const; +const sizes = ['small', 'medium', 'large'] as const; + +type InputLike = React.ComponentType<{ + appearance?: (typeof appearances)[number]; + size?: (typeof sizes)[number]; + disabled?: boolean; + value?: string; + placeholder?: string; + 'aria-invalid'?: boolean; + contentBefore?: React.ReactElement; + contentAfter?: React.ReactElement; + root?: { style?: React.CSSProperties }; +}>; + +const row: React.CSSProperties = { display: 'flex', gap: 8, alignItems: 'center' }; + +const LONG = 'A very long single line of text that cannot fit'; + +/** No cell is focused, so the focus underline sits at scaleX(0) in all 93 of them. */ +export const InputVrScene = ({ Input, Icon }: { Input: InputLike; Icon: React.ComponentType }): React.ReactNode => { + const band = (key: string, props: (size: (typeof sizes)[number]) => React.ComponentProps) => + sizes.map(size => ( +
+ {appearances.map(appearance => ( + + ))} +
+ )); + + return ( +
+ {band('value', () => ({ value: 'Text' }))} + {band('placeholder', size => ({ placeholder: `Placeholder ${size}` }))} + {band('disabled', () => ({ disabled: true, value: 'Text' }))} + {band('invalid', () => ({ 'aria-invalid': true, value: 'Text' }))} + {band('before', () => ({ contentBefore: , value: 'Text' }))} + {band('after', () => ({ contentAfter: , value: 'Text' }))} + {band('both', () => ({ contentBefore: , contentAfter: , value: 'Text' }))} +
+ {sizes.map(size => ( + } + contentAfter={} + /> + ))} +
+
+ {sizes.map(size => ( + + ))} +
+
+ {sizes.map(size => ( + } + contentAfter={} + root={{ style: { width: 160 } }} + /> + ))} +
+
+ ); +}; diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/InputWindmod.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/InputWindmod.stories.tsx new file mode 100644 index 00000000000000..32da4b52843c64 --- /dev/null +++ b/packages/react-components/react-windmod-preview/stories/src/vr/InputWindmod.stories.tsx @@ -0,0 +1,14 @@ +import * as React from 'react'; +import { Input, ThemeProvider } from '@fluentui/react-windmod-preview'; +import { CalendarMonthFilled, CalendarMonthRegular } from '@fluentui/react-icons'; +import { bundleIcon } from '@fluentui/react-icons/headless'; + +import { InputVrScene } from './InputVrScene'; + +const CalendarMonth = bundleIcon(CalendarMonthFilled, CalendarMonthRegular); + +export const InputWindmod = (): React.ReactNode => ( + + + +); diff --git a/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx b/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx index c5d0c2a76e6a94..40c756b9da0bc7 100644 --- a/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx +++ b/packages/react-components/react-windmod-preview/stories/src/vr/index.stories.tsx @@ -8,6 +8,8 @@ export { DividerWindmod } from './DividerWindmod.stories'; export { DividerGriffel } from './DividerGriffel.stories'; export { ImageWindmod } from './ImageWindmod.stories'; export { ImageGriffel } from './ImageGriffel.stories'; +export { InputWindmod } from './InputWindmod.stories'; +export { InputGriffel } from './InputGriffel.stories'; export { LabelWindmod } from './LabelWindmod.stories'; export { LabelGriffel } from './LabelGriffel.stories'; export { LinkWindmod } from './LinkWindmod.stories'; From e7f61d7f9e089f0d17eb7dd9a2944390a9ea3f36 Mon Sep 17 00:00:00 2001 From: Ray Knight Date: Sat, 22 Aug 2026 01:37:24 -0700 Subject: [PATCH 040/246] feat(react-windmod-preview): add Textarea (headless + Tailwind, pixel-identical to Griffel) Full component set: types, headless-composing component, immutable styles hook, single-l1-layer module.css, tests, demo + VR stories, api.md. VR gate: identical at 1248x1716, zero diff pixels vs the Griffel suite. Notable: - ::after focus ring drops border-solid (phantom-border rule re-derived for this element: counterfactual measured 3px borders on three sides). - Disabled resize guard tested across all four resize values; work review closed a data-size stamp-displacement hole (spread-order mutation). - 31 implementer + 25 reviewer mutations killed; CSS blocks owned by the zero-tolerance VR diff (module.css idents are jest-proxied by design). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i --- .../library/etc/react-windmod-preview.api.md | 37 +++ .../library/etc/textarea.api.md | 48 +++ .../library/package.json | 10 + .../components/Textarea/Textarea.module.css | 138 ++++++++ .../src/components/Textarea/Textarea.test.tsx | 298 ++++++++++++++++++ .../src/components/Textarea/Textarea.tsx | 30 ++ .../src/components/Textarea/Textarea.types.ts | 27 ++ .../library/src/components/Textarea/index.ts | 3 + .../components/Textarea/useTextareaStyles.ts | 61 ++++ .../library/src/index.ts | 3 + .../library/src/textarea.ts | 11 + .../src/Textarea/TextareaDefault.stories.tsx | 45 +++ .../TextareaGriffelComparison.stories.tsx | 64 ++++ .../stories/src/Textarea/index.stories.tsx | 9 + .../src/vr/TextareaGriffel.stories.tsx | 10 + .../stories/src/vr/TextareaVrScene.tsx | 64 ++++ .../src/vr/TextareaWindmod.stories.tsx | 10 + .../stories/src/vr/index.stories.tsx | 2 + 18 files changed, 870 insertions(+) create mode 100644 packages/react-components/react-windmod-preview/library/etc/textarea.api.md create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Textarea/Textarea.module.css create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Textarea/Textarea.test.tsx create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Textarea/Textarea.tsx create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Textarea/Textarea.types.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Textarea/index.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/components/Textarea/useTextareaStyles.ts create mode 100644 packages/react-components/react-windmod-preview/library/src/textarea.ts create mode 100644 packages/react-components/react-windmod-preview/stories/src/Textarea/TextareaDefault.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/Textarea/TextareaGriffelComparison.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/Textarea/index.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/TextareaGriffel.stories.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/TextareaVrScene.tsx create mode 100644 packages/react-components/react-windmod-preview/stories/src/vr/TextareaWindmod.stories.tsx diff --git a/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md b/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md index 2b6fd81f06120b..80da9a82ef1f88 100644 --- a/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md +++ b/packages/react-components/react-windmod-preview/library/etc/react-windmod-preview.api.md @@ -57,6 +57,7 @@ import { renderRatingItem } from '@fluentui/react-headless-components-preview/ra import { renderSkeleton } from '@fluentui/react-headless-components-preview/skeleton'; import { renderSkeletonItem } from '@fluentui/react-headless-components-preview/skeleton'; import { renderSpinner } from '@fluentui/react-headless-components-preview/spinner'; +import { renderTextarea } from '@fluentui/react-headless-components-preview/textarea'; import { renderToggleButton } from '@fluentui/react-headless-components-preview/toggle-button'; import { renderTooltip } from '@fluentui/react-headless-components-preview/tooltip'; import type { SkeletonItemProps as SkeletonItemProps_2 } from '@fluentui/react-headless-components-preview/skeleton'; @@ -73,6 +74,9 @@ import { teamsDarkV21ThemeClassName } from '@fluentui/react-tailwind-theme-previ import { teamsHighContrastThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; import { teamsLightThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; import { teamsLightV21ThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; +import type { TextareaProps as TextareaProps_2 } from '@fluentui/react-headless-components-preview/textarea'; +import { TextareaSlots } from '@fluentui/react-headless-components-preview/textarea'; +import type { TextareaState as TextareaState_2 } from '@fluentui/react-headless-components-preview/textarea'; import { ThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; import { themeClassNames } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; import type { ToggleButtonProps as ToggleButtonProps_2 } from '@fluentui/react-headless-components-preview/toggle-button'; @@ -99,6 +103,7 @@ import { useSkeleton } from '@fluentui/react-headless-components-preview/skeleto import { useSkeletonContextValues } from '@fluentui/react-headless-components-preview/skeleton'; import { useSkeletonItem } from '@fluentui/react-headless-components-preview/skeleton'; import { useSpinner } from '@fluentui/react-headless-components-preview/spinner'; +import { useTextarea } from '@fluentui/react-headless-components-preview/textarea'; import { useToggleButton } from '@fluentui/react-headless-components-preview/toggle-button'; import { useTooltip } from '@fluentui/react-headless-components-preview/tooltip'; import { webDarkThemeClassName } from '@fluentui/react-tailwind-theme-preview/theme-class-names'; @@ -420,6 +425,8 @@ export { renderSkeletonItem } export { renderSpinner } +export { renderTextarea } + export { renderToggleButton } export { renderTooltip } @@ -511,6 +518,31 @@ export { teamsLightThemeClassName } export { teamsLightV21ThemeClassName } +// @public +export const Textarea: ForwardRefComponent; + +// @public +export type TextareaAppearance = 'outline' | 'filled-darker' | 'filled-lighter'; + +// @public +export const textareaClassNames: { + root: string; +}; + +// @public +export type TextareaProps = TextareaProps_2 & { + appearance?: TextareaAppearance; + size?: TextareaSize; +}; + +// @public +export type TextareaSize = 'small' | 'medium' | 'large'; + +export { TextareaSlots } + +// @public +export type TextareaState = TextareaState_2 & Required>; + export { ThemeClassName } export { themeClassNames } @@ -653,6 +685,11 @@ export { useSpinner } // @public export const useSpinnerStyles: (state: SpinnerState) => SpinnerState; +export { useTextarea } + +// @public +export const useTextareaStyles: (state: TextareaState) => TextareaState; + export { useToggleButton } // @public diff --git a/packages/react-components/react-windmod-preview/library/etc/textarea.api.md b/packages/react-components/react-windmod-preview/library/etc/textarea.api.md new file mode 100644 index 00000000000000..2a24007ee9520e --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/etc/textarea.api.md @@ -0,0 +1,48 @@ +## API Report File for "@fluentui/react-windmod-preview" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { ForwardRefComponent } from '@fluentui/react-utilities'; +import { renderTextarea } from '@fluentui/react-headless-components-preview/textarea'; +import type { TextareaProps as TextareaProps_2 } from '@fluentui/react-headless-components-preview/textarea'; +import { TextareaSlots } from '@fluentui/react-headless-components-preview/textarea'; +import type { TextareaState as TextareaState_2 } from '@fluentui/react-headless-components-preview/textarea'; +import { useTextarea } from '@fluentui/react-headless-components-preview/textarea'; + +export { renderTextarea } + +// @public +export const Textarea: ForwardRefComponent; + +// @public +export type TextareaAppearance = 'outline' | 'filled-darker' | 'filled-lighter'; + +// @public +export const textareaClassNames: { + root: string; +}; + +// @public +export type TextareaProps = TextareaProps_2 & { + appearance?: TextareaAppearance; + size?: TextareaSize; +}; + +// @public +export type TextareaSize = 'small' | 'medium' | 'large'; + +export { TextareaSlots } + +// @public +export type TextareaState = TextareaState_2 & Required>; + +export { useTextarea } + +// @public +export const useTextareaStyles: (state: TextareaState) => TextareaState; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/react-components/react-windmod-preview/library/package.json b/packages/react-components/react-windmod-preview/library/package.json index 925fd1f8351709..5bb1356d994c84 100644 --- a/packages/react-components/react-windmod-preview/library/package.json +++ b/packages/react-components/react-windmod-preview/library/package.json @@ -203,6 +203,16 @@ "default": "./lib-commonjs/spinner.cjs" } }, + "./textarea": { + "import": { + "types": "./dist/textarea.d.ts", + "default": "./lib/textarea.js" + }, + "require": { + "types": "./dist/textarea.d.cts", + "default": "./lib-commonjs/textarea.cjs" + } + }, "./toggle-button": { "import": { "types": "./dist/toggle-button.d.ts", diff --git a/packages/react-components/react-windmod-preview/library/src/components/Textarea/Textarea.module.css b/packages/react-components/react-windmod-preview/library/src/components/Textarea/Textarea.module.css new file mode 100644 index 00000000000000..b38ab2c8e95a88 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Textarea/Textarea.module.css @@ -0,0 +1,138 @@ +@reference '#theme'; + +@layer fui.components.l1 { + /* Root-modifier block order is the cascade: every selector below is :where()-flat, so source + order alone arbitrates. .outlineInteractive after .outline, .invalid after every appearance + block. */ + .root { + @apply relative m-0 box-border inline-flex rounded-medium p-0 pb-thick align-top; + } + + .disabled { + @apply border-(length:--spacing-thin) border-solid border-neutral-stroke-disabled bg-transparent-background; + + @variant forced-colors { + @apply border-[GrayText]; + } + } + + .filled { + @apply border-(length:--spacing-thin) border-solid border-transparent-stroke; + + @variant hover, focus-within { + @apply border-transparent-stroke-interactive; + } + } + + .filledDarker { + @apply bg-neutral-background-3; + } + + .filledLighter { + @apply bg-neutral-background-1; + } + + .outline { + @apply border-(length:--spacing-thin) border-solid border-neutral-stroke-1 border-b-neutral-stroke-accessible bg-neutral-background-1; + } + + .interactive { + @variant after { + @apply absolute -inset-x-1 -bottom-1 box-border rounded-b-medium border-b-(length:--spacing-thick) border-b-compound-brand-stroke content-['']; + + height: max(var(--spacing-thick), var(--radius-medium)); + clip-path: inset(calc(100% - var(--spacing-thick)) 0 0 0); + transform: scaleX(0); + transition-property: transform; + transition-duration: var(--duration-ultra-fast); + + @media screen and (prefers-reduced-motion: reduce) { + transition-duration: 0.01ms; + } + } + + @variant focus-within { + @apply outline-(length:--spacing-thick) outline-transparent outline-solid; + + @variant after { + transform: scaleX(1); + transition-property: transform; + transition-duration: var(--duration-normal); + + @media screen and (prefers-reduced-motion: reduce) { + transition-duration: 0.01ms; + } + } + + @variant active { + @variant after { + @apply border-b-compound-brand-stroke-pressed; + } + } + } + } + + .outlineInteractive { + @variant hover { + @apply border-neutral-stroke-1-hover border-b-neutral-stroke-accessible-hover; + } + + @variant active { + @apply border-neutral-stroke-1-pressed border-b-neutral-stroke-accessible-pressed; + } + + @variant focus-within { + @apply border-neutral-stroke-1-pressed border-b-compound-brand-stroke; + } + } + + .invalid { + @variant not-focus-within { + @apply border-palette-red-border-2; + } + } + + .textarea { + @apply m-0 box-border h-full grow border-none bg-transparent font-base font-regular text-neutral-foreground-1 outline-none; + + @variant placeholder { + @apply text-neutral-foreground-4 opacity-100; + } + + @variant group-size-small/fui-textarea { + @apply max-h-200 min-h-40 px-horizontal-s py-vertical-xs text-base-200 leading-base-200; + } + + @variant group-size-medium/fui-textarea { + @apply max-h-260 min-h-52 px-horizontal-m py-vertical-s-nudge text-base-300 leading-base-300; + } + + @variant group-size-large/fui-textarea { + @apply max-h-320 min-h-64 px-14 py-vertical-s text-base-400 leading-base-400; + } + + @variant group-disabled/fui-textarea { + @apply cursor-not-allowed text-neutral-foreground-disabled; + + @variant placeholder { + @apply text-neutral-foreground-disabled; + } + } + } + + .resizeNone { + @apply resize-none; + } + + .resizeHorizontal { + @apply resize-x; + } + + .resizeVertical { + @apply resize-y; + } + + .resizeBoth { + @apply resize; + } +} diff --git a/packages/react-components/react-windmod-preview/library/src/components/Textarea/Textarea.test.tsx b/packages/react-components/react-windmod-preview/library/src/components/Textarea/Textarea.test.tsx new file mode 100644 index 00000000000000..88bb0f5f41aae1 --- /dev/null +++ b/packages/react-components/react-windmod-preview/library/src/components/Textarea/Textarea.test.tsx @@ -0,0 +1,298 @@ +import * as React from 'react'; +import { fireEvent, render } from '@testing-library/react'; + +import { isConformant } from '../../testing/isConformant'; +import { Textarea } from './Textarea'; +import type { TextareaState } from './Textarea.types'; +import { textareaClassNames, useTextareaStyles } from './useTextareaStyles'; + +import styles from './Textarea.module.css'; + +const appearances = ['outline', 'filled-darker', 'filled-lighter'] as const; +const sizes = ['small', 'medium', 'large'] as const; +const resizes = ['none', 'horizontal', 'vertical', 'both'] as const; + +const resizeClasses: Record<(typeof resizes)[number], string> = { + none: styles.resizeNone, + horizontal: styles.resizeHorizontal, + vertical: styles.resizeVertical, + both: styles.resizeBoth, +}; + +const appearanceClasses = [styles.filled, styles.filledDarker, styles.filledLighter, styles.outline]; + +// `textarea` is the primary slot, so every native prop — data-testid included — lands on the +//