@charcuterie/ui 2.10.0 → 2.10.2

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.
package/README.md CHANGED
@@ -172,11 +172,16 @@ rename breaks only the rendered page).
172
172
  widths, via `ContainerBoard`), `Interactive` (the complete keyboard path).
173
173
  4. Export from `src/index.ts` — the one sanctioned barrel. Components import each other
174
174
  directly, never through it.
175
- 5. **If it is an overlay, use the platform's top layer — never a portal.** `<dialog>` +
176
- `showModal()`, or `popover="manual"`. Both stay where they are in the DOM, so
177
- `canvas.getByRole(…)` still finds the panel; a portal moves it to `document.body`, out
178
- of the element a test or an agent scoped its queries to
179
- ([decision](../../docs/decisions/2026-07-30-overlays-use-the-top-layer-not-a-portal.md)).
175
+ 5. **If it is an overlay, portal it to the body** — `FloatingPortal`, through
176
+ `OverlayPanel`, never a hand-rolled `<dialog>` + `showModal()` or `popover="manual"`. The
177
+ top layer wins paint order but is still laid out in place, so a `transform` or
178
+ `overflow: hidden` ancestor still clips a fixed panel; a portal to `document.body` has no
179
+ ancestor to be clipped by
180
+ ([decision](../../docs/decisions/2026-08-03-overlays-portal-to-the-body-not-the-top-layer.md),
181
+ superseding [M4's](../../docs/decisions/2026-07-30-overlays-use-the-top-layer-not-a-portal.md)).
182
+ The old objection stands answered rather than waved: `useRole` writes
183
+ `aria-controls`/`aria-labelledby` across the boundary, and the stories scope panel queries
184
+ to `body`.
180
185
  6. **If it is built on a registering kind, look at its first paint.** Members register
181
186
  from effects, so before those run a `RovingFocus` has no active value and a
182
187
  `SinglePicker` / `VisibilityGroup` has no selected key — only a pending one. Two of M4's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@charcuterie/ui",
3
- "version": "2.10.0",
3
+ "version": "2.10.2",
4
4
  "description": "The Charcuterie components. Tokens for look, @charcuterie/logic for state, ARIA that agents can actually drive.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -49,7 +49,7 @@
49
49
  },
50
50
  "dependencies": {
51
51
  "@charcuterie/logic": "1.2.0",
52
- "@charcuterie/tokens": "1.3.0",
52
+ "@charcuterie/tokens": "1.5.0",
53
53
  "@floating-ui/react": "^0.27.20",
54
54
  "@tanstack/react-virtual": "^3.13.12"
55
55
  },
@@ -7,7 +7,7 @@ import { mountStory } from "../mountStory.testHelpers.ts"
7
7
  import { expectAgentDrivable } from "../testing/index.ts"
8
8
  import * as stories from "./Listbox.stories.tsx"
9
9
 
10
- const { AllStates, Default, Interactive } =
10
+ const { AllStates, AllVariants, Default, Interactive } =
11
11
  composeStories(stories)
12
12
 
13
13
  test("a button opens a listbox whose rows are options", async () => {
@@ -196,3 +196,58 @@ test("Escape dismisses without choosing, and there is one tab stop", async () =>
196
196
  role: "button",
197
197
  })
198
198
  })
199
+
200
+ /**
201
+ * The rich-option story is the reason `Listbox` exists — a native
202
+ * `<option>` cannot hold a second line — and it was the one story
203
+ * `Listbox.test.tsx` never mounted. So the `content.muted` detail
204
+ * line inside a row had **no** axe coverage in any state, let alone
205
+ * the highlighted one, while it sat at 4.11:1 on
206
+ * `intent.neutral.surfaceHover` in the fleet's default variant.
207
+ *
208
+ * This is the same shape as the `ButtonLink` miss: there,
209
+ * `AllVariants` *was* audited but forced no `:hover`, so the failing
210
+ * hex passed locally and only the token gate caught it. Auditing the
211
+ * resting row would repeat that mistake exactly, so the row is
212
+ * driven to its highlighted state and the fill is **asserted** —
213
+ * without that assertion a silently-resting row would make this test
214
+ * pass for the wrong reason.
215
+ */
216
+ test("a highlighted rich option row has no contrast violation", async () => {
217
+ const { body, canvas } = await mountStory(AllVariants)
218
+
219
+ await userEvent.click(
220
+ expectAgentDrivable(canvas, {
221
+ name: "Choose a track",
222
+ role: "button",
223
+ }),
224
+ )
225
+
226
+ const listbox = expectAgentDrivable(body, {
227
+ role: "listbox",
228
+ })
229
+
230
+ // Opening focuses the first option, and `ListboxOption` fills the
231
+ // focused row with `focus:bg-intent-neutral-surface-hover`. Prove
232
+ // the row really is painted before trusting what axe says about it.
233
+ const highlighted = body.getAllByRole("option")[0]
234
+
235
+ await waitFor(() => {
236
+ expect(document.activeElement).toBe(highlighted)
237
+ })
238
+
239
+ const rowFill =
240
+ getComputedStyle(highlighted).backgroundColor
241
+ const panelFill =
242
+ getComputedStyle(listbox).backgroundColor
243
+
244
+ expect(rowFill).not.toBe("rgba(0, 0, 0, 0)")
245
+ expect(rowFill).not.toBe(panelFill)
246
+
247
+ // The detail line is the `content.muted` half of the pair.
248
+ expect(
249
+ highlighted.querySelector(".text-content-muted"),
250
+ ).not.toBeNull()
251
+
252
+ await expectNoAxeViolations(listbox)
253
+ })