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 284 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 284 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 21, 2026 20:21
Headless useRatingDisplay/useRatingItem + Tailwind CSS-Modules styling,
pixel-identical to the Griffel suite across values, half fills, colors, sizes and
compact mode. Star glyphs come from the headless icon factories — the library's
first icon dependency — and a package-local context carries color and size to
items. The half-fill overlay replicates the upstream clip geometry exactly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
First Button-family composition: useToggleButtonStyles composes useButtonStyles,
the root carries both marker pairs, and toggle overrides ride fui.components.l2
over Button's l1. Glyph display swaps live in an l1 block — importance inverts
layer order, so only the icons stylesheet's own layer can arbitrate visibility.
Checked visuals are pixel-identical to the Griffel suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
Completes the component whose initial files were swept into the ToggleButton
commit: the review-stage forced-colors repair for transparent hover (Griffel
hoists media buckets past hover buckets), the look-prop default and slot-props
tests, and the VR scene registration. Pixel-identical to the Griffel suite
across the 106-cell appearance/content/size/icon matrix. The styles hook stamps
data-icon-position to close a headless omission.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
Headless useInput + Tailwind CSS-Modules styling, pixel-identical to the Griffel
suite across appearances, sizes, states and content slots. Block order carries the
cascade, mirroring the upstream merge list; the invalid gate computes in JS with
the upstream predicate; the styles hook stamps the content-slot attributes the
headless hook omits. Also activates the previously dead export-map conformance
test for every component and fixes a focus-only phantom border on the underline
pseudo-element.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…-identical to Griffel)

Full component set: types, headless-composing component, immutable styles
hook, single-l1-layer module.css, tests, demo + VR stories, api.md.
VR gate: identical at 1248x1716, zero diff pixels vs the Griffel suite.

Notable:
- ::after focus ring drops border-solid (phantom-border rule re-derived
  for this element: counterfactual measured 3px borders on three sides).
- Disabled resize guard tested across all four resize values; work review
  closed a data-size stamp-displacement hole (spread-order mutation).
- 31 implementer + 25 reviewer mutations killed; CSS blocks owned by the
  zero-tolerance VR diff (module.css idents are jest-proxied by design).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…dentical to Griffel)

Full component set: types, headless-composing component, immutable styles
hook, single-l1-layer module.css, tests, demo + VR stories, api.md.
VR gate: identical at 1248x1082, zero diff pixels, first capture (67 cells).

Catalog appends (shipped entries untouched):
- vertical-flag: the headless library spells vertical orientation two ways
  (enumerated data-orientation vs boolean data-vertical); one entry per
  spelling rather than editing the shipped vertical entry.
- focus-within-visible: keyborg-gated ring trigger matching Griffel's
  createFocusOutlineStyle — work review caught windmod drawing the 2px
  ring on mouse click where Griffel draws nothing (attribute stamps only
  during keyboard navigation).

Notable:
- Plan's size-forwarding test premise refuted by measurement: React drops
  non-numeric size attribute values, so the guard is proven with a numeric
  size={16} probe instead.
- 59 mutations run across the three stages (one measured-equivalent
  survivor documented); focus/RTL/forced-colors cells counted genuinely
  VR-invisible by a scene-matrix oracle and recorded as accepted gaps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…atively via :has(:focus-visible)

Replaces the keyborg attribute gate with the browser's own modality
heuristic, per operator preference for native pseudo-classes over data
attributes. Measured equivalent for range/radio/checkbox controls: no
match on mouse click, match on keyboard focus and on a mouse-then-
keyboard modality switch. Text-editable descendants always match, which
is the behavior text controls carry everywhere anyway.

Verified against the rebuilt stylesheets: Slider draws its 2px ring on
keyboard focus only (mouse click 0px), all gates green, Slider VR
identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…ditions at the emitted .cjs files

Eight subpaths (avatar-group, color-picker, compound-button, menu-button,
overflow, split-button, swatch-picker, tag-picker) named
lib-commonjs/<name>.js in their node and require conditions while the
build emits <name>.cjs — any CommonJS require() of those subpaths threw
module-not-found. The other 67 packages with lib-commonjs export maps all
point at files that exist; this restores the same consistency here.
TypeScript consumers never noticed because tsconfig.base.json's source
path mapping bypasses the export maps.

Verified: every exports target in the package now resolves on disk, and
require.resolve('./compound-button') succeeds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
… the ARIA button union

`BreadcrumbButtonBaseProps` was declared with a plain `Omit`:

    export type BreadcrumbButtonBaseProps = Omit<BreadcrumbButtonProps, 'size'>;

`BreadcrumbButtonProps` includes `ComponentProps<ButtonSlots>`, whose `root`
slot is `ARIAButtonSlotProps<'a'>` -- a union over `{ as?: 'button' } & button
attrs` and `{ as: 'a' } & anchor attrs`. Plain `Omit` is `Pick<T, Exclude<keyof
T, K>>`, and `keyof` a union keeps only the keys common to every member, so the
omit collapses the union and every anchor-only prop (`href`, `target`, `rel`,
...) disappears from the derived type. `@fluentui/react-button` avoids exactly
this on the same shape by using `DistributiveOmit`
(Button.types.ts:72,84); this makes `react-breadcrumb` consistent with it.

Healed -- all three now type-check against the base surface where none did
before:

  <BreadcrumbButton href="#a">              (the spelling react-breadcrumb's
                                             own Default story uses)
  <BreadcrumbButton as="a" href="#a">
  <BreadcrumbButton as="a" href target rel>

Not changed, and honestly not a regression: a props object literal whose only
property is `href` is still rejected. That is TypeScript weak-type detection --
the `{ as?: 'button' }` union member has no required properties and shares no
property with `{ href }`, so the source only matches the `a` member, which then
demands an explicit `as: 'a'`. It fires identically on the Griffel
`BreadcrumbButtonProps`, and adding any shared property (`children`, which every
real JSX usage has) satisfies it on both. Runtime behaviour is unchanged: this
file declares types only and emits nothing.

etc/react-breadcrumb.api.md regenerated by the build.

Verified: react-breadcrumb type-check + lint pass; react-headless-components-preview
and react-components type-check pass. react-breadcrumb:test is 2 failed / 105
passed both with and without this change (pre-existing @fluentui/react-icons
snapshot drift -- SVG path data and the `fui-Icon` class -- unrelated to it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…nders a layout-participating root

BREAKING (preview): ThemeProvider is renamed FluentProvider (component,
./provider subpath -> ./fluent-provider, styles, stories, tests) for
migration consistency with the Griffel suite, and the provider's root
drops display:contents to render a real div carrying the same globals
Griffel's FluentProvider authors: color, background-color, text-start,
font-family/size/weight, line-height — in @layer fui.components.l1, so
consumer overrides win normally. dir is resolved and stamped
unconditionally; componentMarkers('fluent-provider') added.

Apps already account for a provider div under Griffel, and the change is
measured as a fidelity gain: at strict threshold, 15 of 16 VR scenes go
byte-identical to the Griffel suite (previously up to 2.46M differing
pixels hidden under the gate threshold and the storybook decorator's
outer Griffel provider). Full sweep after the change: 15/15 root scenes
zero-diff at both thresholds, tooltip at its documented 244 residue.

Work review hardening: import-specifier re-sorting across renamed
stories, prettier width fixes, stale display:contents comment removed,
README corrected, stale dist/provider.d.* pruned; 19 mutations run
(17 caught by named tests; the stylesheet-presence blind spot is
documented and queued as a VR-report improvement).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…el-identical to Griffel)

First component of the post-decision queue: the uniform glyph rule ships
here first (post-hook immutable children ?? fallback, with the pre-hook
materialization companion for slots without renderByDefault). The
measured divergence set from Griffel is exactly the two intentional rows
({children: null} and {children: undefined} render the chevron where
Griffel renders an empty span); all seven other inputs are structurally
identical including the render-function subtlety.

l2 composition over Button's l1 (ToggleButton precedent), quad marker
set, two catalog appends (generic not-icon-only; package
appearance-secondary). VR: identical at 1248x832, zero diff pixels,
first capture (112 cells).

Review hardening: a glyph-identity test (the wrong-chevron mutation
survived presence-only assertions), a forced-colors scope error proven
no-op and documented, 31 mutations killed across the three stages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
The icons fork's headless stylesheet dropped its single !important
([data-fui-icon-hidden]{display:none}) — the rule that forced every
glyph-swap above it to escalate. With it gone:

- Button (8 sites), ToggleButton (6), MenuButton (12) lose all their
  !important glyph-swap declarations — 46 compiled !important
  declarations across windmod+theme dist drop to zero.
- ToggleButton and MenuButton collapse their two @layer blocks to one
  l2 block; the l1-glyph-swap exception is retired.
- The icons stylesheet import moves to layer(fui.base) (element resets,
  its charter), making glyph-swap behavior stylesheet-order-independent
  (measured: the old arrangement broke 4-5 of 15 state cells under
  sheet reversal).
- 17 story files stop feeding Griffel icon atoms into the headless
  bundleIcon (contract violation; proven load-bearing — reverting one
  file fails Button VR by 1,944px).

Local icons tarball rewritten in place (sha 090bb64b…, +72 bytes) —
the standing revert-before-merge item is unchanged. Fork source edit
lives in the fluentui-system-icons working tree, uncommitted, for
operator review.

Verified: full VR sweep 16 root scenes zero-diff + tooltip's documented
244; glyph matrices 15/15, 54/54, 114/114, MenuButton 120-cell baseline;
declaration totals unchanged (1843→1843, display-only diffs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…dentical to Griffel)

Full component set: types, headless-composing component, immutable styles
hook, single-l1-layer module.css, tests, demo + VR stories, api.md.
VR gate: identical at 1248x872, zero diff pixels, first capture (57 cells).

Notable:
- The uniform glyph rule lands with ZERO divergence from Griffel here —
  Griffel's own Select merges with children ??=, which is exactly the
  uniform rule's semantics (independently re-derived from slot.ts and
  the shipped build, 8/8 rows).
- Permissive focus-within confirmed (zero keyborg occurrences in
  Griffel's compiled Select — the Input/Textarea class).
- The .disabledUnderline forced-colors re-assert verified against built
  CSS byte offsets; the JS data-invalid gate carries Griffel's exact
  === 'true' predicate.
- 39 mutations across three stages (37 killed, 2 measured-equivalent
  survivors documented — one recorded as the D1-family freeze-harness
  question for the operator).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…xel-identical to Griffel)

Completes the Button family. First l3 consumer (double composition over
Button l1 + MenuButton l2 — measured order-independent) and first D5
double-swap: the headless hook binds headless children as slot element
types, so windmod swaps in its own Button and MenuButton exactly as
Griffel's styled hook does one layer up. The chevron arrives free from
MenuButton's glyph rule — zero new seams.

First RTL VR band in the loop (per-side provider wrapper; wiring proven
load-bearing at 18,748px when mutated). Theme layer comment updated for
the l3 claim (the D9-sanctioned line).

VR: identical at 1248x1404, zero diff pixels, first capture. 38+
mutations across three stages; review closed an unpinned size/shape
defaults hole and added the divider comparator's negative control
(36 mismatches when block order is broken).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…-identical to Griffel)

VR: identical at 1248x740, zero diff pixels, first capture (62 cells).

The plan-time 'pure-CSS checkmark' verdict was wrong — the headless
indicator ships no children; the uniform glyph rule restores the
checkmark and mixed-dot families (80-row matrix vs Griffel: zero
divergence; both glyph families pinned by path data per the MenuButton
lesson). First consumer of the native focus-within-visible ring on a
checkbox: mouse-click no-ring parity measured in Chrome against the
built CSS. checked-mixed/not-checked-mixed catalog pair appended.

40 mutations across three stages (M36 ??→|| and a cross-slot leak
closed in review; the byte-identical re-stamp class documented as the
accepted survivor family). Forced-colors measured live via CDP — no
hoisting inversion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…dentical to Griffel)

First consumer of the peer marker: peerMarker('switch') emits
peer/fui-switch on the native input only, and the indicator styles off
the input's real :checked through Tailwind's peer variants. Proven live
in review: a real click on an uncontrolled Switch (no React re-render)
restyles track and thumb byte-identically to the natively-checked twin
and to Griffel — the upstream data-checked stamp (dead for uncontrolled)
is read nowhere.

The thumb glyph restoration follows the uniform rule (8/8 matrix match
vs Griffel, both sides rendered). Refactor caught and fixed a real RTL
defect (checked thumb overshot the track; now an exact LTR mirror,
Griffel's paired-transform values). Forced-colors measured 58/58
identical via static-capture (the CDP emulation path crashes this
Chromium — method recorded).

VR: identical at 1248x1752, zero diff pixels (58 cells); the
peer-checked CSS mutation was run through the real VR gate and fails at
7,740 pixels — the gate's coverage is verified, not estimated.
49 mutations across three stages.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…native pseudo-class

The keyborg attribute branch was mathematically redundant — anything
matching [data-fui-focus-within]:focus-within already matches
:focus-within, so the union collapsed. Identical behavior, simpler
compiled selectors for Input/Textarea/Select. All gates green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…o channel values resolve

adjustChannel picked its per-channel result with ||, so a legitimately
falsy value — channel value 0 on a fully dark colour — fell through to
the hue branch. Measured consequence: a channel="value" ColorSlider on
a dark colour rendered value="210" max="100", the thumb at 210% of its
own track. ?? falls back only on missing entries.

Found during the windmod ColorPicker planning probes; recorded in the
campaign findings roster as fixed-in-tree.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…lwind, pixel-identical to Griffel)

Completes the input family. Radio ships root-level beside RadioGroup,
mirroring the Griffel package's structure. Second peer-marker consumer —
and the case that justified the design: sibling-click deselection proven
in a real browser with an independent probe (zero events and zero
class/data writes on the deselected radio; border, dot, and label all
restyle purely off the input's :checked loss; Griffel line-for-line).
The indicator dot is confirmed pure CSS on both sides (::after content
toggle — no glyph restoration needed).

VR: 0 strict-diff pixels (1248x1120) under the new strict-zero gate.
40 mutations killed; review fixed a real scene bug (the disabled band
passed disabled={false} to every cell, silently testing enabled radios —
an own disabled key beats group inheritance at ANY value, reproduced in
Griffel and noted for migration), closed a consumer-style merge hole,
and measured forced-colors parity via CDP.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…; neutralize process vocabulary in comments

- testing/freezeState.ts: test-only deepFreezeState helper (skips
  ref-shaped objects; shallow-freezes React elements). Five components
  (Select, MenuButton, Checkbox, Switch, Radio) gain a frozen-headless-
  state spec proving the styled layer copies rather than mutates — the
  one mutation class behavioral tests structurally cannot see.
- Comment hygiene: Textarea's module.css header and styles-hook doc
  now state only Textarea-specific facts and cross-reference Input for
  shared reasoning; internal decision vocabulary removed from shipped
  comments package-wide (sweep verified at zero hits).

610/610 tests, all gates green, every api.md and dist/styles.css
byte-identical — comment/test-only change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…require specifiers

Two-part defect in the css-modules build pass for type:module packages:
the CJS-side class maps kept a .js extension (CommonJS body, ESM-parsed
by node → ReferenceError: exports is not defined), and the specifier
rewrite globbed **/*.js only — matching nothing under lib-commonjs/
after the .cjs rename pass, so require('./X.module.css') resolved
through node's legacy extension search straight onto the unloadable
file. That never-firing rewrite is why the crash was require-order
dependent.

The extension now mirrors the existing cjsRenameTransforms gate
(commonjs packages provably unchanged — verified against a stashed
rebuild), the rewrite globs both extensions, and the generated docblock
no longer names a lib-commonjs path that doesn't exist. Five regression
tests cover both halves plus the no-change case.

Verified: windmod's 26 CJS class maps emit as .cjs with repointed
requires; the previously-crashing probe require chain runs clean with
no workaround; all gates green; api.md byte-idempotent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
… sweep found (focus ring, ToggleButton hover/press)

Three regressions from the state-based revalidation sweep (26,120
comparisons across 22 components; these were the only three), all in
interaction states no gate had pixel-checked:

- Focus ring lost its outer 1px on subtle/transparent/disabledFocusable
  cells across the Button family — two distinct causes: appearance
  blocks' own border-transparent, and .root's disabled border sub-block,
  each beating the ring by source order. Fixed in Button.module.css
  alone (ring block repositioned; border-color-only re-asserts on
  subtle/transparent so SplitButton's divider edge stays exactly
  Griffel's). The composed family needed no edits.
- ToggleButton outline+checked border now moves on hover/press
  (neutral-stroke-1-hover/-pressed, as Griffel's bucket order does).
- ToggleButton accessible+checked press drops the focus outline under
  :active (Griffel's compound outranks its indicator too), and the
  subtle icon hover colour resolves as Griffel's insertion order does —
  batch 1's glyph-swap diagnosis corrected to a colour mechanism, pinned
  by the dedupe-identical cell that never regressed.

Verified: every previously-regressed cell 0 raw px across 3 runs
(4 control-bounded AA residues aside); all six repairs' removals killed
at the sweep's recorded magnitudes with disjoint cell sets; full
22-scene VR sweep strict-zero + tooltip within allowance; 610 tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…entical to Griffel)

First cross-component composition: windmod Label renders Field's label
slot (post-hook, immutable — required asterisk and typography come from
Label's own module; l2 over Label's l1 with measured zero declaration
overlap). Validation-state icons restore via the glyph rule with
state-gated materialisation — the empty-slot form would stamp a
4px icon gutter on stateless fields (proven VR-visible at 2,294 strict
pixels when mutated). Three validate-state catalog entries appended.

VR: 0 strict-diff pixels (1248x1228). 40+ mutations across three
stages; review closed two axis-crossing spec gaps (size x orientation
both directions) and proved the frozen-state contract with valid
in-place writes. Context values identical to Griffel 6/6; the
Field-to-control size propagation intentionally awaits the context
cycle (medium is pixel-identical).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…dentical to Griffel)

The largest module yet: 62 classes in one l1 block, carrying the
30-name colour palette (hash verified 27/27 against Griffel's rendered
classes by two independent methods; all 96 colour tokens map exactly).
Initials recompute dir-aware through the provider context — exact 38/38
in BOTH directions where plain truncation is wrong for 21 of 38 RTL
names. The active/inactive ARIA element is authored (byte-equal to
Griffel on all eight cells), and the image fallback chain is fully
jsdom-tested. Glyph fallback follows the uniform rule (6/8 matrix; the
two intentional rows render the person glyph where Griffel renders an
empty span).

VR: 0 strict-diff pixels across 2,675,712 (1248x2144) — including the
size-96 ring-shadow cell the plan had flagged as an open risk (the
planning-time residue proved a probe-page artifact; the band now
renders every size). 40 mutations killed; 189/189 DOM prop shapes
identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…tical to Griffel)

The dismiss glyph follows the uniform fallback rule at Tag's measured
post-hook position (pre-hook slot creation strips 7px of padding on
non-dismissible tags) — zero divergence from Griffel across the 8-input
matrix. The typography pin the plan designed is DEAD: the provider now
supplies inheritance, and plain font-family inherit reproduces both
root element types exactly (75/75 computed-identical, including the
consumer-font-scope case previously accepted as a gap — withdrawn). The
high-contrast partial border avoids border-solid via per-side widths;
forced-colors measured 75/75.

VR: 0 strict-diff pixels (1248x1616). 50 mutations (49 killed, one
measured-equivalent documented); RTL closed with a mirror-twin oracle;
review fixed a stories type-check break the gates missed (harness gap
recorded) and four non-canonical files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…, pixel-identical to Griffel)

Card, CardHeader, CardFooter, CardPreview in one commit (root-level
dirs mirroring Griffel's layout). The floating-action adjacency ships
as a REAL sibling combinator through the preview's public group marker
(the approved escape hatch — the nth-of-type stand-in and its stamp,
catalog entry, and ordinary-layout risk are all gone; 5/5 permutations
match Griffel). Cross-component group variants style the children off
fui-card's marker; the typography pin dissolved against the provider
(66/66 computed-identical with zero pin utilities — Griffel's own
colour reset kept). Selectable focus uses both variants (focus-visible
for interactive roots, the native keyboard-only ring for the hidden
checkbox — Tab rings, click doesn't, modality switch returns it).

VR: 0 strict-diff pixels (1248x1704). 47 jest + 9 CSS mutations all
killed — review closed six slot-spread holes the class-level mutations
missed and fixed a stories type-check break (the harness gap now has a
seventh gate).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…pair for CardHeader

The Card landing's lane commit staged only the windmod package; this
catalog pair (theme-side per the placement rule — data-description is
generic presence vocabulary) is the other half, caught by the landing
gates' build failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…interactive machinery

RatingItem moves to root level (mirroring Griffel's package layout,
history preserved) and gains its interactive-gated pieces: the radio
input slots with half-step geometry, the outline colour branch, and the
keyboard-only focus ring — every addition a proven no-op for
RatingDisplay (interactive:false context; its VR scene re-ran at 0
strict-diff as the guard). Rating itself wraps the shared item with
hover preview riding native radio semantics — a hovered value-0 rating
renders DOM identical to the equivalent value, class-for-class with
Griffel.

VR: rating 0 strict-diff (1248x2084, 76 cells) and rating-display 0
strict-diff, both twice. 34 mutations killed + one traced equivalent;
review widened the onChange payload pin to the full measured shape and
recorded a cross-cutting focus-modality edge for the catalog ledger.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…ast and its comment (3/3)

The const annotation already types both forwardRef parameters and the emitted declaration,
so the trailing cast and the "Casting is required" comment carried by every component were
dead: with all of them removed the package type-checks, and the emitted .d.ts and api.md
files are byte-identical to the baseline.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…otations

The ForwardRefComponent<Props> annotation on the const already types both parameters; the 35
files that re-annotated (props: XProps, ref: React.Ref<…>) inline now use the unannotated form
the rest of the corpus uses. Three ARIAButtonElement type imports existed only for those
annotations and go with them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…dy const (1/4)

A hook call nested inside an object literal or a call argument hides the unconditional call
order the rules of hooks depend on. Every hook now has its own statement-level const, in call
order: context, state (base when a derived state object follows), styled, contextValues. The
authoring conventions record hooks as the named exception to the single-use-no-const shape.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…dy const (2/4)

A hook call nested inside an object literal or a call argument hides the unconditional call
order the rules of hooks depend on. Every hook now has its own statement-level const, in call
order: context, state (base when a derived state object follows), styled, contextValues. The
authoring conventions record hooks as the named exception to the single-use-no-const shape.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…dy const (3/4)

A hook call nested inside an object literal or a call argument hides the unconditional call
order the rules of hooks depend on. Every hook now has its own statement-level const, in call
order: context, state (base when a derived state object follows), styled, contextValues. The
authoring conventions record hooks as the named exception to the single-use-no-const shape.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…dy const (4/4)

A hook call nested inside an object literal or a call argument hides the unconditional call
order the rules of hooks depend on. Every hook now has its own statement-level const, in call
order: context, state (base when a derived state object follows), styled, contextValues. The
authoring conventions record hooks as the named exception to the single-use-no-const shape.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
Seven lines to four; the three constraints stay (no high-contrast ladder because the reference
has none, no motion block because the global floor pins durations, both byte-asserted so neither
name appears in the file).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…dden on its chevron

References microsoft#36685; the corpus sweep found no other
uncommented attribute restoration (TagPickerGroup's role="listbox" already states its own).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…items, not absolute

The reference positions the two buttons absolutely on top of a grid placement. With the 24px
column and two 1fr rows fixed, the placement alone lands each button in the same box: a probe
over the 53 spin-button VR cells (small and medium, both steppers) measured zero rect changes
on root, input, increment and decrement with the buttons made static, and computed display is
flex either way. Compiled CSS loses exactly `position: absolute`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…der in the offset layout

The offset layout no longer moves the previous button behind the row's children in the DOM. The
headless renderer draws previous, children, next in every layout
(microsoft#36684); the offset look now comes from CSS order on
the two buttons, with the auto margin on the first of the row's own children. Pixels are
unchanged; reading and tab order now follow the headless order, recorded as MIGRATION delta 60.
A DOM-order test pins the chosen order for both layouts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…S incantations

The vertical range trick (vertical-lr + rtl for max-at-top, root-keyed RTL flip) gets its
constraint stated once in ColorSlider and referenced from Slider; the underline geometry
(radius-tall box, clip trims the border horns, max() survives a zero radius) is stated once in
Input and referenced from Textarea, TagPickerControl and Select (SpinButton's reference line
landed with c1eef6b); Card's visually hidden checkbox and Spinner's ring mask each get one
line. Compiled CSS is byte-identical.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
Preflight ships at the head of fui.base, so a component declaration that
only restates one of its values is dead: box-border (universal
border-box), m-0/p-0 and their longhands on elements the universal rule
already zeroes, border-current beside border-none (the reset's `0 solid`
already resolves the colour to currentColor), the Input/SpinButton
font-*: inherit blocks and font-[inherit] / bg-transparent / border-0 /
rounded-none on form controls, opacity-100 on ::placeholder, list-none
on lists, and InteractionTagPrimary's per-side border-style longhands
(Tag draws the same partial border without them). The justification
comments that cited the user-agent sheet for these are deleted or
rewritten; ImageSwatch states that its border style is the reset's.

Kept, each undoing an author rule on the same element: Badge's size
p-0 and .outline border-current, Toolbar's size py-0, NavDrawerFooter's
l2 p-0, SearchBox/MenuSplitGroup padding undos, Divider's alignment
margin zeroes, Avatar inactive m-0 over the ring margin, ToolbarButton's
m-0, the swatch state-block border-current over the resting colour
(now the one canonical comment), TagPickerButton's visually-hidden p-0
over .root's size padding, and appearance: button on polymorphic roots.

Verified: compiled chunk diff removes 178 declarations and adds none;
a computed-style census over every element and property of all 91 VR
scenes is identical before and after (the census is what caught the
TagPickerButton case); 3604 jest tests pass; full VR sweep 91/91
within ceilings.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…laced-grid form is not pixel-identical

The static-grid form measured layout-identical, but the spin-button VR scene left strict zero:
16 pixels on one stepper chevron's antialiased edge moved by one grey level (136 vs 137). A
self-vs-self control on the same build — the story captured as built, then with
`position: absolute` injected back — reproduces exactly those 16 pixels, so the declaration is
load-bearing for rasterization even though it moves no box. `absolute` returns with that as
its comment; compiled CSS is byte-identical to the pre-lane baseline and the scene is strict 0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
Second chunk of the same sweep (see the previous commit): the remaining
module.css files, plus the skill's contract line — components depend on
the base sheet's reset and never restate it — and the failure-modes
'restated reset' row with its detection grep and the kept-undo cases.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…hairline ride the base scale

The ColorSwatch/ImageSwatch selected, focus-visible, hover, hover-active
and forced-colors rings spelled their outer spread as a literal 5/6/7px
beside stroke-width tokens that already scale, so at a non-default root
the inner brand ring outgrew the frozen focus ring. The five spreads per
module become calc(Npx * var(--base-scale)). The same class sweep found
the Drawer header/footer scroll-separator hairline (h-px) and DrawerBody's
+1px edge compensation frozen beside InlineDrawer's scaled separator;
both now read the thin stroke token (h-thin, + var(--spacing-thin)).

Verified: compiled chunk diff touches only these 14 values; computed-
style census over all 91 VR scenes identical at the default root; a
forced-state probe (rest/hover/active/focus-visible/hover-active, 305
swatch cells) reads every ring spread byte-identical at 16px and exactly
doubled at a 32px root; swatch-picker and the seven drawer scenes strict
zero.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…lect and unicode-bidi on DialogSurface

A <dialog> in the top layer computes user-select: text, and a <dialog>
computes unicode-bidi: normal where Griffel's <div> surface computes
isolate. OverlayDrawer already neutralises both; DialogSurface, the same
native element opened the same way, did not, and the dialog census had
recorded the divergence. The surface now authors select-auto and
unicode-bidi: isolate.

Verified: computed-style census over all 91 VR scenes changes only
user-select (surface and inheriting descendants, text -> auto) and
unicode-bidi (normal -> isolate) in the dialog scenes; a windmod-vs-
Griffel probe over the modal, non-modal, alert and scroll scene pairs
reads 0 divergences on the surface, title, body and actions; the four
dialog scenes stay within their gates.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…g lost to a whole-file restore

The @supports restoration in TeachingPopoverBody brought the whole file
back from before phase 2A, silently reverting aspect-288/117 and
aspect-288/176 to their bracket forms. Re-applied; the compiled chunk is
byte-identical.

failure-modes.md gains the rows this cycle produced: frozen lengths
beside scaled ones (the swatch spreads and drawer hairline), a census
finding fixed in one file (the DialogSurface user-select/unicode-bidi
miss), and whole-file restores re-opening swept classes — plus the three
detection-grep gaps: `& > *` in the glyph-selector grep, resolved-initial
keyword utilities in the unset row, and named -px utilities beside the
bracket-px grep.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…ir class's resting rule

Button's size arms re-applied utilities identical to .root — small's
rounded-medium, large's min-w-96 rounded-medium font-semibold, the icon
slot's size-20 text-icon-20 under group-size-small and its spacing var
under group-size-large — Griffel-bucket copies that are dead under an
always-on base. The exemplar surviving the first re-assertion sweep
meant its grep had missed a shape, so the sweep re-ran with a broader
one: every nested arm at any depth, on the same element, against the
class's resting @apply. It flagged 48 utilities in 39 arms; 33 were
dead and are deleted here (Button's appearance arms restating
border-transparent and the on-brand text, the .circular/.square focus
blocks, the swatch/slider/avatar focus arms restating border-solid or
the radius, the InteractionTag/Tag disabled arms restating the outline
background, Persona's font-regular, Toolbar's size-large py-4, the
TeachingPopover footer's brand background, the nav dot's outline-solid,
the Combobox/TagPickerInput disabled backgrounds), plus the same
border-solid in AvatarGroupPopover's pie arm. Nine flagged utilities
stay because a sibling arm between the base and them can match the same
element at the same time (a disabled arm over a preceding hover arm;
Toolbar's vertical re-assertion after the size blocks); the tally and
the adjudication rule are recorded in failure-modes.md.

Verified: compiled chunk diff removes 43 declaration lines (6 of them
--tw-* custom properties) and adds none; a forced-state computed-style
census (hover, active, focus-visible, hover-active, focus-within, under
both normal and forced-colors media, every element of all 91 VR scenes,
transitions settled) is identical before and after; the resting census
likewise; a CDP state-matrix probe over 265 Button cells reads the
values the deleted size arms used to force; 3604 jest tests pass; the
31 touched VR scenes are within their gates (persona 0 on two runs
after one frame-offset capture artifact).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…r verifiers and repair theme-infra comments

Lands A1 (verify-tokens-css + verify-theme-values as build dependencies, phantom
citations fixed), A2 (@Property guard rationale), E1 (@ts-check passes, verify-scripts),
E2 (stale citations and comment stumps), E3 (theme-values.json source stamp).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…ight resets and scale the last frozen lengths

Lands B1 (178 preflight restatements deleted corpus-wide, false justification comments
fixed), B2 (swatch focus-ring spreads and the drawer hairline ride --base-scale), B3
(dead variant arms restating the resting rule deleted), C1 (DialogSurface user-select +
unicode-bidi neutralised), C2 (TeachingPopoverBody aspect-fraction spelling re-applied),
plus their failure-modes rows and the ruled preflight contract in authoring-conventions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…p inert casts, and state CSS incantation intent

Lands D1 (popoverArrow.ts folded into popoverOffset.ts), D2 (ForwardRefComponent cast
and comment deleted, emitted .d.ts and api.md byte-identical), AR8 (unannotated forwardRef
params), AR6 (every hook call hoisted to a top-of-body const), AR5 (SpinButton steppers stay
absolute with the measured reason), AR7 (MenuItemSwitch header condensed), AR4 (why-comments
on multi-declaration incantations), AR9 (NavCategoryItem aria-hidden restoration comment),
AR11 (carousel footer keeps headless DOM order, offset look via CSS order).

One conflict, SpinButton.module.css: lane-b deleted the "border-solid is deliberately absent"
justification (a restated preflight reset) and lane-c had appended its AR4 cross-reference to
the same comment; resolved to the reference sentence alone, in the form its Textarea and
TagPickerControl siblings use.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…oggleButton's styles hook and owns no stylesheet

The two toolbar toggle modules were one stylesheet under two marker names: with the
marker normalised, all 78 non-comment lines matched line for line, and the radio file's
own header said so. The radio styles hook now applies ToolbarToggleButton's hook and
restacks its own marker pair over the result, so the radio root carries four marker
pairs (toolbar-radio-button, toolbar-toggle-button, toggle-button, button) and the l3
checked rules reach it through the toolbar-toggle-button pair — the shape NavCategoryItem
and NavSubItem already use over NavItem. One consequence for consumers: a group variant
on fui-toolbar-toggle-button now also matches radio roots.

Verified: ToolbarToggleButton's compiled chunk and class map byte-identical; the retired
radio chunk equals the toggle chunk modulo ident and marker (147 rule lines, 0 differing);
toolbar.api.md unchanged; computed-style matrix over the toolbar scene's 26 toggle and
radio roots plus icons — rest, hover, hover+active, focus, focus-visible, each under
normal and forced colours — 260 cells, 0 differing before and after; toolbar VR scene
strict zero; each of four mutations of the radio hook fails at least one test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…s and drop its inert casts

The Toolbar family held the last hook calls nested inside an object literal or a call
argument; every headless, styles, context and context-values hook now lands in a
top-of-body const under the fixed names (AST scan over 679 files: 19 nested calls before,
0 after). The four `as ForwardRefComponent<…>` casts and their comments go the way of the
rest of the corpus: the emitted toolbar.d.ts declarations and toolbar.api.md are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…eric value-named ramp (AR2)

Replaces the per-step --leading-base-100..600/--leading-hero-700..1000 names
(Decision U) with one flat, generic, value-named ramp: 15 labels (000, 050,
055, 080, 083, 092, 100, 125, 133, 137, 140, 143, 160, 167, 200), each the
3-digit truncation of its ratio*100, no font-size pairing implied by the
name. Font-size keeps its base/hero split; only leading changes.

- LEADING_RAMP is a fixed table in generate-tokens-css.js, with a uniqueness
  assertion on labels and a cross-check that the six lineHeightBase* ratios
  in packages/tokens/src/tokens.ts still land on one of the 15 ramp entries
  (fails loudly on drift instead of silently invalidating the table).
- The ten lineHeightBase*/lineHeightHero* tokens.ts entries are still
  classified (unitlessRatio) so theme-invariance keeps being asserted, but
  are no longer emitted one-per-entry; render() emits LEADING_RAMP directly
  in their place, preserving the original "Line heights" section placement.
- --leading-hero-700/800/900/1000 are dropped entirely (zero corpus sites).
- css/index.css already carried `--leading-*: initial;` ahead of the ramp
  import, wiping Tailwind's keyword ramp (leading-tight/snug/relaxed/loose/
  normal) as intended; a compile probe confirms this. It does NOT block
  Tailwind's numeric leading-<n> utility or the hardcoded leading-none
  utility (neither reads the --leading-* namespace), but every one of our
  15 registered labels wins its own static match over the dynamic numeric
  form (verified by probe), and no site in the corpus authors a bare
  leading-<n> outside the registered set.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…ramp (1/2)

Mechanical rename via .scratch/windmod-loop/leading-ramp/migrate.mjs,
driven by census.mjs's site-by-site mapping — every old leading-base-*/
leading-hero-*/leading-none/leading-[...] form replaced by the new
value-named token with the SAME ratio (pure rename, zero value change):
leading-base-100/500 -> leading-140, base-200/600 -> leading-133,
base-300 -> leading-143, base-400 -> leading-137, leading-none/[1]/
[calc(20/20)]/[calc(16/16)] -> leading-100, [0] -> leading-000,
[calc(20/16)] -> leading-125, [calc(20/12)] -> leading-167,
[calc(20/24)] -> leading-083, [calc(22/24)] -> leading-092,
[calc(32/20)] -> leading-160, [calc(22/40)] -> leading-055,
[calc(20/40)] -> leading-050, [calc(20/10)] -> leading-200,
[calc(16/20)] -> leading-080. First half of the corpus (alphabetical).

Re-verified after migration: zero old forms remain (census.mjs), and a
declaration-level diff of the built, Tailwind-compiled component CSS
(pre- vs post-migration) shows zero computed line-height changes across
all 172 authored line-height sites.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…ramp (2/2)

Second half of the corpus (alphabetical) plus useCssVarValue.ts, whose doc
comment both mechanically renames its var(--leading-base-300) example and
recomputes the token-family measurement table (--leading-* now 15 declared
tokens, 10 literal / 5 calc-string, replacing the old 10 declared / 5 / 5 —
net +5 declared tokens repo-wide, 477 total).

Same migrate.mjs mapping as part 1/2 — see that commit for the full
old-name -> new-name table. Re-verified together with part 1/2: zero old
forms remain (census.mjs) and zero computed line-height changes across all
172 built declaration sites (declaration-level pre/post diff).

Full verification for both halves:
- node .scratch/windmod-loop/gates.mjs: build:theme, build:headless,
  build:windmod, verify:theme, type-check, lint, lint:stories,
  type-check:stories, test (161 suites / 3605 tests) all green.
- 91-scene VR sweep (static storybook build vs Griffel): 74 strict-zero,
  17 pass-allowance (each within its scenes.json ceiling, e.g.
  info-label-open at its 8918 ceiling), 0 fails.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
- tokens-and-scale.md: the ramp table (15 labels, ratios), the label rule
  (3-digit truncation of ratio*100), and "no font-size pairing implied" —
  font size keeps its base/hero split, leading does not.
- authoring-conventions.md: the leading-without-font-size rule restated for
  value-named labels; the leading-[calc(target/ownFontSize)] escape hatch
  now documented as forcing a census.mjs ruling rather than a dead end.
- failure-modes.md: the "leading without an authored font-size" row updated
  to the new names and the census-failure detection path.
- MIGRATION.md: entry 59 updated to the new example names; new entry 61
  documents the AR2 rename itself with a full old-name -> new-name mapping
  table for any consumer reading --leading-* custom properties directly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
…omes pure arithmetic, no theme tokens

Replaces the 15-token --leading-* generic ratio ramp (AR2) with a functional
@Utility override: leading-<n> compiles to calc(n / 100) for any bare integer
n; leading-<a>/<b> compiles to the exact ratio a/b, for the six ratios a
2-decimal-digit label could only approximate (formerly 083/092/133/137/143/
167). Both arms are calc()-wrapped — a bare --value(ratio) substitutes
invalid <ratio> text into line-height, which Chrome silently drops.

Generator: deleted the --leading-* token emission, its invariance assertion
and label-uniqueness assertion entirely; lineHeight is now an explicit
EXCLUSIONS entry, with a generic stripExcluded filter keeping the per-theme
key-parity check honest against theme-values.json's still-present upstream
values. dist/base.css carries zero --leading- declarations.

Corpus: 171 sites renamed (padded labels debadded, six inexact labels moved
to their exact fraction). Switch's --fui-switch-label-offset margin was a
provable algebraic tautology (0px at every state) and is deleted, with root
alignment moved from items-start to items-center for inline label positions
(a no-op for single-line labels; label-above keeps items-start explicitly,
now controlling the column cross-axis). Checkbox/Radio's analogous offset
math is real, non-tautological work and is unchanged, with the removed
var(--leading-*) reference replaced by the literal fraction the token held.

Resolved line-height ratios are byte-identical across all 172 sites vs.
c43691c (lineheight-diff2.mjs). A residual class of sub-pixel VR diffs
(Chrome's Blink LayoutUnit, 1/64px fixed-point, rounds a directly-evaluated
leading value one unit short of the custom-property-indirected form's
whole-pixel result) is ratified as inherent rendering-path quantization,
not a value regression — see MORNING-DECISIONS.md Decision X and the
LayoutUnit 1/64px failure-modes.md row (third occurrence in the campaign).

Docs: tokens-and-scale.md, authoring-conventions.md, css-var-values.md,
MIGRATION.md (entries 59/61-63) and useCssVarValue's doc comment rewritten
for the arithmetic scheme; the 15-token registry has no remaining references
outside historical migration-mapping context.

91-scene VR sweep: 64 strict-zero / 27 within-allowance / 0 fail.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013wmpBCYJpDJCLXcScCWz1i
@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