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 233 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 233 commits into
microsoft:masterfrom
ArrayKnight:styling/react-windmod

Conversation

@ArrayKnight

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

Copy link
Copy Markdown

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), 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.5 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 56-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.3 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 56-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
Ray Knight (ArrayKnight) and others added 22 commits August 28, 2026 16:09
…ruling v3)

15 ladders across 7 styles files converted to the numeric object-notation
lookup — { [+(size <= 24)]: styles.text100, ... }[1] — chosen by the
operator for minimalism. No annotations needed: numeric computed keys
synthesize a numeric index signature typing the lookup as the value
union. Partition audit: 15/15 exact partitions, 0 gaps/overlaps;
178-point domain equivalence, 0 mismatches; guards and dispatches
excluded by design. CONTEXT rule updated (v3 with iteration history);
skill teaches the pattern with both invariants.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…SS files (base + 7 themes, import-and-set)

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

core.autocrlf gives Windows working copies CRLF theme sources, PostCSS preserves
whatever it is fed, and build.js's route-equivalence matching is LF-exact — so a
checkout (rather than a fresh generator run) broke the build. Normalize the
compiled output, mirroring the generator's own --check normalization, so shipped
bytes are identical on every platform.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…ically, convert raw pseudo-element selectors to variants

Tailwind v4 compounds not- onto custom variants in @variant position with
identical (zero) specificity, so the hand-rolled negation is removed. Raw
::before/::after blocks in DrawerHeader, DrawerFooter, and MenuSplitGroup
become before:/after: variants per the pseudo-element content rule; Radio
and Dropdown keep documented raw-selector exceptions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…r variants replace glyph-swap wrapper blocks

Adds the icon-filled/icon-regular custom variants in the direct-child form
(& > :where([data-fui-icon-variant=...])) and deletes the 22 '& *' wrapper
blocks across 8 modules; bundleIcon renders its two glyphs directly inside
the slot element, verified per site against the rendered DOM. Browser A/B
across 84 forced-state cells is computed-identical. InteractionTagPrimary's
4 root-anchored blocks keep the glyph-self spelling: its glyphs sit inside
the tag icon slot, out of direct-child reach — the exception is documented
at the site and in the catalog.

BreadcrumbButton's icon block drops the local --fui-breadcrumb-icon-size
indirection for the established size-N/text-icon-N utilities (the raw
calc(20px * var(--base-scale)) becomes the named 20px step), computed-equal
in all three sizes.

Skill references gain the direct-child shape, the state-guard placement
hazard, and lose the '& .icon *' shape that never shipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…le, not per file

Corpus audit of all 33 above-l1 modules under the per-rule ruling: a rule
styling the component's own DOM is a base style and sits at l1; only rules
overriding ANOTHER component's styles sit one level above the overridden
block. 16 files re-leveled (103 compiled rules demoted l2->l1, 2 l3->l2,
1 l3->l1); 17 files audited and kept — every rule in them genuinely
overrides a composed component (Button/ToggleButton/Divider/Drawer/
MenuItem/NavItem/Input/ColorSlider/Listbox/PopoverSurface families).
SplitButton now spans three levels (own root l1, Button override l2,
MenuButton override l3); InfoLabel drops whole to l2 since InfoButton's
root is now l1.

Verification: compiled per-file CSS diffed before/after — rule content
byte-identical, only @layer membership moved; package tests green
(160 suites / 3580); full 91-scene VR sweep strict-zero on 74 scenes and
within the recorded allowance on all 17 allowance rows (menu-rtl 402 <= 412).
Skill docs (SKILL.md layer table, authoring-conventions Layers section)
restated to the per-rule principle; the one-block-per-file convention
becomes one block per level per file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…order/outline/decoration named utilities

The generator now mirrors the four stroke-width steps into every width
namespace the installed Tailwind registry themes — --border-width-*,
--outline-width-*, --outline-offset-*, --text-decoration-thickness-* — so
border-thin, outline-thick, -outline-offset-thickest and decoration-thin
compile, each byte-identical to the var-reference spelling it replaces.
The duration/ease families were already bound. Stale rationale claiming
v4 has no border-width namespace is corrected against the 4.3.3 registry.

fui-focus-ring's two @apply runs merge into one (single-@apply rule);
z-index moves ahead of box-shadow, with no property emitted twice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…on and easing utilities corpus-wide

Every border-(length:--spacing-*), outline-(length:--spacing-*),
decoration-(length:--spacing-thin), -outline-offset-(--spacing-*),
duration-(--duration-*), ease-(--ease-*), p-(--stroke-width-thick) and
-m-(--stroke-width-thick) var-reference utility becomes its named form
(border-thin, outline-thick, duration-faster, ease-easy-ease, ...).
Compiled output verified byte-identical per file. Raw declarations with
multi-value or component-local vars stay as authored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…pply Listbox, named-form stragglers

The three raw @media (forced-colors: none) blocks (Tag, both
InteractionTags) become @variant not-forced-colors — Tailwind compounds
not- onto the built-in media variant in at-rule position, emitting
@media not (forced-colors: active), equivalent since forced-colors is
binary. Listbox's root merges its two @apply runs into one around the
raw position-anchor line (declaration set unchanged; no property twice).
PopoverSurface/Tooltip pick up -m-thin, whose inlined alias unwraps to
var(--stroke-width-thin) — the same value --spacing-thin resolves to.
Named-utility conversions in these files ride along.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…eric text utility

Convert the 12-token shadow ramp (96 declarations: 12 tokens x 7 themes
+ :root) onto calc(<px> * var(--base-scale)) via a per-namespace
scaleValue hook in the generator, and put Tooltip/PopoverSurface
drop-shadow geometry on the same ramp. Add a numeric text-<n> utility
(font-size: calc(--value(integer) * 1px * var(--base-scale))) and
convert the 5 bracket-escape call sites; collision gate PASS (0 color
declarations lost).

Leading stays dimensioned: the unitless swap was implemented and
measured (722 mismatching elements / 8 signatures across 10 scenes —
line-height inherits as a length, a ratio re-multiplies per descendant),
and the per-site-ratio variant dies on an engine-dependent denominator
(Tab root's 13.3333px is Chrome's UA button default, not a token).
Evidence: .scratch/windmod-loop/plans/leading-shadow-scale.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…arms; collapse identical variant siblings

Null hypothesis first: 39 nested hover/hover-active/focus arms that only
re-assert declarations their parent state block already carries are dead —
every catalog variant is :where()-flat, so the later parent base wins on
source order alone. Deleted, proven computed-identical across 304 forced
pseudo-state x forced-colors cells on real rendered DOM (Button 24 arms,
ToggleButton 8, Link 2, the rest singletons).

The remaining identical CONSECUTIVE sibling bodies collapse to comma
lists (36 sites, 15 files) — emission is one nested rule per member in
list order, verified byte-identical. Non-adjacent identical pairs
(MenuItem root/sub-text hover vs submenu-open) stay separate: an
intervening sibling shares their properties, so hoisting would reorder
the cascade. The identity variant (@custom-variant self (&)) compiles to
safe per-member sibling rules but no site needed it — deletion won
everywhere. Skill reference records the rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
# Conflicts:
#	packages/react-components/react-tailwind-theme-preview/css/themes.css
#	packages/react-components/react-tailwind-theme-preview/css/tokens.css
#	packages/react-components/react-tailwind-theme-preview/scripts/generate-tokens-css.js
… flush margin; Dropdown drops the :has() support guard

The cross-module sibling reach in Card.module.css (.floating-action +
.group/fui-card-preview) moves into CardPreview's own module: the
floating action slot carries peer/fui-card-floating-action, and the new
after-floating-action catalog variant selects the adjacency with the
same + combinator Griffel authors (> .fui-Card__floatingAction +
.fui-CardPreview). Computed margins verified equal across five
orientation/order configurations; the corpus holds no other cross-module
sibling reach.

Dropdown's @supports selector(:has(*)) wrapper goes — the browser floor
has :has. Griffel authors the same guard; the comment records the
deliberate departure. The inner rule is unchanged, and the built chunk
still rewrites the module-local class inside :has().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
# Conflicts:
#	packages/react-components/react-windmod-preview/library/src/components/MenuItemSwitch/MenuItemSwitch.module.css
#	packages/react-components/react-windmod-preview/library/src/components/Switch/Switch.module.css
…and idiom-gap rows onto Tailwind forms

Style-audit phase 2A (triaged): the CENSUS.md section-2 likely-fixable
outliers, the section-5.1 byte-identical conversion rows and the
section-5.2 computed-equivalent rows (item-31 ruling: the idiomatic
utility wins whenever layout is identical, even when the emitted
property/value differs — justification comments claiming otherwise are
deleted). Every file verified by compiled-chunk diff against a
pre-edit baseline: declaration-for-declaration byte identity except the
by-design emissions (items-start family -> flex-start, justify-self ->
flex-*, scale-50 -> scale property, rounded-full -> calc(infinity*1px),
leading-none -> 1, origin-left/right -> 0/100%, drop-shadow var chain,
start-1/4 -> calc fraction, motion-reduce media spelling).

leading-none inheritance probed in-browser: identical on the element
and for plain-text children; the delta exists only for consumer
descendants with restyled font-size.

TeachingPopoverBody drops its three dead `@supports not (aspect-ratio)`
fallbacks under the browser-floor ruling; Griffel authors them, so the
module records the deliberate departure.

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

Fourteen defect classes distilled from the final-review queue, one row
each: wrong form, right form, detection grep or probe. Linked from
SKILL.md's authoring section and the authoring-conventions index so a
contributor checks the diff against it before calling a change done.

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

The phase-2A pass deleted them as dead under the browser floor, but a bare
property name is not a valid declaration test — Chrome evaluates the negation
true and the fallback heights render on every load, matching Griffel's own
authored guard. Bisect-proven: removal shifts the teaching-popover media scene
by 14,135 strict pixels. Failure-modes reference gains the class.

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

Ray Knight (ArrayKnight) commented Aug 29, 2026

Copy link
Copy Markdown
Author
§1 — What ships, in full: the export surface, CSS delivery, and scale

1. What ships

Two preview packages, published as a pair, both at 0.1.0 and both following the shape
@fluentui/react-calendar-preview was scaffolded with (7ca3751429): 0-major versioning, the
-preview suffix, and beachball disallowedChangeTypes: ["major", "prerelease"].

Package Role
@fluentui/react-tailwind-theme-preview palette, type ramp, spacing scale, seven per-theme stylesheets ./themes/*.css (classes fui-theme-web-light, -web-dark, -teams-light, -teams-dark, -teams-high-contrast, -teams-light-v21, -teams-dark-v21; import-and-set, no baked default; ./styles.css retains all seven for zero-config), and the global reduced-motion floor
@fluentui/react-windmod-preview the 151 components and their compiled stylesheets

151 components, one per directory in library/src/components/.

60 export keys, and no root barrel. The package exports components only through 52 family
subpaths
./button, ./card, ./menu, ./dialog — each carrying every part of its family:
CardHeader comes from ./card, MenuItem from ./menu, Tab from ./tab-list. The families are
@fluentui/react-headless-components-preview's own subpaths, one for one, so the styled layer has
the same shape as the headless layer underneath it: whatever
.../react-headless-components-preview/menu gives you hooks for,
.../react-windmod-preview/menu gives you components for. Two more JS subpaths export no component —
./positioning (headless primitives, re-exported) and ./use-css-var-value (windmod-only; the one
subpath with no headless counterpart). The rest are . (present but empty, mirroring headless's own
root), ./base.css, ./css/*, ./styles.css, ./variants.css and ./package.json. (./base.css
and the ./css/* wildcard came with per-component CSS delivery; a wildcard rather than 131 declared
keys, because bundlers follow the JS import and never need the key.)

Family barrels are not a tree-shaking regression: importing one component from a 13-member family
retains 6 of the family's 36 JS modules, measured on the esbuild metafile rather than argued. The
family's CSS does come along — the deliberate trade, and what "sideEffects": ["**/*.css"] buys:
the styling cost is bounded by the family you reached for rather than by the suite. §3 quantifies it.

Component CSS ships per component; two root stylesheets load once per document. Each component's
class map side-effect-imports its own compiled chunk, so a bundler ships exactly the components
the app reaches. What is left to load by hand, head-of-document, is the theme sheet followed by
windmod's ~3.3 KB root sheet ./base.css — the @layer order statement and the global
@property registrations every chunk assumes. ./styles.css is retained unchanged in role: the
batteries-included monolith (root sheet + all 131 chunks in one file), which is a complete correct
setup on its own and is what CommonJS, SSR and <link>-only pipelines should use.

./variants.css publishes the component-specific @custom-variant catalog so a consumer's own
Tailwind CSS can compose against windmod's states. It is an authoring source, not a runtime cost —
it contains no rules and appears nowhere in dist/styles.css.

./use-css-var-value exports useCssVarValue / invalidateCssVars — reads a CSS custom
property's resolved value off an element, with a scope- and root-versioned cache, an SSR/pre-mount
fallback, a record form, and an explicit forceOnRender escape hatch. It exists because windmod
exposes no JS token object; code that used to read a token from webLightTheme in JavaScript reads
it from the DOM instead.

./positioning re-exports the headless positioning entrypoint (usePositioning,
resolvePositioningShorthand), mirroring what Griffel exports publicly. Mechanical re-export, no
styling.

Also in the diff: .github/CODEOWNERS gains the two windmod paths under @microsoft/cxe-prg
(matching the headless package's entry), and .gitignore gains .scratch/.

Scale. 251 commits (211 non-merge) from 5707e79d85 to 68bb856687; against upstream/master
the branch is 1,587 files changed, +102,673 / −140, of which 1,504 files are the two windmod
packages.

§2 — Why Tailwind, and why CSS Modules (including the full DX argument)

2. Why Tailwind, and why CSS Modules

Two arguments, one about the ecosystem and one about the authoring model.

Tailwind is where the ecosystem is. Its tooling is mature and boring in the good sense — an
editor extension with real completions and hover docs, a Prettier plugin that sorts classes
deterministically, an ESLint plugin, and a v4 engine that needs no config file — and, crucially,
it is a vocabulary a great many developers already know. A Fluent that can be styled the
Tailwind way is a Fluent that a team can adopt without first learning a bespoke styling API.

CSS Modules is what makes it a design system rather than a pile of utilities. Utilities in JSX
scatter a component's appearance across its markup; a *.module.css file collects it. windmod uses
Tailwind as the vocabulary inside CSS Modules files, compiled at build time — so the authoring
experience is Tailwind's, and the output is an ordinary, cacheable stylesheet with real rules.

Developer experience

(Ported from the closed 2026-08 proposal, whose DX section was never contested — see §7 for what
happened to that PR and what was dropped.)

You write CSS. No object notation, no camelCased property names, no JS-based abstraction
standing between the author and the stylesheet. Tailwind introduces some indirection of its own (it
isn't perfect), but the escape hatch is always the same: when its vocabulary doesn't cover a case,
you drop to plain CSS in the same file — not to another abstraction.

Styles are grouped, not scattered — and the blast radius is bounded. A component's entire
appearance lives in one *.module.css, organized by slot and state. That bounds the blast radius of
change: editing a component's rule affects that component — whereas editing a shared atomic class,
in code or live in DevTools, changes every element on the page that happens to use it.

Separation of concerns. Behaviour stays in .tsx, appearance in .module.css. Reviews get
smaller and more focused; styling changes stop churning component logic files.

The DOM is parseable. Emitted class names are scoped but semantic
(fuicm-<component>-<slot>-<hash>), so component structure is identifiable by reading the markup in
DevTools — versus a hashed atomic class soup where nothing in the element tells you what it is.

Debugging is the browser's own toolchain. Component styles are real stylesheet rules, so
DevTools is the whole toolchain: select the element and read Styles top to bottom. Chrome and
Firefox both label the @layer each rule came from, and struck-through declarations show exactly
which layer won. If a rule you wrote is losing, check the layer label first — a rule inside
fui.components.l1 losing to one in fui.components.l4 is working as designed.

States are single-sourced. Tailwind v4's compound/stacked variant syntax
(@variant hover, focus-visible { … }) expresses "these states share this appearance" once, instead
of duplicated blocks that silently drift apart.

Overrides are predictable. Cascade layers give deterministic precedence
(fui.themefui.basefui.components.l1–l5fui.utilities): a consumer override wins by
layer, not by specificity games, source-order luck, or !important. And unlayered CSS beats
every layer
, so a consumer's own stylesheet wins over Fluent's rules by default.

Theming is a class. A theme is a CSS class containing only custom-property declarations, so
scoping it is a DOM operation rather than an API call, and switching themes is a className swap
instead of rebuilding and re-injecting a rule set.

Server and client markup become identical. Nothing is inserted at runtime, so there is no
<style> ordering to reason about, no hydration step for styles, and no CSP nonce to thread
through. (There is one static ordering rule — the root stylesheet loads first, §3.1 caveat 2 — but
it is a link-time fact about the document, decided once, not a per-render or per-hydration one.)

§3 — Cost: the full bundle-size and runtime analysis, with the caveats

3. Cost: bundle size and runtime

Two independent measurements, reported with the results that cut against windmod as prominently as
the ones that favour it.

3.1 Bundle size

JS measured on 104b3d8d1e; every CSS figure re-measured on 6057ae252f, after per-component CSS
delivery landed. node v22.12.0, esbuild 0.25.0, gzip level 9, both libraries built from source with
nx build --skip-nx-cache and no source modified. Full methodology, raw JSON and reproduction
scripts in bundle-analysis/.

  • Windmod ships no Griffel runtime, verified rather than claimed. Every JS subpath entry was
    bundled independently and its module graph scanned: zero contain any @griffel/* module. The
    runtime they replace weighs 13,823 gzip standalone.
  • The whole library is 3.36× smaller in JavaScript (99,305 vs 333,922 gzip), 2.50× counting the
    full static stylesheet. Per component the gap is wider: Button 5.16× (2,369 vs 12,216), Dialog
    6.38×.
  • The real saving isn't the runtime, it's the styles. @fluentui/react-components ships 621,799
    raw minified bytes of precompiled Griffel *.styles.js — its entire visual contract as
    JavaScript — plus 79,480 bytes of JS design tokens. Windmod's equivalent is 40,963 bytes of class
    maps and static, CDN-cacheable stylesheet chunks.
  • CSS ships per component, so windmod is smaller from the first component — there is no
    crossover, on any route.
    A Button-only consumer importing @fluentui/react-windmod-preview/button
    transfers 2,666 gzip of CSS — a 1,888 gzip Button chunk plus the 778 gzip root sheet — where the
    old monolith cost 33,750 whatever you imported. Totals: 5,046 vs 12,330 gzip at one component
    (2.44×), widening monotonically to 85,107 vs 167,250 at 49 (1.96×, 49% fewer bytes). The CSS is
    strongly sub-linear: 49× the components for 13.1× the CSS.
  • Themes ship per theme. ./base.css plus one ./themes/<name>.css is a complete
    single-theme setup: 4,718 gzip for web light (827 + 3,891) against 14,679 for the all-seven
    monolith — 3.1× less for the common case. Themes are import-and-set (a class you apply), matching
    Griffel's contract of no baked default; the previous build shipped all seven themes
    unconditionally where Griffel ships one, and the split converts that regression into parity.
  • Family barrels tree-shake JavaScript. Importing one component from a 13-member family retains
    6 of the family's 36 windmod modules; a 7-member family, 8 of 30; a 4-member family, 5 of 14 —
    read off the esbuild metafile by bytes-in-output, not by "appears in the input list". The earlier
    root barrel shook to within 2 bytes of a single-component subpath, which is the same mechanism.
    None of the JS advantage above is a tree-shaking artifact.

Two honest caveats, stated with the numbers rather than after them. The all-or-nothing CSS this PR
used to lead with is gone — and with the root barrel removed there is no longer any import style that
pays it. What replaced it is smaller, and different in kind.

  1. An import reaches its whole family's CSS, not just its component's. ./card brings
    CardHeader, CardFooter and CardPreview's chunks even if you only render Card. Measured
    across the 49-component curve that is +5,493 gzip against the per-component subpaths it
    replaced (25,115 vs 19,622); below ten components the two are byte-identical, because those
    components each own their family. This is the intended trade rather than a defect — a menu or a
    dialog is built from most of its family — and it is what makes the export surface match the
    headless package's. The JS is unaffected: 59,424 vs 59,388 gzip at 49 components, a 36-byte
    minification-ordering difference.
  2. The root stylesheet is a new consumer obligation. ./base.css (3.9 KB raw / 778 gzip) owns
    the @layer order declaration and the global @property registrations that every chunk assumes,
    and it must be loaded head-of-document — imported first in the entry module, or @imported at
    the top of the app's own root stylesheet. Component chunks deliberately carry layer blocks only,
    so exactly one artifact declares the order; if a chunk reaches the document first, layers are
    established in first-use order and inter-component precedence inverts (a ToggleButton chunk
    arriving before Button's loses contested properties to the Button it builds on — measured at
    24 differing computed properties). The guard is a development-mode detector in
    FluentProvider
    that reads back border-style: var(--tw-border-style) and warns, naming which
    of the two mistakes happened; it separates all eight documents of the real-artifact matrix with
    zero disagreements against computed-style ground truth. Consumers who want none of this keep
    importing ./styles.css, the monolith, which inlines the root sheet and is a complete setup on
    its own — with one sharp edge worth naming: that is an alternative only where class maps do not
    pull chunks (CommonJS, <link>-only, SSR). An ESM bundler consumer who adds the monolith on
    top of component imports gets both — measured at 35,358 gzip against 2,277 for the same app
    importing ./base.css instead.

Three caveats a reviewer re-running this must respect: @fluentui/react-headless-components-preview
is windmod-only and its 52,079 raw minified bytes are counted entirely on windmod's side (no
adjustment owed); icons resolve through a local tarball on both sides, so the comparison is
internally consistent but absolute icon bytes may differ from npm; and every bundle must pass
--tsconfig=no-paths.json
— the workspace paths mapping silently redirects esbuild to
TypeScript sources and produces wrong numbers.

3.2 Runtime — newly measured, and it does not all go one way

Previously this PR claimed only "same result, far less shipped code" because runtime had not been
benchmarked. It has been now. Method: one shared tree definition (200 Buttons + 50 Inputs + one open
8-item Menu under the respective provider — verified 201 <button> + 50 <input> rendered on both
sides), esbuild-minified NODE_ENV=production builds resolved through the workspace's real
published-shape output, Chrome 147 headless with the VR harness's flags, one discarded warm-up then
5 measured runs per side with the order alternated every round. Medians, ms:

Metric windmod Griffel
Cold mount (total) 27.8 (26.8–28.2) 48.7 (47.7–50.0) windmod 1.75× faster
— React mount portion 20.8 39.9
Re-render (prop flip across the tree) 26.8 (25.9–27.2) 24.5 (23.8–25.1) Griffel faster by 2.3
— style recalc portion 19.3 15.4 Griffel faster by 3.9
Second mount into the same document 19.0 22.7 windmod faster by 3.7
JS heap after forced GC 3.44 MB 5.83 MB

What this shows, and what it does not. The cold-mount win is real and stable (tight spread,
direction held across three sweeps under alternated order; treat medians as ±2 ms). But the
attribution was measured rather than asserted: mounting the same tree a second time into a
document whose rules are already injected
collapses the gap from 19.1 ms to 2.8 ms — so ~85% of
the advantage is first-mount-only cost that Griffel pays and windmod does not. Attributing that
specifically to rule injection is an inference, not a measurement: it also bundles Griffel's
one-time internal caches and extra JIT warm-up on a 1.9×-larger bundle. A CDP trace corroborates the
mechanism — windmod parses its stylesheet once in 2.6 ms, while Griffel emits no
ParseAuthorStyleSheet event at all because it inserts via CSSOM, so its cost surfaces as JavaScript
inside mount.

Two results that should not be buried. Griffel wins the re-render metric (24.5 vs 26.8 ms):
flat atomic classes recalc more cheaply than a large layered stylesheet, and the trace agrees
independently (UpdateLayoutTree 21.1 vs 26.0 ms). And the re-render metric tests no injection on
either side
— Griffel's rule count was 341 before and after the flip, because makeStyles had
already injected those variants at mount.

And the byte comparison here is not like-for-like. For this 3-component synthetic app windmod
ships more total bytes (749.8 KB vs 459.8 KB), because the harness loads the whole monolith. That
number predates per-component CSS delivery and has not been re-measured: a subpath-import build
of the same three components would carry a small fraction of that CSS (§3.1), which would move the
byte comparison and, plausibly, the 2.6 ms stylesheet parse the trace attributes to windmod. The
runtime medians in the table stand as measured — the same rules are in the document either way — but
treat the 749.8 KB as a pre-split figure. The memory figure is JS heap only and excludes the CSSOM
where windmod's stylesheet lives. This is a one-shot synthetic mount, not an app: real apps mount
smaller trees more often, which shifts weight toward the 2.8 ms steady-state gap rather than the
19.1 ms cold one.

Full write-up, raw per-run tables and re-runnable scripts: perf-bench/RESULTS.md.

§4 — The verification story: VR gate semantics, the eighteen allowances, the no-GPU certification, computed-style parity, mutation testing, gates

4. The verification story

Every number above traces to a recorded measurement. The qualitative evidence is self-serve in
this branch: run the stories workspace and every scene renders live, side by side — each VR
scene is one shared cell-grid component instantiated twice, once with Griffel's components and
once with windmod's, plus per-component GriffelComparison stories built for eyeballing. The
quantitative layer — the pixel-diff harness that produced the numbers, plus plan verification
chains, the decision log, allowance controls, bundle and benchmark data, and the upstream
findings roster — is available to reviewers on request.

The parity claim is not "looks the same" — it is a pixel-diff gate where one differing pixel fails
the scene unless the matcher classifies it as antialiasing, plus computed-style matrices, plus
mutation-tested specs, each with a control that bounds the measurement.

4.1 Visual regression: 91 scenes, 74 at strict zero

Each scene renders one shared <X>VrScene.tsx twice — once inside windmod's provider, once inside
Griffel's FluentProvider with webLightTheme — against a static Storybook build, and diffs the
two captures with pixelmatch at threshold 0. Scene definitions and their ratified ceilings live in
scenes.json (91 entries, 18 carrying allowedStrictDiff; verified by counting the file).

Most components cleared the gate on the first VR pass. Where they did not, the gate found real
defects no other check could see — ToggleButton's importance-inverts-layer-order glyph bug,
ColorPicker's single-bit data-URI flip in the checkerboard, Input's focus-only phantom border.

What "strict zero" asserts, precisely. The runner calls pixelmatch with includeAA at its
default false, so the gate's antialiasing classifier absorbs sub-perceptual rasterization noise
along glyph and border edges. Strict zero therefore means zero non-antialiasing differences — a
pair with 59 differing raw bytes, all of them edge noise, reports 0 — and every other difference
counts: a moved edge, a changed colour, a missing shadow, a wrong radius. That is the rule
throughout this PR, in MIGRATION.md, and for the eighteen allowances in §4.2, which were measured,
decomposed and control-bounded under exactly the same classifier. It is also not the only gate:
per-component computed-style parity passes compare resolved CSS directly, which is what catches the
divergences that paint identically and behave differently — the transition-property spellings, the
dead-declaration ports, the context-merge asymmetries. Pixel counting alone would have missed all of
them.

4.2 The eighteen allowances — each ratified, each decomposed, each controlled

No allowance is a tolerance band. Every one names a specific mechanism, and a diff that does not
decompose the documented way fails the scene even when it sits under the ceiling. Three classes.

Class A — GPU compositing, bounded by an identity control. The two libraries put the same
surface on different Chrome raster paths because windmod promotes to the top layer natively where
Griffel renders inline.

Scene Ceiling Control that bounds it
teaching-popover 21093 Forcing position: fixed on Griffel's own surface reproduces the same pixel set — overlap 21093/21093, gate-only 0, control-only 0.
teaching-popover-placements 8883 Same control at 8883/8883; residue splits 5824 arrow + 3059 rasterization.
teaching-popover-carousel / -brand / -rtl 18386 / 17816 / 18386 Same class in its purest form: identity control = 0 on all three under both GPU and no-GPU. Two are completely-compliant certified (no-GPU strict 0); the RTL 6 is a settle-pass harness artifact bounded by reference-vs-reference at exactly 6.
tag-picker-open-ltr / -rtl, -width-ltr / -rtl 19 / 2 / 57 / 44 Coverage-gamma AA; a Griffel-vs-Griffel control reproduces windmod pixel-identically on 3 of the 4 (residual 0 / 0 / 6Δ1 / 0).
menu, menu-rtl 413 / 409 Top-layer compositing; retroactively bounded at 403/402 full overlap by the promoted-Griffel identity control.
menu-sequel, menu-sequel-rtl 462 / 460 Identity control reproduces the full 460 pixel-for-pixel in both directions (overlap 460/460, gate-only 0, control-only 0) — triple-reproduced. 168 Δ1 shadow-quantisation px at the trigger's bottom edge + 7 thumb-SVG AA px; rects byte-identical. menu-sequel's extra 2 is a second GPU rasterization mode, the same headroom pattern as menu.

Class B — positioning geometry, proven by byte-identity under shift. These survive software
rasterization because they are real geometry, and each was proven to be only a translation.

Scene Ceiling Proof
info-label-open 8545 Whole-assembly translation, dx=1 at medium and dx=3 at large; surface bands byte-identical once shifted. Acceptance is offset-specific: a future diff is accepted iff it decomposes as dx=1 / dx=3 / dx=0; anything else rejects under the ceiling.
popover 6551 1274 px arrow paint (the accepted native anchor-positioning delta) + 3398 px shadow derivative + 1949 px fixed-vs-absolute drop-shadow rasterization, control-bounded at 100% Δ1.
tooltip 8164 Sub-pixel glyph displacement (six cells) + the above-start / below-end arrow displacement.
dialog-scroll 1335 Pure −2px translation of one 248×34 box, reproducing Griffel 1645/1645. Structurally unclosable: windmod's grid tracks resolve inside the content box where Griffel's separate scroller overflows its parent by the border. Closing it costs 76× elsewhere.

Class C — 1-ULP, on Griffel's side. toast-inverted, ceiling 8. Griffel's own GPU
rasterization of the inverted error glyph is bimodal 70/30 with byte-identical DOM — a 1-ULP
blend split. windmod is byte-stable on Griffel's majority face, so windmod-vs-Griffel-majority is raw
0 and the allowance equals its Griffel-vs-Griffel control exactly. Across four GPU measurements
it never fired.

4.3 The no-GPU compliance certification

A dedicated harness pass (--no-gpu, five independent runs per row) re-gated every allowance under
software rasterization to sort real geometry from raster artifact.

  • Four rows certify COMPLETELY COMPLIANT — strict zero on all five runs: teaching-popover
    (21093 → 0), toast-inverted (8 → 0), and two of the three carousel scenes. For
    teaching-popover that is the strongest statement in the table: the 21093-pixel ceiling has no
    CSS component whatsoever
    .
  • Several rows are mechanism-confirmed and persist, exactly as predicted:
    teaching-popover-placements 5824 ×5, info-label-open 8107 ×5 (94.9% of the residue — the
    positioning class, not compositing), popover 4366 ×5, tooltip 4219 ×5, and dialog-scroll
    1335 ×5, byte-identical to its GPU ceiling — a 0% GPU term, the cleanest positioning row.
  • The headline finding is a negative one, recorded rather than hidden. --disable-gpu is not
    a universally cleaner baseline. The TagPicker and Menu rows go up two to three orders of
    magnitude under the flag (tag-picker-open-rtl 2 → 9348). That is a property of the harness mode,
    not of windmod, and the control settles it: shipped-green strict-zero scenes
    (combobox-open-ltr, combobox-open-rtl, dropdown-open-ltr) measure 8964 under the same
    flag, with a byte-identical diff signature (99.87–99.88% |Δ|=1, max |Δ|=2, same bbox). Both
    captures rerasterize wholesale. Those rows keep their GPU ceilings as authoritative and carry a
    NOT A COMPLIANCE STATEMENT note. A no-GPU number is never the GPU number minus a component.
  • One row honestly records instability. menu under --disable-gpu is bimodal (10210 / 9478 /
    10210 / 9478 / 9478). Per the stability rule no noGpu ceiling was invented — the row carries
    none and its stable GPU gate (413) stays authoritative.

4.4 Computed-style parity, beyond what pixels can see

VR captures resting state at one theme. The forced-colors, focus and RTL contracts were verified by
reading computed styles out of a real browser via CDP.

  • TagPicker: 27,126 parity properties compared, 0 divergences on the closed scene.
  • The forced-colors layer sweep ran Emulation.setEmulatedMedia directly (puppeteer's
    emulateMediaFeatures rejects forced-colors), asserted the emulation in-page on every load, and
    drove focus with real Tab keypresses rather than forcePseudoState — because Griffel gates its
    ring on keyborg's data-fui-focus-visible attribute, and forcing :focus-visible produces 46/46
    and 116/116 false divergences. It grew to 593 cells per phase across six scenes × 2 media
    modes × 4 states
    , and closed at 0 divergent cells in all 48 scene-phases, with 0
    newly-broken throughout, identical counts on three consecutive runs, a mutation revert reproducing
    each baseline exactly, and a Griffel-vs-Griffel control at 0 in every phase. None of this is
    visible to VR or to jest.
  • Per-component parity matrices: Menu at 10 passes, zero divergences; InteractionTag at 12
    including real-Tab keyborg and forced-colors.

4.5 Mutation-tested specs

The house bar: deleting any class from a clsx call, breaking any stamped attribute, or mutating
pass-through state must each fail at least one test — verified by running the mutation, not by
reading the tests
. Every component cycle ran a mutation stage and recorded the count and the kill:
Slider 59 across three stages, Textarea 56, TeachingPopover 54/54, RatingDisplay 49 (3 real holes
closed), ColorPicker 42, AvatarGroup 40/40, SplitButton 38+, Menu 38, Toast 35, InfoLabel 33,
MenuButton 31, TagPicker 22. Where a mutation could not be killed it is classified and justified,
not waved through — and where a guard would have asserted only that the file says what the file
says, it was explicitly declined.

4.6 Gates

Every component landed only after the gate script ran green end to end: build (theme + windmod),
type-check, lint, lint:stories, type-check:stories, test — for both packages — then a static
Storybook build and the pixel diff.

At the final SHA (68bb856687) the full gate set ran uncached (--skip-nx-cache) and green:
all eight steps, with the test gate at 160 suites / 3,580 tests, followed by the full sweep at
91/91 — 74 strict zero, 17 within their ratified ceilings.

§5 — The non-windmod commits, in full: mechanism, measurement and the two reproduction traps

Editorial note, added at publish time and not part of the section below. The section is preserved verbatim from the working narrative and says twelve; the table in the opening summary says thirteen. The thirteenth is the generate-api Windows fix (d826380ae5), which was identified after this section was written. Two entries below are also reframed in the summary: the CSS-modules executor CJS fix (f484a3efb9) and the @prettier/sync declaration repair code this branch itself introduces, so they are this PR's own housekeeping rather than upstream defects. Read the summary table as authoritative on the count and the attribution, and this section as authoritative on the mechanisms.

5. Twelve commits that are not windmod

Per PR-4 these stay in this PR; split only if maintainers ask. The rationale is alignment: VR
re-runs and reviewer comparisons against Griffel must see one coherent tree, and split PRs risk long
waits, an aging windmod PR, and continuous rebasing.

What they are. This is a windmod PR, but twelve of its commits do not touch windmod at all: they
repair defects in @fluentui/react-headless-components-preview, three Griffel packages, two
Storybook tooling packages, and the workspace build plugin (twice — one build executor, one sync
generator). They are here because the windmod work
could not proceed around them — each either broke a build, produced a wrong DOM contract, or made a
whole component class unusable — and because leaving them in a scratch report would mean shipping a
preview package that silently depends on defects staying defects.

Every one was measured before and after, and every behavioural change carries a regression test
verified to fail without the fix. Each is a single self-contained commit against a
Microsoft-owned path, and none depends on any windmod commit.

# Commit Package What changed, why it was necessary, and how it was found
1 b95a4152c4 react-breadcrumb OmitDistributiveOmit on BreadcrumbButtonProps. Plain Omit collapsed the distributive as: 'button' | 'a' union and href disappeared — blocking the exact spelling Griffel's own Default story uses. @fluentui/react-button already avoids this the same way. Type-level only. Found trying to reproduce Griffel's Breadcrumb story surface. Two of four originally-claimed losses were misattributed and withdrawn before filing.
2 20924b8a1a react-color-picker adjustChannel's ||??. A channel valued 0 fell through to the hue action; the slider then emitted value="210" max="100" with the thumb at 210%. Found by a parity probe — and it reproduced on both libraries, so it was never a windmod defect but a live bug for any consumer.
3 f484a3efb9 tools/workspace-plugin The CSS-modules build executor emitted CommonJS class maps with a .js extension under "type": "module" packages (node parsed them as ESM → ReferenceError: exports is not defined), and globbed **/*.js where every sibling was already .cjs, so the emitted require() was never repointed. Either half alone reproduces the crash. Affected 21 shipped modules. Found when the first ESM-first CSS-modules package failed to load at all.
4 01ccede78a headless usePopoverSurface pinned popover after the ...props spread, so a consumer could never override it and only one Popover could be open per page. Moved into the surface slot's defaultProps. Measured on an 8-cell probe: openCount 1/8 → 8/8. Found building the Popover skin. Note the half withdrawn on evidence: the review folded id in as "the same defect class"; it is not — the surface id is pinned deliberately and letting a surface-level id win leaves the trigger's aria-details dangling. Applying the drafted id change fails an existing test, measured.
5 40bca2b821 headless The same structural mistake at the family's two remaining sites: useMenuPopover spread popover: 'auto' over the base state's root, and useListbox assigned it after its hook returned. Measured 1/3 → 3/3 open surfaces. Found by systematically auditing every site of the #4 pattern rather than waiting to trip over them.
6 17f7e796d8 headless Dialog's scroll lock clipped body and reserved nothing, so opening a modal on a scrolling page shifted every fixed and centred element sideways: a marker at x 332.5 → 340 (Δx 7.5) where Griffel stayed at 332.5. After: Δx 0. The gutter had to go on documentElement, not body — measured, not assumed (overflow propagates from body to the viewport; scrollbar-gutter does not). The write is guarded on the scrollbar actually taking layout width, because writing it unconditionally introduces the mirror-image defect (measured Δx −7.5).
7 f34b0a1125 react-tag-picker useTagPickerControl cancelled the aside-width rAF in the effect body, racing away the only write of --fui-TagPickerControl-aside-width. Production build, 12 fresh loads: 3/12 wrote it — resting padding-inline-end 30px or 12px, an 18px input-width swing between runs of the same page. After: 12/12. Found because a VR scene would not go green deterministically; a race is not noise, so no allowance was permissible.
8 (new) headless renderTooltip dropped state.arrowClassName from the arrow element, so a styling layer's arrow class was silently discarded. Griffel's own renderTooltip applies it and Tooltip.types.ts documents it as public state. Found when windmod's tooltip arrow rendered unstyled with no error. One-line fix.
9 (new) headless MenuSplitGroup rendered without a contexts argument. useIsInMenuSplitGroup compares the provided value against the module-level default by identity, so no descendant could tell it was inside a split group. Now provides a useMemo-stabilised { menuSplitGroup: { setMultiline } }; setMultiline stays the documented no-op. Found when the trigger half kept icon and checkmark gutters Griffel drops.
10 5a89364f3f headless ./avatar-group published AvatarGroupContextValue and both context-values hooks but neither the reader nor the provider — so a styling layer could not read the size/layout/isOverflow the subpath's own render functions publish. The sibling ./avatar already solved it this way. Additive.
11 9612d547d9 headless Publishes the Button, Link and Avatar contexts. A measured parity defect, not a nicety: a windmod Button inside a windmod MessageBarActions rendered medium where Griffel renders small. The provider side was already correct — the headless renderers are verbatim re-exports of Griffel's and already wrap subtrees in Griffel's providers. Because those are Griffel's own context instances, a locally-declared context can never receive the values, so the re-export is the only fix available to a styling layer.
12 b7c455ee52 tools/workspace-plugin export-maps-sync rebuilds a project's whole exports map from its declared entry points and keeps nothing else, so any subpath it cannot derive from a source file it silently deletes on the next nx sync — fine for an all-TypeScript map, fatal for a package shipping a compiled stylesheet or a raw .css source. Adds metadata.exportMap.staticSubpaths: a list of keys the generator does not own, whose entries stay hand authored in package.json next to the files array that ships them and are read back verbatim, so there is no second copy of the paths to drift. A declared key with nothing to preserve now throws naming the key instead of dropping it. Key emission runs through one ordering pass (. first, ./package.json last, the rest alphabetical). Eleven unit tests in the plugin's existing style; nx sync:check reports the workspace up to date before and after, so no pre-existing package moves. Found by measuring what un-privating windmod would actually do: with no declaration the map collapsed to 2 keys from 151, and with only subpathEntryPoints the loss narrowed to exactly ./styles.css and ./variants.css. Documenting that in a guard note was the earlier answer; this is the fix. (Per-component CSS delivery has since exercised it further: ./base.css and the wildcard ./css/* were added as declared static keys — 153 in the map today — and nx sync needed no generator change, because declared keys are copied verbatim.)

Two more, smaller, in the same spirit: @fluentui/react-storybook-addon-export-to-sandbox's addon
registration pattern matched only forward slashes, so on Windows the registration was never found,
the addon options were silently dropped, and the full-source babel plugin crashed on undefined
importMappings; and @fluentui/babel-preset-storybook-full-source formats through
@prettier/sync (babel visitors are synchronous, prettier 3's format is not) but never declared
it — the published package would have thrown MODULE_NOT_FOUND for any consumer outside this repo.

A thirteenth (b9853103c5) is struck and is not in the PR. Upstream #36606 (1422f2dd92) rewrote
the same eight headless subpath exports more completely while this branch was in flight; the merge
took upstream whole and git diff upstream/master on that file is empty. Documented so a reviewer
diffing against an older report does not go hunting. Worth one line anyway: upstream converged on
the export-map shape windmod had already authored
, and windmod's own isConformant already
asserted it.

Two reproduction traps — please read before rejecting a fix

  1. f34b0a1125 (TagPicker race): reproduce on a PRODUCTION build with NO StrictMode. Under
    StrictMode in a dev build the defect is invisible — unpatched measures 12/12, because the
    double-invoke detaches and re-attaches the ref, the observer fires again after the effect has
    run, and the write lands. Anyone probing a StrictMode dev Storybook will wrongly conclude the
    finding is stale. This is the single most likely way for this fix to be rejected. Also: the
    mechanism is not targetDocument identity churn — instrumented in the same production
    bundle, the effect runs once on 12 of 12. The race is React's passive-effect flush against
    the observer's first callback.
  2. 17f7e796d8 (Dialog scroll gutter): the probe harness and the VR runner both inherit
    puppeteer's default --hide-scrollbars
    , under which the scrollbar takes no layout width and
    this defect cannot appear at all — it even inverts, making Griffel the side that shifts. The
    measurements were taken with ignoreDefaultArgs: ['--hide-scrollbars'].

Change files. Each affected package versions separately through its own change file; thirteen
are included, one per fix for the headless package so the changelog carries the same per-fix
granularity this section does.

§6 — Upstream findings: 45 defects, offered as issues

Editorial note, added at publish time and not part of the section below. The status table below predates the final classification pass; read the opening summary's thirteen-row table as authoritative for what ships. The 45 open findings — the number this section exists for — are unaffected.

6. Upstream findings — 45 defects, offered as issues

BLOCKED_ON_UPSTREAM.md is the consolidated roster: every upstream finding the campaign produced,
re-verified against the post-merge tree.

Status Count
Fixed in tree, shipping in this PR 11 (+2 tooling)
Fixed in tree, struck as superseded upstream 1
OPEN — defects to report 45
OPEN — documented deltas / release-note items 5
House defects (windmod's or the harness's own, never to be filed) 10
Withdrawn on evidence 2
Raw plan-local findings consolidated into the above ~75

Nothing in the OPEN section is applied — they are recommendations, each carrying enough context to
open an issue or a PR without re-deriving the measurement. Five defect classes recur across many
components and are filed once each with all sites listed (one across 9 sites, others across 6, 6, 6,
and 12 packages / 215 import sites), which is why 50 entries carry ~75 raw findings.

The recommendation: do not open 50 issues. The five consolidated classes are one issue each and
the rest group naturally into about a dozen more by package. The highest-value cluster for a
maintainer is the data-* contract group
— the contract the headless package exists to provide.
Each fix is small, and each defect silently mis-styles a consumer who trusts the attribute.

I will file these as issues on request — the roster is written to be pasted. Say the word in
review and they go up, grouped as recommended, whether or not windmod itself goes anywhere. If
nobody takes them up, 45 measured defects sit in a branch nobody outside it can see.

Two entries deserve a maintainer's eye even though they are not defects: MIGRATION.md's "Known
issues that affect both libraries" lists seven consumer-actionable bugs in shared upstream code
where windmod is at exact parityInteractionTagPrimary silently losing selection when you
pass onClick, BreadcrumbButton's as prop mis-parsing as (as ?? href) ? 'a' : 'button',
SwatchPicker discarding a consumer style, a vertical AlphaSlider reporting horizontal
orientation to assistive technology, and three more.

Resolved 2026-08-28 (operator): summarized-only — the evidence stays out of the merge surface; any of it (plan verification chains, decision log, allowance controls, bundle and benchmark data, the full roster) is provided to reviewers on request, as attachments, a gist, or a follow-up commit at their preference.

§7 — Relationship to the closed 2026-08 proposals

7. Relationship to the closed 2026-08 proposals

Two PRs from this line of work were opened and self-closed in 2026-08 — microsoft/fluentui
#36513 and microsoft/fluentui-system-icons #1204. Neither was closed by a maintainer, and both
were still marked draft. #1204 received no comments and no reviews at all.

The one substantive maintainer response was an issue comment whose operative sentences were: "we
aren't going to be able to take a migration of the v9 styling system in this form"
and "The
decoupling you're solving for is something we're already shipping as Fluent UI Headless… Tailwind +
CSS Modules is squarely within what it's designed for."

That objection was about scope, and this PR is built around it. It contested no measurement and
named no technical defect. It also pre-endorsed the pattern: Tailwind + CSS Modules is squarely
within what the headless components are designed for
. #36513 proposed converting the v9 styling
system; this PR proposes a separate, additive package that consumes the headless components
exactly as intended
and changes nothing about @fluentui/react-components.

Things deliberately not carried over from those PRs: every measurement in them (taken against a
workspace pointed at a packed icons-fork tarball, as the PR itself disclosed), the build-time
percentages (#36513 disqualified its own), the seven v9 breaking changes, and the "consumers stop
shipping Griffel" claim — which the old branch's own evaluation self-refutes as conditional for
source code and permanently false for the installed dependency graph. Everything in §2's DX section
is carried over; none of it was ever contested.

§8 — Known deltas: the migration guide, the reduced-motion floor, and where windmod renders more correctly

8. Known deltas

MIGRATION.md is the reference — a migration guide written for the engineer (or agent) moving
an app off the Griffel-styled suite, shipping as an official deliverable of this PR. It enumerates
56 deliberate differences, each a decision rather than a defect, with the mechanism and the
workaround. Highlights:

  • theme is a CSS class name, not a theme object (webDarkThemeClassName, not webDarkTheme).
    There is no useCustomStyleHook_unstable and no overrides_unstable; restyling goes through CSS,
    which cascade layers make the easier path.
  • There is no root barrel; every import names a family. The exported names are unchanged, so a
    migration is a path rewrite rather than an API rewrite: import { Button } from '@fluentui/react-components'
    becomes import { Button } from '@fluentui/react-windmod-preview/button', and CardHeader moves to
    ./card rather than a subpath of its own. The families are the headless package's, so the mapping is
    fixed and codemod-able, and a family import is one line however many of its parts you use.
  • Public class names are lower-case and classNames.root is a pairfui-button (identity,
    selector-safe) plus group/fui-button (Tailwind's named group). Per-slot class names have no
    equivalent; slots are targeted through group variants and data-* state attributes.
  • Focus rings follow the browser (:focus-visible, :has(:focus-visible)), not keyborg;
    arrow-key navigation comes from focusgroup, not tabster.
  • Anchored components require CSS anchor positioning, with no fallback, and Tooltip's and the
    overflow popover's arrows sit differently on corner and aligned placements — the accepted native
    delta behind four of the allowances.
  • No enter motion; Accordion has no collapse animation and keeps closed panels mounted (state
    persists); MessageBar has no group animation.
  • Alpha mixes compile to color-mix(in oklab, …) via Tailwind's default slash modifier
    (bg-(--token)/30) where Griffel spells the same mix in sRGB. Alpha-only mixes are visually
    equivalent under premultiplied interpolation. Griffel's @supports no-color-mix fallback is
    deliberately not carried over: color-mix shipped in all engines in early 2023, far inside the
    browser floor CSS anchor positioning already imposes on this package.
    A note for the team, not a delta: Tailwind's defaults are oklab throughout. Converting the
    Fluent themes to that colour space would be a natural follow-up and would remove a whole class of
    cross-space reasoning.

The flagship divergence: one global reduced-motion floor

Griffel suppresses motion piecemeal — react-motion swaps every presence atom for a 1ms one, and
about a dozen components additionally author their own @media (prefers-reduced-motion: reduce)
CSS. Motion neither path covers keeps running. windmod suppresses once, in the theme, on
*, *::before, *::after, and no windmod component authors a duration override of its own.

This is the only windmod CSS with no Griffel counterpart, and it was ruled deliberately in four
steps:

  • Add the suppression, justified by matching Griffel's user-facing reduced-motion behaviour rather
    than its mechanism (which is JS and cannot be matched in CSS).
  • Make it global on * rather than per-site; 26 per-component suppression blocks and a Nav chevron
    one-off became unreachable and were removed.
  • No !important. The rule wins by being emitted unlayered: every windmod and theme
    declaration lives inside @layer fui.*, and unlayered author styles beat layered ones regardless
    of specificity. A consumer's own unlayered CSS and inline styles still beat it — there is no
    !important to fight.
  • The Spinner exception. A loading indicator that does not move stops being one, so the
    Spinner's rotation is carved out at Griffel's own under-reduce value: 1.8s, linear, infinite.
    Measured under reduce, both libraries report the identical animation on the ring. Only the
    rotation is exempt; the tail takes the floor.

Documented honestly in both directions: Griffel still moves under reduce in four measured places
windmod does not (ProgressBar determinate width 0.3s, AccordionHeader chevron 0.2s, Nav row
background 0.1s, NavItem selection indicator 0.1s), and windmod closes one gap the other way. One
side effect of the bare * is called out: transition-property defaults to all, so
transitionend now fires for elements it previously would not — do not key logic on it.

Two places windmod renders more correctly than Griffel

Both found by the forced-colors survey, both invisible to every VR scene, both accepted and
documented rather than matched.

  • A current BreadcrumbButton keeps its hover and press styling (16 cells). Griffel's cascade
    lets a later rule beat the current-item styling it evidently intends. Matching it would have
    required deliberately making a windmod rule lose in normal mode too.
  • Pressing a Card styles the card, not its descendants (8 cells). Griffel's compiled selector
    is .f193utb4:hover, .f193utb4 :active — a stray descendant combinator before :active. Copying
    it would have imported the bug; it is reported instead.

One design preference recorded, not endorsed

TeachingPopoverFooter restores its primary/secondary Button slots — presented as the
reference's design faithfully reproduced, not an endorsement of the pattern
. The alternative
(a bare flex container, composition over hard-coded slots) was the author's preference but would
have lost brand overrides, role inversion and auto-close; parity won.

One accepted native delta worth flagging separately

The Popover arrow's placement is a fixed inset from the surface edge (CSS anchor positioning) where
Griffel's floating-ui centres it on the target. Accepted as-is — it follows from the headless
implementation — and left to the maintaining team to call whether it needs to change. MIGRATION.md
carries the worst case (a narrow surface on a wide trigger — arrows at opposite ends).

§9 — Out of scope: what is not shipped, and why Overflow never needs porting

9. Out of scope — what is not shipped

windmod reskins what the headless package ships and invents nothing.

Not shipped Why
Text and the typography components, Table/DataGrid, Tree, Virtualizer, List no headless counterpart
MessageBarGroup no headless counterpart, and no visual contract to reskin
Overflow and family scoped out permanently — see below
Persona's presence slot and presenceOnly prop the headless surface omits both

All of these compose over windmod components without a shim — Griffel-styled containers around
windmod-styled children, and nothing in either library's CSS fights the other.

Overflow will never be ported, and needs no migration step. It is renderless: it emits no
element of its own, clones its single child, drives the priority-overflow engine over that subtree,
and stamps data-* attributes. A component with no skin has nothing to reskin. The whole Griffel
Overflow family ships two CSS declarations, both token-free and theme-free
([data-overflowing] { display: none } and [data-overflow-menu] { flex-shrink: 0 }), neither of
which says anything about how items look — which is exactly why they are as correct over
windmod-styled items as over Griffel-styled ones. Measured, not assumed: eight windmod Buttons
across eight container widths, the engine's data-overflowing count equalling the browser's
display: none count at every width, the +N trigger with the right count, no horizontal spill,
full reversal on widening — 18/18 both paths. The cascade win is structural: the component's own
display: inline-flex is layered and [data-overflowing] { display: none } is unlayered.

Mixing the two libraries. Griffel containers around windmod children work. The reverse — a
windmod child inside a headless (unstyled) container — has documented gaps, because a windmod
component reads the windmod context and only a windmod container publishes it.

§10 — Companion PR: microsoft/fluentui-system-icons

10. Companion PR — microsoft/fluentui-system-icons

The plan was two linked PRs: an icons companion carrying a headless entrypoint and a
data-fui-icon-hidden !important fix opens first, and this PR is marked dependent until it merges
and publishes. Measured against upstream/main, neither of those is fork-only work any more
the headless entrypoint is already upstream in full, and upstream never had the !important (the
fork added it and the working tree has since removed it again). The only genuinely fork-only content
is a data-fui-icon-variant attribute on bundled icon glyphs and an unrelated build-script dedupe
fix.

Draft description, corrected inventory, and the housekeeping the fork needs before anything is
pushed: icons-pr-draft.md. The attribute now ships already namespaced — it was renamed from
the fork's original data-variant to data-fui-icon-variant (constant DATA_FUI_ICON_VARIANT)
on 2026-08-28, so it matches every sibling constant on arrival rather than waiting for review to
ask. windmod was updated in the same lockstep and the tarball rebuilt.

The dependency is real, and it is narrow — measured, not assumed. windmod imports
@fluentui/react-icons/headless in 106 files, and that entrypoint is not fork-only: the whole
src/headless/ tree and its ./headless* export conditions exist on the icons repo's
upstream/main. (Careful: the locally installed 2.0.337 is the fork's own tarball, so it proves
nothing about npm on its own — the evidence is upstream's tree, not the install.
the exact published floor gets pinned at the post-merge floor-raise step, when the
resolutions entry is dropped.) So the entrypoint is not what windmod needs the fork for. What it does need
is the data-fui-icon-variant stamp:
windmod selects on it in shipped component CSS (Button.module.css, Tab.module.css) and publishes
selectors against it in variants.css, which is a documented public export. Without the fork,
those rules silently match nothing.

That gives the linkage a precise shape:

  • This PR keeps the resolutions tarball entry until the icons PR merges and publishes, then
    moves @fluentui/react-icons to the published version carrying the attribute and drops the
    entry.
  • The rename that review would have asked for is already done. data-variant
    data-fui-icon-variant landed preemptively on both sides on 2026-08-28: the icons branch stamps
    the namespaced attribute, and windmod's variants.css @custom-variant definitions, the
    InfoButton assertion and the authoring-convention docs were updated in the same pass. The
    variant names (variant-filled / variant-regular) are windmod-internal and unchanged, so no
    component .module.css selector moved. If review asks for a different spelling, only
    variants.css:27-28 has to change again.

The icons PR is therefore genuinely blocking, but for one attribute rather than for the headless
entrypoint the original plan assumed — and it is open now as
microsoft/fluentui-system-icons#1228. This PR stays a draft until it merges and publishes, at
which point the dependency floor moves to the published version and the resolutions entry is
dropped.

§14 — Test plan

14. Test plan

  • nx build react-tailwind-theme-preview and nx build react-windmod-preview green
  • nx type-check, nx lint green for both packages and their stories projects
  • test gate green — 160 suites / 3,580 tests at 68bb856687 (uncached)
  • nx test react-headless-components-preview green (989/989 at the last recorded run)
  • nx test react-breadcrumb, react-color-picker, react-tag-picker — the three Griffel
    packages carrying in-tree fixes. Note a pre-existing @fluentui/react-icons snapshot
    drift that fails one react-tag-picker test identically with and without the change
    (181 passed / 1 failed both ways)
  • Full VR sweep → 91/91, 74 at strict zero, 17 within their ratified ceilings, exit 0
  • No-GPU allowance audit → exit 0 across the rows carrying a noGpu block
    (teaching-popover, toast-inverted and two carousel scenes must be strict zero; menu
    is deliberately excluded)
  • Manual: swap @fluentui/react-components for @fluentui/react-windmod-preview in a sample
    app, import the theme sheet and ./base.css head-of-document (and separately the ./styles.css
    monolith path), confirm the seven theme classes and a Griffel container wrapping
    windmod children
  • Manual: load a component chunk before the root sheet and confirm the development-mode
    warning fires and names the mistake; confirm production builds are silent
  • Manual: prefers-reduced-motion: reduce — everything at 1ms except the Spinner ring at
    1800ms / infinite
  • yarn change — fourteen change files land with this PR (the fourteenth releases windmod
    itself: minor, "feat: release preview package"); beachball check green, including
    "Validating no private package among package dependencies"

…hy choice as a ternary

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

1 participant