Skip to content

feat(react-windmod-preview): a Tailwind v4 + CSS Modules styling layer for Fluent's headless components - #36656

Draft
Ray Knight (ArrayKnight) wants to merge 248 commits into
microsoft:masterfrom
ArrayKnight:styling/react-windmod
Draft

feat(react-windmod-preview): a Tailwind v4 + CSS Modules styling layer for Fluent's headless components#36656
Ray Knight (ArrayKnight) wants to merge 248 commits into
microsoft:masterfrom
ArrayKnight:styling/react-windmod

Conversation

@ArrayKnight

@ArrayKnight Ray Knight (ArrayKnight) commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

feat(react-windmod-preview): a Tailwind v4 + CSS Modules styling layer for Fluent's headless components

The ask

This PR asks the Fluent team to officially support styling Fluent components with Tailwind v4 +
CSS Modules, and offers a complete, pixel-verified reference implementation as the argument.

Everything else here is evidence for it.

It does not propose migrating @fluentui/react-components off Griffel, deprecating Griffel, or
changing what any existing consumer ships. This is an additional path, not a replacement — the
material difference from the 2026-08 proposals closed on scope grounds (§7).

What "supported" would mean, concretely:

  1. The pattern is documented as a legitimate way to consume Fluent, alongside Griffel.
  2. The headless package's data-* and context contracts are treated as public API that styling
    layers may rely on — today several are incomplete or wrong (§6).
  3. These two preview packages, or something like them, live in-tree rather than in a fork.

I am a Microsoft employee, and this is the pattern I intend to adopt in first-party work. I would
rather build on something Fluent supports than maintain a private fork of the visual contract.

What ships

Package Role
@fluentui/react-tailwind-theme-preview palette, type ramp, spacing scale, seven per-theme stylesheets (import-and-set, no baked default), Tailwind's preflight at the head of the fui.base layer, the global reduced-motion floor
@fluentui/react-windmod-preview the 151 components and their compiled stylesheets

Both at 0.1.0, published as a pair. 151 components across 52 family subpaths — the headless
package's own subpaths, one for one, so the styled layer has the same shape as the layer beneath it.
No root barrel. Component CSS ships per component and themes ship per theme; a ~3.9 KB root sheet and one chosen theme sheet load
once per document, with a batteries-included monolith retained for zero-config consumers.

Also shipping: a 59-delta migration guide, an agent skill that makes bulk migration
practical, a bundle-size analysis and a runtime benchmark — all with reproduction scripts.

Headline numbers

windmod Griffel
Library JavaScript, gzip 99,305 333,922 3.36× smaller
Button — JS + CSS, gzip 5,046 12,330 2.44× smaller
49 components — JS + CSS, gzip 85,107 167,250 1.96×; no crossover on any route
Cold mount, median ms 27.8 48.7 windmod 1.75× faster
Re-render, median ms 26.8 24.5 Griffel faster
JS heap after forced GC 3.44 MB 5.83 MB

Windmod ships no Griffel runtime — verified by scanning every subpath's module graph, not
claimed. The real saving is the styles, not the runtime: @fluentui/react-components ships 621,799
raw minified bytes of precompiled *.styles.js, its entire visual contract as JavaScript, where
windmod's equivalent is 40,963 bytes of class maps plus static, CDN-cacheable stylesheets.

Parity: 91 VR scenes, 74 at strict zero, 17 carrying ratified allowances — each decomposed to a
named mechanism and bounded by an identity control, not tolerance bands. Plus computed-style
matrices beyond what pixels can see (TagPicker: 27,126 properties, 0 divergences; forced colors: 0
divergent cells across all 48 scene-phases) and mutation-tested specs.

Results that cut against windmod are reported as prominently as the ones that favour it: Griffel
wins the re-render metric, a family import brings its family's CSS, and the root stylesheet is a new
consumer obligation. §3 and §4 give each its numbers.

Thirteen commits that are not windmod

Each is a single self-contained commit against a Microsoft-owned path, and none depends on any
windmod commit. Each was measured before and after; every behavioural change carries a regression
test verified to fail without the fix. Each now has its own issue — take them separately if
you prefer, and take them whether or not windmod goes anywhere.

Commit Package One line Issue
b95a4152c4 react-breadcrumb Plain Omit collapsed the distributive ARIA button union; href disappeared. #36645
20924b8a1a react-color-picker A channel valued 0 fell through || to the hue action; the slider emitted value="210" max="100". #36646
01ccede78a headless usePopoverSurface pinned popover after ...props, so only one Popover could be open per page. 1/8 → 8/8. #36647
40bca2b821 headless The same defect at useMenuPopover and useListbox. 1/3 → 3/3. #36647
17f7e796d8 headless Dialog's scroll lock reserved no scrollbar gutter, shifting every fixed element sideways. Δx 7.5 → 0. #36648
f34b0a1125 react-tag-picker The aside-width frame was cancelled in the effect body, racing away its own only write. 3/12 → 12/12. #36649
a2f2bc30ba headless renderTooltip silently discarded state.arrowClassName — declared, published state. #36650
568297d44a headless MenuSplitGroup rendered with no contexts argument, so no descendant could tell it was inside one. #36651
5a89364f3f headless ./avatar-group published its context values hooks but neither the reader nor the provider. #36652
9612d547d9 headless Same gap for Button, Link and Avatar — and a measured wrong render: a Button in MessageBarActions came out medium, not small. #36652
510b8c5d21 react-storybook-addon-export-to-sandbox The addon's registration regex matched forward slashes only; on Windows it found nothing, silently. #36653
d826380ae5 tools/workspace-plugin generate-api skipped every export-subpath rollup on Windows — 56 → 0 — with no diagnostic. #36654
b7c455ee52 tools/workspace-plugin export-maps-sync silently deleted every export key it could not derive: 151 → 2. Now preserved by declaration, or a named throw. #36655

They are here because the work could not proceed around them, and because leaving them in a report
would mean shipping a preview package that silently depends on defects staying defects. Split them
out on request; the only reason to keep them together is that VR re-runs and reviewer comparisons
should see one coherent tree.

Four other changes in the diff are ours, not upstream's, and get no issue — named so they are
not mistaken for repairs: the CJS-emit fix to the CSS-modules build executor (a module this branch
introduces), the @prettier/sync declaration (a consequence of this branch's own Prettier 3 bump),
a Reflect.deleteProperty in a test this branch added, and a 'use client' removal from four
context modules this branch added. A fifth is struck: b9853103c5 was superseded upstream by
#36606 while this branch was in flight and is not in the diff.

Three offers

  1. The findings roster. Building a full skin on the headless hooks found 45 further reportable
    defects
    beyond the thirteen above — arguably more valuable to the team than windmod itself, and
    offered whether or not windmod is adopted. Say the word and they go up, grouped as §6 recommends.
  2. Any commit, standalone. Every one of the thirteen can be lifted into its own PR against its
    issue.
  3. The evidence. Verification chains, the decision log, allowance controls, bundle and benchmark
    data, and the raw roster, on request — attachments, a gist, or a follow-up commit, whichever you
    prefer. The qualitative half is already self-serve: run the stories workspace and every scene
    renders live, side by side.

Companion PR: microsoft/fluentui-system-icons#1228 (microsoft/fluentui-system-icons) — one additive icon attribute
this package's CSS selects on. Narrow, and it opens first (§10).

Maintainers: happy to connect internally about adoption. Ping me here and I will share my alias, or
look me up in Teams.


Everything below is the full argument, collapsed. Open what you want to check.

Length note: the eleven numbered deep-dive sections (§1–§14) exceed GitHub's body limit and follow as the first 1 comments on this PR, each a set of collapsibles.


Previous Behavior

No Tailwind- or CSS-Modules-based styling layer exists for Fluent v9. Consuming any v9 component
means taking @griffel/react — its runtime, its makeStyles authoring model, its runtime
injection, and its entire visual contract shipped as precompiled JavaScript (621,799 raw minified
bytes of *.styles.js in @fluentui/react-components, plus 79,480 bytes of JS design tokens).
@fluentui/react-headless-components-preview exposes the behaviour hooks but ships no skin, so a
team wanting to style Fluent's headless components has no reference implementation to work from, and
several of its data-* and context contracts are incomplete or wrong in ways only a styling
consumer discovers.

New Behavior

Two preview packages provide a complete, pixel-verified skin for the headless components — 151
components across 52 headless-aligned family subpaths, Griffel-free, with the visual contract in static
CDN-cacheable stylesheets instead of in JavaScript: a ~3.9 KB root sheet plus one chunk per
component, delivered through each component's own class map, with a batteries-included monolith
retained for zero-config consumers. Eleven defects in Microsoft-owned packages that
blocked the work are fixed in place with regression tests. A 59-delta migration guide, a 50-entry
upstream findings roster, a bundle-size analysis and a runtime benchmark, all with reproduction
scripts, accompany them.

Related Issue(s)

Every non-windmod commit in this PR has its own issue, so each can be taken independently of
windmod and of the others:

Companion PR: microsoft/fluentui-system-icons#1228 (microsoft/fluentui-system-icons).

There is no tracking issue for the ask itself. The closest prior context is the closed 2026-08
proposal PR and the maintainer direction quoted in §7, which is what this PR's scope responds to.

Ray Knight (ArrayKnight) and others added 30 commits August 20, 2026 18:15
…am-block list

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…e package

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…tip and ThemeProvider

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…ring fixes

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…parisons green

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…ty, prettier 3 + class sorting

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…Name

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
… Tailwind-max Button, formatted generator output

- @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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…ppearance edit

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…fork tarball

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
- 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(<number>)) * 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…ences

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
… 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…efore merge]

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
… and unlayer the provider root

Children get their own class blocks with parent state arriving through
group-<variant>/fui-<component>; the provider's display:contents leaves the layer
system entirely so nothing can override box generation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…s 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
componentMarkers emits `fui-<name> group/fui-<name>` 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…ui.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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
… 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…icon instead of svg type selectors

Every @fluentui/react-icons icon stamps data-fui-icon (SVG icons: empty
string; font icons: "font"), so glyph shaping gains a generic direct-child
catalog variant: @custom-variant icon (& > :where([data-fui-icon])). All 12
remaining raw svg type-selector sites convert — Combobox, Dropdown, Option,
Select, TagPickerControl (& svg) and Input, SearchBox x3 each, RatingItem
(& > svg). A DOM probe against the built library verified every site's glyph
is a direct child of its anchor class and carries the attribute; compiled A/B
shows only the selector text changed.

Deliberate delta (MIGRATION.md microsoft#57): a consumer's arbitrary <svg> in an icon
slot is no longer restyled — Fluent icons are the styled contract; stamping
data-fui-icon opts a custom svg in. Font icons now match glyph-shaping rules
the svg type selector never reached; no site depended on excluding them.

Name collision resolved: the generic catalog's [data-icon] presence pair
(icon/not-icon, used only via group-icon/fui-tab and
group-not-icon/fui-accordion-header) is renamed icon-present /
not-icon-present to free the ruled name — Tab and AccordionHeader compiled
output is byte-identical before and after. The skill's authoring-conventions,
failure-modes and variant-catalog references now document the generic
variant; the raw-svg exception class shrinks to non-direct-child sites only
(InteractionTagPrimary).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aj9uA3rCVgosnh2zNn8qkc
@dmytrokirpa

Copy link
Copy Markdown
Contributor

Hey Ray Knight (@ArrayKnight), this is really impressive work, but I don’t think we can take the PR in its current size and complexity. As the surface area keeps growing, it would be difficult for the team to maintain. We’d be happy to pull out fixes for the issues raised in this PR and the linked issues, but for everything else, it would probably be better to host it in your/community repo rather than in the FluentUI core.

Please feel free to reach out here if you have any questions/suggestions/feedback etc.

Thank you!

@ArrayKnight

Copy link
Copy Markdown
Contributor Author

Thanks Dmytro Kirpa (@dmytrokirpa) — completely fair on size, and I'll take you up on the fixes offer immediately: each in-tree fix from this PR is now its own isolated PR, one per linked issue:

On the rest — before I move it to a community repo, I'd like to understand which cost is the actual blocker, because they point to different paths:

  1. If it's this PR's review surface, I can decompose much further — theme package alone first, then per-family batches, each pixel-verified independently.
  2. If it's the prospect of maintaining two styling systems indefinitely, that's the conversation I'd most value having, because the design goal was the opposite: the layer holds pixel-parity with Griffel across 91 VR scenes precisely so it can serve as a migration path — one that would let Griffel be deprecated over time and shrink the maintenance surface rather than double it. We proposed the big-bang version of this before and understood the appetite wasn't there; the preview-package shape was meant as the incremental route to the same destination. If there's any appetite for that direction, I'd gladly shape the work to whatever on-ramp the team could sustain.

If neither lands, I'll host it as a community package — in that case: would the team be open to a discoverability pointer (docs/community listing), and is the headless package's public API + DOM structure a contract you'd consider stable enough for an external styling layer to build against?

…lowest fui.preflight layer

Decision U (operator, 2026-08-28) overrules the day-one no-preflight posture:
tailwindcss/preflight.css now ships in a dedicated fui.preflight layer placed
FIRST in the fui.* order statement, so every authored theme/component rule
outranks the reset by layer order alone while consumer documents get a
normalized base instead of per-browser UA defaults. Deliberate divergence from
Griffel's no-reset posture.

Both single-owner copies of the order statement gain the layer: the theme
package's css/index.css (emitted verbatim into dist/base.css and the
dist/styles.css monolith) and CANONICAL_LAYER_STATEMENT in the workspace-plugin
css-modules executor (emitted into the windmod package's dist/base.css and
aggregate); the cross-package drift-guard spec's whole-line prefix follows.

Generator checks pass; gates green. S1 inventory sweep: 74/91 strict-zero,
16 within ratified allowances, 1 over ceiling (info-label-open, +373 px,
anchor-displacement symptom of the leaning-on-UA-button-font class staged for
S2). Fallout table: .scratch/windmod-loop/plans/reset-unitless-leading.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aj9uA3rCVgosnh2zNn8qkc
… always rides an authored text step

Decision U stage S2. Census over the whole module corpus plus a DOM x
built-source join across all 91 scenes:

- Tab root authored leading-base-300 with no font-size — the line box
  was welded to whatever font-size inheritance delivered (Chrome's
  13.3333px button constant before preflight). Now authors
  text-base-300, matching the Griffel twin's computed 14px at every
  size variant.
- InfoButton root (the S1 fallout site) authors text-base-300; its
  24x24 box is measured font-independent, the step declares the
  metrics instead of riding the surrounding context.
- Exempt with cause: Spinner root's leading-[0] (zero is
  font-size-independent, Griffel authors the same bare value) and
  Avatar's icon-initials leading-none (font-size authored on the
  component's own root, Griffel-identical shape).
- Interactive-element sweep: 53 flagged (tag, class) tuples, every
  box invariant under line-height/font-size perturbation — no other
  site leans on its own line box.

Skill: authoring-conventions gains the pairing rule — line-height
never authored without font-size on the same element; native
interactive elements always author their text step.

Verification: element-keyed computed-style probe over the touched
scenes, 855 elements — zero font-size, line-height or rect moved.
tab-list, info-label, info-label-rtl strict-zero. info-label-open
holds at S1's 8918 (deterministic x3, ceiling 8545): measured as pure
GPU shadow-raster banding — the no-GPU pass scores 8071, inside its
ratified 8107 ceiling — with buttons, icons and surfaces
pixel-identical; escalated for ceiling re-ratification rather than
self-granted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aj9uA3rCVgosnh2zNn8qkc
…erived from the type-ramp pairing (Decision U S3)

Generator: every --leading-* token is now the idiomatic unitless ratio of its
1:1 ramp pairing (leading-base-300 = 20px/14px = calc(20 / 14)), derived from
theme-values.json at generation time — an exact decimal where the division is
finite, a calc() fraction where it repeats. No --base-scale factor: the paired
--text-* carries it and the ratio multiplies the element's own font-size, so
line boxes still scale. The ratios are theme-INVARIANT (both halves of the
pairing asserted byte-identical across all seven shipped themes) and move from
the per-theme files into the base sheet's :root,:host block. The lane-D
unitless-rejection rationale in the generator is superseded in place: its
blocking cohort (Tab root dividing by Chrome's UA button font-size) dissolved
when S1 preflight + S2 explicit font-size authoring made every denominator a
token.

Modules: lane-D's rule-surface compensation map applied — every rule whose
element previously inherited a px line box across a font-size boundary now
authors its own explicit ratio against its authored font-size, spelled as the
self-documenting fraction leading-[calc(target/fontsize)]. Sites re-derived
against the current corpus where the map's assumptions had drifted: Input/
SearchBox icon glyphs keep their authored line-height:0; RatingItem indicators
keep Griffel-identical inheritance (corpus context is the fs-0 VR furniture);
Dropdown's shared icon bucket splits per context (&:is(button) — the clear
slot divides the root's 20px line box, the chevron the trigger's per-size
one); Badge tiny/extra-small and ToastBody's subtitle drop their dimensioned
arbitrary values for ratio 1; the Checkbox/Radio/Switch label-offset calcs
re-derive their line box as var(--text-*) * var(--leading-*).

Verification: element-keyed computed font-size/line-height/rect probe over all
91 scenes, 22950 elements, pre- vs post-conversion builds: 0 font-size changes,
0 rect changes (spinner's 164 rect rows reproduce between two captures of the
same build — rotating-glyph animation phase, instrument noise), and 204
line-height rows confined to the avatar/avatar-group VR-furniture subtrees
(inline lineHeight:0 wrappers) where the avatar text-ladder now authors the
20px-context ratio the in-corpus persona and avatar-group-open scenes require —
all 204 rect-identical, computed-only. Full 91-scene sweep: byte-parity with
the S2 state (74 strict-zero + 16 in-allowance + info-label-open at its known
8918, Decision V pending). 32px-root probe: line boxes scale x2; leading stays
in base.css (theme-invariance held).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aj9uA3rCVgosnh2zNn8qkc
…tless leading (Decision U S5)

MIGRATION.md gains deltas microsoft#58 (Tailwind's preflight ships in the lowest
fui.preflight layer — deliberate divergence from Griffel's no-reset
posture, with the layer guarantee and the document-global check note)
and microsoft#59 (leading tokens are unitless ratios — inheritance becomes
number-not-length, and a consumer reading --leading-* for a length
multiplies by the paired --text-* token); the delta count and the
base-sheet size citations are refreshed from the S4-verified dists
(theme base.css 7.7 KB raw / 2.2 KB gzip, windmod base.css 3.8 KB raw /
805 B gzip, gzip -9).

Skill: authoring-conventions adds the fui.preflight layer contract
(order statement leads with it; never author into it; components
outrank it by construction) and restates the leading-pairs-with-
font-size rule for the unitless end-state, including the
leading-[calc(target/ownFontSize)] fraction spelling; failure-modes
adds the Tab-shape row (leading without an authored font-size);
setup/overriding/troubleshooting/SKILL order statements gain
fui.preflight, with the first-appearance omission hazard called out in
the consumer recipes; griffel-deltas gains rows 58/59; tokens-and-scale
marks the leading family unitless; delta counts move to fifty-nine
everywhere. Both package READMEs pick up the preflight description,
the 423-declaration theme files, and the re-measured sheet sizes.

useCssVarValue's measured-family doc table (and its css-var-values.md
mirror) corrects the leading row to 5 literal / 5 calc-string
(totals 420/52), measured from the shipped dist: the ramp's finite
divisions emit as decimals and the repeating ones as calc() divisions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aj9uA3rCVgosnh2zNn8qkc
…of fui.base, retiring the fui.preflight layer

Operator ruling 2026-09-01 refining Decision U: Tailwind's preflight belongs in
the EXISTING fui.base layer — the placement Tailwind itself gives it — not a
dedicated lowest layer. The @layer order statement loses fui.preflight in both
authored owners (css/index.css and CANONICAL_LAYER_STATEMENT) and the drift-guard
spec's whole-line prefix follows.

Same-layer cascade hazard resolved: fui.base's icons sheet opens with the
deliberately zero-specificity :where([data-fui-icon]) display default, which
preflight's (0,0,1) replaced-elements rule would beat regardless of source order.
An icon-defaults guard (svg:where([data-fui-icon]) { display: inline }) directly
after the preflight import wins that tie back. Computed-parity probe old-vs-new
dists: 49,296 properties across every preflight target and icons selector shape,
0 diffs; negative control with the guard stripped shows the 14-diff display flip
the guard exists to prevent.

Docs reworded to "preflight ships at the head of fui.base" (skill SKILL/setup/
authoring-conventions/overriding/troubleshooting/griffel-deltas, MIGRATION delta
58 incl. anchor, theme README); the setup hazard note about keeping fui.preflight
first becomes a general copy-the-statement-exactly note.

Verification: gates 8/8; full 91-scene sweep 74 strict-zero / 17 in-allowance /
0 fail with info-label-open at its operator-ratified post-preflight GPU ceiling
(8918; Decision V) and no-GPU control 8071 <= 8107 unchanged; Decision W (avatar
furniture probe residual) accepted with no code change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aj9uA3rCVgosnh2zNn8qkc
…appearance onto module classes

O-19 appearance-doctrine convergence, the computed-identical sites: Toast gains
.inverted, SkeletonItem gains .translucent, TabList gains a single .rounded for
the two circular values (Griffel's own bucket name and isRounded predicate).
data-appearance stamps stay for children and consumer group variants. SkeletonItem's
rounded-[4px] gets its justification comment (Griffel authors the literal; radii sit
off the base-scale ramp). Card, Link and Tab stay on the variant form - their
appearance x state cross-products resolve by source order inside one block, and a
class conversion would re-add the re-assertion arms the 15c sweep deleted.

Compiled A/B decl-for-decl identical at equal specificity; class-composition
assertions added and mutation-verified (3/3 killed); VR skeleton/tab-list/toast
scenes strict-zero.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aj9uA3rCVgosnh2zNn8qkc
…s, template utilities, dead rules, keyframe pruning

The deferred census rows, adjudicated per site:
- MenuItem/MenuPopover/Persona: the last unscaled bracket-px literals join the
  numeric scale (max-w-290/max-w-300/min-w-138/-mt-2) - Tag's identical Griffel
  construct already rode -mt-2; zero bracket-px utilities remain besides
  SkeletonItem's justified radius.
- MessageBar: grid-template cols/rows ride grid-cols-[...]/grid-rows-[1fr];
  grid-template-areas stays raw beside them (arbitrary-property ban), cohesion kept.
- MenuSplitGroup: raw &:nth-of-type(N) -> nth-of-type-N variants (v4 has them;
  byte-identical emission). The ~ sibling reach stays raw.
- InlineDrawer: the bottom separator's 1px literal reads --stroke-width-thin like
  its two logical siblings.
- NavDrawerBody align-items:unset and NavDrawerHeader margin:unset deleted - no
  author rule sets either property on those elements, so unset restated the
  initial value; computed A/B against Griffel byte-equal.
- Spinner: identity from/to keyframes deleted (synthesized from transform:none;
  paused-animation probe matrix-equal at 14 timeline points); tail-rotate's 50%
  frame measured load-bearing and stays.
- failure-modes.md gains the two new classes (unset-restating-initial, identity
  keyframe frames); corpus swept clean for both.

Compiled A/B per file; nav-drawer scenes + drawer/menu/message-bar/persona/
skeleton/spinner/toast batch VR green (21 strict-zero, menu family at ratified
ceilings).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aj9uA3rCVgosnh2zNn8qkc
@ArrayKnight

Copy link
Copy Markdown
Contributor Author

Dmytro Kirpa (@dmytrokirpa) Following up with something concrete: I've mapped what a decomposed submission would look like, in case it's useful for weighing the options — sized in an attempt to align with the PR sizes this repo typically reviews.

The shape: ~16 PRs.

  1. Theme package first (~4k reviewable lines — the 7 theme files are generated; you'd review the generator and its assertions, not 160KB of tokens). Standalone value even if nothing else lands: Fluent's tokens as a Tailwind v4 layer. Depends only on feat(workspace-plugin): let export-maps-sync preserve hand authored asset subpaths #36673.
  2. Infrastructure + one component (~13k): the build pipeline, provider, conformance machinery — with Button end-to-end as the proof, VR evidence included. This is the one genuinely dense review; everything after it is repetition of a pattern you've already approved.
  3. Twelve family batches (~3–6k source lines each), ordered so every cross-component override lands after the component it overrides. Each PR carries its own gate: green builds, its VR scenes' pixel-parity status with evidence in the body, and its migration-guide rows.
  4. Docs last (~3k).

How they'd flow — the dependency graph is shallow and wide:

ring can be open simultaneously
now theme package (parallel with the fix PRs already up)
after theme + icons PR infrastructure + Button — the one serialization point
after infrastructure 7 batches in parallel: form inputs, display primitives, avatar, tags, overlays, surfaces/status, color suite
as those merge 4 more: button family, pickers, nav/tabs, flagship compositions
tail menus/toolbars, then docs

The critical path is only five sequential PRs — everything else overlaps. "Can be parallel" is an offer, not a plan: they'd land at whatever cadence works for the team, one at a time if that's the right speed. The fixes you offered to take are already out (#36663#36673).

All of this only matters if review surface is the main concern — if it's the longer-term cost of a second styling system, that's a different conversation (the one in my earlier comment), and the community repo may be the right home regardless.

…dmod

# Conflicts:
#	packages/react-components/react-headless-components-preview/library/src/avatar.ts
#	packages/react-components/react-headless-components-preview/library/src/components/Avatar/index.ts
…exports on upstream's location after merge

Upstream microsoft#36659 exposes useAvatarGroupContext from useAvatarGroup; the branch's
avatarGroupContext re-export now carries only AvatarGroupProvider, and the
subpath barrel drops the duplicate the auto-merge produced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aj9uA3rCVgosnh2zNn8qkc
…invariant emission + attr()-derived --fui-scale knob

--base-scale becomes calc(1rem / 16px * var(--fui-scale, 1)) — a computed
no-op at the default (the resolved probe panel is byte-identical pre/post;
every textual custom-property diff across root + all 7 themes is exactly
the '* 1' insertion; probes in
.scratch/windmod-loop/base-scale-zoom/results-scale-region.*).

The theme-invariant token block is emitted at
':root, :host, .fui-scale-region' — one grouped rule. The generator absorbs
--base-scale, --spacing and --text-icon-* into it, mirrored from
css/index.css's @theme block at generation time (Tailwind emits @theme
values at ':root, :host' only, and a scale region needs every scale-riding
formula re-declared AT the region element so it re-substitutes the local
factor). An adjacent generated rule derives the knob from the element's own
attribute via typed attr():

  .fui-scale-region { --fui-scale: attr(data-fui-scale type(<number>), 1); }

Measured (HeadlessChrome/147, shipped dists): typed attr() coercion works
on a display:contents element; a theme-class + .fui-scale-region +
data-fui-scale element zooms a real Button exactly linearly at 1.5 and 3
(the TEST C 14/15 result — border-radius stays fixed by design); nested
regions replace the factor absolutely; a region without the attribute is
inert (fallback 1). Per-theme files unchanged; generator --check green and
idempotent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aj9uA3rCVgosnh2zNn8qkc
…ess factor on the ambient base scale

A display:contents div (kebab module class, @apply contents) stamping the
theme package's .fui-scale-region mechanism class (a plain string constant —
the rules ship with the theme sheet, not this package's modules),
data-fui-scale={scale}, and the ambient theme class read from a new
private-internal ThemeClassContext. FluentProvider provides the context:
its own theme, or — themeless nested provider — the inherited one, so the
region always re-stamps the class that actually themes it (theme-owned
formulas re-substitute only where the theme class re-applies). A
development-only once-per-document warning fires for a region with no
themed provider above it, following the rootStylesheetCheck pattern.

scale is absolute: nested regions replace the factor, never compound; a
nested region without scale resets to 1 (the attr() fallback). Exported
from the ./provider family barrel (provider-adjacent, windmod-original —
no headless counterpart); api rollup regenerated, nx sync clean.

Tests: class map + mechanism class + data attribute stamping, theme-class
re-stamping through themeless and re-themed nested providers, className
last, ref forwarding, warning + latch + themed-silence paths, full
conformance. Computed display:contents and end-to-end zoom are verified in
the browser probes recorded in .scratch/windmod-loop/base-scale-zoom/
(typed attr() on display:contents measured first — passes; real Button
exactly linear at 1.5 and 3). Gates 8/8 green; full 91-scene VR sweep
0 failures — 74 strict-zero, 17 allowance rows at or under their ceilings
(info-label-open 8918/8918).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aj9uA3rCVgosnh2zNn8qkc
…at ScaleRegion

The rule stays (a raw --base-scale override mid-tree is still inert); it now says why and
names the sanctioned mechanism.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aj9uA3rCVgosnh2zNn8qkc
…ly in the region rule

Tailwind's @theme block already covers :root/:host; repeating the axis in the
grouped rule was a proven no-op but not a needed one. The mirror now lives
solely in .fui-scale-region beside its knob — same drift-guarded source read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aj9uA3rCVgosnh2zNn8qkc
…ramp

Operator ruling 2026-09-02, superseding the scale batch's B-17
radii-off-the-ramp reading: radii scale consistent with the rest of the
system. The generator's new radiusScaledValue hook (same scaleValue
mechanism shadows use) emits the 9 px radius steps as
calc(<N>px * var(--base-scale)) in every theme file — 63 declarations,
9 x 7 themes, radius block still byte-identical across all seven.
--radius-none (0) and --radius-circular (the 10000px fully-round
sentinel the browser clamps to half the box) stay literal: neither
meaningfully scales, and circular's value is asserted so an upstream
retune trips the build.

Corpus sweep of the frozen-literal defect class: SkeletonItem's
rounded-[4px] converts to rounded-medium — its Griffel-literal-parity
justification dissolved once the token itself became the responsive
spelling. Everything else already reads tokens (or is a circle:
rounded-full / --radius-circular, scale-invariant by construction).

Verified: probe-scale-region pre/post — panel A computed kit
byte-identical at scale 1 (no-op proof); panel B 63 textual diffs, all
--radius-*, none other; the zoom kit is now 15/15 at x3 (was 14/15 with
radius the lone holdout) and the real Button's radius reads 6px at x1.5
and 12px at x3. Gates 8/8 green (3604/3604 tests); full 91-scene VR
sweep 74 strict-zero / 17 within-allowance / 0 failures — pixel-identical
at the default root everywhere.

Docs: MIGRATION microsoft#9 + README + ScaleRegion.types + skill tokens-and-scale
now state radii follow the base scale (grow with the system under a
non-default root font size where Griffel's stay fixed px; identical at
the 16px default).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Aj9uA3rCVgosnh2zNn8qkc
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants