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

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 (79) hide show
  1. package/ADOPTING.md +416 -0
  2. package/README.md +32 -31
  3. package/dist/components/alert-dialog/alert-dialog.js +61 -59
  4. package/dist/components/alert-dialog/alert-dialog.js.map +1 -1
  5. package/dist/components/avatar/avatar.js +6 -6
  6. package/dist/components/avatar/avatar.js.map +1 -1
  7. package/dist/components/badge/badge.js +47 -39
  8. package/dist/components/badge/badge.js.map +1 -1
  9. package/dist/components/button/button.js +71 -68
  10. package/dist/components/button/button.js.map +1 -1
  11. package/dist/components/checkbox/checkbox.js +23 -20
  12. package/dist/components/checkbox/checkbox.js.map +1 -1
  13. package/dist/components/command/command.js +50 -35
  14. package/dist/components/command/command.js.map +1 -1
  15. package/dist/components/data-table/data-table.js +89 -88
  16. package/dist/components/data-table/data-table.js.map +1 -1
  17. package/dist/components/density-provider/density-provider.js +28 -0
  18. package/dist/components/density-provider/density-provider.js.map +1 -0
  19. package/dist/components/dialog/dialog.js +122 -100
  20. package/dist/components/dialog/dialog.js.map +1 -1
  21. package/dist/components/drawer/drawer.js +61 -59
  22. package/dist/components/drawer/drawer.js.map +1 -1
  23. package/dist/components/dropdown-menu/dropdown-menu.js +88 -85
  24. package/dist/components/dropdown-menu/dropdown-menu.js.map +1 -1
  25. package/dist/components/field/field-combobox.js +121 -106
  26. package/dist/components/field/field-combobox.js.map +1 -1
  27. package/dist/components/field/field.js +105 -104
  28. package/dist/components/field/field.js.map +1 -1
  29. package/dist/components/list/list.js +282 -308
  30. package/dist/components/list/list.js.map +1 -1
  31. package/dist/components/pill/pill.js +58 -56
  32. package/dist/components/pill/pill.js.map +1 -1
  33. package/dist/components/popover/popover.js +47 -38
  34. package/dist/components/popover/popover.js.map +1 -1
  35. package/dist/components/radio/radio.js +21 -18
  36. package/dist/components/radio/radio.js.map +1 -1
  37. package/dist/components/select/select.js +185 -156
  38. package/dist/components/select/select.js.map +1 -1
  39. package/dist/components/skeleton/skeleton.js +2 -2
  40. package/dist/components/skeleton/skeleton.js.map +1 -1
  41. package/dist/components/switch/switch.js +34 -29
  42. package/dist/components/switch/switch.js.map +1 -1
  43. package/dist/components/table/table-pagination.js +92 -90
  44. package/dist/components/table/table-pagination.js.map +1 -1
  45. package/dist/components/table/table.js +109 -121
  46. package/dist/components/table/table.js.map +1 -1
  47. package/dist/components/toggle/toggle.js +29 -29
  48. package/dist/components/toggle/toggle.js.map +1 -1
  49. package/dist/components/tooltip/tooltip.js +36 -34
  50. package/dist/components/tooltip/tooltip.js.map +1 -1
  51. package/dist/index.d.ts +600 -427
  52. package/dist/index.js +215 -212
  53. package/dist/index.js.map +1 -1
  54. package/dist/packages/volt-runtime/dist/sheet.js +27 -10
  55. package/dist/packages/volt-runtime/dist/sheet.js.map +1 -1
  56. package/dist/packages/volt-runtime/dist/styles/recipe.js +76 -0
  57. package/dist/packages/volt-runtime/dist/styles/recipe.js.map +1 -0
  58. package/dist/styles/document-context.js +12 -0
  59. package/dist/styles/document-context.js.map +1 -0
  60. package/dist/styles/make-styles.js +18 -10
  61. package/dist/styles/make-styles.js.map +1 -1
  62. package/dist/tokens/core.generated.js +1238 -1166
  63. package/dist/tokens/core.generated.js.map +1 -1
  64. package/dist/tokens/index.d.ts +442 -415
  65. package/dist/tokens/index.js +1105 -1078
  66. package/dist/tokens/index.js.map +1 -1
  67. package/dist/tokens/install.js +9 -5
  68. package/dist/tokens/install.js.map +1 -1
  69. package/dist/tokens/use-palette-tokens.js +20 -0
  70. package/dist/tokens/use-palette-tokens.js.map +1 -0
  71. package/dist/tokens.json +83 -2
  72. package/package.json +13 -5
  73. package/scripts/migration-diagnostics-core.mjs +859 -0
  74. package/scripts/migration-diagnostics.mjs +353 -0
  75. package/scripts/migration-sweep.mjs +179 -0
  76. package/AGENTS.md +0 -153
  77. package/AUTHORING.md +0 -292
  78. package/dist/packages/volt-runtime/dist/styles/make-styles.js +0 -22
  79. package/dist/packages/volt-runtime/dist/styles/make-styles.js.map +0 -1
package/ADOPTING.md ADDED
@@ -0,0 +1,416 @@
1
+ # Putting a micro-frontend on this package
2
+
3
+ For an agent doing the work in a **consumer repo**. `AGENTS.md` and `AUTHORING.md` govern files
4
+ inside this package and do not apply to you; the README is the reference for individual APIs. This
5
+ file is the procedure, and it exists because three pilot migrations — `variable-builder`,
6
+ `scheduler-capability`, `flows-sidebar` — each rediscovered the same traps at their own expense.
7
+
8
+ Every number below was measured on one of those three. Nothing here is inferred.
9
+
10
+ ---
11
+
12
+ ## The fast path
13
+
14
+ ```
15
+ 1 Toolchain: install, DO NOT externalize, prove an EMPTY tree builds. ← before any UI
16
+ 2 Flag gate: parallel tree, flag off = byte-identical. Verify it turns ON.
17
+ 3 Token layer: /tokens subpath, semantic families, read the treatment cell.
18
+ 4 Components: five engine rules that fail silently.
19
+ 5 Host cascade: the consumer's own CSS is the adversary, not this package.
20
+ 6 Verify: typecheck, sweep, then a browser. In that order.
21
+ ```
22
+
23
+ Two things decide whether this takes a day or a week, and both are in steps 1 and 2. Do them first,
24
+ in full, and do not start writing UI until an empty tree builds and the flag demonstrably flips.
25
+
26
+ ---
27
+
28
+ ## 1. Toolchain
29
+
30
+ **Bundled, not externalized.** The opposite of Volt UI v1. Never add this package to webpack
31
+ `externals` and never give it an import-map entry — it is an ordinary per-MFE dependency, and the
32
+ whole point of the architecture is that two MFEs can hold different versions. Externalizing it puts
33
+ you back on a shared singleton.
34
+
35
+ **`react/jsx-runtime` is the one to check.** String externals match the exact request, so `react`
36
+ being external does **not** cover `react/jsx-runtime`, which means it is bundled from local
37
+ `node_modules`. If your repo declares React 17 while the shell serves 18 — true in at least two
38
+ epilot MFEs — you are bundling 17's jsx factory against the host's 18. That works today (17's
39
+ factory produces `Symbol.for('react.element')` and reads the internals 18 still provides) and it is
40
+ what `origin/main` already ships in those repos, verified by building both and diffing the
41
+ `System.register` arrays. So it is pre-existing, not yours to fix, and worth a separate ticket
42
+ rather than a detour here.
43
+
44
+ **Tests.** Jest 26 and 27 ignore `exports`. Take the shipped preset:
45
+
46
+ ```js
47
+ const griffel = require("@epilot/volt-ui-experimental-griffel/testing/jest-preset.cjs")
48
+ module.exports = griffel.withGriffel(
49
+ { preset: "ts-jest" },
50
+ { alsoTransform: ["@xyflow"] }
51
+ )
52
+ ```
53
+
54
+ See the README's Testing section for why `withGriffel` replaces `transformIgnorePatterns` instead of
55
+ merging, and why there is no CommonJS build.
56
+
57
+ **Then stop and build.** `tsc`, lint, test and a production build must pass on a tree that contains
58
+ the dependency and no components. That run is what proves your TypeScript can parse the package's
59
+ declarations before you have written anything that would have to be rewritten. One pilot skipped it
60
+ and spent the afternoon on a resolution problem it mistook for a styling problem.
61
+
62
+ ---
63
+
64
+ ## 2. The flag gate
65
+
66
+ **The shape.** A parallel tree behind a feature flag, with the legacy tree untouched, so flag-off is
67
+ byte-identical to today. All three pilots took this shape and none regretted it. It costs duplicated
68
+ logic — roughly 600 lines in the scheduler — which is the right trade while the flag is short-lived,
69
+ because the alternative is refactoring the renderer everyone currently depends on. Revisit if the
70
+ flag will live for months.
71
+
72
+ **Read the flag like this:**
73
+
74
+ ```ts
75
+ const enabled = useFeatureFlagEnabled("<mfe>-griffel") === true
76
+ ```
77
+
78
+ `=== true` matters: the hook returns `undefined` while PostHog resolves, and that must read as off
79
+ rather than flickering the new UI in and out. A missing flag is correctly off.
80
+
81
+ **The trap that cost the most time in this whole programme.** `useFeatureFlagEnabled` resolves its
82
+ client from `PostHogContext`. With no provider mounted, that context yields the MFE's **own bundled,
83
+ never-initialised** posthog-js instance — a different object from `window.posthog`. The hook then
84
+ never returns `true`, whatever you do to the flag. In the variable-builder this was verified from the
85
+ console: `window.posthog.isFeatureEnabled(flag)` was `true`, the override was persisted, the flag
86
+ count was 1, and the gated `React.lazy` chunk was still never fetched, across a reload.
87
+
88
+ So **prove the gate turns on before you build behind it.** Either mount the provider, or read
89
+ `window.posthog` directly in the gate, or force a constant during development — but whichever you
90
+ pick, watch the lazy chunk actually load in the network panel. A gate that cannot flip means every
91
+ line you write behind it is unexercised.
92
+
93
+ If you force a constant, that constant is item one on your pre-merge checklist. A `// REVERT BEFORE
94
+ MERGE` comment is not a mechanism; one pilot shipped exactly that comment to the end of a session.
95
+
96
+ **Lazy-load with exactly one dynamic import, and make it yours:**
97
+
98
+ ```ts
99
+ // Right: one module of your own, which reaches the package by static import.
100
+ const Tree = React.lazy(() => import("./components/mfe-griffel"))
101
+
102
+ // Wrong: a dynamic import of the barrel. Webpack cannot statically prove the property accesses on
103
+ // a namespace object, so tree-shaking is DISABLED for that module and the entire library lands in
104
+ // the chunk — @dnd-kit, @tanstack/table-core, sonner, cmdk, every unused component.
105
+ import("@epilot/volt-ui-experimental-griffel")
106
+ ```
107
+
108
+ The scheduler measured that mistake at **846 KB**, falling to **29 KB** once the dynamic import
109
+ named one of its own modules. The build succeeds either way and nothing warns. Flag-off cost after
110
+ the fix: **5,227 bytes**, against 343,894 for an eager import.
111
+
112
+ ---
113
+
114
+ ## 3. The token layer
115
+
116
+ **Import from the `/tokens` subpath, never the root `tokens` namespace.** A namespace object cannot
117
+ be tree-shaken. Measured on the sixteen bindings one real consumer needed: 9,138 bytes gzip through
118
+ the namespace, 207 through the subpath.
119
+
120
+ **Prefer the semantic intent families over the decorative ones.** Two sheets ship: core installs
121
+ itself on every use and carries `accent`, `gray`, `error`, `info`, `success`, `warning` and the whole
122
+ `sem*` layer; the fifteen decorative families (`blue`, `green`, `yellow`, `teal`, …) live in an
123
+ opt-in sheet. Name a decorative family without the sheet and you get a `var()` for a custom property
124
+ nothing declares: no throw, no warning, nothing painted. The scheduler's SCSS coloured every
125
+ schedule status from `--volt-blue-3`, `--volt-green-3` and `--volt-yellow-3`; ported literally, all
126
+ three status colours would have vanished silently.
127
+
128
+ The fix is almost never `ensurePaletteTokens()`. It is that status **is** semantic — pending is
129
+ `info`, triggered is `success`, a pending change is `warning` — and those are in core. Two of the
130
+ mappings are free:
131
+
132
+ | decorative | semantic | steps 3 and 6 |
133
+ | ---------- | --------- | ------------------------------------------------------------ |
134
+ | `blue` | `info` | byte-identical |
135
+ | `green` | `success` | byte-identical |
136
+ | `yellow` | `warning` | **not** identical — warning is amber, `#ffefd6` vs `#fffab8` |
137
+
138
+ If you genuinely need a decorative family, the package installs the sheet for you whenever a
139
+ `color`-prop component resolves to one. Call `ensurePaletteTokensFor(family)` yourself only if you
140
+ are building your own coloured component.
141
+
142
+ **Read the treatment cell before mapping roles.** Role names do not predict visual weight. In
143
+ `plain` — the `:root` default, which reproduces today's portal — an **Object paints nothing**:
144
+ `surfaceObject`, `borderObject` and `shadowObject` all resolve to transparent, while
145
+ `surfaceContainer` is white with a shadow. The scheduler mapped its visible card onto Object from
146
+ the role names, and every card would have disappeared with a clean typecheck, clean lint and a
147
+ successful build. Open `dist/tokens/core.generated.js` and read the cell, or read the role/treatment
148
+ table in the docs.
149
+
150
+ **Ink on a soft fill is `on-soft`, not step 11.** Step 11 is documented as the low-contrast text
151
+ step and is designed against steps 1–2. On step 3 — the natural fill for a tinted chip — it fails AA
152
+ in light mode: info 4.26, success 4.27, warning 3.99. `sem<Family>OnSoft` lands between 10.27 and
153
+ 12.48 across every intent family in both modes.
154
+
155
+ **Focus rings are `semFocusRing`.** Not `accentA8`, which reads like the obvious choice and
156
+ composites to **2.36:1**, under the 3:1 that WCAG 2.1 SC 1.4.11 requires. Not `accent9` either: it
157
+ passes light at 6.29 and **fails dark at 2.92**. Only steps 11 and 12 clear the bar in both modes.
158
+
159
+ **Stamp treatments on `document.documentElement`.** Treatment blocks are bare attribute selectors,
160
+ so a stamp on your MFE root looks like it works — until a popover, select or tooltip portals to
161
+ `document.body`, leaves your subtree, and resolves against `:root` instead. See the README section.
162
+
163
+ **Density is `[data-density]`, CSS-only, opt-in, and partial today.** Stamp
164
+ `[data-density="compact"|"normal"|"comfortable"]` on `document.documentElement` (or any subtree
165
+ ancestor) and `Button` (`base`/`icon` sizes), `Field` (root padding, `FieldSet` gap, and
166
+ `FieldInput` height), `List`/`ListItemIndex`, and `Select` (`SelectItem`, `SelectContent` — see
167
+ below) pick it up through ordinary CSS custom-property inheritance — no provider, no
168
+ context, no JS density state to wire up, and an absent/invalid attribute is transparent (inherits
169
+ an ancestor, or falls back to `normal` at the root). `Button[size="base"]`'s compact tier keeps the
170
+ same 32px height as normal — the density lever there is horizontal padding, since a fixed
171
+ line-height plus fixed vertical padding already floors the box at 32px; only comfortable grows it,
172
+ the same way `lg` already does with an explicit `minHeight`. `Button`'s `xs`/`sm`/`lg`/`icon-xs`/
173
+ `icon-sm`/`icon-lg` sizes, `FieldLabel` (fixed 16px at every tier), standalone `SelectTrigger`
174
+ (Field-wrapped or not — a `SelectTrigger` inside a `Field` inherits the Field wrapper's now-bound
175
+ padding, never its own height), `FieldComboboxInput` (`field-combobox.tsx` has its own separate
176
+ `makeVoltStyles` call and its own unrelated `input` key with no height — this binding does not
177
+ reach it), and every other component do **not** respond yet — density binding lands
178
+ component-by-component; check `DENSITY-SPEC.md`'s matrix (§2) before assuming a given slot moves.
179
+ `Table`/`DataTable` (`TableHead`'s height, `TableCell`'s vertical padding) are density-bound the
180
+ same way as of TABLE-DENSITY-IMPLEMENTATION.md (2026-09-09) — see the dedicated paragraph below for
181
+ the migration this required.
182
+
183
+ **`List` is CSS-bound too, with two things worth knowing.** `ListItem`/`ListItemIndex` (min-height,
184
+ padding, gap, index min-width, index horizontal padding) inherit from `[data-density]` exactly like
185
+ Button/Field — omitted `density` writes no attribute at all (ambient CSS only); an explicit value
186
+ (including `"relaxed"`) stamps a canonical `data-density` boundary. `"relaxed"` is a **permanent**
187
+ alias for `"normal"` geometry — identical numbers, forever — but its **DOM representation** is
188
+ normalized to `data-density="normal"`, not the literal string `"relaxed"`: the generator's three
189
+ selector blocks only ever match `"compact"`/`"normal"`/`"comfortable"`, so a raw `"relaxed"`
190
+ attribute would match none of them. Read the attribute back with `getAttribute("data-density")` if
191
+ you need it, not the `density` prop value you passed. `List`'s `DragOverlay` (dnd-kit) is an
192
+ ordinary DOM sibling of the `<ol>`, not a portal (confirmed from the installed
193
+ `@dnd-kit/core` source) — an explicit `density` re-asserts the same canonical boundary directly on
194
+ the dragged row's overlay clone; an omitted one inherits ambient CSS through the page's real DOM
195
+ ancestors with no stamp at all, same rule as everywhere else. `ListGroupLabel`'s collapsible
196
+ trigger shares `ListItem`'s bound geometry; its standalone (non-collapsible) variant is unrelated,
197
+ unbound, unchanged.
198
+
199
+ **A raw `data-density` attribute spread directly onto `<ol>` still wins over the `density` prop —
200
+ same JSX spread order as always — but what that precedence now controls is new.** Before this
201
+ binding, an attribute the caller spread onto `<ol>` was decorative: nothing in the CSS read it, so
202
+ whichever one "won" was inert either way. Now it drives real geometry, so the same unchanged
203
+ precedence rule has a real, visible consequence for the first time. It is also **narrower than it
204
+ looks**: a raw attribute set only on the `<ol>` does not propagate to the sibling `DragOverlay` —
205
+ the overlay clone reads only the `density` **prop**, never a raw attribute a caller set by hand on
206
+ the list element. A caller who needs the dragged row's overlay to match a manually-overridden `<ol>`
207
+ should use the `density` prop (or an ancestor `[data-density]` boundary) instead of a raw
208
+ attribute — this gap is not closed in this pass, only documented.
209
+
210
+ **`DensityProvider` is the explicit-override mechanism DENSITY-SPEC.md §4.4 specifies — reach for
211
+ it only when the ambient CSS path above isn't enough.** Most callers never need it: an `<html
212
+ data-density="compact">` (or any DOM ancestor `[data-density]`) already reaches every non-portalled
213
+ element, and every portal-content component listed below, through ordinary CSS custom-property
214
+ inheritance — no JavaScript, no import. `DensityProvider` exists for the one case that mechanism
215
+ cannot cover: forcing a density onto content a Radix `Portal` moves to `document.body` (or a custom
216
+ `container`), which is a DOM **sibling** of wherever it was written in JSX, not a descendant of any
217
+ ancestor `[data-density]` boundary there. `<DensityProvider density="compact">` sets a real DOM
218
+ `data-density` boundary (for its non-portalled descendants) **and** a React context value (read via
219
+ `useDensity()`, `undefined` with no ancestor `DensityProvider` — never a JS reading of ambient CSS)
220
+ in one component, so a portal-content component nested anywhere inside it — even many React levels
221
+ deep, even past other portals — can re-stamp itself correctly. Ordinary `<div>` semantics: forwards
222
+ `ref`/`className`/`style`/every other div prop, no `asChild`, no new polymorphic API. Runtime input
223
+ outside `"compact"|"normal"|"comfortable"` normalizes to `"normal"`, matching the CSS fallback rule
224
+ above.
225
+
226
+ **Portal-content components stamp `data-density` themselves, from `useDensity()`, only when an
227
+ explicit override exists.** `DialogContent`, `AlertDialogContent`, `DrawerContent`,
228
+ `DropdownMenuContent`, `DropdownMenuSubContent`, `PopoverContent`, `SelectContent`, and
229
+ `TooltipContent` each read `useDensity()` at their own portal root and spread `data-density` only
230
+ when it is defined — omitted, they stamp nothing and inherit the ambient shell value through the
231
+ real DOM tree (the portal container is still a descendant of `<html>`), exactly `List`'s
232
+ `DragOverlay` rule extended to eight more components. A `Select` rendered inside a `Popover`'s
233
+ content (or a `Dialog`'s, or nested inside a `DropdownMenu`) is proof this works even through
234
+ **two** independent portals stacked in the React tree: both `PopoverContent`/`DialogContent` and
235
+ the nested `SelectContent` read the *same* `DensityProvider` context value and stamp it
236
+ independently, even though `SelectContent` is a DOM **sibling** of `PopoverContent`, not its
237
+ descendant — real-Chromium-verified in `vr/density-provider-portal-interaction.test.ts`.
238
+ `table-pagination.tsx`'s inline `SelectPrimitive.Portal`-based page-size menu (still built directly
239
+ from raw Radix primitives, not the shared `select.tsx` component) now reads `useDensity()` and
240
+ stamps `data-density` the same way as the eight components above — the gap this paragraph used to
241
+ describe is closed; see the dedicated paragraph below.
242
+
243
+ **`Table`/`DataTable` density (TABLE-DENSITY-IMPLEMENTATION.md, 2026-09-09) — resolves the
244
+ compatibility question `TABLE-LIST-DENSITY-COMPATIBILITY-NOTE.md` (density/'s own worktree) left
245
+ open, by user-approved decision: direct `TableDensityContext.Provider` consumers are no longer a
246
+ supported way to control geometry.** `TableHead`'s height and `TableCell`'s vertical padding now
247
+ read `tokens.tableHeadHeight`/`tokens.tableCellPaddingY` directly — the same `[data-density]`
248
+ CSS-cascade mechanism as everywhere else, not a JS class switch. `Table`'s and `DataTable`'s own
249
+ `density` prop is genuinely optional now (no more `= "normal"` default): omitted, no `data-density`
250
+ is stamped and ambient shell/`DensityProvider` density inherits through ordinary DOM/CSS; explicit —
251
+ including explicit `"normal"` under a non-normal ambient — always stamps its own value on the
252
+ `<table>`/root element, resetting the subtree. `TableHead`/`TableCell`'s own `density` prop works
253
+ the same way, stamped directly on that `<th>`/`<td>`, overriding locally; a nested, density-omitted
254
+ `<Table>` (e.g. inside a custom `DataTable` cell renderer) inherits its nearest real `[data-density]`
255
+ boundary exactly like any other nested content. A raw caller `data-density` attribute can never
256
+ override the normalized `density` prop, on `Table`, `DataTable`, `TableHead` or `TableCell` alike.
257
+ `table-pagination.tsx`'s portal calls `useDensity()` and stamps its own override the same way as the
258
+ other eight portal-content components, reading whatever explicit density the nearest `Table`/
259
+ `DataTable` ancestor established (via an internal, non-public context-only boundary — `Table` can't
260
+ use `DensityProvider` itself, since its wrapping `<div>` cannot be injected inside `<table>` markup).
261
+ `TableDensityContext` is still exported (its type and default are unchanged, so nothing to migrate
262
+ at the type level) but is now a **deprecated, inert** compatibility export: `Table`/`DataTable` no
263
+ longer provide a value into it, `TableHead`/`TableCell` no longer read it, and wrapping anything in
264
+ a raw `<TableDensityContext.Provider>` no longer has any effect on rendered geometry. Migrate to the
265
+ `density` prop on `Table`/`DataTable`/`TableHead`/`TableCell`, or wrap a subtree in
266
+ `DensityProvider`. No real consumer of `TableDensityContext` was found in this package, the already-
267
+ in-scope My Account app, or root-config (checked read-only) — if one exists elsewhere, it needs its
268
+ own separate, bounded migration.
269
+
270
+ **`Select`'s density bindings — `SelectItem` and `SelectContent` only.** `SelectItem`'s vertical
271
+ padding (`select.item-padding-y`, 4/6/10px) and left padding (`select.item-padding-left`,
272
+ 4/8/12px; right padding stays a fixed 32px at every tier — it reserves the check-indicator's
273
+ space) and `SelectContent`'s popup padding (`select.content-padding`, 4/8/12px) and gap
274
+ (`select.content-gap`, 2/4/6px) now inherit from `[data-density]`. `SelectTrigger` (standalone or
275
+ Field-wrapped) is **unchanged** — still intrinsic sizing at every tier, per `DENSITY-SPEC.md` §2.3;
276
+ this pass did not invent new trigger geometry. `SelectContent`'s slide-in animation offsets reuse
277
+ the same plain (non-density) primitive `SelectContent`'s padding used before this binding —
278
+ deliberately left alone, since they are not in the approved matrix.
279
+
280
+ ---
281
+
282
+ ## 4. Components: the rules that fail silently
283
+
284
+ Every one of these has already cost this library a real bug, and none is caught by a build, a
285
+ typecheck or a passing test suite.
286
+
287
+ 1. **`&` must be the first token of a selector.** `"[data-open] &"` compiles to
288
+ `.hash[data-open] .hash` and can never match. Fifteen dead declarations were live when a gate
289
+ first caught it. Drive ancestor state through props instead.
290
+ 2. **Declaration order inside a slot is the precedence rule**, not specificity and not Tailwind's
291
+ ordering. Transcribing a v1 class order inverts results — 17% and 44% pixel diffs on Checkbox and
292
+ Radio. The state that must win goes **last**.
293
+ 3. **No CSS shorthands.** `padding: "8px"` paints nothing and breaks atom dedup; Griffel only warns,
294
+ at runtime, in dev. Use the re-exported `shorthands` — `import { shorthands } from
295
+ "@epilot/volt-ui-experimental-griffel"`. Do not add a direct `@griffel/core` dependency: that is
296
+ a second engine copy in your graph.
297
+ 4. **Animations use the object form**, `animationName: { from, to }`. Keyframe names are
298
+ content-hashed but not salted, so a hand-written global `@keyframes` collides silently between
299
+ co-resident copies.
300
+ 5. **No reset ships.** `box-sizing: border-box` exists in the portal only because root-config
301
+ imports Tailwind's preflight. Set it on your own root **and** on descendants
302
+ (`"& *": { boxSizing: "border-box" }`). Note it cannot reach portalled package components, which
303
+ are exactly the ones with padded internals.
304
+
305
+ **A typecheck catches less of this than it looks.** Before 0.2.1 the declarations imported types
306
+ from a package consumers do not install, so every style object was silently `any`. That is fixed and
307
+ `GriffelStyle` is real now — but measured against a real consumer's config, it catches invalid
308
+ keyword **values** (`borderTopStyle: "wobbly"`) and little else. It cannot catch rule 3: csstype
309
+ considers `padding` a perfectly ordinary property, and the value type is `string | 0`, so
310
+ `width: 0` is legal by construction. So the two rules that fail silently at runtime are also the two
311
+ a typecheck will not save you from. Both pilots ended up writing a small static gate for exactly
312
+ these, and that is the right instinct.
313
+
314
+ Dev-only noise to ignore: one `classNameHashSalt` console error per `makeStyles` call site. It is a
315
+ false positive, documented in the README, absent in production.
316
+
317
+ ---
318
+
319
+ ## 5. The host cascade is the adversary
320
+
321
+ Nothing in this package can beat a consumer's own stylesheet, and this is where flag-gated
322
+ migrations actually break.
323
+
324
+ **An ID-scoped Tailwind preflight outranks every Griffel atom.** `#mfe-root button { … }` carries
325
+ specificity (1,0,1); a Griffel atom is (0,1,0). `:where()` and `@layer` both lose to a bare element
326
+ reset, and an ID-scoped one is stronger still. The variable-builder's `variable-builder.css` nested
327
+ `@tailwind base` under an ID selector; the fix was moving that import out of the shared router and
328
+ into the legacy page chunks, so it loads with the legacy tree and never on the new path. Two lines,
329
+ and flag-off is unchanged.
330
+
331
+ **Global `!important` blocks keep applying.** The scheduler's `root.module.scss` ends ~90 lines of
332
+ `!important` neutralising the portal's MUI globals, including a `:global([data-slot="popover-content"])`
333
+ block that hits every popover in the document. It does not stop applying because your subtree
334
+ changed. Two consequences: your new tree has to survive it without answering in kind, and a focus
335
+ ring built from `boxShadow` is **erased** by `box-shadow: none !important` — under `:focus-visible`
336
+ too, so keyboard users get no indicator at all. Use `outline` longhands; that reset does not touch
337
+ them. Measured at 6.44:1 after the fix.
338
+
339
+ **Verify the insertion anchor early.** Griffel's whole override model rests on its sheet being first
340
+ in `<head>`, and the portal injects a lot. Call `assertAnchorIsFirstInHead()` at runtime and expect
341
+ `{ ok: true }`. It held 10/10 rounds in the real portal for flows, but it is the highest-risk
342
+ unknown in any new host.
343
+
344
+ ---
345
+
346
+ ## 6. Verifying, in the order that pays
347
+
348
+ **Cheapest first, and know what each one cannot see.**
349
+
350
+ ```
351
+ tsc + lint + build resolution, and invalid keyword values. Not shorthands, not `0`.
352
+ migration-sweep catches the three defects nothing else can. Seconds.
353
+ unit tests catch logic. They cannot see any of section 4.
354
+ a browser the only thing that can see sections 3, 4 and 5.
355
+ ```
356
+
357
+ **Run the sweep against your new tree**, not the repo:
358
+
359
+ ```sh
360
+ node node_modules/@epilot/volt-ui-experimental-griffel/scripts/migration-sweep.mjs \
361
+ src/components/<name>-griffel src/surface
362
+ ```
363
+
364
+ It finds orphaned `volt-`-prefixed classes, `var(--volt-*)` reads and unloaded token sheets — three
365
+ things that all still _work today_ because v1's singleton is loaded at the portal root and is quietly
366
+ supplying what you forgot to bring. They fail on the day that singleton retires, in several MFEs at
367
+ once, with no error. All four earlier migrations shipped at least one. Pointed at a repo root during
368
+ a flag-gated migration it will also report the legacy tree, which is correct and useless — scope it.
369
+
370
+ **The suite is not your regression net.** The scheduler had **zero test files** with
371
+ `passWithNoTests: true`, so a green vitest run said nothing at all. Check what your suite actually
372
+ covers before citing it. A feature walkthrough of the old UI against the new one is what catches
373
+ regressions here.
374
+
375
+ **In the browser, check both flag states.** Flag off: the legacy tree renders, `document.head`
376
+ carries zero Griffel sheets, and the gated chunk is never requested. Flag on: the chunk loads, tokens
377
+ resolve to the treatment cell you expect, and portalled content is not painted in a different
378
+ treatment from the tree that opened it. Capture at 1280×720 at `deviceScaleFactor: 1`, the platform
379
+ minimum.
380
+
381
+ ---
382
+
383
+ ## What not to spend time on
384
+
385
+ - **Don't fix pre-existing bugs.** Establish the baseline first — error counts, warning counts, test
386
+ counts — and hold your change to "no worse". The scheduler recorded two pre-existing lint warnings
387
+ and left them; that is the right instinct.
388
+ - **Don't port a v1 defect faithfully when it makes something non-functional**, but do check which
389
+ v1 you are comparing against. The scheduler filed the time picker's missing column labels as a
390
+ package regression; v1 had in fact deleted them itself in June, and the repo's v1 was four minor
391
+ versions ahead of the copy that repo had installed (1.7.0 against 1.3.14). Read the version, not the vibe.
392
+ - **Don't migrate a table to `DataTable` because it exists.** For a two-to-four-row price-tier table
393
+ it adds a TanStack column model and a context provider to render what `<table>` renders. Both
394
+ pilots kept the hand-built one and recorded the choice.
395
+ - **Don't reach for `Card` inside a role recipe.** It is already an opinionated surface, so you get
396
+ two borders and two shadows. The role recipe _is_ the card.
397
+ - **Don't trust a delegated report.** Grep the artifact. Every claim in this file that survived came
398
+ from reading a built bundle, a computed style or a pixel; several that did not survive came from
399
+ reading source and sounding right.
400
+
401
+ ---
402
+
403
+ ## Owe it back
404
+
405
+ Keep two files in your tree as you go, because both pilots found them worth more than the commit
406
+ messages:
407
+
408
+ - **`LIBRARY.md`** — what you had to declare locally because the grammar does not express it, with
409
+ the literal values, so promoting it later is a move rather than a rediscovery.
410
+ - **`DIVERGENCES.md`** — where you departed from the design, and why. A static mock has no data
411
+ model, no interaction states and no error states; the departures are the interesting part of the
412
+ work, not an embarrassment to bury.
413
+
414
+ Anything that reads as a package defect goes to the package, not into a local workaround that the
415
+ next MFE will write again. Every improvement in 0.2.1 through 0.2.3 came from a pilot doing exactly
416
+ that.
package/README.md CHANGED
@@ -140,12 +140,6 @@ fails on the insertion-point count and `gate:class-salt-skew` fails on atom-name
140
140
  `Collapsible` has no equivalent here, and neither does v1 — depend on `@radix-ui/react-collapsible`
141
141
  directly, as both pilot MFEs do.
142
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.
148
-
149
143
  There is no `--volt-*` compatibility layer. Consumer CSS reading v1's variable names by hand needs
150
144
  migrating to this package's exported token bindings — 217 references across six MFEs, two thirds of
151
145
  them in `entity`.
@@ -188,12 +182,10 @@ keeps its mode inside a dark page.
188
182
 
189
183
  ## Testing
190
184
 
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.
185
+ The package is ESM-only. Jest 26 and 27 ignore the `exports` field, so they resolve through `main`,
186
+ which points at the same ESM entry.
195
187
 
196
- What they still need is a transform, because the file is ESM and Jest's default
188
+ What they need is a transform, because that file is ESM and Jest's default
197
189
  `transformIgnorePatterns` skips everything under `node_modules`. Take it from here:
198
190
 
199
191
  ```js
@@ -226,26 +218,35 @@ Every check below has a `GATE_NEGATIVE_CONTROL` mode that has been demonstrated
226
218
  property it checks breaks, and to trip only the checks it should. `bun run gates` runs them
227
219
  fail-fast. A green gate is evidence for that gate's own assertions and nothing wider:
228
220
 
229
- | check | what it proves |
230
- | ------------------------------- | ------------------------------------------------------------------------------------------------- |
231
- | `bun run gate:vr` | 270 specimens rendered under v1 and v2 and diffed pixel by pixel |
232
- | `bun run gate:isolation` | two copies mount separately, emit no global selectors, share one insertion point and one renderer |
233
- | `bun run gate:token-value-skew` | two builds differing only in token VALUES paint independently |
234
- | `bun run gate:class-salt-skew` | two builds differing only in CLASS SALT keep disjoint atom names |
235
- | `bun run gate:cascade` | a consumer utility can still override a component, in a real browser |
236
- | `bun run gate:griffel-contract` | `insertionCache` still dedups, and every copy resolves the same renderer |
237
- | `bunx vitest run` | 493 tests, plus 497 under React 18 |
238
-
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.
221
+ | check | what it proves |
222
+ | --------------------------------- | ------------------------------------------------------------------------------------------------- |
223
+ | `bun run gate:vr` | 270 specimens rendered under v1 and v2 and diffed pixel by pixel |
224
+ | `bun run gate:isolation` | two copies mount separately, emit no global selectors, share one insertion point and one renderer |
225
+ | `bun run gate:token-value-skew` | two builds differing only in token VALUES paint independently |
226
+ | `bun run gate:class-salt-skew` | two builds differing only in CLASS SALT keep disjoint atom names |
227
+ | `bun run gate:cascade` | a consumer utility can still override a component, in a real browser |
228
+ | `bun run gate:griffel-contract` | `insertionCache` still dedups, and every copy resolves the same renderer |
229
+ | `bun run gate:dts-self-contained` | the published declarations resolve with nothing installed but this package and React |
230
+ | `bunx vitest run` | 494 tests, plus 495 under React 18 |
231
+
232
+ `gate:one-bare-specifier` exists but is **not** in `bun run gates`, and fails on purpose: it asserts
233
+ that `dist` externalises the engine, and this release bundles it. Externalising is deferred, so the
234
+ gate returns to the aggregate on the day that lands.
235
+
236
+ **Migrating a micro-frontend onto this package? Read `ADOPTING.md` first.** It is the procedure,
237
+ ordered so nothing gets written twice, with each pitfall attached to the step where it bites. Three
238
+ pilot migrations paid for it separately.
239
+
240
+ `ADOPTING.md` ships beside this file and is the only other document a consumer needs.
241
+
242
+ The rest govern editing the package itself, so they stay in the repository rather than in your
243
+ `node_modules`, on branch
244
+ [`experiment/griffel`](https://gitlab.com/e-pilot/product/frontend/volt-ui/-/tree/experiment/griffel).
245
+ `AGENTS.md` is the normative rule set for changing a component and `AUTHORING.md` gives the defect
246
+ behind each rule; both are about authoring this package, which is why they are not in the tarball.
247
+ `DECISIONS.md` at the repository root holds the architecture rationale, and `STATUS.md` and
248
+ `MEASUREMENT.md` are dated snapshots of measurement and provenance — more current than this file on
249
+ numbers, never authoritative on rules.
249
250
 
250
251
  ## Releasing
251
252