Repository navigation
feat(react-windmod-preview): a Tailwind v4 + CSS Modules styling layer for Fluent's headless components - #36656
Open
Ray Knight (ArrayKnight) wants to merge 345 commits into
Open
Ray Knight (ArrayKnight) wants to merge 345 commits into
Ray Knight (ArrayKnight) wants to merge 345 commits into
Conversation
…e containers A component rendered inside a providing container now reads the same look contexts the reference implementation does: Button, CompoundButton, MenuButton and ToggleButton take their size from ButtonContext, Link its inline flag from LinkContext, Avatar its shape and size from AvatarContext, and the field controls their size from FieldContext — narrowed to the look key, since the base hooks already apply the aria half. Tag derives the avatar shape and size its children consume. The contexts re-export from the headless package so the provider instances connect; one shared helper folds context into props ahead of destructuring, keeping local values authoritative and letting context beat only the defaults. Verified by container scenes that previously pinned these values and now adjudicate them — message-bar and tag hold strict zero against the reference and fail measurably when any fold is severed. 29 mutations caught, the full visual sweep holds every scene, and the API report deltas are additive only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…ng in the toolbar button variants The toolbar's layer sits above the shared button layers, so its plain declarations beat the forced-colors blocks below: the checked-subtle glyph kept a brand hover colour the system palette replaces arbitrarily, and the checked rest border swallowed the focus border colour. Both variants gain trailing forced-colors re-asserts — the glyph takes the system Highlight under hover and press, the checked root takes HighlightText border colour under focus — repeating only what their resting blocks win back. Verified by computed-style probes under forced-colors emulation across all 75 toolbar cells in four interaction states, including a widened focus walk that reaches past the reference toolbar's roving tabindex: 22 divergences before, zero after, zero newly broken, with the shared button layers unmoved, a reference-vs-reference control at zero, and the baseline reproduced exactly on revert. The normal-mode visual sweep holds every scene, so the change is invisible outside forced-colors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…ng for MenuButton and SplitButton The open menu button's selected background, its focus border, and its disabled colours all sat in blocks that outrank the shared button layer's forced-colors rules, and the split button's divider edge declared a logical border longhand that beats the physical focus shorthand below it — four faces of the same cascade contest the reference resolves by emitting every forced-colors rule last. Both modules gain trailing forced-colors re-asserts inside the winning blocks, including the disabled colour crossing whose text colour also restores the chevron glyph fill through currentColor. Verified by computed-style probes under forced-colors emulation across all 250 menu-button and split-button cells with a widened focus walk: 106 divergences before, zero after, zero newly broken across every probed scene and phase, with a reference-vs-reference control at zero and the baseline reproduced exactly on revert. The survey now covers every module in the package's upper layers; the two remaining divergences trace to reference bugs and are recorded, not copied. The normal-mode visual sweep holds every scene, so the change is invisible outside forced-colors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…lass-map keys to camelCase The named font-size tokens across all seven themes become calc products of the shared base-scale ratio, so text now scales with the root font size the way spacing and stroke widths already do — identical at the default 16px root. Icon glyph sizes gain their own named tokens. The CSS-module class-map serializer and the storybook loader both export each kebab-case local under a camelCase alias so styles hooks keep dot access. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…ponents A-C Arbitrary-property utilities become plain declarations, class-map locals go kebab-case behind the camelCase aliases, multi-branch ternaries flatten to if-returns, single-use props destructure in the parameter list with state literals inlined, and transition-property adopts the reference shorthands. No public API, ident-independent pixel, or behaviour change; the full visual sweep holds every scene. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…ponents D-M Arbitrary-property utilities become plain declarations, class-map locals go kebab-case behind the camelCase aliases, multi-branch ternaries flatten to if-returns, single-use props destructure in the parameter list with state literals inlined, and transition-property adopts the reference shorthands. No public API, ident-independent pixel, or behaviour change; the full visual sweep holds every scene. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…ponents N-S Arbitrary-property utilities become plain declarations, class-map locals go kebab-case behind the camelCase aliases, multi-branch ternaries flatten to if-returns, single-use props destructure in the parameter list with state literals inlined, and transition-property adopts the reference shorthands. No public API, ident-independent pixel, or behaviour change; the full visual sweep holds every scene. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…ponents T-Z Arbitrary-property utilities become plain declarations, class-map locals go kebab-case behind the camelCase aliases, multi-branch ternaries flatten to if-returns, single-use props destructure in the parameter list with state literals inlined, and transition-property adopts the reference shorthands. No public API, ident-independent pixel, or behaviour change; the full visual sweep holds every scene. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…-identical to Griffel) TagGroup is mostly wiring: one flex row with size-stepped column gaps, and the look channel its children read. The headless context publishes no appearance or size, so the group fills the reference context for any Tag implementation inside it, and a local context carries the same pair to this package's Tag, whose defaults now yield to the group exactly as the reference resolves them — proven byte-identical out of a group across a 160-pair identity matrix. Dismiss and disabled behaviour were already wired base-hook to base-hook. Selection interaction belongs to the interaction tags and ships with them. Verified pixel-identical to the Griffel implementation at strict zero on both the new scene and the Tag guard scene, 24 planned and review mutations killed with one gate-owned, and byte-idempotent API reports across all 76. 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/Tag/Tag.tsx
…identical to Griffel) Persona lays its avatar and up to four text lines on a named grid whose line names are load-bearing only when theirs is the first rendered row — the visual gate covers that with sparse ladders that make each name carry the layout in turn. The avatar renders through this package's Avatar via the slot channel the reference uses, composing identically with the surrounding avatar context, and a consumer-supplied avatar object no longer leaks onto the root as a stringified attribute — the one place this component corrects the headless layer rather than mirroring it. Verified pixel-identical to the Griffel implementation at strict zero over 76 cells including right-to-left, coinless and centred bands, 27 mutations killed, and byte-idempotent API reports. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…y and InteractionTagSecondary (headless + Tailwind, pixel-identical to Griffel) The interaction tags split a Tag into two focusables: a primary action that carries the selection behaviour the tag group defers here, and a secondary dismiss button. The reference implementation styles the primary's inner slots by importing Tag's own style buckets, so Tag's slot presentation is extracted into one shared plain function both components consume — any drift now fails both components' specs. The family reads the group look context, provides its own, and derives the avatar sizing its media slot imposes, all through the landed context architecture. Verified pixel-identical to the Griffel implementation at strict zero on its own scene and both guard scenes, a twelve-pass computed-style parity matrix covering hover, press, keyboard focus and forced colors in both directions, and 98 mutations across the planned, review and spec tables all accounted for, with byte-idempotent API reports. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…iving story and README section Overflow ships no styled port because the overflow machinery is renderless — there is nothing to style. The story proves the claim live: eight Buttons in a resizable strip hide and restore through the reference overflow engine on both the recommended barrel path and the Griffel-free headless path, with an overflow-count trigger. The README records the measured compatibility, the two unlayered consumer declarations only the headless path needs, and the lint-sanctioned import route through the components barrel. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
MIGRATION.md sits at the package root so it ships in the tarball, and walks a react-components consumer through the port: the subpath import map for all 79 components, the parity contract, the twenty-eight deliberate differences each with its consumer guidance — root-font-size scaling, cascade layers, native focus modality, mount persistence, the anchored components' browser support — the not-shipped list, and the upstream issues that affect both libraries. Every code snippet compiles strict against the built package and every subpath in the map resolves. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
… win on the PopoverSurface slot The surface slot spelled its popover attribute after the consumer props spread, so the prop was silently discarded — popover exclusivity then capped a page at one open Popover where eight should show, and a consumer switching to manual dismiss was ignored. It moves into the slot's defaultProps, the placement the Tooltip hook already uses: the hook supplies the default, the consumer wins. The id spelled alongside it deliberately stays put. Popover.types documents `<Popover id>` as the surface's id channel, and usePopoverTrigger points aria-details at that same value, so letting a surface-level id win would leave the trigger referencing an element that no longer exists. PopoverSurface.test.tsx already guards that linkage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…urface (headless + Tailwind, visually matching Griffel within a documented allowance) The surface renders inline and rises through the native top layer, so it stays inside the provider and reads the theme live — no portal, no variable re-declaration. Positioning is CSS anchor positioning end to end; the browser's popover and dialog defaults that Griffel's portaled div never met are each neutralised or proven inert, including the modal backdrop. The pointer arrow sits a fixed inset from the surface edge, where the reference centres it on the target — an accepted consequence of the headless layer's CSS-only positioning, recorded with its worst case in the migration notes, alongside the top-layer drop-shadow rasterization the scene's documented allowance bounds with a reference-free control. Centred placements are pixel-exact. Verified across fourteen placement, size and appearance cells, nineteen mutations killed including two owned by the visual gate alone, focus-trap behaviour checks, and byte-idempotent API reports. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
… win on the MenuPopover and Listbox slots Both surfaces pinned their popover attribute where a consumer's prop could not reach it. useMenuPopover spread `popover: 'auto'` over the base state's root after the base hook had already merged props, and useListbox assigned `state.root.popover = 'auto'` outright after its hook returned. Because every auto popover on a page closes the others, that capped a page at one open Menu and one open listbox: measured, three listboxes give one open surface, and three Menus give one. The default now merges before the base state rather than over it, which is the order slot.always already uses for defaultProps — the hook supplies 'auto', a consumer spelling popover="manual" wins, and the same three-cell pages then show three open surfaces. Nothing else rides either assignment. Unlike PopoverSurface, where an id was spelled alongside the attribute and had to stay put, these two sites set the popover attribute alone: the listbox's id and its aria-labelledby linkage are owned by useListboxSlot, which this does not touch. Light dismiss under the default is unchanged, and a closed listbox still stays mounted at display:none. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…while a dialog locks scroll lockDocumentScroll sets body.style.overflow = 'visible clip' and nothing else, so the page scrollbar disappears without its space being reserved and every fixed or centred element on the page shifts sideways by half the scrollbar width when a modal opens. Measured on a scrolling 1280px page: a centred marker sits at x 332.5 with the dialog closed and jumps to 340 when it opens, where the Griffel equivalent stays at 332.5 because react-dialog's useDisableBodyScroll also reserves the gutter. The reservation goes on the document element, not on body. `overflow` propagates from body to the viewport — which is why the lock works at all with `<html>` left `visible` — but `scrollbar-gutter` does not: measured, `scrollbar-gutter: stable` on body alongside the clip reserves nothing and leaves the same 7.5px jump. The docblock's promise to leave `<html>` untouched cannot be kept and still fix this; padding body instead was measured too and does not help, because it narrows body rather than the initial containing block a top-layer surface resolves against. `stable` reserves a gutter whether or not the page had a scrollbar, so writing it unconditionally would introduce the mirror-image defect: a 7.5px shift on a page that never scrolled. It is written only when the scrollbar is actually taking layout width, read before the lock removes it. That reads 0 under overlay scrollbars as well, where nothing needs reserving and Griffel's own height-based guard over-reserves. Unlock restores the previous inline gutter next to the previous overflow, so a host application that had already set one gets it back, and the existing lockCount reference count scopes both to the outermost dialog. Non-modal dialogs never take the lock and are untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…, DialogTitle, DialogHeader, DialogBody and DialogActions (headless + Tailwind, visually matching Griffel within a documented allowance) The surface is a real native dialog: modal and alert open through showModal with a styled backdrop that tracks the theme, non-modal through the popover channel, and the reference grid merges directly onto the dialog element with its tracks measured identical. The browser attribute autofocus is stamped through a ref callback because React never renders it and the native focusing steps would otherwise override the intended target — focus lands on the same element the reference picks in every band. Scrolling content lives in the body element with the header and actions static, exactly as the reference arranges it; the scrollable area runs two pixels shorter because a merged element's grid resolves inside its own content box where the reference's separate scroller overflows its parent by the border width — an accepted, documented difference bounded to a single translated box. Verified across four scenes — three at strict zero and the scroll scene wholly accounted to the accepted delta — 30 mutation rows with one proven semantic no-op, focus and dismiss behaviour checks, and byte-idempotent API reports. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…ss + Tailwind, pixel-identical to Griffel) First half of the drawer family: the base Drawer plus header, header title, header navigation, body and footer. Scroll-state hairlines key on the headless scroll stamps, and the scroll scene parks mid-scroll so both genuinely paint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…less + Tailwind, pixel-identical to Griffel) The overlay drawer rides the native dialog with the browser defaults neutralised down to a single border-none — restating the reference border colour turned out to manufacture the one divergence the plan had accepted, so removing it closed that gap. Slide positions author logical properties where the reference uses physical ones with a build transform, measured equal in both directions; the resting shadow exists only in the reference's motion end state, so the scenes finish the animations into exactly the rest the reference shows. The inline drawer participates in layout with its position-keyed separator. Verified pixel-identical at strict zero across all seven scenes — six overlay viewports and a fourteen-cell inline grid — with fifty mutation rows killed across jest, probe and visual ownership, and byte-idempotent API reports. 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/config/tests.cjs
…Group (headless + Tailwind, pixel-identical to Griffel) The combobox anchors its listbox surface with pure CSS anchor positioning, matching the reference's JavaScript-measured width through the anchor's border box — resolved synchronously where the reference waits two frames, settling equal. The never-shown popover surface carries five browser defaults neutralised, including the anchor association itself on a standalone listbox. Appearance reproduces the reference combobox's own fork of the input machinery — importing the finished input interactive buckets was measured to diverge — and the selected-option checkmark holds its row space through visibility, with the reference's three-valued glyph honoured including its empty multiselect form. Cascade order follows the reference's specificity outcomes on all twelve order-dependent pairs. Verified pixel-identical at strict zero across six scenes three times each, twenty-five mutation rows killed with browser-owned rows proven observable, an eleven-pass interaction parity matrix at zero, and byte-idempotent API reports. 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/etc/react-windmod-preview.api.md # packages/react-components/react-windmod-preview/library/package.json # packages/react-components/react-windmod-preview/library/src/index.ts
…wer and combobox merges Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
Ray Knight (ArrayKnight)
requested review from
a team,
Justin Slone (JustSlone),
Juraj Kapsiar (jurokapsiar) and
Tudor Popa (tudorpopams)
as code owners
September 9, 2026 18:28
Tudor Popa (tudorpopams)
requested a review
from Paul Mardling (PaulGMardling)
September 14, 2026 11:09
…dmod # Conflicts: # packages/react-components/react-provider/library/src/components/FluentProvider/createCSSRuleFromTheme.test.ts # yarn.lock
…dmod # Conflicts: # tools/workspace-plugin/package.json # yarn.lock
…dmod # Conflicts: # yarn.lock
Contributor
Author
Contributor
Author
|
Dmytro Kirpa (@dmytrokirpa) If review size is the main concern, the proposed submission order is:
I would recut the batches against the current tree once there is agreement on this approach and a review cadence the team can sustain. The independent fix PRs can proceed separately. If long-term maintenance of the styling layer is the main concern, the adoption/community-package decision in that comment comes first. |
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this PR proposes
A Tailwind v4 + CSS Modules styling layer for Fluent's headless components, offered for official support and discussion of a sustainable landing plan. It adds preview packages without changing the styling system shipped by existing
@fluentui/react-componentsconsumers.Following the maintainer feedback about review size, the independent fixes have been split into their own PRs. The remaining decision is whether the styling layer should be submitted in smaller in-tree pieces or maintained as a community package. The follow-up comments describe those options.
What ships
@fluentui/react-tailwind-theme-preview@fluentui/react-windmod-preview@fluentui/postcss-tailwind-css-modulesThe styling packages include a 64-entry migration guide and a shipped authoring skill. Checkbox, Radio, and Switch use a grid to align the indicator with the first label line, including wrapping labels. The CSS layer and scaling contracts are documented with the packages.
Independent fixes and current status
Omitstable both-edgesgutters after reviewThis branch incorporates current upstream and the reviewed Dialog, TagPicker, and context-export fixes. The four StackShim compiler tests were leftovers from the earlier whole-library conversion: they referenced a CSS module that is absent from this PR. That obsolete test group is removed. The existing v8-to-v9 StackShim implementation remains intact. The PostCSS declarations now match the callable CommonJS export, with type fixtures for CommonJS and ESM consumers.
Review caveats
The separate TagPicker PR contains React 18 and React 19 regression coverage; Dialog's preservation tests cover inline and stylesheet-provided gutters. Review those fixes in their linked PRs above.
Remaining dependencies and decisions
@fluentui/react-icons@2.0.341headless API. The shared Tailwind variants target its public filled/regular class tokens with zero added specificity; the local tarball resolution is removed. The extra attribute proposed in feat(react-icons): expose the bundled icon variant as a data attribute fluentui-system-icons#1228 is no longer required. The cascade requirement is noted above.TeachingPopoverCarouselFooterdrops thelayoutprop and the slot order it drives, so the offset footer's DOM and tab order cannot be reproduced through its renderer #36684 asks whether the headless Carousel layout omission was intentional. The question remains open; the current documented Windmod behavior is retained.Related issues
#36645, #36646, #36647, #36648, #36649, #36650, #36651, #36652, #36653, #36654, #36655, #36685.
The individual fix PRs own issue closure. The larger argument, historical measurements, and detailed findings remain in the linked deep dive.