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
…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
|
Hey Ray Knight (@ArrayKnight), this is really impressive work, but I don’t think we can take the PR in its current size and complexity. As the surface area keeps growing, it would be difficult for the team to maintain. We’d be happy to pull out fixes for the issues raised in this PR and the linked issues, but for everything else, it would probably be better to host it in your/community repo rather than in the FluentUI core. Please feel free to reach out here if you have any questions/suggestions/feedback etc. Thank you! |
…lowest fui.preflight layer Decision U (operator, 2026-08-28) overrules the day-one no-preflight posture: tailwindcss/preflight.css now ships in a dedicated fui.preflight layer placed FIRST in the fui.* order statement, so every authored theme/component rule outranks the reset by layer order alone while consumer documents get a normalized base instead of per-browser UA defaults. Deliberate divergence from Griffel's no-reset posture. Both single-owner copies of the order statement gain the layer: the theme package's css/index.css (emitted verbatim into dist/base.css and the dist/styles.css monolith) and CANONICAL_LAYER_STATEMENT in the workspace-plugin css-modules executor (emitted into the windmod package's dist/base.css and aggregate); the cross-package drift-guard spec's whole-line prefix follows. Generator checks pass; gates green. S1 inventory sweep: 74/91 strict-zero, 16 within ratified allowances, 1 over ceiling (info-label-open, +373 px, anchor-displacement symptom of the leaning-on-UA-button-font class staged for S2). Fallout table: .scratch/windmod-loop/plans/reset-unitless-leading.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Aj9uA3rCVgosnh2zNn8qkc
… always rides an authored text step Decision U stage S2. Census over the whole module corpus plus a DOM x built-source join across all 91 scenes: - Tab root authored leading-base-300 with no font-size — the line box was welded to whatever font-size inheritance delivered (Chrome's 13.3333px button constant before preflight). Now authors text-base-300, matching the Griffel twin's computed 14px at every size variant. - InfoButton root (the S1 fallout site) authors text-base-300; its 24x24 box is measured font-independent, the step declares the metrics instead of riding the surrounding context. - Exempt with cause: Spinner root's leading-[0] (zero is font-size-independent, Griffel authors the same bare value) and Avatar's icon-initials leading-none (font-size authored on the component's own root, Griffel-identical shape). - Interactive-element sweep: 53 flagged (tag, class) tuples, every box invariant under line-height/font-size perturbation — no other site leans on its own line box. Skill: authoring-conventions gains the pairing rule — line-height never authored without font-size on the same element; native interactive elements always author their text step. Verification: element-keyed computed-style probe over the touched scenes, 855 elements — zero font-size, line-height or rect moved. tab-list, info-label, info-label-rtl strict-zero. info-label-open holds at S1's 8918 (deterministic x3, ceiling 8545): measured as pure GPU shadow-raster banding — the no-GPU pass scores 8071, inside its ratified 8107 ceiling — with buttons, icons and surfaces pixel-identical; escalated for ceiling re-ratification rather than self-granted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Aj9uA3rCVgosnh2zNn8qkc
…erived from the type-ramp pairing (Decision U S3) Generator: every --leading-* token is now the idiomatic unitless ratio of its 1:1 ramp pairing (leading-base-300 = 20px/14px = calc(20 / 14)), derived from theme-values.json at generation time — an exact decimal where the division is finite, a calc() fraction where it repeats. No --base-scale factor: the paired --text-* carries it and the ratio multiplies the element's own font-size, so line boxes still scale. The ratios are theme-INVARIANT (both halves of the pairing asserted byte-identical across all seven shipped themes) and move from the per-theme files into the base sheet's :root,:host block. The lane-D unitless-rejection rationale in the generator is superseded in place: its blocking cohort (Tab root dividing by Chrome's UA button font-size) dissolved when S1 preflight + S2 explicit font-size authoring made every denominator a token. Modules: lane-D's rule-surface compensation map applied — every rule whose element previously inherited a px line box across a font-size boundary now authors its own explicit ratio against its authored font-size, spelled as the self-documenting fraction leading-[calc(target/fontsize)]. Sites re-derived against the current corpus where the map's assumptions had drifted: Input/ SearchBox icon glyphs keep their authored line-height:0; RatingItem indicators keep Griffel-identical inheritance (corpus context is the fs-0 VR furniture); Dropdown's shared icon bucket splits per context (&:is(button) — the clear slot divides the root's 20px line box, the chevron the trigger's per-size one); Badge tiny/extra-small and ToastBody's subtitle drop their dimensioned arbitrary values for ratio 1; the Checkbox/Radio/Switch label-offset calcs re-derive their line box as var(--text-*) * var(--leading-*). Verification: element-keyed computed font-size/line-height/rect probe over all 91 scenes, 22950 elements, pre- vs post-conversion builds: 0 font-size changes, 0 rect changes (spinner's 164 rect rows reproduce between two captures of the same build — rotating-glyph animation phase, instrument noise), and 204 line-height rows confined to the avatar/avatar-group VR-furniture subtrees (inline lineHeight:0 wrappers) where the avatar text-ladder now authors the 20px-context ratio the in-corpus persona and avatar-group-open scenes require — all 204 rect-identical, computed-only. Full 91-scene sweep: byte-parity with the S2 state (74 strict-zero + 16 in-allowance + info-label-open at its known 8918, Decision V pending). 32px-root probe: line boxes scale x2; leading stays in base.css (theme-invariance held). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Aj9uA3rCVgosnh2zNn8qkc
…tless leading (Decision U S5) MIGRATION.md gains deltas microsoft#58 (Tailwind's preflight ships in the lowest fui.preflight layer — deliberate divergence from Griffel's no-reset posture, with the layer guarantee and the document-global check note) and microsoft#59 (leading tokens are unitless ratios — inheritance becomes number-not-length, and a consumer reading --leading-* for a length multiplies by the paired --text-* token); the delta count and the base-sheet size citations are refreshed from the S4-verified dists (theme base.css 7.7 KB raw / 2.2 KB gzip, windmod base.css 3.8 KB raw / 805 B gzip, gzip -9). Skill: authoring-conventions adds the fui.preflight layer contract (order statement leads with it; never author into it; components outrank it by construction) and restates the leading-pairs-with- font-size rule for the unitless end-state, including the leading-[calc(target/ownFontSize)] fraction spelling; failure-modes adds the Tab-shape row (leading without an authored font-size); setup/overriding/troubleshooting/SKILL order statements gain fui.preflight, with the first-appearance omission hazard called out in the consumer recipes; griffel-deltas gains rows 58/59; tokens-and-scale marks the leading family unitless; delta counts move to fifty-nine everywhere. Both package READMEs pick up the preflight description, the 423-declaration theme files, and the re-measured sheet sizes. useCssVarValue's measured-family doc table (and its css-var-values.md mirror) corrects the leading row to 5 literal / 5 calc-string (totals 420/52), measured from the shipped dist: the ramp's finite divisions emit as decimals and the repeating ones as calc() divisions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Aj9uA3rCVgosnh2zNn8qkc
…of fui.base, retiring the fui.preflight layer Operator ruling 2026-09-01 refining Decision U: Tailwind's preflight belongs in the EXISTING fui.base layer — the placement Tailwind itself gives it — not a dedicated lowest layer. The @layer order statement loses fui.preflight in both authored owners (css/index.css and CANONICAL_LAYER_STATEMENT) and the drift-guard spec's whole-line prefix follows. Same-layer cascade hazard resolved: fui.base's icons sheet opens with the deliberately zero-specificity :where([data-fui-icon]) display default, which preflight's (0,0,1) replaced-elements rule would beat regardless of source order. An icon-defaults guard (svg:where([data-fui-icon]) { display: inline }) directly after the preflight import wins that tie back. Computed-parity probe old-vs-new dists: 49,296 properties across every preflight target and icons selector shape, 0 diffs; negative control with the guard stripped shows the 14-diff display flip the guard exists to prevent. Docs reworded to "preflight ships at the head of fui.base" (skill SKILL/setup/ authoring-conventions/overriding/troubleshooting/griffel-deltas, MIGRATION delta 58 incl. anchor, theme README); the setup hazard note about keeping fui.preflight first becomes a general copy-the-statement-exactly note. Verification: gates 8/8; full 91-scene sweep 74 strict-zero / 17 in-allowance / 0 fail with info-label-open at its operator-ratified post-preflight GPU ceiling (8918; Decision V) and no-GPU control 8071 <= 8107 unchanged; Decision W (avatar furniture probe residual) accepted with no code change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Aj9uA3rCVgosnh2zNn8qkc
…appearance onto module classes O-19 appearance-doctrine convergence, the computed-identical sites: Toast gains .inverted, SkeletonItem gains .translucent, TabList gains a single .rounded for the two circular values (Griffel's own bucket name and isRounded predicate). data-appearance stamps stay for children and consumer group variants. SkeletonItem's rounded-[4px] gets its justification comment (Griffel authors the literal; radii sit off the base-scale ramp). Card, Link and Tab stay on the variant form - their appearance x state cross-products resolve by source order inside one block, and a class conversion would re-add the re-assertion arms the 15c sweep deleted. Compiled A/B decl-for-decl identical at equal specificity; class-composition assertions added and mutation-verified (3/3 killed); VR skeleton/tab-list/toast scenes strict-zero. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Aj9uA3rCVgosnh2zNn8qkc
…s, template utilities, dead rules, keyframe pruning The deferred census rows, adjudicated per site: - MenuItem/MenuPopover/Persona: the last unscaled bracket-px literals join the numeric scale (max-w-290/max-w-300/min-w-138/-mt-2) - Tag's identical Griffel construct already rode -mt-2; zero bracket-px utilities remain besides SkeletonItem's justified radius. - MessageBar: grid-template cols/rows ride grid-cols-[...]/grid-rows-[1fr]; grid-template-areas stays raw beside them (arbitrary-property ban), cohesion kept. - MenuSplitGroup: raw &:nth-of-type(N) -> nth-of-type-N variants (v4 has them; byte-identical emission). The ~ sibling reach stays raw. - InlineDrawer: the bottom separator's 1px literal reads --stroke-width-thin like its two logical siblings. - NavDrawerBody align-items:unset and NavDrawerHeader margin:unset deleted - no author rule sets either property on those elements, so unset restated the initial value; computed A/B against Griffel byte-equal. - Spinner: identity from/to keyframes deleted (synthesized from transform:none; paused-animation probe matrix-equal at 14 timeline points); tail-rotate's 50% frame measured load-bearing and stays. - failure-modes.md gains the two new classes (unset-restating-initial, identity keyframe frames); corpus swept clean for both. Compiled A/B per file; nav-drawer scenes + drawer/menu/message-bar/persona/ skeleton/spinner/toast batch VR green (21 strict-zero, menu family at ratified ceilings). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Aj9uA3rCVgosnh2zNn8qkc
|
Dmytro Kirpa (@dmytrokirpa) Following up with something concrete: I've mapped what a decomposed submission would look like, in case it's useful for weighing the options — sized in an attempt to align with the PR sizes this repo typically reviews. The shape: ~16 PRs.
How they'd flow — the dependency graph is shallow and wide:
The critical path is only five sequential PRs — everything else overlaps. "Can be parallel" is an offer, not a plan: they'd land at whatever cadence works for the team, one at a time if that's the right speed. The fixes you offered to take are already out (#36663–#36673). All of this only matters if review surface is the main concern — if it's the longer-term cost of a second styling system, that's a different conversation (the one in my earlier comment), and the community repo may be the right home regardless. |
…dmod # Conflicts: # packages/react-components/react-headless-components-preview/library/src/avatar.ts # packages/react-components/react-headless-components-preview/library/src/components/Avatar/index.ts
…exports on upstream's location after merge Upstream microsoft#36659 exposes useAvatarGroupContext from useAvatarGroup; the branch's avatarGroupContext re-export now carries only AvatarGroupProvider, and the subpath barrel drops the duplicate the auto-merge produced. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Aj9uA3rCVgosnh2zNn8qkc
…invariant emission + attr()-derived --fui-scale knob --base-scale becomes calc(1rem / 16px * var(--fui-scale, 1)) — a computed no-op at the default (the resolved probe panel is byte-identical pre/post; every textual custom-property diff across root + all 7 themes is exactly the '* 1' insertion; probes in .scratch/windmod-loop/base-scale-zoom/results-scale-region.*). The theme-invariant token block is emitted at ':root, :host, .fui-scale-region' — one grouped rule. The generator absorbs --base-scale, --spacing and --text-icon-* into it, mirrored from css/index.css's @theme block at generation time (Tailwind emits @theme values at ':root, :host' only, and a scale region needs every scale-riding formula re-declared AT the region element so it re-substitutes the local factor). An adjacent generated rule derives the knob from the element's own attribute via typed attr(): .fui-scale-region { --fui-scale: attr(data-fui-scale type(<number>), 1); } Measured (HeadlessChrome/147, shipped dists): typed attr() coercion works on a display:contents element; a theme-class + .fui-scale-region + data-fui-scale element zooms a real Button exactly linearly at 1.5 and 3 (the TEST C 14/15 result — border-radius stays fixed by design); nested regions replace the factor absolutely; a region without the attribute is inert (fallback 1). Per-theme files unchanged; generator --check green and idempotent. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Aj9uA3rCVgosnh2zNn8qkc
…ess factor on the ambient base scale A display:contents div (kebab module class, @apply contents) stamping the theme package's .fui-scale-region mechanism class (a plain string constant — the rules ship with the theme sheet, not this package's modules), data-fui-scale={scale}, and the ambient theme class read from a new private-internal ThemeClassContext. FluentProvider provides the context: its own theme, or — themeless nested provider — the inherited one, so the region always re-stamps the class that actually themes it (theme-owned formulas re-substitute only where the theme class re-applies). A development-only once-per-document warning fires for a region with no themed provider above it, following the rootStylesheetCheck pattern. scale is absolute: nested regions replace the factor, never compound; a nested region without scale resets to 1 (the attr() fallback). Exported from the ./provider family barrel (provider-adjacent, windmod-original — no headless counterpart); api rollup regenerated, nx sync clean. Tests: class map + mechanism class + data attribute stamping, theme-class re-stamping through themeless and re-themed nested providers, className last, ref forwarding, warning + latch + themed-silence paths, full conformance. Computed display:contents and end-to-end zoom are verified in the browser probes recorded in .scratch/windmod-loop/base-scale-zoom/ (typed attr() on display:contents measured first — passes; real Button exactly linear at 1.5 and 3). Gates 8/8 green; full 91-scene VR sweep 0 failures — 74 strict-zero, 17 allowance rows at or under their ceilings (info-label-open 8918/8918). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Aj9uA3rCVgosnh2zNn8qkc
…at ScaleRegion The rule stays (a raw --base-scale override mid-tree is still inert); it now says why and names the sanctioned mechanism. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Aj9uA3rCVgosnh2zNn8qkc
…ly in the region rule Tailwind's @theme block already covers :root/:host; repeating the axis in the grouped rule was a proven no-op but not a needed one. The mirror now lives solely in .fui-scale-region beside its knob — same drift-guarded source read. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Aj9uA3rCVgosnh2zNn8qkc
…ramp Operator ruling 2026-09-02, superseding the scale batch's B-17 radii-off-the-ramp reading: radii scale consistent with the rest of the system. The generator's new radiusScaledValue hook (same scaleValue mechanism shadows use) emits the 9 px radius steps as calc(<N>px * var(--base-scale)) in every theme file — 63 declarations, 9 x 7 themes, radius block still byte-identical across all seven. --radius-none (0) and --radius-circular (the 10000px fully-round sentinel the browser clamps to half the box) stay literal: neither meaningfully scales, and circular's value is asserted so an upstream retune trips the build. Corpus sweep of the frozen-literal defect class: SkeletonItem's rounded-[4px] converts to rounded-medium — its Griffel-literal-parity justification dissolved once the token itself became the responsive spelling. Everything else already reads tokens (or is a circle: rounded-full / --radius-circular, scale-invariant by construction). Verified: probe-scale-region pre/post — panel A computed kit byte-identical at scale 1 (no-op proof); panel B 63 textual diffs, all --radius-*, none other; the zoom kit is now 15/15 at x3 (was 14/15 with radius the lone holdout) and the real Button's radius reads 6px at x1.5 and 12px at x3. Gates 8/8 green (3604/3604 tests); full 91-scene VR sweep 74 strict-zero / 17 within-allowance / 0 failures — pixel-identical at the default root everywhere. Docs: MIGRATION microsoft#9 + README + ScaleRegion.types + skill tokens-and-scale now state radii follow the base scale (grow with the system under a non-default root font size where Griffel's stay fixed px; identical at the 16px default). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Aj9uA3rCVgosnh2zNn8qkc
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-previewfui.baselayer, the global reduced-motion floor@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.9 KB root sheet and one chosen theme sheet load
once per document, with a batteries-included monolith retained for zero-config consumers.
Also shipping: a 59-delta migration guide, an agent skill that makes bulk migration
practical, a bundle-size analysis and a runtime benchmark — all with reproduction scripts.
Headline numbers
Windmod 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.9 KB root sheet plus one chunk per
component, delivered through each component's own class map, with a batteries-included monolith
retained for zero-config consumers. Eleven defects in Microsoft-owned packages that
blocked the work are fixed in place with regression tests. A 59-delta migration guide, a 50-entry
upstream findings roster, a bundle-size analysis and a runtime benchmark, all with reproduction
scripts, accompany them.
Related Issue(s)
Every non-windmod commit in this PR has its own issue, so each can be taken independently of
windmod and of the others:
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.