feat(react-windmod-preview): a Tailwind v4 + CSS Modules styling layer for Fluent's headless components - #36656
Conversation
…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
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
…s spec 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
…s rule 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
…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
§1 — What ships, in full: the export surface, CSS delivery, and scale1. What shipsTwo preview packages, published as a pair, both at
151 components, one per directory in 60 export keys, and no root barrel. The package exports components only through 52 family Family barrels are not a tree-shaking regression: importing one component from a 13-member family Component CSS ships per component; two root stylesheets load once per document. Each component's
Also in the diff: Scale. 251 commits (211 non-merge) from §2 — Why Tailwind, and why CSS Modules (including the full DX argument)2. Why Tailwind, and why CSS ModulesTwo 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 CSS Modules is what makes it a design system rather than a pile of utilities. Utilities in JSX Developer experience(Ported from the closed 2026-08 proposal, whose DX section was never contested — see §7 for what You write CSS. No object notation, no camelCased property names, no JS-based abstraction Styles are grouped, not scattered — and the blast radius is bounded. A component's entire Separation of concerns. Behaviour stays in The DOM is parseable. Emitted class names are scoped but semantic Debugging is the browser's own toolchain. Component styles are real stylesheet rules, so States are single-sourced. Tailwind v4's compound/stacked variant syntax Overrides are predictable. Cascade layers give deterministic precedence Theming is a class. A theme is a CSS class containing only custom-property declarations, so Server and client markup become identical. Nothing is inserted at runtime, so there is no §3 — Cost: the full bundle-size and runtime analysis, with the caveats3. Cost: bundle size and runtimeTwo independent measurements, reported with the results that cut against windmod as prominently as 3.1 Bundle sizeJS measured on
Two honest caveats, stated with the numbers rather than after them. The all-or-nothing CSS this PR
Three caveats a reviewer re-running this must respect: 3.2 Runtime — newly measured, and it does not all go one wayPreviously this PR claimed only "same result, far less shipped code" because runtime had not been
What this shows, and what it does not. The cold-mount win is real and stable (tight spread, Two results that should not be buried. Griffel wins the re-render metric (24.5 vs 26.8 ms): And the byte comparison here is not like-for-like. For this 3-component synthetic app windmod Full write-up, raw per-run tables and re-runnable scripts: §4 — The verification story: VR gate semantics, the eighteen allowances, the no-GPU certification, computed-style parity, mutation testing, gates4. The verification story
The parity claim is not "looks the same" — it is a pixel-diff gate where one differing pixel fails 4.1 Visual regression: 91 scenes, 74 at strict zeroEach scene renders one shared Most components cleared the gate on the first VR pass. Where they did not, the gate found real What "strict zero" asserts, precisely. The runner calls pixelmatch with 4.2 The eighteen allowances — each ratified, each decomposed, each controlledNo allowance is a tolerance band. Every one names a specific mechanism, and a diff that does not Class A — GPU compositing, bounded by an identity control. The two libraries put the same
Class B — positioning geometry, proven by byte-identity under shift. These survive software
Class C — 1-ULP, on Griffel's side. 4.3 The no-GPU compliance certificationA dedicated harness pass (
4.4 Computed-style parity, beyond what pixels can seeVR captures resting state at one theme. The forced-colors, focus and RTL contracts were verified by
4.5 Mutation-tested specsThe house bar: deleting any class from a 4.6 GatesEvery component landed only after the gate script ran green end to end: build (theme + windmod), At the final SHA ( §5 — The non-windmod commits, in full: mechanism, measurement and the two reproduction traps
5. Twelve commits that are not windmodPer PR-4 these stay in this PR; split only if maintainers ask. The rationale is alignment: VR What they are. This is a windmod PR, but twelve of its commits do not touch windmod at all: they Every one was measured before and after, and every behavioural change carries a regression test
Two more, smaller, in the same spirit: A thirteenth ( Two reproduction traps — please read before rejecting a fix
Change files. Each affected package versions separately through its own change file; thirteen §6 — Upstream findings: 45 defects, offered as issues
6. Upstream findings — 45 defects, offered as issues
Nothing in the OPEN section is applied — they are recommendations, each carrying enough context to The recommendation: do not open 50 issues. The five consolidated classes are one issue each and I will file these as issues on request — the roster is written to be pasted. Say the word in Two entries deserve a maintainer's eye even though they are not defects: MIGRATION.md's "Known
§7 — Relationship to the closed 2026-08 proposals7. Relationship to the closed 2026-08 proposalsTwo PRs from this line of work were opened and self-closed in 2026-08 — The one substantive maintainer response was an issue comment whose operative sentences were: "we That objection was about scope, and this PR is built around it. It contested no measurement and Things deliberately not carried over from those PRs: every measurement in them (taken against a §8 — Known deltas: the migration guide, the reduced-motion floor, and where windmod renders more correctly8. Known deltas
The flagship divergence: one global reduced-motion floorGriffel suppresses motion piecemeal — This is the only windmod CSS with no Griffel counterpart, and it was ruled deliberately in four
Documented honestly in both directions: Griffel still moves under Two places windmod renders more correctly than GriffelBoth found by the forced-colors survey, both invisible to every VR scene, both accepted and
One design preference recorded, not endorsed
One accepted native delta worth flagging separatelyThe Popover arrow's placement is a fixed inset from the surface edge (CSS anchor positioning) where §9 — Out of scope: what is not shipped, and why Overflow never needs porting9. Out of scope — what is not shippedwindmod reskins what the headless package ships and invents nothing.
All of these compose over windmod components without a shim — Griffel-styled containers around
Mixing the two libraries. Griffel containers around windmod children work. The reverse — a §10 — Companion PR: microsoft/fluentui-system-icons10. Companion PR —
|
…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
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-componentsoff Griffel, deprecating Griffel, orchanging 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:
data-*and context contracts are treated as public API that stylinglayers may rely on — today several are incomplete or wrong (§6).
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
@fluentui/react-tailwind-theme-preview@fluentui/react-windmod-previewBoth at
0.1.0, published as a pair. 151 components across 52 family subpaths — the headlesspackage'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 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-componentsships 621,799raw minified bytes of precompiled
*.styles.js, its entire visual contract as JavaScript, wherewindmod'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.
b95a4152c4react-breadcrumbOmitcollapsed the distributive ARIA button union;hrefdisappeared.20924b8a1areact-color-picker0fell through||to the hue action; the slider emittedvalue="210" max="100".01ccede78ausePopoverSurfacepinnedpopoverafter...props, so only one Popover could be open per page. 1/8 → 8/8.40bca2b821useMenuPopoveranduseListbox. 1/3 → 3/3.17f7e796d8f34b0a1125react-tag-pickera2f2bc30barenderTooltipsilently discardedstate.arrowClassName— declared, published state.568297d44aMenuSplitGrouprendered with no contexts argument, so no descendant could tell it was inside one.5a89364f3f./avatar-grouppublished its context values hooks but neither the reader nor the provider.9612d547d9MessageBarActionscame out medium, not small.510b8c5d21react-storybook-addon-export-to-sandboxd826380ae5tools/workspace-plugingenerate-apiskipped every export-subpath rollup on Windows — 56 → 0 — with no diagnostic.b7c455ee52tools/workspace-pluginexport-maps-syncsilently deleted every export key it could not derive: 151 → 2. Now preserved by declaration, or a named throw.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/syncdeclaration (a consequence of this branch's own Prettier 3 bump),a
Reflect.deletePropertyin a test this branch added, and a'use client'removal from fourcontext modules this branch added. A fifth is struck:
b9853103c5was superseded upstream by#36606 while this branch was in flight and is not in the diff.
Three offers
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.
issue.
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 attributethis 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.
Previous Behavior
No Tailwind- or CSS-Modules-based styling layer exists for Fluent v9. Consuming any v9 component
means taking
@griffel/react— its runtime, itsmakeStylesauthoring model, its runtimeinjection, and its entire visual contract shipped as precompiled JavaScript (621,799 raw minified
bytes of
*.styles.jsin@fluentui/react-components, plus 79,480 bytes of JS design tokens).@fluentui/react-headless-components-previewexposes the behaviour hooks but ships no skin, so ateam 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 stylingconsumer 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:
hrefis not assignable #36645 — BreadcrumbOmitcollapses the ARIA button union||fallthrough in adjustChannel) #36646 — ColorPickeradjustChannelzero-value fallthroughpopoverattribute after the props spread, so only one surface can be open per page #36647 — headlesspopoverattribute pinned after the props spread (3 sites)renderTooltipsilently discardsstate.arrowClassName#36650 — headlessrenderTooltipdiscardsstate.arrowClassNameMenuSplitGrouprenders without a contexts argument, souseIsInMenuSplitGroupcan never return true #36651 — headlessMenuSplitGrouprenders without a contexts argumentgenerate-apisilently skips every export-subpath rollup on Windows #36654 —generate-apiskips every export-subpath rollup on Windowsexport-maps-syncsilently deletes any export-map key it cannot derive from a source entry point #36655 —export-maps-syncsilently deletes non-derivable export keysCompanion 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.