@epilot/volt-ui-experimental-griffel 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/AGENTS.md +153 -0
  2. package/AUTHORING.md +292 -0
  3. package/README.md +85 -7
  4. package/dist/components/alert-dialog/alert-dialog.js +18 -18
  5. package/dist/components/avatar/avatar.js +18 -19
  6. package/dist/components/avatar/avatar.js.map +1 -1
  7. package/dist/components/badge/badge.js +13 -13
  8. package/dist/components/badge/badge.js.map +1 -1
  9. package/dist/components/breadcrumb/breadcrumb.js +6 -6
  10. package/dist/components/button/button.js +20 -20
  11. package/dist/components/button-group/button-group.js +6 -6
  12. package/dist/components/callout/callout.js +6 -6
  13. package/dist/components/card/card.js +8 -8
  14. package/dist/components/collapsible-sidebar/collapsible-sidebar.js +4 -4
  15. package/dist/components/command/command.js +15 -15
  16. package/dist/components/data-table/data-table-column-header.js +5 -5
  17. package/dist/components/data-table/data-table-error.js +13 -13
  18. package/dist/components/data-table/data-table-loading.js +4 -4
  19. package/dist/components/date-range-picker/date-range-picker.js +8 -8
  20. package/dist/components/date-time-picker/calendar.js +15 -15
  21. package/dist/components/date-time-picker/date-time-picker-panel.js +73 -67
  22. package/dist/components/date-time-picker/date-time-picker-panel.js.map +1 -1
  23. package/dist/components/date-time-picker/date-time-picker.js +53 -49
  24. package/dist/components/date-time-picker/date-time-picker.js.map +1 -1
  25. package/dist/components/date-time-picker/time-picker.js +77 -71
  26. package/dist/components/date-time-picker/time-picker.js.map +1 -1
  27. package/dist/components/dialog/dialog.js +21 -21
  28. package/dist/components/drawer/drawer.js +16 -16
  29. package/dist/components/dropdown-menu/dropdown-menu.js +13 -13
  30. package/dist/components/field/field-combobox.js +9 -9
  31. package/dist/components/field/field.js +22 -22
  32. package/dist/components/label/label.js +11 -11
  33. package/dist/components/list/list.js +13 -13
  34. package/dist/components/option-card/option-card.js +24 -24
  35. package/dist/components/pill/pill.js +21 -20
  36. package/dist/components/pill/pill.js.map +1 -1
  37. package/dist/components/popover/popover.js +4 -4
  38. package/dist/components/radio/radio.js +8 -8
  39. package/dist/components/scroll-overflow/scroll-overflow.js +6 -6
  40. package/dist/components/select/select.js +25 -25
  41. package/dist/components/skeleton/skeleton.js +17 -17
  42. package/dist/components/skeleton/skeleton.js.map +1 -1
  43. package/dist/components/table/table-pagination.js +74 -76
  44. package/dist/components/table/table-pagination.js.map +1 -1
  45. package/dist/components/tabs/tabs.js +19 -19
  46. package/dist/components/toast/toast.js +24 -24
  47. package/dist/components/toggle/toggle.js +17 -17
  48. package/dist/components/tooltip/tooltip.js +6 -6
  49. package/dist/index.d.ts +1490 -1056
  50. package/dist/index.js +276 -273
  51. package/dist/index.js.map +1 -1
  52. package/dist/tokens/core.generated.js +1168 -1154
  53. package/dist/tokens/core.generated.js.map +1 -1
  54. package/dist/tokens/index.d.ts +1076 -1054
  55. package/dist/tokens/index.js +1078 -1056
  56. package/dist/tokens/index.js.map +1 -1
  57. package/dist/tokens/install.js +21 -9
  58. package/dist/tokens/install.js.map +1 -1
  59. package/dist/tokens/palette.generated.js +1323 -1293
  60. package/dist/tokens/palette.generated.js.map +1 -1
  61. package/dist/tokens.json +45 -1
  62. package/package.json +7 -2
  63. package/testing/jest-preset.cjs +64 -0
package/AGENTS.md ADDED
@@ -0,0 +1,153 @@
1
+ # Agent rules — `@epilot/volt-ui-experimental-griffel`
2
+
3
+ These rules govern every file under `packages/experimental-griffel/`. They are normative: where any
4
+ other document in this package disagrees with this file, **this file wins** and the other one is a
5
+ bug worth fixing.
6
+
7
+ `packages/experimental/AGENTS.md` does **not** apply here. It governs
8
+ `@epilot/volt-ui-experimental-css-modules`, a different package with a different styling engine, and
9
+ its rules on CSS Modules, `var(--volt-*)` tokens and `bun run *:experimental` scripts are all wrong
10
+ for this one. The two are siblings, not parent and child.
11
+
12
+ **What the other documents are for.** `AUTHORING.md` explains the defect behind each rule — read it
13
+ when you want to know _why_, not _what_. `DECISIONS.md` records architecture rationale as a dated
14
+ log. `STATUS.md` is a snapshot of measurements and progress at a point in time; treat every number
15
+ in it as of its commit, never as a live fact. This file holds no counts and no measurements on
16
+ purpose, because those are what rot.
17
+
18
+ ## Architecture, as it is now
19
+
20
+ - **Primitives are Radix**, at the same versions v1 pins, plus `sonner` for Toast and `cmdk` for
21
+ Command. There is no `@base-ui/react` in this package — not in the manifest, not in `src`.
22
+ Anything telling you to use Base UI primitives, or to prefer `data-checked` over Radix's
23
+ `data-state="checked"`, is describing an abandoned detour. Radix state attributes are
24
+ **value-based**: `&[data-state='checked']`, not `&[data-checked]`.
25
+ - **Styling is Griffel**, emitted as runtime atoms. No Tailwind, no `cn`, no `tailwind-merge`, no
26
+ shipped stylesheet. Use `mergeClasses` from `@epilot/volt-ui-runtime` — this package
27
+ does not import `@griffel/core` anywhere, and `gate:one-bare-specifier` enforces that.
28
+ - **The runtime target is React 18**, while the repo compiles against `@types/react@19`. Both suites
29
+ must stay green.
30
+ - ESM only. `react` and `react-dom` are peers; everything else is vendored into `dist`.
31
+ `sideEffects: false`.
32
+
33
+ ## Authoring rules
34
+
35
+ 1. **`&` must be the first token of every selector.** Griffel prepends its class to the whole
36
+ selector and substitutes `&` nowhere else, so `"[data-pressed] &"` emits
37
+ `.hash[data-pressed] .hash` and can never match. No warning, no error, no failing test. To style a
38
+ descendant from an ancestor's state, declare the rule on the _ancestor's_ slot and reach down via
39
+ `SLOT_ATTR`. A selector list is fine when each part is legal alone.
40
+ `src/ancestor-selector.test.ts` enforces this.
41
+
42
+ 2. **Declaration order inside a slot encodes precedence, and v1's order does not.** Griffel emits
43
+ atoms in source order, so the later rule wins. v1's precedence comes from Tailwind's utility
44
+ layer, where `aria-invalid:` beats `disabled:` regardless of class order. Transcribing v1's order
45
+ inverts the outcome. Put the state that must **win** last.
46
+
47
+ 3. **Match a neighbour's property shape, not just its value.** Griffel dedupes only when expanded
48
+ property keys match, so `paddingLeft`/`paddingRight` against a neighbour's
49
+ `shorthands.paddingInline` leaves both standing and the cascade picks the wrong one.
50
+
51
+ 4. **No CSS shorthands** — an atom is one declaration, so a shorthand cannot be resolved against a
52
+ longhand. Use `shorthands.*`. Griffel only warns at runtime, so the warnings-as-errors test is
53
+ what catches it.
54
+
55
+ 5. **Animations use the object form**, `animationName: { from: {…}, to: {…} }`. Griffel hashes
56
+ keyframe names from content but does **not** apply the class-name salt to them, so a hand-authored
57
+ global `@keyframes` collides across co-resident copies silently.
58
+
59
+ 6. **`forwardRef` is required.** `ref`-as-a-plain-prop is React 19; every consumer runs React 18,
60
+ where a function component without `forwardRef` never receives `ref`.
61
+
62
+ 7. **`asChild` is supported and should stay supported.** Components wrapping a Radix primitive
63
+ inherit it through `React.ComponentProps<typeof Primitive.X>` and need no work. Components that
64
+ render their own element — Button and BreadcrumbItem — implement it with Radix's `Slot`. v1
65
+ exposes it on thirteen components and consumers use it to wrap router links, so dropping it
66
+ breaks call sites. It was briefly removed while this package was on Base UI; that reason no
67
+ longer exists. Do not expose Base UI's `render` prop, which is a different thing.
68
+
69
+ 8. **Colour is a value, never part of a class name.** Components with a `color` prop follow Badge:
70
+ atoms read inline custom properties the component sets. Never emit a per-colour rule.
71
+
72
+ 9. **Call `ensurePaletteTokens()` whenever you reference any family outside
73
+ accent / gray / error / warning / success / info** — whether or not the component has a `color`
74
+ prop. Those six are in the core sheet; every other family is in the palette sheet, which is not
75
+ loaded by default, and referencing it without installing gives an invalid computed value and no
76
+ error.
77
+
78
+ 10. **No module-scope DOM or stylesheet mutation.** Installing tokens, inserting styles or touching
79
+ `document` must happen lazily at first render, never at import time — that is what makes
80
+ `sideEffects: false` true and keeps an unused import free.
81
+ `src/components/avatar/avatar.tsx` currently calls `ensurePaletteTokens()` at module scope. **That
82
+ is a known defect, not a precedent.** Do not copy it.
83
+
84
+ 11. **`SLOT_ATTR` for selectors, `data-slot` for diagnostics.** Emit both, and never write a selector
85
+ keyed on `data-slot` — those values are shared with v1, so such a rule also matches v1-rendered
86
+ DOM. Portalled parts each carry their own styling class and their own slot marker; a class on an
87
+ ancestor outside the portal does not reach them.
88
+
89
+ 12. **Preserve the caller's `className` and forward DOM refs.**
90
+
91
+ ## Tokens
92
+
93
+ - Use a token wherever one exists (`tokens.textSm`, `tokens.spacingElement2`, `tokens.grayA11`).
94
+ Where none exists, a literal is correct — say so in your report. Never
95
+ `calc(var(--spacing) * n)`: reintroducing Tailwind's numeric scale is what this rebuild exists to
96
+ avoid.
97
+ - **Spacing and size are different axes.** There is no dimension token family, so `width: "0.75rem"`
98
+ on an svg stays a literal even though `spacingElement3` shares the number.
99
+ - **Never edit a generated token file, and never hardcode a namespace or salt hash.** Run
100
+ `bun run tokens`.
101
+ - Core and palette carry **independent** namespaces, so a palette-only change does not rename core
102
+ properties. `GRIFFEL_CLASS_SALT` covers the pinned Griffel version and nothing else — token
103
+ isolation is the namespaces' job, not the salt's.
104
+
105
+ ## Rule 0 — v1's rendered OUTPUT is the fidelity target
106
+
107
+ Not v1's source, and not a task file's summary of it. Where a task file and v1 disagree on a visual
108
+ value, v1 wins; where v1's source and what v1 actually renders disagree, **what it renders wins**.
109
+ Reproduce v1's defects when they are cosmetic, and say so. Do not reproduce one that makes a
110
+ component non-functional. Every intentional difference needs an explicit, reviewed VR exception.
111
+
112
+ There is one standing exception, and it is the only one: **leading**. v1's shipped sheet references
113
+ every `--text-*--line-height` unprefixed and so applies none of them, and v2 follows what v1
114
+ declares instead of what it renders. That is decided in `DECISIONS.md` D9, corrected on the v1 arm of
115
+ the VR harness by `V1_DECLARED_LEADING_SHIM`, and dated by `vr/v1-leading-references.test.ts`. Pair
116
+ every `fontSize: tokens.text*` with the matching `lineHeight: tokens.leading*` in the same slot; do
117
+ not read it as licence to prefer v1's source anywhere else.
118
+
119
+ ## Verifying
120
+
121
+ Run these, by name — not against a remembered count:
122
+
123
+ ```sh
124
+ bun run build # tokens, tsc, vite
125
+ bun run test # React 19
126
+ bun run react18:test # React 18
127
+ bun run gate:vr # visual regression against v1
128
+ bun run gates # all architecture gates, fail-fast
129
+ ```
130
+
131
+ Wire your component into `src/index.ts` **before** running the gates: several build the package from
132
+ its entrypoint, so an unexported component is invisible to them and they will pass without having
133
+ seen it.
134
+
135
+ **A green gate is evidence for the assertions that gate actually makes, and nothing wider.** Every
136
+ gate here has a `GATE_NEGATIVE_CONTROL` mode, documented in its own header, demonstrated to trip
137
+ exactly the checks it should. If you add or strengthen an architectural claim, add a control that
138
+ breaks the property under test and show it failing. A check that cannot fail is worse than no check,
139
+ because it reads as coverage — this suite had two such checks for months.
140
+
141
+ ## Done means
142
+
143
+ Exported from `src/index.ts`, both suites green, tsc clean, VR specimens covering the component's
144
+ **states** and not merely its default, and gates green. A component with no state-bearing specimens
145
+ is not finished.
146
+
147
+ ## Working agreements
148
+
149
+ - Do not add dependencies or edit `package.json` unless the task says so explicitly.
150
+ - Do not commit, push, or run any git write command unless asked.
151
+ - Put throwaway probes in `.scratch/` (gitignored) inside this package, never in `/tmp`.
152
+ - Report: file paths, the vitest and tsc results, any literal used for want of a token, any behaviour
153
+ delta from v1, and anything you were unsure about.
package/AUTHORING.md ADDED
@@ -0,0 +1,292 @@
1
+ # Authoring volt-ui-v2 — what went wrong, and why the rules exist
2
+
3
+ `AGENTS.md` is the brief: what to do, and it is normative. This is the reasoning: what broke, how it was found,
4
+ and what it cost. Every item here is a defect that actually shipped into the branch, and most of them
5
+ passed source review, a green unit suite and a source-level audit before a rendering comparison caught
6
+ them.
7
+
8
+ Read this before changing a component or adding one. The single most useful thing in it is the pattern
9
+ at the end: **the same class of mistake recurred four times, and it was never caught by reading.**
10
+
11
+ ---
12
+
13
+ ## 1. The engine — four Griffel constraints, all of which fail silently
14
+
15
+ ### 1.1 `&` must be the first token of a selector
16
+
17
+ Writing `"[data-pressed] &"` reads as "style me when an ancestor is pressed". Griffel does not
18
+ substitute `&` in that position; it prepends its own class to the whole selector and emits:
19
+
20
+ ```css
21
+ .f1y9ypwo[data-pressed] .f1y9ypwo {
22
+ opacity: 1;
23
+ }
24
+ ```
25
+
26
+ That needs one element to carry the class AND be pressed AND contain a second element with the same
27
+ class. It never matches. No warning, no error, no failing test.
28
+
29
+ **Cost: fifteen dead declarations across six components**, every one a feature that silently did not
30
+ work — all four of Toast's type colours (every toast looked identical), OptionCard's six
31
+ selected-state glyphs, Label's disabled dimming, Select's chevron rotation.
32
+
33
+ Only OptionCard's was caught by the visual gate, because it was the only one with a specimen that
34
+ exercised the state. `src/ancestor-selector.test.ts` now forbids the form, and reads source for
35
+ exactly that reason.
36
+
37
+ **Instead:** declare the rule on the slot of the element that carries the state, and reach the
38
+ descendant by `SLOT_ATTR`:
39
+
40
+ ```ts
41
+ [`&[data-pressed] [${SLOT_ATTR}="option-card-radio-dot"]`]: { opacity: 1 }
42
+ ```
43
+
44
+ If the ancestor belongs to another component, the rule belongs in that component — Label's disabled
45
+ rules live in `field.tsx`. **And there is a real capability loss here:** v1 expresses these with
46
+ Tailwind's `group-data-[…]:` and `peer-disabled:`, which work only because Tailwind emits _global_
47
+ utility classes that can be rooted anywhere. Scoped atomic CSS cannot. A Label used outside a Field,
48
+ next to a disabled input, will not dim.
49
+
50
+ ### 1.2 Declaration order inside a slot encodes precedence
51
+
52
+ Griffel emits atoms in source order, so a later state selector beats an earlier one. **v1's precedence
53
+ does not come from order** — it comes from Tailwind's utility layer, where `aria-invalid:` beats
54
+ `disabled:` no matter where either sits in the class string.
55
+
56
+ Transcribing v1's class order therefore inverts the result. Checkbox and Radio both put
57
+ `&[aria-invalid='true']` before `&[data-disabled]`, so a disabled invalid control lost its error
58
+ border to the gray one: 17% and 44% pixel differences on a 16×16 box.
59
+
60
+ **Put the state that must win last.**
61
+
62
+ ### 1.3 Dedup only happens when the expanded property keys match
63
+
64
+ OptionCard's card is a `ToggleGroupItem`. Toggle declares `shorthands.paddingInline`; the card
65
+ declared `paddingLeft`/`paddingRight`. Different keys, so both survived and the cascade kept Toggle's
66
+ value — the padding stayed 4px-per-side wrong _after the value itself had been corrected_, which is a
67
+ confusing thing to debug.
68
+
69
+ Match the neighbour's property shape, not just its value.
70
+
71
+ ### 1.4 A typo'd slot name used to compile
72
+
73
+ `makeVoltStyles` was generic over the key union:
74
+
75
+ ```
76
+ makeVoltStyles<Slots extends string>(styles: Record<Slots, GriffelStyle>): () => Record<Slots, string>
77
+ ```
78
+
79
+ Inferring a type parameter from `Record<Slots, …>` against an object literal **widens `Slots` to
80
+ `string`**, so the result was `Record<string, string>` and every slot name typechecked — including
81
+ ones that did not exist.
82
+
83
+ OptionCard read `styles.card` and `styles[layout]` with neither declared. The component rendered with
84
+ **no card styling at all** — v1's card properties had been copied onto the `group` slot and applied to
85
+ the wrong element. `tsc` clean, 322 tests green.
86
+
87
+ Now generic over the object, so `keyof S` is the real union. It found exactly three errors, all in
88
+ OptionCard: the hole was library-wide, the exploitation was not.
89
+
90
+ ---
91
+
92
+ ## 2. Shipping no reset — v2 inherits nothing v1 got for free
93
+
94
+ v1's components were never written against a reset either. They inherited whatever the _element Radix
95
+ happened to render_ got from the browser's UA stylesheet. Base UI renders different elements, so v2
96
+ inherits nothing.
97
+
98
+ **Checkbox rendered 2px wide.** Base UI's root is a `<span>`: `display: inline`, where width and
99
+ height **do not apply at all**. v1's Radix root is a `<button>`, which the UA sheet gives
100
+ `inline-block` and `border-box`. v1's own class list declares neither.
101
+
102
+ Two further subtleties from the same fix:
103
+
104
+ - `inline-block` specifically, not `inline-flex`. The box is 16×16 either way, but an inline-flex box
105
+ takes its baseline from its first flex item and sat the line box 2px taller.
106
+ - Declare `boxSizing` where the element type changed, but **not library-wide**. Where v1 also renders
107
+ a plain div, both arms are already `content-box` and agree — adding `border-box` there would
108
+ _create_ a difference. 26 slots across 17 components set a size and a border; only the ones the
109
+ visual gate flagged were actually wrong.
110
+
111
+ **The reset does exist in production, but do not rely on it.** `epilot360-root-config` imports
112
+ `tailwindcss/preflight.css` globally at the single-spa root. So `box-sizing: border-box` is there for
113
+ an MFE inside the portal — and absent for one bundled standalone. Explicit declarations are no-ops in
114
+ the first case and load-bearing in the second.
115
+
116
+ ---
117
+
118
+ ## 3. The primitive layer is Radix — and the Base UI detour is why
119
+
120
+ **Historical. v2 uses Radix, at v1's exact pins.** Nothing in this section is current guidance; it is
121
+ here because the detour cost real time and the reasoning generalises. `src/lib/auto-portal.tsx`, the
122
+ `InsideMenuGroupContext` wrapper and `collectItemLabels` are all deleted — if you find a reference to
123
+ them, it is stale.
124
+
125
+ v2 was first built on Base UI and rolled back. The measured case against it:
126
+
127
+ - **2.1x the size** for the same primitive — tooltip 33.9k vs 16.8k gzip.
128
+ - Its shipped types **forced every consumer onto `@types/react` 19**: 312 errors under 18, none of them
129
+ in our own logic. It was the sole source of that requirement.
130
+ - It needed wrappers Radix does not — `<Dialog.Portal>` is mandatory, `DropdownMenuLabel` must sit
131
+ inside a `Menu.Group` — so **every overlay in the package rendered nothing**: nine specimens, blank
132
+ pages, while 321 tests and four isolation gates stayed green. The package's own tests used the
133
+ explicit composition Base UI documents, which is exactly why they never hit it.
134
+
135
+ Two lessons that outlive the decision:
136
+
137
+ - **Silent acceptance of an unsupported option.** `toast("…", { duration: Infinity })` was accepted,
138
+ did nothing, and reported nothing; all four toast specimens rendered a bare viewport.
139
+ - **Strictness is not uniformly one way.** v1 **throws** on `OptionCardGroup` without a `type` prop
140
+ where v2 tolerates it silently — so "v2 is more permissive" is not a safe generalisation, and neither
141
+ is its opposite.
142
+
143
+ Radix state attributes are what the code uses now: `data-state="checked" | "on" | "open" | "closed"`,
144
+ and enter/exit transitions key on `data-state` rather than on separate start/end hooks.
145
+
146
+ ---
147
+
148
+ ## 4. Rule 0 means v1's OUTPUT, not v1's source
149
+
150
+ This is the subtlest thing in the package, and it cuts both ways.
151
+
152
+ **Four v1 production bugs were found here, all one family — the prefixer rewrites definitions and
153
+ misses references:**
154
+
155
+ 1. `--color-level-2`/`-3` point at `--volt-level-2`/`-3`, which nothing defines.
156
+ 2. `.volt-text-xs` resolves `line-height: var(--volt-leading, var(--text-xs--line-height))` — the
157
+ definition ships prefixed, the reference does not, and the unprefixed name is defined nowhere. So
158
+ **every text utility in v1 computes an invalid line-height and inherits instead.**
159
+ 3. Label's `leading-none` never reaches the DOM at all: v1's `cn()` runs tailwind-merge, which treats
160
+ the later `text-sm` as owning line-height and drops it. Combined with (2), production v1 renders
161
+ labels at preflight's 1.5 rather than at 1.
162
+ 4. ScrollOverflow's viewport declares `[&>div]:!flex` and `[&>div]:!flex-row`, and neither survives
163
+ the build — so v1's _horizontal_ scroller stacks its children vertically.
164
+
165
+ **The rule: reproduce the harmless ones, refuse the harmful ones.**
166
+
167
+ - ScrollOverflow does **not** reproduce bug (4). Matching it would ship a horizontal scroll container
168
+ that cannot scroll horizontally. That field requires a written reason and prints on every run.
169
+ - Nor does anything reproduce bugs (2) and (3), and that is the one **standing** exception, decided
170
+ in DECISIONS.md D9. Every slot that sets `fontSize: tokens.text*` also sets the matching
171
+ `lineHeight: tokens.leading*`, Label included — it follows the `text-sm` its class list actually
172
+ ships, not the `leading-none` tailwind-merge ate. Bug (2) is not cosmetic: it kills the leading at
173
+ every one of the seven steps, so reproducing it means shipping a type scale with no vertical
174
+ rhythm. It is also too wide for an `acceptedDifference` — every specimen containing text would need
175
+ one — so the VR harness corrects it on the v1 arm instead, once and by name
176
+ (`V1_DECLARED_LEADING_SHIM` in `vr/run.mjs`, pinned and dated by
177
+ `vr/v1-leading-references.test.ts`).
178
+
179
+ Read the exception narrowly. It is about leading and nothing else; everywhere else, output still
180
+ wins.
181
+
182
+ The same principle settles smaller cases without any bug involved: v1 doesn't style disabled tabs at
183
+ all, so v2's invented `opacity: 0.5` was removed. v1's toast declares `shadow-md` and renders a fully
184
+ transparent one, so v2 has no shadow. v1's OptionCard has a bare Tailwind `border` with no colour
185
+ utility, so its border is `currentColor` — a near-black `rgba(2, 0, 8, 0.88)` that looks like an
186
+ accident and is nevertheless what ships.
187
+
188
+ **When in doubt, measure the rendered result on both arms. Do not read the class list and infer.**
189
+
190
+ ---
191
+
192
+ ## 5. Retiring a dependency inherits its defaults
193
+
194
+ Toast's width (356px), corner offset (24px), zero-width border and absent shadow were all **sonner**
195
+ defaults, not choices in v1's source. The port replaced sonner's behaviour and kept Tailwind-flavoured
196
+ values — 24rem, 1rem, a real 1px border, a real shadow — so every toast was 28px too wide, 8px too
197
+ close to the corner, and carried an edge and a shadow v1 does not render.
198
+
199
+ Nothing but a pixel comparison would have said so. The same exposure applies to `cmdk` in Command,
200
+ which has no v1 export to compare against at all.
201
+
202
+ ---
203
+
204
+ ## 6. The token layer
205
+
206
+ - **There was no 0.75rem spacing step, and that is why `spacingElement3` now exists.** The scale ran
207
+ 0.25 / 0.5 / 0.5 / 1 / 1.5 / 2 rem while v1 uses Tailwind's `*-3` freely, so authors reached for the
208
+ nearest-_sounding_ token (`spacingGroup1`, which is **0.5rem**) and silently lost 4px per side.
209
+ **That exact mistake was found four times** — Toast, Command, Field, SegmentedControl — which made
210
+ it a scale problem, not four authoring slips. The step was added; use `tokens.spacingElement3`.
211
+ This paragraph used to end "use the literal `0.75rem`", which was correct only before the token
212
+ landed. Sizes are still literals: there is no dimension token family, so `width: "0.75rem"` on an
213
+ svg is not `spacingElement3` — spacing and size are different axes that share a number.
214
+ - **Line-height tokens are v1's own declared values, and they are applied.** This paragraph used to
215
+ read "deliberately unused", on the reasoning that applying them diverges from what v1 _renders_
216
+ (§4 bug 2). That was reversed in DECISIONS.md D9: the divergence is from v1's bug, not from v1, and
217
+ the harness now measures against v1-as-declared. Pair the leading with the size in the same slot,
218
+ `fontSize` first. Leave alone the slots that already declare a line-height — those are v1
219
+ `leading-*` utilities that survived tailwind-merge, so they are v1's output as well as its intent.
220
+ - **The namespace segment is derived from the token values.** `--volt2-<hash>-accent-9` changes
221
+ whenever any token changes; that is what lets two builds coexist on `:root`. **Never hardcode it.**
222
+ One test did and broke the moment line-height tokens were added.
223
+ - The accessor is 942 **named exports**, not an object literal, because a bundler cannot drop unused
224
+ properties of a literal. Worth 6.5 kB gzip to every partial consumer. Authoring is unchanged:
225
+ `import * as tokens` and keep saying `tokens.accentSolid`.
226
+
227
+ ---
228
+
229
+ ## 7. Verifying — where the checks themselves were wrong
230
+
231
+ A gate that cannot fail is worse than no gate, because it launders an unverified claim. Every one of
232
+ these was a real false pass:
233
+
234
+ - **Run gates AFTER wiring exports.** `gates/isolation-fixture.tsx` imports the package namespace, so
235
+ it only inspects what `src/index.ts` exports. Running it on an unwired component is a meaningless
236
+ pass — that is how Select's `data-slot` violation shipped.
237
+ - **The visual gate reads `dist`.** The first negative control perturbed a padding by 2px and still
238
+ reported a clean pass, because the edit was in `src`. Staleness is now a hard error.
239
+ - **A blank render is not a match.** Nine overlays rendered nothing while the gate reported
240
+ "116/124 match", because a blank capture against a mostly-white v1 is a _small_ diff. Uniform
241
+ captures and thrown pages are now hard failures.
242
+ - **A parse error makes everything pass.** Typechecking a real consumer against v2 reported 0 errors,
243
+ and a positive control importing a nonexistent name also reported 0 — because the consumer's
244
+ TypeScript 4.9 could not parse Base UI's `.d.ts`, so the module resolved to an error type. Under TS
245
+ 5.6 the same code reports 148.
246
+ - **A state nobody renders is a state nobody has checked.** That is the whole reason §1.1 needed a
247
+ source scan on top of the visual gate.
248
+ - **Identical results after a change that should have moved something is a signal.** A patch adding
249
+ preflight to the harness silently missed its target line; the numbers came back byte-identical and
250
+ only a direct `box-sizing` measurement caught it.
251
+ - **A test that passes in the file and fails alone has not passed.** Four overlay dismissal tests were
252
+ green under React 19 and red under React 18, and the component behaved _identically_ in both. Run
253
+ the suspect test with `-t` before you believe either colour: all four also failed under React 19 in
254
+ isolation. Three defects were stacked, each masking the next — see §7.1.
255
+
256
+ ### 7.1 The three that hid behind each other
257
+
258
+ Worth reading as a set, because no single one of them was visible while the others stood:
259
+
260
+ 1. **Testing Library's auto-cleanup never ran.** It self-registers only when the framework's
261
+ `afterEach` is a global, and neither vitest config sets `globals: true`. Test files had compensated
262
+ with `document.body.innerHTML = ""`, which detaches nodes without unmounting roots. Radix delivers
263
+ Escape only to the topmost `DismissableLayer`, so layers left behind by earlier tests swallowed the
264
+ key. The tell was `data-scroll-locked="3"` on a page containing one dialog.
265
+ Now registered explicitly in `src/setup.testing-library.ts`, in both configs.
266
+ 2. **The removal assertion could not pass on its own merits.** Radix's `Presence` holds a closed node
267
+ until `animationend`, and jsdom runs no animations. `fireEvent.animationEnd` does not fix it:
268
+ jsdom implements no `AnimationEvent`, so Testing Library falls back to a plain `Event` and drops
269
+ `animationName` — precisely the field `Presence` gates on. Construct the event by hand
270
+ (`src/overlay-test-helpers.ts`). Asserting `data-state="closed"` instead would be vacuous: that
271
+ attribute is set before `Presence` decides anything.
272
+ 3. **Tooltip's Escape raced its own listener.** Radix attaches it in an effect, and the `waitFor`
273
+ resolved on DOM presence — under React 18, before that effect ran, so the keydown landed with
274
+ nothing listening. `await act(async () => {})` first. React 19 flushed early enough to hide it.
275
+
276
+ The general shape: **a version-dependent test result is a claim about the test, until you have shown
277
+ the component behaves differently.** Here it never did.
278
+
279
+ ---
280
+
281
+ ## 8. The pattern worth internalising
282
+
283
+ Four separate times, the same 4px spacing error. Fifteen dead selectors of one shape. Every overlay
284
+ non-functional. A component with no styling at all. None of it was found by reading code — not by
285
+ source review, not by unit tests, not by three source-level audit passes that each found _other_ real
286
+ defects.
287
+
288
+ All of it was found by rendering both versions and comparing pixels.
289
+
290
+ So: **when you change a component, run `bun run gate:vr`.** When you add one, add specimens for its
291
+ states, not just its default. And when a check comes back clean on something that has been wrong
292
+ before, find out whether the check can fail at all.
package/README.md CHANGED
@@ -137,9 +137,14 @@ fails on the insertion-point count and `gate:class-salt-skew` fails on atom-name
137
137
 
138
138
  ## What is not here
139
139
 
140
- `DataTable` is not ported and stays on v1. It is the only component still missing, and the only gap
141
- in what consumers actually import: of 143 distinct Volt symbols used across shipped MFE code, this
142
- package covers 127, and all 16 it does not are DataTable or a TanStack type it re-exports.
140
+ `Collapsible` has no equivalent here, and neither does v1 depend on `@radix-ui/react-collapsible`
141
+ directly, as both pilot MFEs do.
142
+
143
+ **Corrected in 0.2.2:** this section used to say `DataTable` was unported and "the only component
144
+ still missing". It has shipped since 0.2.0 — `DataTable`, `DataTableBody`, `DataTableContent`,
145
+ `DataTableHeader`, `DataTableLoading`, `DataTableError` and `TablePagination` are all exported. The
146
+ stale sentence cost real work: the Variable Builder read it and hand-built its list page on the
147
+ `Table` primitives.
143
148
 
144
149
  There is no `--volt-*` compatibility layer. Consumer CSS reading v1's variable names by hand needs
145
150
  migrating to this package's exported token bindings — 217 references across six MFEs, two thirds of
@@ -148,6 +153,73 @@ them in `entity`.
148
153
  Animation is unverified. The visual harness disables it on both arms, so transitions are the one thing
149
154
  264 matching specimens do not cover.
150
155
 
156
+ ## Treatments: stamp the document element, not a subtree
157
+
158
+ Role tokens resolve per treatment through `[data-surface="…"]` blocks. Those are **bare attribute
159
+ selectors**, not `:root[data-surface=…]`, so the stamp cascades from whatever element carries it —
160
+ and that invites putting it on a micro-frontend root. Don't.
161
+
162
+ `Popover`, `Select`, `Tooltip`, `Dialog` and `DropdownMenu` render their content into
163
+ `document.body`. Griffel's atomic classes are global and travel with the element, but token
164
+ **values** are custom properties resolved against the element's ancestors, and portalled content is
165
+ no longer inside your stamped subtree. It resolves against `:root` instead. So a `glass` panel would
166
+ open a `plain` popover, silently, with no error and nothing in the DOM to suggest why.
167
+
168
+ ```js
169
+ // Right — every portal is a descendant of this.
170
+ document.documentElement.setAttribute("data-surface", "glass")
171
+
172
+ // Wrong — the popover this panel opens escapes the stamp.
173
+ mfeRoot.setAttribute("data-surface", "glass")
174
+ ```
175
+
176
+ Two consumers have not hit this, and only because `plain` is the `:root` default: the role values in
177
+ the bare `:root, .light, [data-theme="light"]` block are identical to `[data-surface="plain"]`, so a
178
+ `plain` consumer needs no stamp at all and a portal that falls back to `:root` lands on the same
179
+ values. The first consumer to stamp `bordered`, `glass`, `tint` or `card` on a subtree is the first
180
+ to see it.
181
+
182
+ Propagating the stamp onto portalled roots would remove the constraint, and is not done here: it
183
+ would mean every portal component reading its trigger's nearest `[data-surface]` at mount, which is
184
+ five components, a new failure mode when the trigger is not yet mounted, and a behaviour change for
185
+ a case no consumer has reached. The **document boundary** pattern is the same mechanism used
186
+ deliberately — customer content stamps `data-theme="light"` plus a treatment on its own root so it
187
+ keeps its mode inside a dark page.
188
+
189
+ ## Testing
190
+
191
+ The package is ESM-only, and Jest 26 and 27 ignore the `exports` field. Before 0.2.2 that made a
192
+ bare import resolve to nothing: `Cannot find module '@epilot/volt-ui-experimental-griffel'`, in a
193
+ suite that had been green only because no test rendered one of these components yet. 0.2.2 adds a
194
+ `main` field, so those resolvers find `dist/index.js` themselves.
195
+
196
+ What they still need is a transform, because the file is ESM and Jest's default
197
+ `transformIgnorePatterns` skips everything under `node_modules`. Take it from here:
198
+
199
+ ```js
200
+ // jest.config.js
201
+ const griffel = require("@epilot/volt-ui-experimental-griffel/testing/jest-preset.cjs")
202
+
203
+ module.exports = griffel.withGriffel(
204
+ {
205
+ preset: "ts-jest",
206
+ transform: { "^.+\\.(ts|tsx)$": ["ts-jest"] },
207
+ },
208
+ { alsoTransform: ["@xyflow"] }
209
+ )
210
+ ```
211
+
212
+ `withGriffel` replaces `transformIgnorePatterns` outright rather than appending to it, because Jest
213
+ ignores a file when **any** pattern matches — so an added pattern can never un-ignore what the
214
+ default `/node_modules/` already caught. Anything else you need transformed out of `node_modules`
215
+ goes in `alsoTransform`. Needs `babel-jest` and `@babel/preset-env` as devDependencies.
216
+
217
+ **There is no CommonJS build, deliberately.** The runtime holds module-level state: the `installed`
218
+ Set that dedups token sheets, and one shared renderer. A dual ESM/CJS package would hand a consumer
219
+ two copies of both, breaking the single-insertion-point and single-renderer properties that
220
+ `gate:isolation` and `gate:griffel-contract` exist to assert. One format is a correctness
221
+ requirement here. See `DECISIONS.md` D13.
222
+
151
223
  ## Verification
152
224
 
153
225
  Every check below has a `GATE_NEGATIVE_CONTROL` mode that has been demonstrated to fail when the
@@ -164,10 +236,16 @@ fail-fast. A green gate is evidence for that gate's own assertions and nothing w
164
236
  | `bun run gate:griffel-contract` | `insertionCache` still dedups, and every copy resolves the same renderer |
165
237
  | `bunx vitest run` | 493 tests, plus 497 under React 18 |
166
238
 
167
- `AGENTS.md` is the normative rule set and overrides every other document in the package. `AUTHORING.md`
168
- states the defect behind each rule. `DECISIONS.md` holds the architecture rationale. `STATUS.md` is a
169
- dated snapshot of measurements more current than this file on numbers, but not authoritative on
170
- rules.
239
+ `AGENTS.md` is the normative rule set and overrides every other document in the package.
240
+ `AUTHORING.md` states the defect behind each rule. **Both ship in the tarball as of 0.2.2** before
241
+ that this paragraph cited four documents that `files` excluded, so a consumer was told the normative
242
+ rules existed and had no way to read them.
243
+
244
+ The other two stay in the repository, on branch
245
+ [`experiment/griffel`](https://gitlab.com/e-pilot/product/frontend/volt-ui/-/tree/experiment/griffel):
246
+ `DECISIONS.md` at the root holds the architecture rationale, and
247
+ `packages/experimental-griffel/STATUS.md` is a dated snapshot of measurements — more current than
248
+ this file on numbers, but not authoritative on rules.
171
249
 
172
250
  ## Releasing
173
251
 
@@ -5,7 +5,7 @@ import { shorthands as c } from "../../packages/volt-runtime/dist/vendor/@griffe
5
5
  import { SLOT_ATTR as n } from "../../packages/volt-runtime/dist/renderer/contract.js";
6
6
  import { mergeClasses as d } from "../../packages/volt-runtime/dist/vendor/@griffel/core/src/mergeClasses.js";
7
7
  import { makeVoltStyles as v } from "../../styles/make-styles.js";
8
- import { spacingGroup2 as f, grayA1 as N, grayA11 as z, leadingSm as F, textSm as W, grayA12 as O, fontWeightSemibold as H, leadingXl as P, textXl as X, containerXl as j, containerLg as k, containerMd as _, containerSm as I, body as L, spacingElement2 as p, radiusXl as B } from "../../tokens/index.js";
8
+ import { spacingElement2 as f, spacingGroup2 as p, grayA1 as N, radiusXl as z, grayA11 as F, leadingSm as W, textSm as O, grayA12 as H, fontWeightSemibold as P, leadingXl as X, textXl as j, containerXl as k, containerLg as _, containerMd as I, containerSm as L, body as B } from "../../tokens/index.js";
9
9
  import { Button as u } from "../button/button.js";
10
10
  const g = v({
11
11
  overlay: {
@@ -34,7 +34,7 @@ const g = v({
34
34
  }
35
35
  },
36
36
  content: {
37
- backgroundColor: L,
37
+ backgroundColor: B,
38
38
  position: "fixed",
39
39
  top: "50%",
40
40
  left: "50%",
@@ -43,10 +43,10 @@ const g = v({
43
43
  display: "grid",
44
44
  width: "100%",
45
45
  maxWidth: "calc(100% - 2rem)",
46
- ...c.gap(f),
47
- ...c.borderRadius(B),
46
+ ...c.gap(p),
47
+ ...c.borderRadius(z),
48
48
  ...c.border("1px", "solid", N),
49
- ...c.padding(f),
49
+ ...c.padding(p),
50
50
  // v1 shadow-lg
51
51
  boxShadow: "0 10px 15px -3px #0000001a, 0 4px 6px -4px #0000001a",
52
52
  outlineStyle: "none",
@@ -80,40 +80,40 @@ const g = v({
80
80
  }
81
81
  },
82
82
  sm: {
83
- maxWidth: I
83
+ maxWidth: L
84
84
  },
85
85
  md: {
86
- maxWidth: _
86
+ maxWidth: I
87
87
  },
88
88
  lg: {
89
- maxWidth: k
89
+ maxWidth: _
90
90
  },
91
91
  xl: {
92
92
  "@media (min-width: 640px)": {
93
- maxWidth: j
93
+ maxWidth: k
94
94
  }
95
95
  },
96
96
  header: {
97
97
  display: "flex",
98
98
  flexDirection: "column",
99
- ...c.gap(p),
99
+ ...c.gap(f),
100
100
  textAlign: "left"
101
101
  },
102
102
  footer: {
103
103
  display: "flex",
104
- ...c.gap(p),
104
+ ...c.gap(f),
105
105
  justifyContent: "flex-end"
106
106
  },
107
107
  title: {
108
- fontSize: X,
109
- lineHeight: P,
110
- fontWeight: H,
111
- color: O
108
+ fontSize: j,
109
+ lineHeight: X,
110
+ fontWeight: P,
111
+ color: H
112
112
  },
113
113
  description: {
114
- fontSize: W,
115
- lineHeight: F,
116
- color: z
114
+ fontSize: O,
115
+ lineHeight: W,
116
+ color: F
117
117
  }
118
118
  }), Y = i.forwardRef(
119
119
  function(t, a) {