Skip to content

feat(react-windmod-preview): a Tailwind v4 + CSS Modules styling layer for Fluent's headless components - #36656

Draft
Ray Knight (ArrayKnight) wants to merge 295 commits into
microsoft:masterfrom
ArrayKnight:styling/react-windmod
Draft

feat(react-windmod-preview): a Tailwind v4 + CSS Modules styling layer for Fluent's headless components#36656
Ray Knight (ArrayKnight) wants to merge 295 commits into
microsoft:masterfrom
ArrayKnight:styling/react-windmod

Conversation

@ArrayKnight

@ArrayKnight Ray Knight (ArrayKnight) commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

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-components off Griffel, deprecating Griffel, or
changing 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:

  1. The pattern is documented as a legitimate way to consume Fluent, alongside Griffel.
  2. The headless package's data-* and context contracts are treated as public API that styling
    layers may rely on — today several are incomplete or wrong (§6).
  3. These two preview packages, or something like them, live in-tree rather than in a fork.

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

Package Role
@fluentui/react-tailwind-theme-preview palette, type ramp, spacing scale, seven per-theme stylesheets (import-and-set, no baked default), Tailwind's preflight at the head of the fui.base layer, the global reduced-motion floor
@fluentui/react-windmod-preview the 151 components and their compiled stylesheets

Both at 0.1.0, published as a pair. 151 components across 52 family subpaths — the headless
package'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 Griffel
Library JavaScript, gzip 99,305 333,922 3.36× smaller
Button — JS + CSS, gzip 5,046 12,330 2.44× smaller
49 components — JS + CSS, gzip 85,107 167,250 1.96×; no crossover on any route
Cold mount, median ms 27.8 48.7 windmod 1.75× faster
Re-render, median ms 26.8 24.5 Griffel faster
JS heap after forced GC 3.44 MB 5.83 MB

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-components ships 621,799
raw minified bytes of precompiled *.styles.js, its entire visual contract as JavaScript, where
windmod'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.

Commit Package One line Issue
b95a4152c4 react-breadcrumb Plain Omit collapsed the distributive ARIA button union; href disappeared. #36645
20924b8a1a react-color-picker A channel valued 0 fell through || to the hue action; the slider emitted value="210" max="100". #36646
01ccede78a headless usePopoverSurface pinned popover after ...props, so only one Popover could be open per page. 1/8 → 8/8. #36647
40bca2b821 headless The same defect at useMenuPopover and useListbox. 1/3 → 3/3. #36647
17f7e796d8 headless Dialog's scroll lock reserved no scrollbar gutter, shifting every fixed element sideways. Δx 7.5 → 0. #36648
f34b0a1125 react-tag-picker The aside-width frame was cancelled in the effect body, racing away its own only write. 3/12 → 12/12. #36649
a2f2bc30ba headless renderTooltip silently discarded state.arrowClassName — declared, published state. #36650
568297d44a headless MenuSplitGroup rendered with no contexts argument, so no descendant could tell it was inside one. #36651
5a89364f3f headless ./avatar-group published its context values hooks but neither the reader nor the provider. #36652
9612d547d9 headless Same gap for Button, Link and Avatar — and a measured wrong render: a Button in MessageBarActions came out medium, not small. #36652
510b8c5d21 react-storybook-addon-export-to-sandbox The addon's registration regex matched forward slashes only; on Windows it found nothing, silently. #36653
d826380ae5 tools/workspace-plugin generate-api skipped every export-subpath rollup on Windows — 56 → 0 — with no diagnostic. #36654
b7c455ee52 tools/workspace-plugin export-maps-sync silently deleted every export key it could not derive: 151 → 2. Now preserved by declaration, or a named throw. #36655

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/sync declaration (a consequence of this branch's own Prettier 3 bump),
a Reflect.deleteProperty in a test this branch added, and a 'use client' removal from four
context modules this branch added. A fifth is struck: b9853103c5 was superseded upstream by
#36606 while this branch was in flight and is not in the diff.

Three offers

  1. The findings roster. Building a full skin on the headless hooks found 45 further reportable
    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.
  2. Any commit, standalone. Every one of the thirteen can be lifted into its own PR against its
    issue.
  3. The evidence. Verification chains, the decision log, allowance controls, bundle and benchmark
    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 attribute
this 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.

Length note: the eleven numbered deep-dive sections (§1–§14) exceed GitHub's body limit and follow as the first 1 comments on this PR, each a set of collapsibles.


Previous Behavior

No Tailwind- or CSS-Modules-based styling layer exists for Fluent v9. Consuming any v9 component
means taking @griffel/react — its runtime, its makeStyles authoring model, its runtime
injection, and its entire visual contract shipped as precompiled JavaScript (621,799 raw minified
bytes of *.styles.js in @fluentui/react-components, plus 79,480 bytes of JS design tokens).
@fluentui/react-headless-components-preview exposes the behaviour hooks but ships no skin, so a
team 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 styling
consumer 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:

Companion 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.

Ray Knight (ArrayKnight) and others added 30 commits August 23, 2026 02:09
…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
@ArrayKnight

Copy link
Copy Markdown
Contributor Author

Reposted below the commit history for visibility — unchanged from the original.

Thanks Dmytro Kirpa (@dmytrokirpa) — completely fair on size, and I'll take you up on the fixes offer immediately: each in-tree fix from this PR is now its own isolated PR, one per linked issue:

On the rest — before I move it to a community repo, I'd like to understand which cost is the actual blocker, because they point to different paths:

  1. If it's this PR's review surface, I can decompose much further — theme package alone first, then per-family batches, each pixel-verified independently.
  2. If it's the prospect of maintaining two styling systems indefinitely, that's the conversation I'd most value having, because the design goal was the opposite: the layer holds pixel-parity with Griffel across 91 VR scenes precisely so it can serve as a migration path — one that would let Griffel be deprecated over time and shrink the maintenance surface rather than double it. We proposed the big-bang version of this before and understood the appetite wasn't there; the preview-package shape was meant as the incremental route to the same destination. If there's any appetite for that direction, I'd gladly shape the work to whatever on-ramp the team could sustain.

If neither lands, I'll host it as a community package — in that case: would the team be open to a discoverability pointer (docs/community listing), and is the headless package's public API + DOM structure a contract you'd consider stable enough for an external styling layer to build against?

@ArrayKnight

Copy link
Copy Markdown
Contributor Author

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.

  1. Theme package first (~4k reviewable lines — the 7 theme files are generated; you'd review the generator and its assertions, not 160KB of tokens). Standalone value even if nothing else lands: Fluent's tokens as a Tailwind v4 layer. Depends only on feat(workspace-plugin): let export-maps-sync preserve hand authored asset subpaths #36673.
  2. Infrastructure + one component (~13k): the build pipeline, provider, conformance machinery — with Button end-to-end as the proof, VR evidence included. This is the one genuinely dense review; everything after it is repetition of a pattern you've already approved.
  3. Twelve family batches (~3–6k source lines each), ordered so every cross-component override lands after the component it overrides. Each PR carries its own gate: green builds, its VR scenes' pixel-parity status with evidence in the body, and its migration-guide rows.
  4. Docs last (~3k).

How they'd flow — the dependency graph is shallow and wide:

ring can be open simultaneously
now theme package (parallel with the fix PRs already up)
after theme + icons PR infrastructure + Button — the one serialization point
after infrastructure 7 batches in parallel: form inputs, display primitives, avatar, tags, overlays, surfaces/status, color suite
as those merge 4 more: button family, pickers, nav/tabs, flagship compositions
tail menus/toolbars, then docs

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants