feat(react-windmod-preview): a Tailwind v4 + CSS Modules styling layer for Fluent's headless components - #36656
Conversation
…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
…ind, pixel-identical to Griffel) Seven components in the queue's largest commit: Toolbar, ToolbarButton, ToolbarToggleButton, ToolbarRadioButton, ToolbarDivider, ToolbarGroup, ToolbarRadioGroup (the last shipping without a module — it reuses Group's styles verbatim per Griffel's own structure). The toggle/radio buttons are the second l3 consumers; review found and fixed a fourth layer inversion (checked-subtle pressed glyph, visible only in :active) and an incomplete forced-colors transliteration. The broken upstream size context is fixed for free by folding the look prop into state — children see the toolbar size with no windmod-local context; the load-bearing data-orientation stamp on ToolbarDivider is mutation-proven (without it neither orientation draws a line). VR: 0 strict-diff pixels (1248x1476). 31 mutations (29 killed, two proven equivalent); review closed two spec gaps and re-derived 62 cells x 50 properties against Griffel's own SSR renderer across five states. A forced-colors divergence in plain shipped Button/ToggleButton was found by the review's controls and filed to the regression queue. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…, pixel-identical to Griffel) Ships the animated selection indicator as a faithful port of Griffel's mechanism — JS-measured rects written as two custom properties that CSS transitions — verified live on both sides to identical values (jump offset -59.375px, scale 0.58960..., RTL +100.703px with the paired transform-origin atoms), with enforced stubbed-rect coverage that dies five ways if the measurement block is touched. The bold-width reservation seam uses Griffel's own spelling (32/32); the root typography pin died under the provider rework. VR: 0 strict-diff pixels (1248x1570, 66 tablists / 184 tabs audited fully inside the captured root). 55 mutations killed + three proven equivalent; review fixed two forced-colors defects (unselected filled-circular labels invisible in High Contrast; the icon's missing forced-colors rules) and closed two real port gaps (the lastAnimatedFrom clear branch and the content-ref strip). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…ilwind, pixel-identical to Griffel) MessageBar, MessageBarTitle, MessageBarBody, MessageBarActions (MessageBarGroup measured out — its whole value is motion). The intent icons restore through the uniform glyph rule with the intent-total lookup as the fallback value — the four-member union makes the record exhaustive with no guard; each intent's glyph pinned by its own path data. Zero catalog appends: first consumer of eight pre-seeded intent entries. The typography/colour pin died against the provider (both halves supplied; Title/Body keep Griffel's own local typography). The containerAction dismiss carries the explicit small sizing Griffel's geometry demands. VR: 0 strict-diff pixels (1248x2574), reproduced twice. 37 mutations across all 21 plan rows killed; the reflow layouts pinned per layout; RTL clean both directions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…lwind, pixel-identical to Griffel) Accordion, AccordionItem, AccordionHeader, AccordionPanel — with two components shipping no stylesheet at all: AccordionItem (Griffel authors zero rules for it) and Accordion itself (its only planned rules were the typography pin, dead under the provider). The closed panel hides via the approved two-line rule (headless keeps it mounted where Griffel unmounts — the state-persistence delta is in the migration ledger). The chevron restores through the uniform glyph rule bare, its rotation authored as CSS group variants on the icon class per the operator's ruling — consumer-supplied icons rotate too, and can unset. VR: 0 strict-diff pixels (1792128 total) — after the harness's determinism step learned to cancel WAAPI animations: Griffel's finished enter animations kept panels composited, flipping text antialiasing with zero computed-style difference (the Griffel-vs-Griffel control was 0, so no allowance was permissible; the full 30-scene sweep holds under the cancellation). 35 mutations killed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…avDivider (headless + Tailwind, pixel-identical to Griffel) Nav's root reduces to Griffel's verbatim flex column now that FluentProvider authors the shared typography and colour globals. The row presentation that NavItem shares with the category components ships as a plain pure function of state rather than a hook, so future consumers compose it without hook-order constraints. The selected indicator is pure CSS. Density folds into state and reaches descendants through the headless context; the small-density selector lives in the library's variant catalog. Verified pixel-identical to the Griffel implementation at a strict zero-diff gate (1,010,880 px), 13 planned + 28 review mutations all accounted for, and byte-idempotent API reports. Co-Authored-By: Claude Fable 5 <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
…bel alignment (parity FAILS) Replaces Checkbox's --fui-checkbox-label-offset margin math with a two-row grid (grid-rows-[1lh_auto]) expressing "align control to label's first line" as pure layout: indicator gets row-start-1 + self-center, label gets row-[1/span_2]. Root also declares font-base/text-base-300/leading-20/14 explicitly so the root's 1lh is guaranteed to equal the label's own line box, rather than relying on FluentProvider's ambient body typography happening to match. Internal indicator-to-label-first-line alignment is measured EXACT vs the committed margin-math version (medium: indicator 2px below label top; large: 0px offset; both single-line and 3-line-wrapped cases, both label-before/label-after) -- see the parity table in the session report. That required moving the label's block padding from symmetric (py-vertical-s) to bottom-only (pb-vertical-s): the label spans both grid rows and stretches to fill them, so any top padding sits between the grid area's top (row 1's start) and the text, pushing the first line out of alignment with the row-1-centered indicator. Bottom-only padding was the only value that reproduces the original offset exactly (measured, not assumed). That fix breaks a DIFFERENT invariant: the checkbox's overall root height is no longer coupled to the indicator's own size at all. Original root height equals the indicator's margin-box height (32px medium, 36px large -- it grows with the indicator). The grid's row-1 track is a fixed 1lh independent of the indicator, and row-2 is driven only by the label's own overflow/padding need, so height no longer scales with indicator size: measured 27.984px for BOTH medium and large under the prototype, a -4.02px delta at medium and a full -8.02px delta at large. This is a provable structural conflict, not a tuning miss: satisfying the alignment invariant requires the label's effective top padding to be ~0, while satisfying the height invariant requires it to be large enough to reproduce the indicator's margin-box height -- the two pulls point in opposite directions for a single padding value with no offset math. The real VR gate confirms this at the pixel level: `node run.mjs --scene checkbox` hard-fails on a dimension mismatch before any pixel diff runs (1248x656 windmod vs 1248x740 Griffel, an 84px shorter total page height across the scene). Per operator instruction, this is committed as-is rather than reverted so the operator can finish it by hand; Radio and Switch are untouched. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…(parity PASSES) Resolves the structural conflict from the prior grid prototype (wip ddc7b78) by moving outer spacing off the children and onto the grid itself, per the operator's ruled spec: - Row 2 is `minmax(0, auto)` (bare `auto` measured to contribute a phantom 8px). Indicator stays `row-start-1 self-center`; label is `row-[1/span_2] self-start` so a wrapped label starts at the row rather than centering. - Indicator and label are bare boxes now -- no margin, no padding of their own. The grid owns the control-to-label gap via `gap-x-horizontal-m` (measured: the old `indicator mx-horizontal-s` + label's near-side `px-horizontal-xs` summed to a constant 12px in every position/size combination, which is exactly `--spacing-horizontal-m`). - The root owns the outer footprint: `px-horizontal-s` reproduces the old margin/padding on both outer edges symmetrically (verified: the far/outer side of both indicator and label was always the untouched `s` value, regardless of label-before/after), and per-size `py` reproduces the Griffel root heights against a fixed 1lh (20px) first row -- `py-vertical-s-nudge` (6px) at medium, `py-vertical-s` (8px) at large, each solving `heightTarget = 20 + 2*py` exactly. No calc(), no offset custom properties anywhere. - Large's 20px indicator equals the 20px 1lh row exactly (no escalation case for Checkbox). Verified via a repo-local probe (18-cell rect table: indicator top/left/ size + label first-line top/left/size, 16 single-line + 2 three-line-wrap cells) reconstructed against the pre-grid CSS: all 16 single-line cells match the given baseline exactly; the 2 wrap cells match on vertical alignment (the invariant this rewrite is about) and diverge only in horizontal width/left, an artifact of the probe's own test text/width choice rather than of the CSS (the wrap3 story band was probe-only, never committed). A measured, documented residue: `leading-20/14` compiles to a truncated decimal (`line-height: 1.42857`, not the exact `20/14` ratio), which lands the `1lh` grid track fractionally short (19.984375px, one Blink LayoutUnit below 20) -- root heights measure 31.984375/35.984375 against a 32/36 nominal. This is the documented LayoutUnit-1/64 class (authoring- conventions.md), confirmed via an isolated identity control (an exact `calc(20/14)` ratio does not reproduce it). It does not survive into the real gate: the checkbox VR scene (`run.mjs --scene checkbox`) reports 0 strict-diff pixels at matching dimensions (1248x740, no heightTolerance needed) -- Griffel's own render lands on the same sub-pixel value. Gates: build, type-check, lint, test (3605/3605) all green for react-windmod-preview. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…SES) Same grid-first-line model as Checkbox (grid-rows-[1lh_minmax(0,auto)], indicator row-start-1 self-center, label row-[1/span_2] self-start, gap-x on the grid, px/py on the root), applied to Radio and extended to its label-below layout via a documented per-variant override. Radio has only one indicator size (16px < the 20px 1lh row -- no escalation case) and no label-before, only after/below, so the DOM's fixed input/indicator/label order needs no column reordering for the after case. - Root: `px-horizontal-s` (reproduces the old indicator mx + label's unmodified far-side px, both always 8px regardless of position) and `gap-x-horizontal-m` (12px -- the old indicator mx + label's near-side ps-horizontal-xs summed to the same constant as Checkbox). Default `py-vertical-s-nudge` (6px) solves the after-case root height (32px) against the fixed 1lh row, identically to Checkbox medium. - `label-below` is a per-variant override, not a special case bolted onto the same template: single implicit column (both indicator and label pin to distinct rows, so auto-placement never needs a second column), `grid-rows-[auto_auto]` (indicator's own height, then label's own line box -- no longer sharing the 1lh row with anything), `gap-y-vertical-m` (12px, the same old indicator my + label's pt-vertical-xs sum, just on the block axis), `justify-items-center`, and `py-vertical-s` (8px -- the old indicator's own my-vertical-s and the label's unmodified pb were both already 8px, so the root reproduces both edges with one token). This deletes `--fui-radio-label-offset` (the calc() correction the grid rewrite exists to remove) entirely -- no replacement formula. - Indicator needs no label-below override at all: `row-start-1 self-center` already centers it correctly in either row template, since row 1 is always sized to exactly fit it (1lh in the after template, auto in the below one). Verified: `run.mjs --scene radio-group` (the only VR coverage Radio has, via RadioGroupVrScene -- there is no standalone `radio` scene) reports 0 strict-diff pixels at matching dimensions (1248x1120), covering every band in that scene (all three RadioGroup layouts, group-disabled, per-item disabled, the label-below-beats-layout-default band, bare Radios in both positions x checked x disabled, custom indicators, a 160px-wrapped multi-line label in both positions, and required). Root heights measured 31.984375/63.984375 against 32/64 nominal -- the same documented LayoutUnit-1/64 residue as Checkbox (leading-20/14 compiling to a truncated `1.42857` decimal), which the VR gate confirms Griffel's own render shares (0 diff, no heightTolerance needed). Gates: build, type-check, lint, test (3605/3605) all green for react-windmod-preview. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…g needs re-ratification)
Same grid-first-line model as Checkbox/Radio, extended to Switch's third
label position (above, in addition to before/after) and its size axis
(medium/small, which also changes the label's own font metrics -- unlike
Checkbox/Radio, Switch's 1lh row is genuinely size-dependent).
- Root mirrors the label's typography per size: base `text-base-300
leading-20/14`, `@variant size-small { text-base-200 leading-16/12; }` --
needed for real, not cosmetic: 1lh must equal 20px at medium and 16px at
small, matching the indicator's own h-20/h-16 exactly at both sizes (no
escalation case at either size).
- `px-horizontal-s` and `gap-x-horizontal-m` are constant across size AND
position (measured: indicator's own mx and the label's own near-side
padding never had a size-conditional value, so the 12px gap and 8px
outer footprint hold everywhere). `py-vertical-s` (8px) is likewise
constant across size for before/after -- unlike Checkbox, indicator's
own vertical margin (vertical-s) was ALREADY the exact half-height
delta at both sizes by construction, so no per-size override is needed.
- `label-above` is a documented per-variant override, structurally
different from Radio's label-below: label takes row 1 alone (its
`row-[1/span_2]` base narrows to `row-[1]`), the indicator moves to
row 2 via its existing `.label-above` conditional class (already
applied only in that case per useSwitchStyles -- repurposed from a
`mt-0` margin reset to a `row-[2]` placement), asymmetric root padding
(`pt-vertical-xs` / `pb-vertical-s`, matching the old label's own
distinct top-padding value vs the indicator's own unchanged bottom
margin), and `gap-y-vertical-xs` (4px, the old label's own bottom
padding plus the indicator's now-zeroed top margin). No justify-items
override -- explicit-width children default to start-aligned in a
stretch track, reproducing the old `items-start` for free.
- `.input`/`.anchored` (the absolutely-positioned hit target) needed no
changes: inset properties resolve against the padding box, which does
not move when a padding VALUE changes, so the anchoring math is
unaffected by root's flex-to-grid switch.
This deletes `--fui-switch-label-offset`... except that offset was
already deleted from a prior session (the comment explaining why
items-center didn't need it); this change removes the `items-center`/
`flex-col items-start` cross-axis logic it referred to.
MEASURED, NOT YET RATIFIED: `run.mjs --scene switch` reports 3132
strict-diff pixels (stable across 3 runs) against the currently-ratified
`allowedStrictDiff: 1664` (Decision X, layoutunit-1/64 class), and needs
`heightTolerance: 1` (1771px vs Griffel's 1772px, the same mechanism as
the ratified `persona` row) to get past the dimension check at all.
Root-caused with a control: swapping in the pre-grid CSS against the
same scene reproduces the ratified 1664 EXACTLY, with matching
dimensions and no height tolerance needed -- proving the pre-grid root's
height (flex-computed from the indicator's own integer h-20/h-16, never
touching a leading value) carried NONE of this residue at the root
level. The grid root's own height computation, mandated by spec
("Row 1 = 1lh"), newly runs `1lh` through `leading-20/14`'s already-
documented "N - 1/64px, any directly-evaluated spelling" behavior
(authoring-conventions.md), compounding with the label's own pre-existing
line-box residue rather than replacing it -- the same "compounding"
language already used for several Decision X ceiling raises in
scenes.json (menu, menu-rtl, menu-sequel, teaching-popover-placements).
Fully decomposed, zero unattributed pixels: two switch-thumb pill
silhouettes (1062px medium, 828px small -- edge-AA from a sub-pixel
vertical shift of the whole rounded shape, tapering to near-zero at the
flat mid-sides and peaking at the rounded caps, the signature of a pure
sub-pixel translation) + one 42px cluster + one 1200px near-full-width
single-row diff (a thin element boundary at a device-pixel-different
row, the same "row-edge, thin border-line" mechanism the `divider` row
already carries this ceiling class for). 1062+828+42+1200 = 3132 exactly.
Direct measurement confirms each instance carries exactly one 1/64px
shortfall in its own height (19.984375/15.984375 against the 20/16
nominal 1lh; the indicator's own integer-valued second grid row is
untouched), consistent with compounding across the many stacked
instances in this scene rather than a per-instance logic error.
Per the allowance protocol (authoring-conventions.md), a ceiling raise is
a reviewer's call, not the implementer's -- this is reported, not
self-granted. The .scratch/windmod-vr/scenes.json harness config (a
never-committed local file) still carries the 1664 ceiling; a
heightTolerance:1 + allowedStrictDiff>=3132 update there is the operator
action this finding recommends.
Gates: build, type-check, lint, test (3605/3605) all green for
react-windmod-preview (all four are independent of the VR ceiling).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
… grid-hosted control indicators Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…io and Switch — spacing owned by the grid, no offset math Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…corated with Badge's visual contract Composes windmod's useBadgeStyles (Button-family pattern, l2-over-l1 layering) and adds the dot (forces a 6px circle regardless of size) and hidden (empty, no-dot) presentations. Look props (appearance/color/shape/size) live in windmod with Griffel's defaults; count/dot/overflowCount/ showZero stay headless state, already stamped as data-count/-dot/-hidden/-overflowed. VR scene (sizes x appearances x colors x dot/overflow) verified strict-zero against Griffel's CounterBadge. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…th per-size Fluent glyphs Standalone component (not composed over Badge's styles — Griffel's own PresenceBadge is a bespoke visual, not a Badge-appearance skin). Look prop is size only; status/outOfOffice stay headless state, already stamped as data-status/-out-of-office. windmod restores the default glyph itself (headless ships an empty icon slot) by mirroring @fluentui/react-badge's presenceIcons status/outOfOffice/size selection with the matching react-icons/headless assets, including its two reused-pixel-size cases (tiny reuses the 10px asset, extra-large the 20px one) so the CSS forces only those two to their own footprint. VR scene (statuses x outOfOffice x sizes) verified strict-zero against Griffel's PresenceBadge. 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.