feat(react-windmod-preview): a Tailwind v4 + CSS Modules styling layer for Fluent's headless components - #36656
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
Headless useRatingDisplay/useRatingItem + Tailwind CSS-Modules styling, pixel-identical to the Griffel suite across values, half fills, colors, sizes and compact mode. Star glyphs come from the headless icon factories — the library's first icon dependency — and a package-local context carries color and size to items. The half-fill overlay replicates the upstream clip geometry exactly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
First Button-family composition: useToggleButtonStyles composes useButtonStyles, the root carries both marker pairs, and toggle overrides ride fui.components.l2 over Button's l1. Glyph display swaps live in an l1 block — importance inverts layer order, so only the icons stylesheet's own layer can arbitrate visibility. Checked visuals are pixel-identical to the Griffel suite. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
Completes the component whose initial files were swept into the ToggleButton commit: the review-stage forced-colors repair for transparent hover (Griffel hoists media buckets past hover buckets), the look-prop default and slot-props tests, and the VR scene registration. Pixel-identical to the Griffel suite across the 106-cell appearance/content/size/icon matrix. The styles hook stamps data-icon-position to close a headless omission. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
Headless useInput + Tailwind CSS-Modules styling, pixel-identical to the Griffel suite across appearances, sizes, states and content slots. Block order carries the cascade, mirroring the upstream merge list; the invalid gate computes in JS with the upstream predicate; the styles hook stamps the content-slot attributes the headless hook omits. Also activates the previously dead export-map conformance test for every component and fixes a focus-only phantom border on the underline pseudo-element. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…-identical to Griffel) Full component set: types, headless-composing component, immutable styles hook, single-l1-layer module.css, tests, demo + VR stories, api.md. VR gate: identical at 1248x1716, zero diff pixels vs the Griffel suite. Notable: - ::after focus ring drops border-solid (phantom-border rule re-derived for this element: counterfactual measured 3px borders on three sides). - Disabled resize guard tested across all four resize values; work review closed a data-size stamp-displacement hole (spread-order mutation). - 31 implementer + 25 reviewer mutations killed; CSS blocks owned by the zero-tolerance VR diff (module.css idents are jest-proxied by design). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…dentical to Griffel)
Full component set: types, headless-composing component, immutable styles
hook, single-l1-layer module.css, tests, demo + VR stories, api.md.
VR gate: identical at 1248x1082, zero diff pixels, first capture (67 cells).
Catalog appends (shipped entries untouched):
- vertical-flag: the headless library spells vertical orientation two ways
(enumerated data-orientation vs boolean data-vertical); one entry per
spelling rather than editing the shipped vertical entry.
- focus-within-visible: keyborg-gated ring trigger matching Griffel's
createFocusOutlineStyle — work review caught windmod drawing the 2px
ring on mouse click where Griffel draws nothing (attribute stamps only
during keyboard navigation).
Notable:
- Plan's size-forwarding test premise refuted by measurement: React drops
non-numeric size attribute values, so the guard is proven with a numeric
size={16} probe instead.
- 59 mutations run across the three stages (one measured-equivalent
survivor documented); focus/RTL/forced-colors cells counted genuinely
VR-invisible by a scene-matrix oracle and recorded as accepted gaps.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…atively via :has(:focus-visible) Replaces the keyborg attribute gate with the browser's own modality heuristic, per operator preference for native pseudo-classes over data attributes. Measured equivalent for range/radio/checkbox controls: no match on mouse click, match on keyboard focus and on a mouse-then- keyboard modality switch. Text-editable descendants always match, which is the behavior text controls carry everywhere anyway. Verified against the rebuilt stylesheets: Slider draws its 2px ring on keyboard focus only (mouse click 0px), all gates green, Slider VR identical. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…ditions at the emitted .cjs files
Eight subpaths (avatar-group, color-picker, compound-button, menu-button,
overflow, split-button, swatch-picker, tag-picker) named
lib-commonjs/<name>.js in their node and require conditions while the
build emits <name>.cjs — any CommonJS require() of those subpaths threw
module-not-found. The other 67 packages with lib-commonjs export maps all
point at files that exist; this restores the same consistency here.
TypeScript consumers never noticed because tsconfig.base.json's source
path mapping bypasses the export maps.
Verified: every exports target in the package now resolves on disk, and
require.resolve('./compound-button') succeeds.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
… the ARIA button union
`BreadcrumbButtonBaseProps` was declared with a plain `Omit`:
export type BreadcrumbButtonBaseProps = Omit<BreadcrumbButtonProps, 'size'>;
`BreadcrumbButtonProps` includes `ComponentProps<ButtonSlots>`, whose `root`
slot is `ARIAButtonSlotProps<'a'>` -- a union over `{ as?: 'button' } & button
attrs` and `{ as: 'a' } & anchor attrs`. Plain `Omit` is `Pick<T, Exclude<keyof
T, K>>`, and `keyof` a union keeps only the keys common to every member, so the
omit collapses the union and every anchor-only prop (`href`, `target`, `rel`,
...) disappears from the derived type. `@fluentui/react-button` avoids exactly
this on the same shape by using `DistributiveOmit`
(Button.types.ts:72,84); this makes `react-breadcrumb` consistent with it.
Healed -- all three now type-check against the base surface where none did
before:
<BreadcrumbButton href="#a"> (the spelling react-breadcrumb's
own Default story uses)
<BreadcrumbButton as="a" href="#a">
<BreadcrumbButton as="a" href target rel>
Not changed, and honestly not a regression: a props object literal whose only
property is `href` is still rejected. That is TypeScript weak-type detection --
the `{ as?: 'button' }` union member has no required properties and shares no
property with `{ href }`, so the source only matches the `a` member, which then
demands an explicit `as: 'a'`. It fires identically on the Griffel
`BreadcrumbButtonProps`, and adding any shared property (`children`, which every
real JSX usage has) satisfies it on both. Runtime behaviour is unchanged: this
file declares types only and emits nothing.
etc/react-breadcrumb.api.md regenerated by the build.
Verified: react-breadcrumb type-check + lint pass; react-headless-components-preview
and react-components type-check pass. react-breadcrumb:test is 2 failed / 105
passed both with and without this change (pre-existing @fluentui/react-icons
snapshot drift -- SVG path data and the `fui-Icon` class -- unrelated to it).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…nders a layout-participating root BREAKING (preview): ThemeProvider is renamed FluentProvider (component, ./provider subpath -> ./fluent-provider, styles, stories, tests) for migration consistency with the Griffel suite, and the provider's root drops display:contents to render a real div carrying the same globals Griffel's FluentProvider authors: color, background-color, text-start, font-family/size/weight, line-height — in @layer fui.components.l1, so consumer overrides win normally. dir is resolved and stamped unconditionally; componentMarkers('fluent-provider') added. Apps already account for a provider div under Griffel, and the change is measured as a fidelity gain: at strict threshold, 15 of 16 VR scenes go byte-identical to the Griffel suite (previously up to 2.46M differing pixels hidden under the gate threshold and the storybook decorator's outer Griffel provider). Full sweep after the change: 15/15 root scenes zero-diff at both thresholds, tooltip at its documented 244 residue. Work review hardening: import-specifier re-sorting across renamed stories, prettier width fixes, stale display:contents comment removed, README corrected, stale dist/provider.d.* pruned; 19 mutations run (17 caught by named tests; the stylesheet-presence blind spot is documented and queued as a VR-report improvement). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…el-identical to Griffel)
First component of the post-decision queue: the uniform glyph rule ships
here first (post-hook immutable children ?? fallback, with the pre-hook
materialization companion for slots without renderByDefault). The
measured divergence set from Griffel is exactly the two intentional rows
({children: null} and {children: undefined} render the chevron where
Griffel renders an empty span); all seven other inputs are structurally
identical including the render-function subtlety.
l2 composition over Button's l1 (ToggleButton precedent), quad marker
set, two catalog appends (generic not-icon-only; package
appearance-secondary). VR: identical at 1248x832, zero diff pixels,
first capture (112 cells).
Review hardening: a glyph-identity test (the wrong-chevron mutation
survived presence-only assertions), a forced-colors scope error proven
no-op and documented, 31 mutations killed across the three stages.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
The icons fork's headless stylesheet dropped its single !important
([data-fui-icon-hidden]{display:none}) — the rule that forced every
glyph-swap above it to escalate. With it gone:
- Button (8 sites), ToggleButton (6), MenuButton (12) lose all their
!important glyph-swap declarations — 46 compiled !important
declarations across windmod+theme dist drop to zero.
- ToggleButton and MenuButton collapse their two @layer blocks to one
l2 block; the l1-glyph-swap exception is retired.
- The icons stylesheet import moves to layer(fui.base) (element resets,
its charter), making glyph-swap behavior stylesheet-order-independent
(measured: the old arrangement broke 4-5 of 15 state cells under
sheet reversal).
- 17 story files stop feeding Griffel icon atoms into the headless
bundleIcon (contract violation; proven load-bearing — reverting one
file fails Button VR by 1,944px).
Local icons tarball rewritten in place (sha 090bb64b…, +72 bytes) —
the standing revert-before-merge item is unchanged. Fork source edit
lives in the fluentui-system-icons working tree, uncommitted, for
operator review.
Verified: full VR sweep 16 root scenes zero-diff + tooltip's documented
244; glyph matrices 15/15, 54/54, 114/114, MenuButton 120-cell baseline;
declaration totals unchanged (1843→1843, display-only diffs).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…dentical to Griffel) Full component set: types, headless-composing component, immutable styles hook, single-l1-layer module.css, tests, demo + VR stories, api.md. VR gate: identical at 1248x872, zero diff pixels, first capture (57 cells). Notable: - The uniform glyph rule lands with ZERO divergence from Griffel here — Griffel's own Select merges with children ??=, which is exactly the uniform rule's semantics (independently re-derived from slot.ts and the shipped build, 8/8 rows). - Permissive focus-within confirmed (zero keyborg occurrences in Griffel's compiled Select — the Input/Textarea class). - The .disabledUnderline forced-colors re-assert verified against built CSS byte offsets; the JS data-invalid gate carries Griffel's exact === 'true' predicate. - 39 mutations across three stages (37 killed, 2 measured-equivalent survivors documented — one recorded as the D1-family freeze-harness question for the operator). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…xel-identical to Griffel) Completes the Button family. First l3 consumer (double composition over Button l1 + MenuButton l2 — measured order-independent) and first D5 double-swap: the headless hook binds headless children as slot element types, so windmod swaps in its own Button and MenuButton exactly as Griffel's styled hook does one layer up. The chevron arrives free from MenuButton's glyph rule — zero new seams. First RTL VR band in the loop (per-side provider wrapper; wiring proven load-bearing at 18,748px when mutated). Theme layer comment updated for the l3 claim (the D9-sanctioned line). VR: identical at 1248x1404, zero diff pixels, first capture. 38+ mutations across three stages; review closed an unpinned size/shape defaults hole and added the divider comparator's negative control (36 mismatches when block order is broken). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…-identical to Griffel) VR: identical at 1248x740, zero diff pixels, first capture (62 cells). The plan-time 'pure-CSS checkmark' verdict was wrong — the headless indicator ships no children; the uniform glyph rule restores the checkmark and mixed-dot families (80-row matrix vs Griffel: zero divergence; both glyph families pinned by path data per the MenuButton lesson). First consumer of the native focus-within-visible ring on a checkbox: mouse-click no-ring parity measured in Chrome against the built CSS. checked-mixed/not-checked-mixed catalog pair appended. 40 mutations across three stages (M36 ??→|| and a cross-slot leak closed in review; the byte-identical re-stamp class documented as the accepted survivor family). Forced-colors measured live via CDP — no hoisting inversion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…dentical to Griffel)
First consumer of the peer marker: peerMarker('switch') emits
peer/fui-switch on the native input only, and the indicator styles off
the input's real :checked through Tailwind's peer variants. Proven live
in review: a real click on an uncontrolled Switch (no React re-render)
restyles track and thumb byte-identically to the natively-checked twin
and to Griffel — the upstream data-checked stamp (dead for uncontrolled)
is read nowhere.
The thumb glyph restoration follows the uniform rule (8/8 matrix match
vs Griffel, both sides rendered). Refactor caught and fixed a real RTL
defect (checked thumb overshot the track; now an exact LTR mirror,
Griffel's paired-transform values). Forced-colors measured 58/58
identical via static-capture (the CDP emulation path crashes this
Chromium — method recorded).
VR: identical at 1248x1752, zero diff pixels (58 cells); the
peer-checked CSS mutation was run through the real VR gate and fails at
7,740 pixels — the gate's coverage is verified, not estimated.
49 mutations across three stages.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…native pseudo-class The keyborg attribute branch was mathematically redundant — anything matching [data-fui-focus-within]:focus-within already matches :focus-within, so the union collapsed. Identical behavior, simpler compiled selectors for Input/Textarea/Select. All gates green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…o channel values resolve adjustChannel picked its per-channel result with ||, so a legitimately falsy value — channel value 0 on a fully dark colour — fell through to the hue branch. Measured consequence: a channel="value" ColorSlider on a dark colour rendered value="210" max="100", the thumb at 210% of its own track. ?? falls back only on missing entries. Found during the windmod ColorPicker planning probes; recorded in the campaign findings roster as fixed-in-tree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…lwind, pixel-identical to Griffel)
Completes the input family. Radio ships root-level beside RadioGroup,
mirroring the Griffel package's structure. Second peer-marker consumer —
and the case that justified the design: sibling-click deselection proven
in a real browser with an independent probe (zero events and zero
class/data writes on the deselected radio; border, dot, and label all
restyle purely off the input's :checked loss; Griffel line-for-line).
The indicator dot is confirmed pure CSS on both sides (::after content
toggle — no glyph restoration needed).
VR: 0 strict-diff pixels (1248x1120) under the new strict-zero gate.
40 mutations killed; review fixed a real scene bug (the disabled band
passed disabled={false} to every cell, silently testing enabled radios —
an own disabled key beats group inheritance at ANY value, reproduced in
Griffel and noted for migration), closed a consumer-style merge hole,
and measured forced-colors parity via CDP.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…; neutralize process vocabulary in comments - testing/freezeState.ts: test-only deepFreezeState helper (skips ref-shaped objects; shallow-freezes React elements). Five components (Select, MenuButton, Checkbox, Switch, Radio) gain a frozen-headless- state spec proving the styled layer copies rather than mutates — the one mutation class behavioral tests structurally cannot see. - Comment hygiene: Textarea's module.css header and styles-hook doc now state only Textarea-specific facts and cross-reference Input for shared reasoning; internal decision vocabulary removed from shipped comments package-wide (sweep verified at zero hits). 610/610 tests, all gates green, every api.md and dist/styles.css byte-identical — comment/test-only change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…require specifiers
Two-part defect in the css-modules build pass for type:module packages:
the CJS-side class maps kept a .js extension (CommonJS body, ESM-parsed
by node → ReferenceError: exports is not defined), and the specifier
rewrite globbed **/*.js only — matching nothing under lib-commonjs/
after the .cjs rename pass, so require('./X.module.css') resolved
through node's legacy extension search straight onto the unloadable
file. That never-firing rewrite is why the crash was require-order
dependent.
The extension now mirrors the existing cjsRenameTransforms gate
(commonjs packages provably unchanged — verified against a stashed
rebuild), the rewrite globs both extensions, and the generated docblock
no longer names a lib-commonjs path that doesn't exist. Five regression
tests cover both halves plus the no-change case.
Verified: windmod's 26 CJS class maps emit as .cjs with repointed
requires; the previously-crashing probe require chain runs clean with
no workaround; all gates green; api.md byte-idempotent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
… sweep found (focus ring, ToggleButton hover/press) Three regressions from the state-based revalidation sweep (26,120 comparisons across 22 components; these were the only three), all in interaction states no gate had pixel-checked: - Focus ring lost its outer 1px on subtle/transparent/disabledFocusable cells across the Button family — two distinct causes: appearance blocks' own border-transparent, and .root's disabled border sub-block, each beating the ring by source order. Fixed in Button.module.css alone (ring block repositioned; border-color-only re-asserts on subtle/transparent so SplitButton's divider edge stays exactly Griffel's). The composed family needed no edits. - ToggleButton outline+checked border now moves on hover/press (neutral-stroke-1-hover/-pressed, as Griffel's bucket order does). - ToggleButton accessible+checked press drops the focus outline under :active (Griffel's compound outranks its indicator too), and the subtle icon hover colour resolves as Griffel's insertion order does — batch 1's glyph-swap diagnosis corrected to a colour mechanism, pinned by the dedupe-identical cell that never regressed. Verified: every previously-regressed cell 0 raw px across 3 runs (4 control-bounded AA residues aside); all six repairs' removals killed at the sweep's recorded magnitudes with disjoint cell sets; full 22-scene VR sweep strict-zero + tooltip within allowance; 610 tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…entical to Griffel) First cross-component composition: windmod Label renders Field's label slot (post-hook, immutable — required asterisk and typography come from Label's own module; l2 over Label's l1 with measured zero declaration overlap). Validation-state icons restore via the glyph rule with state-gated materialisation — the empty-slot form would stamp a 4px icon gutter on stateless fields (proven VR-visible at 2,294 strict pixels when mutated). Three validate-state catalog entries appended. VR: 0 strict-diff pixels (1248x1228). 40+ mutations across three stages; review closed two axis-crossing spec gaps (size x orientation both directions) and proved the frozen-state contract with valid in-place writes. Context values identical to Griffel 6/6; the Field-to-control size propagation intentionally awaits the context cycle (medium is pixel-identical). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…dentical to Griffel) The largest module yet: 62 classes in one l1 block, carrying the 30-name colour palette (hash verified 27/27 against Griffel's rendered classes by two independent methods; all 96 colour tokens map exactly). Initials recompute dir-aware through the provider context — exact 38/38 in BOTH directions where plain truncation is wrong for 21 of 38 RTL names. The active/inactive ARIA element is authored (byte-equal to Griffel on all eight cells), and the image fallback chain is fully jsdom-tested. Glyph fallback follows the uniform rule (6/8 matrix; the two intentional rows render the person glyph where Griffel renders an empty span). VR: 0 strict-diff pixels across 2,675,712 (1248x2144) — including the size-96 ring-shadow cell the plan had flagged as an open risk (the planning-time residue proved a probe-page artifact; the band now renders every size). 40 mutations killed; 189/189 DOM prop shapes identical. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…tical to Griffel) The dismiss glyph follows the uniform fallback rule at Tag's measured post-hook position (pre-hook slot creation strips 7px of padding on non-dismissible tags) — zero divergence from Griffel across the 8-input matrix. The typography pin the plan designed is DEAD: the provider now supplies inheritance, and plain font-family inherit reproduces both root element types exactly (75/75 computed-identical, including the consumer-font-scope case previously accepted as a gap — withdrawn). The high-contrast partial border avoids border-solid via per-side widths; forced-colors measured 75/75. VR: 0 strict-diff pixels (1248x1616). 50 mutations (49 killed, one measured-equivalent documented); RTL closed with a mirror-twin oracle; review fixed a stories type-check break the gates missed (harness gap recorded) and four non-canonical files. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…, pixel-identical to Griffel) Card, CardHeader, CardFooter, CardPreview in one commit (root-level dirs mirroring Griffel's layout). The floating-action adjacency ships as a REAL sibling combinator through the preview's public group marker (the approved escape hatch — the nth-of-type stand-in and its stamp, catalog entry, and ordinary-layout risk are all gone; 5/5 permutations match Griffel). Cross-component group variants style the children off fui-card's marker; the typography pin dissolved against the provider (66/66 computed-identical with zero pin utilities — Griffel's own colour reset kept). Selectable focus uses both variants (focus-visible for interactive roots, the native keyboard-only ring for the hidden checkbox — Tab rings, click doesn't, modality switch returns it). VR: 0 strict-diff pixels (1248x1704). 47 jest + 9 CSS mutations all killed — review closed six slot-spread holes the class-level mutations missed and fixed a stories type-check break (the harness gap now has a seventh gate). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…pair for CardHeader The Card landing's lane commit staged only the windmod package; this catalog pair (theme-side per the placement rule — data-description is generic presence vocabulary) is the other half, caught by the landing gates' build failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…interactive machinery RatingItem moves to root level (mirroring Griffel's package layout, history preserved) and gains its interactive-gated pieces: the radio input slots with half-step geometry, the outline colour branch, and the keyboard-only focus ring — every addition a proven no-op for RatingDisplay (interactive:false context; its VR scene re-ran at 0 strict-diff as the guard). Rating itself wraps the shared item with hover preview riding native radio semantics — a hovered value-0 rating renders DOM identical to the equivalent value, class-for-class with Griffel. VR: rating 0 strict-diff (1248x2084, 76 cells) and rating-display 0 strict-diff, both twice. 34 mutations killed + one traced equivalent; review widened the onChange payload pin to the full measured shape and recorded a cross-cutting focus-modality edge for the catalog ledger. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…ast and its comment (3/3) The const annotation already types both forwardRef parameters and the emitted declaration, so the trailing cast and the "Casting is required" comment carried by every component were dead: with all of them removed the package type-checks, and the emitted .d.ts and api.md files are byte-identical to the baseline. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…otations The ForwardRefComponent<Props> annotation on the const already types both parameters; the 35 files that re-annotated (props: XProps, ref: React.Ref<…>) inline now use the unannotated form the rest of the corpus uses. Three ARIAButtonElement type imports existed only for those annotations and go with them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…dy const (1/4) A hook call nested inside an object literal or a call argument hides the unconditional call order the rules of hooks depend on. Every hook now has its own statement-level const, in call order: context, state (base when a derived state object follows), styled, contextValues. The authoring conventions record hooks as the named exception to the single-use-no-const shape. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…dy const (2/4) A hook call nested inside an object literal or a call argument hides the unconditional call order the rules of hooks depend on. Every hook now has its own statement-level const, in call order: context, state (base when a derived state object follows), styled, contextValues. The authoring conventions record hooks as the named exception to the single-use-no-const shape. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…dy const (3/4) A hook call nested inside an object literal or a call argument hides the unconditional call order the rules of hooks depend on. Every hook now has its own statement-level const, in call order: context, state (base when a derived state object follows), styled, contextValues. The authoring conventions record hooks as the named exception to the single-use-no-const shape. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…dy const (4/4) A hook call nested inside an object literal or a call argument hides the unconditional call order the rules of hooks depend on. Every hook now has its own statement-level const, in call order: context, state (base when a derived state object follows), styled, contextValues. The authoring conventions record hooks as the named exception to the single-use-no-const shape. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
Seven lines to four; the three constraints stay (no high-contrast ladder because the reference has none, no motion block because the global floor pins durations, both byte-asserted so neither name appears in the file). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…dden on its chevron References microsoft#36685; the corpus sweep found no other uncommented attribute restoration (TagPickerGroup's role="listbox" already states its own). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…items, not absolute The reference positions the two buttons absolutely on top of a grid placement. With the 24px column and two 1fr rows fixed, the placement alone lands each button in the same box: a probe over the 53 spin-button VR cells (small and medium, both steppers) measured zero rect changes on root, input, increment and decrement with the buttons made static, and computed display is flex either way. Compiled CSS loses exactly `position: absolute`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…der in the offset layout The offset layout no longer moves the previous button behind the row's children in the DOM. The headless renderer draws previous, children, next in every layout (microsoft#36684); the offset look now comes from CSS order on the two buttons, with the auto margin on the first of the row's own children. Pixels are unchanged; reading and tab order now follow the headless order, recorded as MIGRATION delta 60. A DOM-order test pins the chosen order for both layouts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…S incantations The vertical range trick (vertical-lr + rtl for max-at-top, root-keyed RTL flip) gets its constraint stated once in ColorSlider and referenced from Slider; the underline geometry (radius-tall box, clip trims the border horns, max() survives a zero radius) is stated once in Input and referenced from Textarea, TagPickerControl and Select (SpinButton's reference line landed with c1eef6b); Card's visually hidden checkbox and Spinner's ring mask each get one line. Compiled CSS is byte-identical. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
Preflight ships at the head of fui.base, so a component declaration that only restates one of its values is dead: box-border (universal border-box), m-0/p-0 and their longhands on elements the universal rule already zeroes, border-current beside border-none (the reset's `0 solid` already resolves the colour to currentColor), the Input/SpinButton font-*: inherit blocks and font-[inherit] / bg-transparent / border-0 / rounded-none on form controls, opacity-100 on ::placeholder, list-none on lists, and InteractionTagPrimary's per-side border-style longhands (Tag draws the same partial border without them). The justification comments that cited the user-agent sheet for these are deleted or rewritten; ImageSwatch states that its border style is the reset's. Kept, each undoing an author rule on the same element: Badge's size p-0 and .outline border-current, Toolbar's size py-0, NavDrawerFooter's l2 p-0, SearchBox/MenuSplitGroup padding undos, Divider's alignment margin zeroes, Avatar inactive m-0 over the ring margin, ToolbarButton's m-0, the swatch state-block border-current over the resting colour (now the one canonical comment), TagPickerButton's visually-hidden p-0 over .root's size padding, and appearance: button on polymorphic roots. Verified: compiled chunk diff removes 178 declarations and adds none; a computed-style census over every element and property of all 91 VR scenes is identical before and after (the census is what caught the TagPickerButton case); 3604 jest tests pass; full VR sweep 91/91 within ceilings. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…laced-grid form is not pixel-identical The static-grid form measured layout-identical, but the spin-button VR scene left strict zero: 16 pixels on one stepper chevron's antialiased edge moved by one grey level (136 vs 137). A self-vs-self control on the same build — the story captured as built, then with `position: absolute` injected back — reproduces exactly those 16 pixels, so the declaration is load-bearing for rasterization even though it moves no box. `absolute` returns with that as its comment; compiled CSS is byte-identical to the pre-lane baseline and the scene is strict 0. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
Second chunk of the same sweep (see the previous commit): the remaining module.css files, plus the skill's contract line — components depend on the base sheet's reset and never restate it — and the failure-modes 'restated reset' row with its detection grep and the kept-undo cases. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…hairline ride the base scale The ColorSwatch/ImageSwatch selected, focus-visible, hover, hover-active and forced-colors rings spelled their outer spread as a literal 5/6/7px beside stroke-width tokens that already scale, so at a non-default root the inner brand ring outgrew the frozen focus ring. The five spreads per module become calc(Npx * var(--base-scale)). The same class sweep found the Drawer header/footer scroll-separator hairline (h-px) and DrawerBody's +1px edge compensation frozen beside InlineDrawer's scaled separator; both now read the thin stroke token (h-thin, + var(--spacing-thin)). Verified: compiled chunk diff touches only these 14 values; computed- style census over all 91 VR scenes identical at the default root; a forced-state probe (rest/hover/active/focus-visible/hover-active, 305 swatch cells) reads every ring spread byte-identical at 16px and exactly doubled at a 32px root; swatch-picker and the seven drawer scenes strict zero. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…lect and unicode-bidi on DialogSurface A <dialog> in the top layer computes user-select: text, and a <dialog> computes unicode-bidi: normal where Griffel's <div> surface computes isolate. OverlayDrawer already neutralises both; DialogSurface, the same native element opened the same way, did not, and the dialog census had recorded the divergence. The surface now authors select-auto and unicode-bidi: isolate. Verified: computed-style census over all 91 VR scenes changes only user-select (surface and inheriting descendants, text -> auto) and unicode-bidi (normal -> isolate) in the dialog scenes; a windmod-vs- Griffel probe over the modal, non-modal, alert and scroll scene pairs reads 0 divergences on the surface, title, body and actions; the four dialog scenes stay within their gates. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…g lost to a whole-file restore The @supports restoration in TeachingPopoverBody brought the whole file back from before phase 2A, silently reverting aspect-288/117 and aspect-288/176 to their bracket forms. Re-applied; the compiled chunk is byte-identical. failure-modes.md gains the rows this cycle produced: frozen lengths beside scaled ones (the swatch spreads and drawer hairline), a census finding fixed in one file (the DialogSurface user-select/unicode-bidi miss), and whole-file restores re-opening swept classes — plus the three detection-grep gaps: `& > *` in the glyph-selector grep, resolved-initial keyword utilities in the unset row, and named -px utilities beside the bracket-px grep. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…ir class's resting rule Button's size arms re-applied utilities identical to .root — small's rounded-medium, large's min-w-96 rounded-medium font-semibold, the icon slot's size-20 text-icon-20 under group-size-small and its spacing var under group-size-large — Griffel-bucket copies that are dead under an always-on base. The exemplar surviving the first re-assertion sweep meant its grep had missed a shape, so the sweep re-ran with a broader one: every nested arm at any depth, on the same element, against the class's resting @apply. It flagged 48 utilities in 39 arms; 33 were dead and are deleted here (Button's appearance arms restating border-transparent and the on-brand text, the .circular/.square focus blocks, the swatch/slider/avatar focus arms restating border-solid or the radius, the InteractionTag/Tag disabled arms restating the outline background, Persona's font-regular, Toolbar's size-large py-4, the TeachingPopover footer's brand background, the nav dot's outline-solid, the Combobox/TagPickerInput disabled backgrounds), plus the same border-solid in AvatarGroupPopover's pie arm. Nine flagged utilities stay because a sibling arm between the base and them can match the same element at the same time (a disabled arm over a preceding hover arm; Toolbar's vertical re-assertion after the size blocks); the tally and the adjudication rule are recorded in failure-modes.md. Verified: compiled chunk diff removes 43 declaration lines (6 of them --tw-* custom properties) and adds none; a forced-state computed-style census (hover, active, focus-visible, hover-active, focus-within, under both normal and forced-colors media, every element of all 91 VR scenes, transitions settled) is identical before and after; the resting census likewise; a CDP state-matrix probe over 265 Button cells reads the values the deleted size arms used to force; 3604 jest tests pass; the 31 touched VR scenes are within their gates (persona 0 on two runs after one frame-offset capture artifact). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…r verifiers and repair theme-infra comments Lands A1 (verify-tokens-css + verify-theme-values as build dependencies, phantom citations fixed), A2 (@Property guard rationale), E1 (@ts-check passes, verify-scripts), E2 (stale citations and comment stumps), E3 (theme-values.json source stamp). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…ight resets and scale the last frozen lengths Lands B1 (178 preflight restatements deleted corpus-wide, false justification comments fixed), B2 (swatch focus-ring spreads and the drawer hairline ride --base-scale), B3 (dead variant arms restating the resting rule deleted), C1 (DialogSurface user-select + unicode-bidi neutralised), C2 (TeachingPopoverBody aspect-fraction spelling re-applied), plus their failure-modes rows and the ruled preflight contract in authoring-conventions. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…p inert casts, and state CSS incantation intent Lands D1 (popoverArrow.ts folded into popoverOffset.ts), D2 (ForwardRefComponent cast and comment deleted, emitted .d.ts and api.md byte-identical), AR8 (unannotated forwardRef params), AR6 (every hook call hoisted to a top-of-body const), AR5 (SpinButton steppers stay absolute with the measured reason), AR7 (MenuItemSwitch header condensed), AR4 (why-comments on multi-declaration incantations), AR9 (NavCategoryItem aria-hidden restoration comment), AR11 (carousel footer keeps headless DOM order, offset look via CSS order). One conflict, SpinButton.module.css: lane-b deleted the "border-solid is deliberately absent" justification (a restated preflight reset) and lane-c had appended its AR4 cross-reference to the same comment; resolved to the reference sentence alone, in the form its Textarea and TagPickerControl siblings use. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…oggleButton's styles hook and owns no stylesheet The two toolbar toggle modules were one stylesheet under two marker names: with the marker normalised, all 78 non-comment lines matched line for line, and the radio file's own header said so. The radio styles hook now applies ToolbarToggleButton's hook and restacks its own marker pair over the result, so the radio root carries four marker pairs (toolbar-radio-button, toolbar-toggle-button, toggle-button, button) and the l3 checked rules reach it through the toolbar-toggle-button pair — the shape NavCategoryItem and NavSubItem already use over NavItem. One consequence for consumers: a group variant on fui-toolbar-toggle-button now also matches radio roots. Verified: ToolbarToggleButton's compiled chunk and class map byte-identical; the retired radio chunk equals the toggle chunk modulo ident and marker (147 rule lines, 0 differing); toolbar.api.md unchanged; computed-style matrix over the toolbar scene's 26 toggle and radio roots plus icons — rest, hover, hover+active, focus, focus-visible, each under normal and forced colours — 260 cells, 0 differing before and after; toolbar VR scene strict zero; each of four mutations of the radio hook fails at least one test. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…s and drop its inert casts The Toolbar family held the last hook calls nested inside an object literal or a call argument; every headless, styles, context and context-values hook now lands in a top-of-body const under the fixed names (AST scan over 679 files: 19 nested calls before, 0 after). The four `as ForwardRefComponent<…>` casts and their comments go the way of the rest of the corpus: the emitted toolbar.d.ts declarations and toolbar.api.md are unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…eric value-named ramp (AR2) Replaces the per-step --leading-base-100..600/--leading-hero-700..1000 names (Decision U) with one flat, generic, value-named ramp: 15 labels (000, 050, 055, 080, 083, 092, 100, 125, 133, 137, 140, 143, 160, 167, 200), each the 3-digit truncation of its ratio*100, no font-size pairing implied by the name. Font-size keeps its base/hero split; only leading changes. - LEADING_RAMP is a fixed table in generate-tokens-css.js, with a uniqueness assertion on labels and a cross-check that the six lineHeightBase* ratios in packages/tokens/src/tokens.ts still land on one of the 15 ramp entries (fails loudly on drift instead of silently invalidating the table). - The ten lineHeightBase*/lineHeightHero* tokens.ts entries are still classified (unitlessRatio) so theme-invariance keeps being asserted, but are no longer emitted one-per-entry; render() emits LEADING_RAMP directly in their place, preserving the original "Line heights" section placement. - --leading-hero-700/800/900/1000 are dropped entirely (zero corpus sites). - css/index.css already carried `--leading-*: initial;` ahead of the ramp import, wiping Tailwind's keyword ramp (leading-tight/snug/relaxed/loose/ normal) as intended; a compile probe confirms this. It does NOT block Tailwind's numeric leading-<n> utility or the hardcoded leading-none utility (neither reads the --leading-* namespace), but every one of our 15 registered labels wins its own static match over the dynamic numeric form (verified by probe), and no site in the corpus authors a bare leading-<n> outside the registered set. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…ramp (1/2) Mechanical rename via .scratch/windmod-loop/leading-ramp/migrate.mjs, driven by census.mjs's site-by-site mapping — every old leading-base-*/ leading-hero-*/leading-none/leading-[...] form replaced by the new value-named token with the SAME ratio (pure rename, zero value change): leading-base-100/500 -> leading-140, base-200/600 -> leading-133, base-300 -> leading-143, base-400 -> leading-137, leading-none/[1]/ [calc(20/20)]/[calc(16/16)] -> leading-100, [0] -> leading-000, [calc(20/16)] -> leading-125, [calc(20/12)] -> leading-167, [calc(20/24)] -> leading-083, [calc(22/24)] -> leading-092, [calc(32/20)] -> leading-160, [calc(22/40)] -> leading-055, [calc(20/40)] -> leading-050, [calc(20/10)] -> leading-200, [calc(16/20)] -> leading-080. First half of the corpus (alphabetical). Re-verified after migration: zero old forms remain (census.mjs), and a declaration-level diff of the built, Tailwind-compiled component CSS (pre- vs post-migration) shows zero computed line-height changes across all 172 authored line-height sites. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…ramp (2/2) Second half of the corpus (alphabetical) plus useCssVarValue.ts, whose doc comment both mechanically renames its var(--leading-base-300) example and recomputes the token-family measurement table (--leading-* now 15 declared tokens, 10 literal / 5 calc-string, replacing the old 10 declared / 5 / 5 — net +5 declared tokens repo-wide, 477 total). Same migrate.mjs mapping as part 1/2 — see that commit for the full old-name -> new-name table. Re-verified together with part 1/2: zero old forms remain (census.mjs) and zero computed line-height changes across all 172 built declaration sites (declaration-level pre/post diff). Full verification for both halves: - node .scratch/windmod-loop/gates.mjs: build:theme, build:headless, build:windmod, verify:theme, type-check, lint, lint:stories, type-check:stories, test (161 suites / 3605 tests) all green. - 91-scene VR sweep (static storybook build vs Griffel): 74 strict-zero, 17 pass-allowance (each within its scenes.json ceiling, e.g. info-label-open at its 8918 ceiling), 0 fails. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
- tokens-and-scale.md: the ramp table (15 labels, ratios), the label rule (3-digit truncation of ratio*100), and "no font-size pairing implied" — font size keeps its base/hero split, leading does not. - authoring-conventions.md: the leading-without-font-size rule restated for value-named labels; the leading-[calc(target/ownFontSize)] escape hatch now documented as forcing a census.mjs ruling rather than a dead end. - failure-modes.md: the "leading without an authored font-size" row updated to the new names and the census-failure detection path. - MIGRATION.md: entry 59 updated to the new example names; new entry 61 documents the AR2 rename itself with a full old-name -> new-name mapping table for any consumer reading --leading-* custom properties directly. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…omes pure arithmetic, no theme tokens Replaces the 15-token --leading-* generic ratio ramp (AR2) with a functional @Utility override: leading-<n> compiles to calc(n / 100) for any bare integer n; leading-<a>/<b> compiles to the exact ratio a/b, for the six ratios a 2-decimal-digit label could only approximate (formerly 083/092/133/137/143/ 167). Both arms are calc()-wrapped — a bare --value(ratio) substitutes invalid <ratio> text into line-height, which Chrome silently drops. Generator: deleted the --leading-* token emission, its invariance assertion and label-uniqueness assertion entirely; lineHeight is now an explicit EXCLUSIONS entry, with a generic stripExcluded filter keeping the per-theme key-parity check honest against theme-values.json's still-present upstream values. dist/base.css carries zero --leading- declarations. Corpus: 171 sites renamed (padded labels debadded, six inexact labels moved to their exact fraction). Switch's --fui-switch-label-offset margin was a provable algebraic tautology (0px at every state) and is deleted, with root alignment moved from items-start to items-center for inline label positions (a no-op for single-line labels; label-above keeps items-start explicitly, now controlling the column cross-axis). Checkbox/Radio's analogous offset math is real, non-tautological work and is unchanged, with the removed var(--leading-*) reference replaced by the literal fraction the token held. Resolved line-height ratios are byte-identical across all 172 sites vs. c43691c (lineheight-diff2.mjs). A residual class of sub-pixel VR diffs (Chrome's Blink LayoutUnit, 1/64px fixed-point, rounds a directly-evaluated leading value one unit short of the custom-property-indirected form's whole-pixel result) is ratified as inherent rendering-path quantization, not a value regression — see MORNING-DECISIONS.md Decision X and the LayoutUnit 1/64px failure-modes.md row (third occurrence in the campaign). Docs: tokens-and-scale.md, authoring-conventions.md, css-var-values.md, MIGRATION.md (entries 59/61-63) and useCssVarValue's doc comment rewritten for the arithmetic scheme; the 15-token registry has no remaining references outside historical migration-mapping context. 91-scene VR sweep: 64 strict-zero / 27 within-allowance / 0 fail. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
|
Reposted below the commit history for visibility — unchanged from the original. 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. |
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.