cssints 0.27.0 → 0.29.0

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 (2) hide show
  1. package/docs/API.md +1689 -1
  2. package/package.json +2 -2
package/docs/API.md CHANGED
@@ -1792,6 +1792,7 @@ createPanZoom(options?: {
1792
1792
  zoomIn(): void;
1793
1793
  zoomOut(): void;
1794
1794
  setScale(scale: number, origin?: { x: number; y: number }): void; // origin in the viewport's own space, its centre by default
1795
+ panBy(dx: number, dy: number): void; // a relative pan through the same clamp as a drag; a host's glide uses this
1795
1796
  viewportProps: {
1796
1797
  tabindex: 0;
1797
1798
  role: "group";
@@ -1814,7 +1815,7 @@ createPanZoom(options?: {
1814
1815
 
1815
1816
  Wheel zooms **only with `ctrl` / `meta`** held: the browser's pinch gesture arrives that way, and a bare wheel is left to the page so trackpad scrolling still works — a swallowed wheel is never acceptable. Arrows pan the image with the arrow (like a drag), and with `bounds: "clamp"` they are inert at `min`, where there is nothing to reveal. The clamp bounds the offset by half the viewport's scaled overflow, so the image is expected to fill the viewport at `min` (`width: 100%; height: 100%` / `object-fit: cover`); a letterboxed image cannot be held gap-free, and `bounds: "free"` lets it be dragged out of view instead. `valueProps` carries no `hidden` — a `hidden` live region is not announced — so the host hides it visually.
1816
1817
 
1817
- The host owns the box and the look: the viewport's size and `overflow: hidden`, `touch-action: none` if a touch drag should pan, `transform-origin: 50% 50%` (the default this maths assumes), and any transition on the transform. Controlled is `scale` + `onScaleChange`; the offset has no controlled prop, so a host that ignores a scale request also gets the offset resolved for the scale it refused. Not modelled: inertia, rotation, and pinching on platforms without `PointerEvent` (the browser's own pinch is the `ctrl+wheel` path).
1818
+ The host owns the box and the look: the viewport's size and `overflow: hidden`, `touch-action: none` if a touch drag should pan, `transform-origin: 50% 50%` (the default this maths assumes), and any transition on the transform. Controlled is `scale` + `onScaleChange`; the offset has no controlled prop, so a host that ignores a scale request also gets the offset resolved for the scale it refused. A host that moves the pan without a pointer calls `panBy(dx, dy)`: it takes the same bounded `move` as a drag, so the clamp and `onPanChange` hold for an inertial glide and a drag alike (the kit's `Panzoom` glides after a flick this way). Not modelled: rotation, and pinching on platforms without `PointerEvent` (the browser's own pinch is the `ctrl+wheel` path).
1818
1819
 
1819
1820
  ### Headless: `createMenu` (`@cssints/headless`)
1820
1821
 
@@ -2291,6 +2292,1693 @@ createRearrangeableListbox(options: {
2291
2292
  - **The primitive owns the ARIA of a listbox, not the position or the drag.** It writes `role`, `tabindex`, `aria-multiselectable`, `aria-activedescendant`, `aria-selected` and the handlers; it does not write `aria-posinset` / `aria-setsize` (a listbox's order is implicit), and it has no pointer drag. A host that wants a drag translates it to `move`; the `reorder` starter in `apps/ui` does exactly that, and adds the position attributes itself.
2292
2293
  - **Both lists or one.** A host renders `listProps("a")` and `listProps("b")` for the full pattern, or the first alone for a reorderable list. With one list, Enter and Delete have nowhere to transfer, so the host ignores them.
2293
2294
 
2295
+ ### Headless: `createDisclosure` (`@cssints/headless`)
2296
+
2297
+ The APG Disclosure: one trigger that shows and hides one panel. Its behaviour is
2298
+ `packages/apg-specs/specs/apg/disclosure.qnt`, which
2299
+ `packages/headless/test/disclosure.browser.test.tsx` holds it to by replaying the ported scenarios of the APG,
2300
+ ariakit, base-ui and React Aria (`packages/apg-specs/scenarios/apg/disclosure.*.json`) and by a seeded
2301
+ differential walk in seven configurations: uncontrolled, expanded, disabled, and a controlled host that
2302
+ accepts or holds a request. The host renders a `<button>` and a panel and spreads the two prop objects; the
2303
+ primitive owns the state and the attributes, not the elements or the look. Options are read lazily, so pass a
2304
+ component's `props` or getters, never destructured values.
2305
+
2306
+ `defaultValue` starts the uncontrolled state (`false`), and `value` + `onValueChange` make the host own it:
2307
+ every intent is a request, so a refused one leaves the state untouched. `setExpanded`, `expand`, `collapse`
2308
+ and `toggle` are requests too.
2309
+
2310
+ ```ts skip
2311
+ createDisclosure(options?: {
2312
+ value?: boolean | undefined; // controlled; undefined = uncontrolled
2313
+ defaultValue?: boolean; // false: collapsed at mount
2314
+ disabled?: boolean; // the trigger's native attribute, and its click is dropped
2315
+ id?: string; // base of `<id>-trigger` and `<id>-panel`; generated when absent
2316
+ onValueChange?(expanded: boolean): void; // a request the host may ignore
2317
+ }): {
2318
+ expanded(): boolean;
2319
+ setExpanded(expanded: boolean): void; // a request, like expand / collapse / toggle
2320
+ expand(): void;
2321
+ collapse(): void;
2322
+ toggle(): void;
2323
+ triggerProps: {
2324
+ id; // `<id>-trigger`
2325
+ type: "button";
2326
+ "aria-expanded"; // "true" | "false", from the state
2327
+ "aria-controls"; // `<id>-panel`, always present
2328
+ disabled; // the option, read on every read
2329
+ onClick;
2330
+ };
2331
+ panelProps: {
2332
+ id; // `<id>-panel`
2333
+ hidden; // true while collapsed
2334
+ };
2335
+ };
2336
+ ```
2337
+
2338
+ - **The panel stays mounted.** `panelProps.hidden` is the whole hide, so `aria-controls` always names a real
2339
+ element and a closing animation has something to animate. Base UI unmounts the closed panel instead, and
2340
+ that missing `aria-controls` while collapsed is a named divergence. So are React Aria's `aria-hidden`
2341
+ beside `hidden` (which already removes the panel from the accessibility tree) and its `role="group"` on
2342
+ the panel.
2343
+ - **The pattern's attributes only.** React Aria forwards an attribute the consumer supplied; this primitive
2344
+ returns only the pattern's, so the host adds its own (an `aria-label`, a `class`).
2345
+ - **Ids.** `id` is the base of `<id>-trigger` and `<id>-panel`; a page with two disclosures passes its own base
2346
+ to each, since one base names one pair.
2347
+
2348
+ No key is bound: the trigger is a native `<button>`, so Enter and Space arrive as its `click`, and the host
2349
+ sees one request path for pointer and keyboard. `disabled` is the trigger's native attribute, so the browser
2350
+ skips it in the tab order and drops its click; the primitive guards its `onClick` as well.
2351
+
2352
+ ### Headless: `createAccordion` (`@cssints/headless`)
2353
+
2354
+ The APG Accordion: N headings, each a button that shows and hides its region, under the mode constraints of
2355
+ `packages/apg-specs/specs/apg/accordion.qnt`. `packages/headless/test/accordion.browser.test.tsx` replays the
2356
+ ported base-ui and React Aria scenarios and walks the model in eleven configurations (uncontrolled, expanded,
2357
+ multiple, non-collapsible, and a controlled host that accepts or holds a request). `items` is the number of
2358
+ panels (3, at least one), and the host renders a heading, a button and a panel per index, spreading
2359
+ `headingProps`, `triggerProps` and `panelProps`. Options are read lazily; `defaultValue` is the uncontrolled
2360
+ expanded set and `value` + `onValueChange` make the host own it.
2361
+
2362
+ ```ts skip
2363
+ createAccordion(options?: {
2364
+ items?: number; // 3; at least 1
2365
+ value?: ReadonlyArray<number> | undefined; // controlled: the expanded indices
2366
+ defaultValue?: ReadonlyArray<number>; // []: all collapsed
2367
+ multiple?: boolean; // false: at most one panel expanded
2368
+ collapsible?: boolean; // true: the last expanded panel may collapse
2369
+ headingLevel?: number; // 3, clamped into 1..6
2370
+ disabled?: boolean; // every trigger
2371
+ disabledItems?: ReadonlyArray<number>; // the named triggers only
2372
+ id?: string; // base of `<id>-trigger-<i>`, `<id>-panel-<i>`, `<id>-heading-<i>`
2373
+ onValueChange?(expanded: ReadonlyArray<number>): void; // a request the host may ignore
2374
+ }): {
2375
+ items: number;
2376
+ expanded(): ReadonlyArray<boolean>; // one flag per panel
2377
+ isExpanded(index: number): boolean;
2378
+ toggle(index: number): void; // a request; a disabled index changes nothing
2379
+ setMultiple(value: boolean): void; // re-normalizes the uncontrolled set only
2380
+ setCollapsible(value: boolean): void; // likewise
2381
+ setHeadingLevel(level: number): void; // clamped into 1..6
2382
+ triggerProps(index: number): {
2383
+ id;
2384
+ type: "button";
2385
+ "aria-expanded";
2386
+ "aria-controls"; // the panel's id
2387
+ "aria-disabled": "true" | undefined; // the sole expanded trigger when it cannot collapse
2388
+ disabled; // `disabled` or `disabledItems`
2389
+ onClick;
2390
+ };
2391
+ panelProps(index: number): {
2392
+ id;
2393
+ role: "region";
2394
+ "aria-labelledby"; // the trigger's id
2395
+ tabindex: 0;
2396
+ hidden;
2397
+ };
2398
+ headingProps(index: number): { id; role: "heading"; "aria-level": number };
2399
+ };
2400
+ ```
2401
+
2402
+ - **The mode constraints are the spec's `normExpanded`.** `multiple: false` keeps only the first expanded
2403
+ index, and `collapsible: false` re-expands the first panel when the set would be empty. A controlled host
2404
+ owns the set verbatim, so `setMultiple` / `setCollapsible` change their flag everywhere but re-normalize
2405
+ only while uncontrolled.
2406
+ - **The panel that cannot collapse says so.** While `collapsible` is false and this trigger is the only
2407
+ expanded one, `toggle` refuses it and `triggerProps` announces `aria-disabled="true"`; it stays focusable
2408
+ (the native `disabled` comes only from `disabled` / `disabledItems`), so a keyboard user meets the same
2409
+ refusal as a pointer.
2410
+ - **The panel is a region and a tab stop.** `role="region"`, named by its trigger through `aria-labelledby`,
2411
+ with `tabindex=0` whatever it holds and `hidden` while collapsed.
2412
+ - **`headingProps` is the wrapper, not a level of markup.** It carries `role="heading"` and the clamped
2413
+ `aria-level`, so the host spreads it on the element it would have used anyway (`<h3>`, a `<div>`).
2414
+ - **Two divergences from React Aria:** panels are keyed by index where React Aria keys them by item text, and
2415
+ `onValueChange` reports the whole expanded index set (the base-ui model) where React Aria reports one
2416
+ boolean per change.
2417
+
2418
+ No key is bound beyond the native button's: Enter and Space on a focused trigger arrive as its `click`, and
2419
+ Tab walks the triggers and the open panels (no roving tabindex, as the pattern's keyboard section has it).
2420
+
2421
+ ### Headless: `createSwitch` (`@cssints/headless`)
2422
+
2423
+ The APG Switch: one binary on/off control whose label never changes with its state. Its behaviour is
2424
+ `packages/apg-specs/specs/apg/switch.qnt`, replayed by
2425
+ `packages/headless/test/switch.browser.test.tsx` against the ported base-ui and React Aria scenarios and a
2426
+ differential walk in nine configurations (unchecked, checked, disabled, the optional Enter key, and a
2427
+ controlled host that accepts or holds a request). The host spreads `switchProps` onto the focusable element;
2428
+ `defaultValue` is the uncontrolled state (`false`) and `value` + `onValueChange` make the host own it.
2429
+
2430
+ The platform behind the pattern is the native `<input type="checkbox" switch>` as progressive enhancement
2431
+ (Safari only), with a checkbox carrying `role="switch"` as the safe baseline. This primitive returns that
2432
+ baseline's contract (`role="switch"`, `aria-checked`, `tabindex=0`, the handlers) and no element.
2433
+
2434
+ ```ts skip
2435
+ createSwitch(options?: {
2436
+ value?: boolean | undefined; // controlled; undefined = uncontrolled
2437
+ defaultValue?: boolean; // false: off at mount
2438
+ disabled?: boolean; // aria-disabled; every intent is dropped
2439
+ readOnly?: boolean; // aria-readonly; focus works, every intent is dropped
2440
+ enterToggles?: boolean; // false: the pattern's optional Enter key
2441
+ onValueChange?(checked: boolean): void; // a request the host may ignore
2442
+ }): {
2443
+ checked(): boolean;
2444
+ disabled(): boolean;
2445
+ readOnly(): boolean;
2446
+ enterToggles(): boolean;
2447
+ focused(): boolean;
2448
+ setChecked(checked: boolean): void; // a request
2449
+ setDisabled(value: boolean): void; // also disarms a held Space
2450
+ setEnterToggles(value: boolean): void;
2451
+ toggle(): void; // a request
2452
+ switchProps: {
2453
+ role: "switch";
2454
+ "aria-checked": "true" | "false";
2455
+ "aria-disabled": "true" | undefined;
2456
+ "aria-readonly": "true" | undefined;
2457
+ tabindex: 0;
2458
+ onKeyDown;
2459
+ onKeyUp;
2460
+ onClick;
2461
+ onFocus;
2462
+ onBlur;
2463
+ };
2464
+ };
2465
+ ```
2466
+
2467
+ - **Space is armed, then released.** `onKeyDown` arms on Space and `preventDefault`s the page scroll;
2468
+ `onKeyUp` flips. A repeated keydown does not re-arm, and a blur disarms. Enter flips on keydown only while
2469
+ `enterToggles` is on.
2470
+ - **`disabled` and `readOnly` block the same intents and announce differently.** Both drop every key, click
2471
+ and `toggle`, but `disabled` says `aria-disabled` and `readOnly` says `aria-readonly`. Only `disabled` has a
2472
+ setter; a host that flips `readOnly` flips the prop, which is read on every read.
2473
+ - **`focused()` is the primitive's own signal.** Focus sets it, a click sets it (a click focuses the control
2474
+ in a browser) and blur clears it; the primitive never moves the DOM focus.
2475
+ - **Two divergences:** React Aria renders a native input and carries the HTML `checked` / `disabled`
2476
+ attributes, where this primitive exposes `role="switch"` + `aria-checked`; base-ui reports its clicks to
2477
+ ancestor elements, where this primitive owns only the switch's callbacks.
2478
+
2479
+ ### Headless: `createRadioGroup` (`@cssints/headless`)
2480
+
2481
+ The APG Radio Group: one `radiogroup` with `role="radio"` items, where selection follows the focus. Its
2482
+ behaviour is `packages/apg-specs/specs/apg/radio.qnt`, replayed by
2483
+ `packages/headless/test/radio.browser.test.tsx` against the ported ariakit, base-ui and React Aria scenarios
2484
+ and a differential walk in six configurations (unchecked, checked, disabled, `activedescendant`, and a
2485
+ controlled host that accepts or holds a request). `values` is the option list (a getter is fine, so a list
2486
+ that arrives later is still one widget), `defaultValue` is the uncontrolled check and `value` +
2487
+ `onValueChange` make the host own it.
2488
+
2489
+ `value` may be a getter, and then `undefined` means "nothing checked" as a controlled state: it is the bare
2490
+ `undefined` prop that says uncontrolled.
2491
+
2492
+ ```ts skip
2493
+ createRadioGroup<T>(options: {
2494
+ values: ReadonlyArray<T> | (() => ReadonlyArray<T>); // one per radio
2495
+ value?: T | (() => T | undefined) | undefined; // controlled; bare undefined = uncontrolled
2496
+ defaultValue?: T; // nothing checked
2497
+ flavor?: "roving" | "activedescendant"; // "roving"
2498
+ disabled?: ReadonlyArray<number>; // indices never focused, checked or picked as the tab stop
2499
+ orientation?: "horizontal" | "vertical"; // sets aria-orientation only
2500
+ dir?: "ltr" | "rtl"; // "ltr": under rtl the horizontal arrows swap
2501
+ id?: string; // base of `<id>-radio-<i>`
2502
+ describedby?: string | undefined; // aria-describedby on every radio
2503
+ onValueChange?(value: T): void; // a request the host may ignore
2504
+ onFocus?(target: string): void; // "radio:<index>"
2505
+ onBlur?(target: string): void;
2506
+ }): {
2507
+ checked(): number | null; // the index, null when nothing is checked
2508
+ focused(): number | null;
2509
+ flavor(): RadioFlavor;
2510
+ values: ReadonlyArray<T>;
2511
+ value(): T | null;
2512
+ focus(): void;
2513
+ blur(): void;
2514
+ keyDown(key: string): void;
2515
+ click(index: number): void;
2516
+ setDisabled(indices: ReadonlyArray<number>): void;
2517
+ setFlavor(flavor: RadioFlavor): void;
2518
+ setPreselect(index: number | null): void;
2519
+ radiogroupProps: {
2520
+ role: "radiogroup";
2521
+ "aria-orientation";
2522
+ "aria-activedescendant": string | undefined; // only the activedescendant flavor
2523
+ tabindex: 0 | undefined; // 0 only in the activedescendant flavor
2524
+ onKeyDown;
2525
+ onFocus;
2526
+ onBlur;
2527
+ };
2528
+ radioProps(index: number): {
2529
+ id;
2530
+ role: "radio";
2531
+ "aria-checked": "true" | "false";
2532
+ "aria-disabled": "true" | "false";
2533
+ "aria-describedby";
2534
+ type: "button"; // keeps a surrounding form from submitting
2535
+ tabindex: 0 | -1 | undefined; // roving only: 0 for the tab stop
2536
+ onClick;
2537
+ onFocus;
2538
+ };
2539
+ };
2540
+ ```
2541
+
2542
+ - **Two flavors, one machine.** `"roving"` (the default) keeps the tab stop on the radios: `tabindex=0` on
2543
+ the checked radio, or on the first enabled one while nothing is checked, `-1` on the rest.
2544
+ `"activedescendant"` puts the tab stop on the group (`tabindex=0` there, nothing on the radios) and names
2545
+ the focused radio in `aria-activedescendant`; the host keeps the DOM focus on the group. The primitive
2546
+ moves no DOM focus in either flavor: `focused()` reads synchronously, so the host applies it.
2547
+ - **Selection follows the focus.** An arrow moves to the next enabled radio (wrapping) and requests it, Space
2548
+ requests the focused one, a click focuses and requests, and Tab enters on the tab stop. `setPreselect`
2549
+ starts the check without a request, and `setDisabled` / `setFlavor` reset the focus.
2550
+ - **Config remounts fire no request.** `setDisabled`, `setFlavor` and `setPreselect` write the uncontrolled
2551
+ local directly; with `value` set, what is checked is the prop's, so they only move the focus.
2552
+ - **Two divergences:** React Aria renders native `<input type="radio">` elements and carries the HTML
2553
+ `disabled` attribute; one `onFocus` / `onBlur` reports the radio (`"radio:<index>"`) where the React Aria
2554
+ scenario uses a callback per radio.
2555
+
2556
+ ArrowDown / ArrowRight are forward and ArrowUp / ArrowLeft backward, with `dir: "rtl"` swapping the
2557
+ horizontal pair; both axes work whatever `orientation` says, since that option only sets `aria-orientation`.
2558
+ The arrows wrap past the ends and skip every disabled index; Home / End are not handled, as the pattern has
2559
+ no such key.
2560
+
2561
+ ### Headless: `createTooltipGroup` (`@cssints/headless`)
2562
+
2563
+ The APG Tooltip with WCAG 1.4.13 (dismissable, hoverable, persistent), over the spec's pair of triggers. Its
2564
+ behaviour is `packages/apg-specs/specs/apg/tooltip.qnt`, which
2565
+ `packages/headless/test/tooltip.browser.test.tsx` holds it to by replaying the ported ariakit, base-ui and
2566
+ React Aria scenarios and by a differential walk in four configurations (uncontrolled, controlled accepting,
2567
+ controlled holding, and one holding a tooltip open). Timers are events, not clocks: the spec and the walk
2568
+ fire `showTimer`, `hideTimer` and `warmExpire`, and the host wires its own `setTimeout` from
2569
+ `showDelayMs()` / `hideDelayMs()`, resolved from `prefs` (600 ms and 300 ms by default).
2570
+
2571
+ The visible tooltip is the controlled value: `0` none, `1` the first trigger's, `2` the second's.
2572
+ `defaultOpen` is the uncontrolled index and `onOpenChange(open, index)` is the request. The rest of the
2573
+ machine (focus, pointer, pending timers, the dismissal memory) tracks every event either way and only the
2574
+ commit is gated, so a refused request leaves the visible tooltip where it was.
2575
+
2576
+ ```ts skip
2577
+ createTooltipGroup(options: {
2578
+ triggers: ReadonlyArray<{ id?: string; tooltipId?: string }>; // the spec's pair
2579
+ disabled?: boolean; // focus and pointer are ignored entirely
2580
+ openOnFocus?: boolean; // keyboard focus opens at once (the host applied its delay)
2581
+ openOnHover?: boolean; // hover opens at once (a delay of 0)
2582
+ nativePointer?: boolean; // true: the elements' own pointer events drive the state
2583
+ defaultOpen?: number; // trigger index whose tooltip starts visible
2584
+ value?: number | undefined; // controlled: 0 none, 1 first, 2 second; undefined = uncontrolled
2585
+ onOpenChange?(open: boolean, index: number): void; // a request the host may ignore
2586
+ prefs?: Partial<Prefs> | undefined; // tooltipShowDelayMs / tooltipHideDelayMs
2587
+ }): {
2588
+ visibleIndex(): number | null;
2589
+ showDelayMs(): number;
2590
+ hideDelayMs(): number;
2591
+ described(index: number): boolean; // this trigger names its tooltip now
2592
+ focusTrigger(index: number): void;
2593
+ blur(): void;
2594
+ pointerEnter(index: number): void;
2595
+ pointerEnterTip(): void;
2596
+ pointerLeave(): void; // also calls hideTimer()
2597
+ showTimer(index: number): void; // the host's show setTimeout fired
2598
+ hideTimer(): void; // the grace period expired
2599
+ escape(): void;
2600
+ warmExpire(): void; // closes the warm window
2601
+ setWarmup(on: boolean): void; // enables the spec's warm-up variant
2602
+ hostSet(vis: number): void; // call before a controlled write of `value`
2603
+ hostToggle(): void; // call before a controlled flip between none and the first
2604
+ triggerProps(index: number): {
2605
+ id;
2606
+ "aria-describedby": string | undefined; // the tooltip, only while visible
2607
+ onFocus;
2608
+ onPointerEnter;
2609
+ onPointerLeave;
2610
+ onKeyDown; // Escape
2611
+ };
2612
+ tooltipProps(index: number): {
2613
+ id;
2614
+ role: "tooltip";
2615
+ hidden;
2616
+ onPointerEnter; // hoverable: the pointer may travel onto the tooltip
2617
+ onPointerLeave;
2618
+ };
2619
+ };
2620
+ ```
2621
+
2622
+ - **Ids.** A trigger's `id` and `tooltipId` name it and its tooltip (`<base>-trigger-<i>` and
2623
+ `<base>-tooltip-<i>` when absent); `aria-describedby` names the tooltip only while it is visible, and
2624
+ `described(index)` is that same condition as a boolean.
2625
+ - **The delays belong to the host.** `openOnFocus` / `openOnHover` open with no delay, for a host that has
2626
+ already applied its own; otherwise focus and hover only arm a show timer, and `showTimer(index)` is the
2627
+ host's `setTimeout` firing. A pointer leaving a trigger or the tooltip calls `hideTimer()` — the grace
2628
+ period is the host's too.
2629
+ - **Hoverable is the tooltip's own handlers.** `tooltipProps` carries `onPointerEnter` / `onPointerLeave`, so
2630
+ the pointer can travel onto the tooltip without closing it. `nativePointer: false` stops the rendered
2631
+ elements from driving the state, for a host that feeds `pointerEnter` / `pointerLeave` itself.
2632
+ - **Warm-up.** `setWarmup(true)` enables the spec's variant: while a tooltip is visible, or was hidden within
2633
+ the warm window, a newly entered trigger opens immediately. `warmExpire()` closes that window, and an
2634
+ Escape leaves the widget warm, so the next trigger inside the window opens at once.
2635
+ - **Host writes.** `hostSet(vis)` and `hostToggle()` drop the timers and the dismissal memory a controlled
2636
+ write invalidates, so call one before writing `value`; both do nothing while uncontrolled.
2637
+ - **One divergence:** the base-ui scenario that starts open with `defaultOpen` and then unhovers a trigger
2638
+ never sees a pointer event in the DOM, so the hide grace is never armed there.
2639
+ - **Two triggers.** The spec models a pair: index `0` is the first and `1` the second. There is no `id`
2640
+ option, because each trigger names its own elements.
2641
+
2642
+ Escape on a trigger hides the tooltip (`escape()`) without moving the focus or the pointer, and nothing
2643
+ reopens it until that trigger is left and entered again — WCAG's dismissable part. No other key is bound:
2644
+ focus and hover are the other two ways in, and the pointer handlers above are the hoverable part.
2645
+
2646
+ ### Headless: `createToolbar` (`@cssints/headless`)
2647
+
2648
+ The APG Toolbar: a row or column of controls behind one tab stop, with the roving tabindex of
2649
+ `shared/focusgroup.qnt` and the delta of `packages/apg-specs/specs/apg/toolbar.qnt` (`aria-orientation`, the
2650
+ author's wrap choice, disabled-but-focusable items, embedded controls that consume keys, and activation).
2651
+ `packages/headless/test/toolbar.browser.test.tsx` replays the ported ariakit, base-ui and React Aria
2652
+ scenarios and walks the model horizontally (no wrap) and vertically (wrapping); the fixture composes
2653
+ `createMenu` for the spec's menu-button, which the primitive never imports.
2654
+
2655
+ Platform: the roving tabindex is the `focusgroup="toolbar"` contract, so ship the
2656
+ `@microsoft/focusgroup-polyfill` first and switch to the native attribute when Chrome/Edge is the floor.
2657
+
2658
+ `items` is fixed at construction, as label strings or `{ label, kind, disabled }`, and `kinds` names a kind per
2659
+ index for the string form (`"button"` by default). `defaultFocus` is the index that starts focused, and
2660
+ `focus: -1` means "outside the toolbar".
2661
+
2662
+ ```ts skip
2663
+ createToolbar(options: {
2664
+ items: ReadonlyArray<string | { label: string; kind?: ToolbarItemKind; disabled?: boolean }>;
2665
+ kinds?: ReadonlyArray<"button" | "text" | "spin">; // per index, for the string form
2666
+ orientation?: "horizontal" | "vertical"; // "horizontal"
2667
+ wrap?: boolean; // false: the arrows stop at the ends
2668
+ defaultFocus?: number; // -1: nothing focused yet
2669
+ disabled?: ReadonlyArray<number>; // items disabled at mount, still focusable
2670
+ id?: string; // base of `<id>-item-<i>`
2671
+ onActivate?(index: number): void; // a button item, by click, Enter or Space
2672
+ }): {
2673
+ focus(): number; // -1 when the toolbar is left
2674
+ tabStop(): number;
2675
+ orientation(): "horizontal" | "vertical";
2676
+ wrap(): boolean;
2677
+ items: ReadonlyArray<{ id; label; kind; disabled }>;
2678
+ focusIn(): void; // Tab into the toolbar: the tab stop
2679
+ leave(): void;
2680
+ focusItem(index: number): void;
2681
+ keyDown(key: string): void;
2682
+ tab(back: boolean): void;
2683
+ clickItem(index: number): void;
2684
+ activate(): void; // a button item
2685
+ toggleDisabled(index: number): void;
2686
+ setOrientation(orientation: "horizontal" | "vertical"): void;
2687
+ setWrap(wrap: boolean): void;
2688
+ toolbarProps: {
2689
+ role: "toolbar";
2690
+ "aria-orientation": "horizontal" | "vertical";
2691
+ onKeyDown;
2692
+ };
2693
+ itemProps(index: number): {
2694
+ id;
2695
+ role: "button";
2696
+ "aria-disabled": "true" | undefined;
2697
+ tabindex: 0 | -1; // the roving tab stop
2698
+ onClick;
2699
+ onFocus;
2700
+ };
2701
+ };
2702
+ ```
2703
+
2704
+ - **Disabled but focusable.** A disabled item keeps its place in the arrows and the tab-stop order and
2705
+ announces `aria-disabled="true"`; it is never activated, so `onActivate` does not fire for it.
2706
+ `toggleDisabled(index)` flips the mask at runtime.
2707
+ - **Embedded controls consume their keys.** A `"text"` item swallows the axis navigation keys and a
2708
+ `"spin"` item swallows Up / Down and Home / End, so typing or stepping inside an item never moves the
2709
+ toolbar's focus. The embedded control's own value is not modelled: the host renders it and owns it.
2710
+ - **Divergences.** base-ui and ariakit skip disabled items in roving navigation, where `toolbar.qnt` keeps
2711
+ them focusable; the tab stop starts on item `0` even when that item is disabled, where the abstract
2712
+ Composite picks the first non-disabled item; and the primitive renders `role="toolbar"` with
2713
+ `aria-orientation`, where the abstract Composite role carries neither. A toggle group's `aria-pressed` is
2714
+ not this primitive.
2715
+ - **Limits (the source's own):** no separators, no RTL, and `items` does not grow after construction.
2716
+
2717
+ `Right` / `Left` on a horizontal toolbar and `Down` / `Up` on a vertical one move the roving tab stop along
2718
+ the axis (`ArrowRight` and `Right` are normalised to one key), Home / End jump to the ends, and `wrap`
2719
+ decides whether an edge wraps or stops. Enter and Space activate the focused `button` item, and both are
2720
+ `preventDefault`ed. Tab from a `button` item leaves the toolbar (the browser moves on); from a `text` item it
2721
+ steps to the neighbouring item and only leaves at the edge, which is how Tab escapes a text field inside a
2722
+ composite. `focusIn`, `leave` and `tab(back)` are those steps as methods, and `focusItem`, `clickItem` and
2723
+ `activate` are the pointer and programmatic paths.
2724
+
2725
+ ### Headless: `createTextInput` and `createTextArea` (`@cssints/headless`)
2726
+
2727
+ `createField` plus the props a host forgets: a text input or a textarea with the field's label, hint, error
2728
+ and description contract attached. There is no APG pattern for text editing (an `<input>` already is the
2729
+ pattern), so there is no Quint spec and `packages/headless/test/textbox.browser.test.tsx` is the oracle for
2730
+ both. The field half — ids, `aria-describedby` order, `aria-invalid` only while invalid, `role="alert"` on a
2731
+ shown error — is `createField`'s and is documented above.
2732
+
2733
+ ```ts skip
2734
+ createTextInput(options?: {
2735
+ // the field's options: id, required, invalid, hint, error, describedby
2736
+ type?: "text" | "email" | "tel" | "url" | "search" | "password"; // "text"
2737
+ autoComplete?: AutoCompleteToken; // the HTML token list; the browser's autofill is the accessible path
2738
+ spellCheck?: boolean; // spellcheck, "true" | "false"; absent when the option is
2739
+ autoCapitalize?: "none" | "sentences" | "words" | "characters";
2740
+ inputMode?: "none" | "text" | "tel" | "url" | "email" | "numeric" | "decimal" | "search";
2741
+ maxLength?: number; // the browser's own limit
2742
+ readOnly?: boolean; // focusable, selectable, submitted
2743
+ disabled?: boolean; // out of the tab order, not submitted
2744
+ name?: string;
2745
+ dir?: "ltr" | "rtl" | "auto";
2746
+ placeholder?: string;
2747
+ onValueChange?(value: string): void; // every `input` event
2748
+ }): {
2749
+ // the field's members: invalid(), hint(), error(), describedIds(), labelProps, hintProps, errorProps
2750
+ controlProps: {
2751
+ id; // `<id>-control`, from the field
2752
+ "aria-describedby"; // hint, then error, while shown, then the host's describedby
2753
+ "aria-invalid"; // "true" only while invalid
2754
+ "aria-required"; // "true" while required
2755
+ required; // true while required
2756
+ type;
2757
+ autoComplete;
2758
+ spellcheck: "true" | "false" | undefined;
2759
+ autocapitalize;
2760
+ inputmode;
2761
+ maxlength;
2762
+ readOnly: true | undefined;
2763
+ disabled: true | undefined;
2764
+ name;
2765
+ dir;
2766
+ placeholder;
2767
+ onInput;
2768
+ };
2769
+ };
2770
+ ```
2771
+
2772
+ ```ts skip
2773
+ createTextArea(options?: {
2774
+ // the text input's options and members, plus:
2775
+ rows?: number;
2776
+ wrap?: "soft" | "hard" | "off";
2777
+ resize?: "none" | "vertical" | "horizontal" | "both"; // carried as a style
2778
+ }): {
2779
+ controlProps: {
2780
+ // …the input's controlProps, plus
2781
+ rows: number | undefined;
2782
+ wrap: "soft" | "hard" | "off" | undefined;
2783
+ style: { resize: "none" | "vertical" | "horizontal" | "both" } | undefined;
2784
+ };
2785
+ };
2786
+ ```
2787
+
2788
+ - **Both `required` attributes.** The native one is what the browser enforces, `aria-required` is what a
2789
+ custom widget reading the same props needs. A host that validates itself puts `noValidate` on the form
2790
+ rather than dropping the attribute.
2791
+ - **`maxLength` is the browser's.** The primitive emits the attribute and reports the value it is given; it
2792
+ never truncates in JS, which would break paste and IME.
2793
+ - **`disabled` is not `readOnly`.** Disabled is out of the tab order and is not submitted; readOnly is
2794
+ focusable, selectable and submitted, which is what a value the user must read before signing wants.
2795
+ - **One callback per edit.** `onValueChange` is wired to `input`, so a keystroke, a paste, a dropped
2796
+ character and an IME commit all reach it; `change` is not bound.
2797
+ - **The textarea adds `rows`, `wrap` and `resize`.** `resize` is a `style` because CSS has no attribute for
2798
+ it, and it is absent when the option is; `none` is what a layout that owns its own height wants.
2799
+ - **Options are read on every attribute read,** so `disabled`, `hint` and `error` may change on a mounted
2800
+ control.
2801
+
2802
+ No key is bound: editing is the platform's, with its arrows, Home / End, selection, undo, IME and paste
2803
+ behaviour. The primitive neither commits on Enter nor intercepts Tab, and a multiline field's Enter is the
2804
+ browser's too, so a host that wants Enter to submit reads the key itself.
2805
+
2806
+ ### Headless: `createLink` (`@cssints/headless`)
2807
+
2808
+ The APG link as attribute objects: a native `<a href>` when the host has an `href` (the default),
2809
+ and otherwise a custom element that takes `role="link"`, `tabindex="0"` and an Enter handler,
2810
+ because the pattern says that "applying the link role will not cause browsers to enhance the
2811
+ element". Its behaviour is `packages/apg-specs/specs/apg/link.qnt`;
2812
+ `packages/headless/test/link.browser.test.tsx` replays the ariakit, Base UI and react-aria scenarios
2813
+ (`packages/apg-specs/scenarios/apg/link.*.json`) against it and walks it differentially against the
2814
+ model in both the native and the custom configuration.
2815
+
2816
+ ```ts skip
2817
+ createLink(options?: {
2818
+ href?: string | undefined; // set: a native anchor, unless native: false
2819
+ native?: boolean | undefined; // defaults to href !== undefined
2820
+ disabled?: boolean; // false
2821
+ current?: boolean; // aria-current="page", for a trail's current entry
2822
+ onActivate?(): void; // Enter or a click reached the link
2823
+ onNavigate?(href: string): void; // router integration: where the activation would go
2824
+ }): {
2825
+ native(): boolean;
2826
+ href(): string | undefined;
2827
+ disabled(): boolean;
2828
+ linkProps: {
2829
+ role: "link" | undefined; // undefined on a native anchor
2830
+ tabindex: number | undefined; // undefined natively, -1 while disabled, else 0
2831
+ href: string | undefined; // only a native anchor carries it
2832
+ "aria-disabled": "true" | undefined;
2833
+ "aria-current": "page" | undefined;
2834
+ onKeyDown;
2835
+ onClick;
2836
+ };
2837
+ };
2838
+ ```
2839
+
2840
+ The primitive holds no state: `native`, `href`, `disabled` and `current` are read when an attribute
2841
+ is read, so a host that flips one of them reacts without rebuilding anything. `linkProps` is
2842
+ getter-backed for the same reason: spread it once and every attribute follows the props.
2843
+
2844
+ **The primitive never navigates.** `onClick` prevents the anchor's default action on every click and
2845
+ then calls `onActivate()` and `onNavigate(href())`, which is what makes a router integration a
2846
+ callback rather than an intercept of the browser's own follow. A middle click is an `auxclick`,
2847
+ which `linkProps` does not handle, so "open in a new tab" keeps the browser's path. A disabled
2848
+ custom link leaves the tab order (`tabindex="-1"`); a disabled native one keeps its tab stop and
2849
+ only announces `aria-disabled="true"`, so a host that must truly retire the link drops `href` while
2850
+ it is disabled. The one react-aria scenario that stays a negative test is
2851
+ `accessible-props-passed-through`: it passes `aria-current` through a consumer prop, and the
2852
+ primitive only returns the pattern's own attributes.
2853
+
2854
+ **Keyboard, and what the host owns.** Enter activates through the same handler for both shapes, so a
2855
+ native anchor's activation reaches `onNavigate` instead of the address bar; Space is consumed
2856
+ (`preventDefault`) but does nothing, as the pattern's key table says, so the page never scrolls on a
2857
+ focused link. There is no live region and no focus management (`link.qnt`'s "focus moves to the
2858
+ target" is the browser's job, or the host's after `onActivate`). `role` and `tabindex` exist only
2859
+ for the custom element: a native anchor gets focusability and activation from the browser.
2860
+
2861
+ ### Headless: `createListbox` (`@cssints/headless`)
2862
+
2863
+ The APG listbox as state plus attribute objects, one widget for the three machines the scenarios
2864
+ use: single-select (with optional selection-follows-focus), multi-select in the recommended model,
2865
+ and type-ahead. `packages/apg-specs/specs/apg/listbox-single.qnt`, `listbox-multi.qnt` and
2866
+ `listbox-typeahead.qnt` are its models; `packages/headless/test/listbox.browser.test.tsx` replays
2867
+ the ariakit, Base UI and react-aria scenarios (`packages/apg-specs/scenarios/apg/listbox.*.json`)
2868
+ and walks it differentially in thirteen configurations: single, follows-focus, a default selection,
2869
+ multi with and without one, type-ahead with `selectionMode: "none"`, and each of those controlled by
2870
+ a host that accepts every request and one that holds its value.
2871
+
2872
+ ```ts skip
2873
+ // ListboxItemInput is a label string or { id?, label, textValue?, disabled? }
2874
+ createListbox(options: {
2875
+ items: ReadonlyArray<ListboxItemInput> | (() => ReadonlyArray<ListboxItemInput>);
2876
+ selectionMode?: "single" | "multiple" | "none"; // "single"
2877
+ followsFocus?: boolean; // false; single-select only: the active option is also selected
2878
+ value?: ReadonlyArray<number> | undefined; // controlled selection, by option index
2879
+ defaultValue?: ReadonlyArray<number>; // uncontrolled; defaultSelected is an alias
2880
+ defaultActive?: number; // 0
2881
+ activeValue?: number | undefined; // controlled active option (the type-ahead focus move)
2882
+ disabled?: ReadonlyArray<number>; // indices that cannot be selected or reached
2883
+ focusOnHover?: boolean; // false: hoverOption moves the active option only when true
2884
+ id?: string; // base of the generated option ids
2885
+ label?: string | undefined; // aria-label on the listbox
2886
+ onValueChange?(selected: ReadonlyArray<number>): void; // a request; onSelectionChange is an alias
2887
+ onActiveChange?(index: number): void; // a request
2888
+ }): {
2889
+ active(): number;
2890
+ selected(): ReadonlyArray<number>; // sorted, de-duplicated, in range
2891
+ focused(): boolean;
2892
+ items: ReadonlyArray<{ id; label; textValue; disabled }>; // getter-backed
2893
+ selectionMode: "single" | "multiple" | "none";
2894
+ focus(): number; // the option that takes focus on entry
2895
+ blur(): void;
2896
+ keyDown(key: string, modifiers?: { shift?; ctrl?; meta?; alt? }): void;
2897
+ type(text: string): void;
2898
+ clearTypeahead(): void;
2899
+ clickOption(index: number): void;
2900
+ hoverOption(index: number): void;
2901
+ setFollowsFocus(value: boolean): void;
2902
+ listboxProps: {
2903
+ role: "listbox";
2904
+ "aria-multiselectable": "true" | "false" | undefined; // undefined in "none"
2905
+ "aria-activedescendant": string | undefined; // only while the widget holds focus
2906
+ "aria-label": string | undefined;
2907
+ onKeyDown;
2908
+ onFocusOut;
2909
+ };
2910
+ optionProps(index: number): {
2911
+ id;
2912
+ role: "option";
2913
+ "aria-selected": "true" | "false";
2914
+ "aria-disabled": "true" | undefined;
2915
+ "aria-posinset";
2916
+ "aria-setsize";
2917
+ tabindex: 0 | -1; // 0 on the active option only
2918
+ onClick;
2919
+ onFocus;
2920
+ onMouseEnter;
2921
+ };
2922
+ };
2923
+ ```
2924
+
2925
+ `items` takes a getter (`() => items()`) and is read at attribute-read time, so a list that arrives
2926
+ later, or is filtered, keeps the active option and the selection instead of being rebuilt; the
2927
+ normalized list is cached by the identity of the array it came from, so one re-rendered option does
2928
+ not walk the whole list. A selection or a `defaultActive` chosen before the items landed still
2929
+ applies once they do, because `selected()` normalizes against the live count at every read.
2930
+ Selection is a set of indices, always returned sorted and de-duplicated, and an option that is
2931
+ disabled either by the `disabled` index list or by its own `disabled` field is skipped by the
2932
+ arrows, Home/End, clicks and hover.
2933
+
2934
+ Selection is uncontrolled by default (`defaultValue`, or `defaultActive` for the active option) and
2935
+ controlled by `value` + `onValueChange` (the active option by `activeValue` + `onActiveChange`,
2936
+ which is the type-ahead machine's value). Every selection change and every focus move is a request:
2937
+ uncontrolled it commits, controlled it commits nothing until the host writes the prop back, and a
2938
+ request equal to the current state is not made at all.
2939
+
2940
+ **Keyboard.** In `"single"` and `"none"` a printable character is type-ahead: a fresh buffer
2941
+ searches from the option after the active one, wrapping, and a growing buffer searches from the
2942
+ active one inclusive, both on the item's `textValue` (its label by default); a Space continues a
2943
+ non-empty buffer instead of selecting. ArrowDown / ArrowUp move the active option without wrapping,
2944
+ Home / End jump to the first / last enabled option, and Space or Enter selects the active option in
2945
+ single-select (in `"none"` there is nothing to select). In `"multiple"` Space toggles the active
2946
+ option, Shift+Space selects the run from the anchor to the active option, Shift+Arrow toggles the
2947
+ option it enters, Ctrl+Shift+Home / End select to that edge, and Ctrl+A (or Meta+A) selects all, or
2948
+ clears when all are selected. Ctrl+Space and Ctrl+Arrow are consumed and move nothing: the pattern's
2949
+ alternative model, where the arrows move focus only, is not implemented. The type-ahead keys are
2950
+ single- and none-only; `type(text)` itself searches in any mode.
2951
+
2952
+ There is no live region and no timer: the host calls `clearTypeahead()` from its own idle timer, and
2953
+ `focusOnHover` is off by default because moving the active option on hover fights a nested scroll or
2954
+ a tooltip. `focus()` names the entry option (the first selected one, else the first), returns the
2955
+ active one when the widget already has focus, and, in single-select with `followsFocus`, commits it.
2956
+ `aria-activedescendant` is emitted while the widget holds focus, but the container itself has no
2957
+ tabindex: the active option is the single tab stop, so the host keeps the DOM focus on that option.
2958
+ The scenario divergences: `autoFocus` focuses the option, not the listbox, so a probe reads
2959
+ `option:0`; and `onAction` / `onPress` are react-aria extensions the primitive does not report (only
2960
+ the selection change).
2961
+
2962
+ ### Headless: `createMeter` (`@cssints/headless`)
2963
+
2964
+ A graphical display of a numeric value within a range, for a battery or a fuel gauge: **not a
2965
+ progress bar** (there is no completion) and not for an unbounded value. The platform does the work
2966
+ (`<meter>`), so the primitive only derives the ARIA contract and runs no state machine:
2967
+ `packages/apg-specs/specs/apg/meter.qnt` checks the three properties the pattern states (min below
2968
+ max, the value clamped into the range, a value text that describes the current value),
2969
+ `packages/headless/test/meter.browser.test.tsx` replays the Base UI and react-aria scenarios
2970
+ (`packages/apg-specs/scenarios/apg/meter.*.json`) and walks it differentially with in-range,
2971
+ offset-range and out-of-range configurations.
2972
+
2973
+ ```ts skip
2974
+ createMeter(options?: {
2975
+ value?: number; // 0; clamped into [min, max]
2976
+ min?: number; // 0
2977
+ max?: number; // 100
2978
+ valueText?: string | undefined; // aria-valuetext; the rounded percentage when absent
2979
+ label?: string | undefined; // aria-label
2980
+ labelId?: string | undefined; // aria-labelledby, for a visible label element
2981
+ }): {
2982
+ min(): number;
2983
+ max(): number;
2984
+ value(): number; // the clamped value, never the raw one
2985
+ percent(): number; // 0..100, rounded to a whole number
2986
+ meterProps: {
2987
+ role: "meter";
2988
+ "aria-valuemin";
2989
+ "aria-valuemax";
2990
+ "aria-valuenow";
2991
+ "aria-valuetext"; // always present: the host's text or "42%"
2992
+ "aria-label";
2993
+ "aria-labelledby";
2994
+ };
2995
+ };
2996
+ ```
2997
+
2998
+ The primitive is controlled by construction: the host feeds `value`, `min` and `max`, and nothing is
2999
+ stored. A value outside the range is clamped for both `aria-valuenow` and `percent()`, and a range
3000
+ whose `max` does not exceed its `min` reports `min` and 0% rather than a negative ratio: the model
3001
+ ignores such a range change and keeps the last valid one, where a pure function of the current props
3002
+ has nothing to keep. `percent()` is the skin's input (fill width, needle angle); the primitive draws
3003
+ nothing. Pass `label` or `labelId`, not both: both attributes are emitted as given, and
3004
+ `aria-labelledby` wins in ARIA.
3005
+
3006
+ **Keyboard and limits.** None: a meter is not focusable, binds no key and has no live region,
3007
+ because the value it shows is not a control the user operates. `aria-valuetext` is always emitted,
3008
+ so a host that wants a friendlier sentence ("3 of 5 bars") passes it; the default is the rounded
3009
+ percentage. Neither a progressbar's completion semantics nor the value's history belongs here: a
3010
+ task that finishes, and has a `role="progressbar"` of its own, is not a meter.
3011
+
3012
+ ### Headless: `createPagination` (`@cssints/headless`)
3013
+
3014
+ A navigation landmark over a page list. **No APG pattern exists for pagination**; the requirements
3015
+ are TetraLogical's (accessible-components-library v1.0.0), and
3016
+ `packages/headless/test/pagination.browser.test.tsx` is its oracle: exactly one current page, the
3017
+ ends kept in place and disabled rather than removed, the count announced once, and an `href` for
3018
+ every link so the control works without JS.
3019
+
3020
+ ```ts skip
3021
+ createPagination(options: {
3022
+ page: number; // required, 1-based
3023
+ pageCount: number; // required
3024
+ siblings?: number; // 1: pages shown on each side of the current one
3025
+ boundaries?: number; // 1: pages always shown at each end
3026
+ hrefFor?: ((page: number) => string) | undefined; // set: anchors; absent: buttons
3027
+ disabled?: boolean; // false
3028
+ labels?: {
3029
+ nav?; // "Pagination"
3030
+ previous?; // "Previous page"
3031
+ next?; // "Next page"
3032
+ page?; // the prefix of a page link's name, "Page"
3033
+ gap?; // declared, but unused: a gap carries its key, the host writes its text
3034
+ status?: (page: number, count: number) => string; // "Page <page> of <count>"
3035
+ } | undefined;
3036
+ onPageChange?(page: number): void; // a request the host may ignore
3037
+ }): {
3038
+ items(): ReadonlyArray<
3039
+ { kind: "page"; page: number; current: boolean } | { kind: "gap"; key: string }
3040
+ >;
3041
+ current(): number; // page, clamped into 1..max(pageCount, 1)
3042
+ navProps: { "aria-label": string };
3043
+ listProps: { role: "list" };
3044
+ itemProps(page: number): { role: "listitem"; "aria-current": "page" | undefined };
3045
+ linkProps(page: number): {
3046
+ href: string | undefined;
3047
+ "aria-current": "page" | undefined;
3048
+ "aria-disabled": "true" | undefined; // disabled, or a page outside the range
3049
+ "aria-label": string;
3050
+ rel: undefined;
3051
+ onClick;
3052
+ };
3053
+ previousProps: { href; "aria-current": undefined; "aria-disabled"; "aria-label"; rel: "prev"; onClick };
3054
+ nextProps: { href; "aria-current": undefined; "aria-disabled"; "aria-label"; rel: "next"; onClick };
3055
+ status(): string;
3056
+ statusProps: { role: "status"; "aria-live": "polite"; "aria-atomic": "true" };
3057
+ };
3058
+ ```
3059
+
3060
+ `items()` is the page list: the current page, `siblings` pages either side of it, `boundaries` pages
3061
+ at each end, and **one `gap` entry per elided run**, keyed by the run's start so the list is stable.
3062
+ It is recomputed on every read, so a host may change `page` and `pageCount` without rebuilding;
3063
+ `current()` clamps the page it was given, and the page list and both ends are built from the
3064
+ clamped value. A gap entry has no handler and no role: the host renders its own marker, hidden from
3065
+ assistive technology (the count is already in the status line).
3066
+
3067
+ The ends are `previousProps` and `nextProps`, getters that resolve their page from the current one
3068
+ at read time, so a host that spreads the object onto an anchor once still steps past the first move;
3069
+ both carry `rel`, `aria-disabled` at a boundary, and an `href` from `hrefFor`. A page link's
3070
+ `aria-label` is `"Page <n>"`; `itemProps(page)` is meant for the `<li>` around the page link and
3071
+ around each end. `status()` is the one announcement, and the current page is marked on its item and
3072
+ on its link. The host's `hrefFor` is what makes the control work without JS; without it the props
3073
+ carry no `href` and a click only asks `onPageChange`. Target size is the skin's job: 24x24 CSS px at
3074
+ AA, 44x44 at AAA.
3075
+
3076
+ **Keyboard and limits.** The primitive binds no key and moves no focus: the props go on anchors or
3077
+ buttons, so Tab, Enter and Space are the platform's, and `disabled` only sets `aria-disabled` and
3078
+ drops the request (the host keeps its anchors out of the tab order if it must). `labels` is the
3079
+ whole translation surface, including the status sentence, which defaults to English. The current
3080
+ page is still rendered as a page link and still gets its `href`; a click on it is refused
3081
+ (`navigate` returns early when the page is current), so the mark changes and the page does not
3082
+ reload.
3083
+
3084
+ ### Headless: `createBreadcrumb` (`@cssints/headless`)
3085
+
3086
+ The APG breadcrumb: a trail of links in hierarchical order, the current page last and marked
3087
+ `aria-current="page"` on both the item and its link. The platform carries the navigation landmark,
3088
+ so the primitive only returns the trail's attributes. Its behaviour is
3089
+ `packages/apg-specs/specs/apg/breadcrumb.qnt`; `packages/headless/test/breadcrumb.browser.test.tsx`
3090
+ replays the react-aria scenarios (`packages/apg-specs/scenarios/apg/breadcrumb.react-aria.json`) and
3091
+ walks it differentially for a trail of four items and of two.
3092
+
3093
+ ```ts skip
3094
+ createBreadcrumb(options: {
3095
+ items: ReadonlyArray<{ label: string; href?: string | undefined }>;
3096
+ currentIndex?: number; // 1-based; defaults to the last item, 0 marks no item
3097
+ disabled?: boolean; // false: aria-disabled on every item and every press refused
3098
+ label?: string | undefined; // the landmark's name, "Breadcrumbs"
3099
+ labelledby?: string | undefined; // aria-labelledby, for a visible heading
3100
+ describedby?: string | undefined; // aria-describedby
3101
+ onPress?(index: number): void; // a request: the host navigates or cuts the trail
3102
+ }): {
3103
+ navProps: { "aria-label": string | undefined; "aria-labelledby"; "aria-describedby" };
3104
+ listProps: { role: "list" };
3105
+ itemProps(index: number): { role: "listitem"; "aria-current": "page" | undefined; "aria-disabled" };
3106
+ linkProps(index: number): { href: string; "aria-current": "page" | undefined; onClick };
3107
+ };
3108
+ ```
3109
+
3110
+ The trail's length is read once at creation, so the array is not a getter: a trail that grows (the
3111
+ user descends) or shrinks (an ancestor link cuts it back) is a new `createBreadcrumb` call, or the
3112
+ host renders fewer items itself. `currentIndex` is clamped into `0..items.length`, which gives the
3113
+ model's three states: the last item marks the trail's end, `0` marks nothing (the spec's "none"),
3114
+ and an index past the end clamps to the last item.
3115
+ `linkProps(index).href` falls back to `"#"` for an item with no `href`, because an anchor without
3116
+ one is not a link; the current item may be rendered as text instead (the pattern allows
3117
+ `aria-current` on the item alone), which is the fixture's `renderText` case. Pressing any link,
3118
+ current one included, calls `onPress(index)`; the host decides whether that navigates or cuts the
3119
+ trail. Nothing is prevented, so a native anchor follows its own `href` as well: `onPress` is a
3120
+ notification that can cut the trail, and the address the link points at stays the host's.
3121
+
3122
+ **Keyboard and limits.** None of its own: the entries are plain links, so Tab and Enter are the
3123
+ browser's, and there is no live region and no focus management (the react-aria scenario that focuses
3124
+ an ancestor at mount stays a negative test). `navProps` carries `aria-label` even beside a
3125
+ `labelledby`, so pass one of them per landmark; `aria-labelledby` wins in ARIA. **No overflow is
3126
+ built in**: a trail too long for the width is the host's collapse (the APG example truncates the
3127
+ middle behind a menu), and this primitive returns one item per input, in order. The other two
3128
+ divergences are the upstream's generic `aria-label` on every item and its expectation that an item
3129
+ wrapper carries no role: the primitive always renders a `listitem` around the link, and an item's
3130
+ label stays its text.
3131
+
3132
+ ### Headless: `createTreeview` (`@cssints/headless`)
3133
+
3134
+ The APG tree view: a `role="tree"` of `role="treeitem"` rows, one roving tab stop, a selection
3135
+ that is a request and an expansion that is always uncontrolled. Its behaviour is
3136
+ `packages/apg-specs/specs/apg/treeview.qnt`, checked against the real DOM by the differential walk
3137
+ in `packages/headless/test/treeview.browser.test.tsx` (single and multi, uncontrolled, controlled
3138
+ accepting and holding). The ported scenarios are
3139
+ `packages/apg-specs/scenarios/apg/treeview.apg.json`: 30 of the 35 replay, two stay recorded
3140
+ divergences (APG moves focus to the selected node when the tree receives focus; the spec and the
3141
+ primitive keep the roving node), and three skip because their steps carry a `modifiers` field the
3142
+ bridge cannot express (Shift+ArrowUp / Shift+ArrowDown, Ctrl+A), which the walk covers instead. A
3143
+ tree with columns and cell navigation is `createTreegrid`, below; this one is the single-column
3144
+ tree.
3145
+
3146
+ `nodes` is read once, at creation — ids in pre-order (document order), `parent` 0 for a root —
3147
+ so a list that arrives later needs a fresh primitive; the default is `TREEVIEW_NODES`, the spec's
3148
+ seven-node file tree. The host renders every node in document order and spreads `treeProps` on
3149
+ the tree and `itemProps(id)` on each item: `aria-level`, `aria-setsize`, `aria-posinset`,
3150
+ `aria-expanded` on parents only, `aria-selected` on every node, the roving `tabindex` and `hidden`
3151
+ for a node inside a closed parent. Render all of them and let `hidden` hide a subtree: the
3152
+ setsize / posinset of a visible row count its hidden siblings too. There is no expander button in
3153
+ the primitive: the host draws its chevron from `aria-expanded` and attaches no handler to it,
3154
+ because a click on a parent row already toggles it as well as selecting and focusing it.
3155
+
3156
+ Expansion is uncontrolled (`defaultExpanded` + `onExpandedChange`); the selection is `value` +
3157
+ `onValueChange` when the host owns it and `defaultValue` (or its alias `defaultSelected`) when it
3158
+ does not, and a request fires `onSelectionChange` once per newly selected node. The primitive never
3159
+ calls `focus()`: it publishes the roving node as `focus()`, `tabindex="0"` and `hasFocus`, and the
3160
+ host moves the DOM focus after an arrow (the fixture does it in an effect). `select`,
3161
+ `toggleSelected`, `expand`, `collapse` and `toggleExpanded` are the programmatic requests.
3162
+
3163
+ ```ts skip
3164
+ createTreeview(options?: {
3165
+ nodes?: ReadonlyArray<TreeviewNode>; // read once: { id, parent, label }, parent 0 = a root, document order
3166
+ multiSelectable?: boolean; // false: one selected node, and Space / Enter / a click replace it
3167
+ value?: ReadonlyArray<number> | undefined; // controlled: the selected node ids; undefined = uncontrolled
3168
+ defaultValue?: ReadonlyArray<number>; // the selection at mount
3169
+ defaultSelected?: ReadonlyArray<number>; // an alias for defaultValue
3170
+ defaultExpanded?: ReadonlyArray<number>; // the parent ids open at mount
3171
+ defaultFocus?: number; // the roving node at mount; the first node by default
3172
+ label?: string; // the tree's aria-label
3173
+ orientation?: "vertical" | "horizontal"; // "vertical": announced only, the keys are the vertical set
3174
+ onExpandedChange?(node: TreeviewNode): void; // once per node whose expansion changed
3175
+ onSelectionChange?(node: TreeviewNode): void; // once per newly selected node
3176
+ onValueChange?(selected: ReadonlyArray<number>): void; // a request the host may ignore
3177
+ onActivate?(node: TreeviewNode): void; // Enter's default action
3178
+ }): {
3179
+ nodeIds(): ReadonlyArray<number>;
3180
+ expanded(): ReadonlyArray<number>;
3181
+ selected(): ReadonlyArray<number>;
3182
+ focus(): number; // the roving node
3183
+ hasFocus(): boolean;
3184
+ isExpanded(id: number): boolean;
3185
+ isSelected(id: number): boolean;
3186
+ isVisible(id: number): boolean; // not inside a closed parent
3187
+ isParent(id: number): boolean;
3188
+ expand(id: number): void;
3189
+ collapse(id: number): void;
3190
+ toggleExpanded(id: number): void;
3191
+ select(id: number): void; // replace the selection with this node
3192
+ toggleSelected(id: number): void;
3193
+ resetTypeahead(): void; // the timeout, modelled explicitly
3194
+ treeProps: {
3195
+ role: "tree";
3196
+ "aria-multiselectable": "true" | "false";
3197
+ "aria-label": string | undefined;
3198
+ "aria-orientation": "horizontal" | undefined; // vertical carries nothing
3199
+ onKeyDown;
3200
+ onFocusIn;
3201
+ onFocusOut;
3202
+ };
3203
+ itemProps(id: number): {
3204
+ role: "treeitem";
3205
+ "aria-level": number;
3206
+ "aria-setsize": number; // the siblings, hidden ones included
3207
+ "aria-posinset": number;
3208
+ "aria-expanded": "true" | "false" | undefined; // parents only
3209
+ "aria-selected": "true" | "false";
3210
+ tabindex: 0 | -1; // the roving node
3211
+ hidden: boolean; // inside a closed parent
3212
+ onClick;
3213
+ };
3214
+ };
3215
+ ```
3216
+
3217
+ **Keyboard.** ArrowDown / ArrowUp move over the visible nodes, a closed subtree skipped; ArrowRight
3218
+ opens a closed parent, and on an open parent it moves to the first child; ArrowLeft closes an open
3219
+ parent, else moves to the parent; Home / End are the first and last visible node; `*` opens every
3220
+ closed sibling of the focused node (the focused node included when it is a parent); Enter toggles a
3221
+ parent, selects it when single-select, and then fires `onActivate`; Space selects it (multi:
3222
+ toggles); Shift+ArrowUp / Shift+ArrowDown move and toggle the node they land on; Ctrl+A or Cmd+A
3223
+ (multi) selects every node and a second press clears, nodes inside collapsed subtrees included.
3224
+ Type-ahead matches the visible labels from the node after the roving one for one character and from
3225
+ the roving node for two; the buffer holds two characters and a third starts a new one, and its
3226
+ timeout is the host's `resetTypeahead()` call, so no timer and no `prefs` delay is involved.
3227
+
3228
+ **Limits.** There is no live region: the roving focus and the ARIA the host spreads are the
3229
+ announcement. Single-select selection does not follow the focus (Space, Enter and a click select;
3230
+ the arrows only move), and range selection (Shift+Space, Ctrl+Shift+Home / End), Ctrl+Arrow,
3231
+ Ctrl+click, `aria-current` navigation trees and `aria-disabled` nodes are not modelled. The model
3232
+ checks three levels; the primitive computes `aria-level` at any depth. `orientation` is announced
3233
+ only, and the ids pack into a 32-bit mask, so the widget takes at most 32 nodes.
3234
+
3235
+ ### Headless: `createTreegrid` (`@cssints/headless`)
3236
+
3237
+ The APG treegrid: rows of cells with a tree in the first column, the example's three navigation
3238
+ models and row selection. Its behaviour is `packages/apg-specs/specs/apg/treegrid.qnt` — the Email
3239
+ Inbox tree, `A > {A.1, A.2 > {A.2.1}}` and `B > {B.1, B.2}` — checked against the real DOM by the
3240
+ differential walk in `packages/headless/test/treegrid.browser.test.tsx` over seven configurations:
3241
+ each navigation model, single and multi selection, controlled accepting and holding. The ported
3242
+ scenarios (`scenarios/apg/treegrid.apg.json` and `scenarios/apg/treegrid.react-aria.json`, 86
3243
+ together) are replayed but every one skips: their configs carry a dynamic `tree` / `columns` /
3244
+ `expanded` the bridge cannot express, because the model is the spec's fixed seven-row tree. The
3245
+ walk, not the ported scenarios, is the oracle, and it holds the three navigation models rather
3246
+ than the tree a host passes.
3247
+
3248
+ `parents` is the tree and its only tree data: the parent of each row in document order, `-1` for a
3249
+ root and otherwise the index of that row's parent. **It is read once, at creation**, and the
3250
+ default is the spec's seven rows — so the primitive is not tied to the spec tree, but nothing
3251
+ rebuilds a tree that changes underneath it. `cols` is the column count (3 by default; the spec
3252
+ fixes three). Expansion is uncontrolled — `defaultExpanded` at mount, then `toggleRow` /
3253
+ `setExpanded`, with no expansion callback — while the selection is `value` + `onValueChange` or
3254
+ `defaultValue`.
3255
+
3256
+ The host renders `gridProps` on the treegrid, `rowProps(row)` on every row, `cellProps(row, col)` on
3257
+ every cell and `expanderProps(row)` on a button inside the first cell of a row where
3258
+ `hasChildren(row)` is true. The primitive owns the roving tab stop (exactly one row or cell is
3259
+ tabbable, remembered while the focus is outside), `aria-level` / `aria-setsize` / `aria-posinset`,
3260
+ `aria-expanded` on parent rows, `aria-selected`, the ids (`<id>-row-<row>` and
3261
+ `<id>-cell-<row>-<col>`) and the key handling. `focusTargetPart()` names the tab stop as `row:<row>`
3262
+ or `cell:<row>,<col>`, and `treegridFocusTargetPart(grid)` is the same value for a host that holds
3263
+ the primitive in a signal; the host focuses that element after a key, as the fixture does.
3264
+ `gridProps` carries no accessible name: put `aria-label` or `aria-labelledby` on the element
3265
+ yourself.
3266
+
3267
+ ```ts skip
3268
+ createTreegrid(options?: {
3269
+ nav?: "rowsFirst" | "cellsFirst" | "cellsOnly"; // "rowsFirst": Tab lands on the row
3270
+ selection?: "single" | "multi"; // "single": the selection follows the focus
3271
+ cols?: number; // 3; the spec fixes three
3272
+ parents?: ReadonlyArray<number>; // read once: -1 is a root, else the index of the row's parent
3273
+ defaultExpanded?: ReadonlyArray<number>; // the rows open at mount, by index
3274
+ value?: ReadonlyArray<number> | undefined; // controlled: the selected rows; undefined = uncontrolled
3275
+ defaultValue?: ReadonlyArray<number>; // the selection at mount
3276
+ id?: string; // base of <id>-row-<row> and <id>-cell-<row>-<col>
3277
+ onValueChange?(selected: ReadonlyArray<number>): void; // a request the host may ignore
3278
+ }): {
3279
+ rows(): number;
3280
+ cols(): number;
3281
+ nav(): "rowsFirst" | "cellsFirst" | "cellsOnly";
3282
+ selection(): "single" | "multi";
3283
+ focusedRow(): number;
3284
+ focusedCol(): number;
3285
+ kind(): "row" | "cell"; // which of the two holds the tab stop
3286
+ inGrid(): boolean;
3287
+ visible(row: number): boolean;
3288
+ hasChildren(row: number): boolean;
3289
+ isExpanded(row: number): boolean;
3290
+ isSelected(row: number): boolean;
3291
+ focusTargetPart(): string | null; // "row:2" / "cell:2,1"; null while the focus is out
3292
+ gridProps: {
3293
+ role: "treegrid";
3294
+ "aria-multiselectable": "true" | undefined; // multi only
3295
+ onFocusOut;
3296
+ };
3297
+ rowProps(row: number): {
3298
+ role: "row";
3299
+ id: string;
3300
+ "aria-level": number;
3301
+ "aria-setsize": number;
3302
+ "aria-posinset": number;
3303
+ "aria-expanded": "true" | "false" | undefined; // parent rows only
3304
+ "aria-selected": "true" | "false" | undefined; // undefined on unselected rows when single
3305
+ tabindex: 0 | -1;
3306
+ onFocus;
3307
+ onKeyDown;
3308
+ };
3309
+ cellProps(row: number, col: number): {
3310
+ role: "gridcell";
3311
+ id: string;
3312
+ tabindex: 0 | -1;
3313
+ onClick;
3314
+ onFocus;
3315
+ onKeyDown;
3316
+ };
3317
+ expanderProps(row: number): {
3318
+ role: "button";
3319
+ "aria-label": "Expand" | "Collapse";
3320
+ "aria-expanded": "true" | "false";
3321
+ onMouseDown; // keeps the focus where it is
3322
+ onClick; // toggles the row, and does not reach the cell
3323
+ };
3324
+ focus(): void; // moves the tab stop into the grid; the host focuses it
3325
+ blur(): void;
3326
+ clickCell(row: number, col: number): void;
3327
+ toggleRow(row: number): void;
3328
+ configureNav(nav: "rowsFirst" | "cellsFirst" | "cellsOnly"): void; // resets expansion and focus
3329
+ configureSelection(selection: "single" | "multi"): void;
3330
+ setExpanded(row: number, expanded: boolean): void;
3331
+ };
3332
+ ```
3333
+
3334
+ - **`nav`** picks the tab stop and the Left / Right path. `"rowsFirst"` (the default) puts Tab on
3335
+ the row: Right enters the row's first cell, then moves right, and Left on the first cell returns
3336
+ to the row. `"cellsFirst"` puts Tab on the first cell, and Left from it still reaches the row.
3337
+ `"cellsOnly"` never focuses a row: Left stays among the cells, and Enter on the first cell of a
3338
+ parent toggles it.
3339
+ - **Movement** is the same in all three. Up / Down move over the visible rows, staying in the
3340
+ column in cell mode; Home / End are the first / last cell in cell mode and the first / last
3341
+ visible row in row mode; Ctrl+Home / Ctrl+End are the first / last visible row, keeping the
3342
+ column. Right on a collapsed parent expands it and keeps the row; on an expanded row it
3343
+ enters the row's first cell, and the next press moves right. Left on an expanded parent
3344
+ collapses it and keeps the row.
3345
+ - **Selection** is per row. `"single"` follows the focus, and `aria-selected` is on the selected
3346
+ row only (absent elsewhere); a cell click selects its row. `"multi"` sets
3347
+ `aria-multiselectable`, writes `aria-selected` true or false on every row, and changes only on
3348
+ Shift+Space (toggle the focused row), Ctrl+A (every row, no second press to clear) and
3349
+ Shift+ArrowUp / Shift+ArrowDown in row mode (move and add the neighbouring visible row to the
3350
+ selection); a click or a plain arrow does not select.
3351
+ - **`configureNav` / `configureSelection`** are a host's switches between models: they reset the
3352
+ expansion, the tab stop and an uncontrolled selection to the new model's start, and leave a
3353
+ controlled selection alone.
3354
+
3355
+ **Keyboard and limits.** Enter acts in `cellsOnly` only, and the other two models
3356
+ `preventDefault` it without doing anything, so a row's default action is the host's; a click is a
3357
+ cell click, not a row click. The expander takes no focus on `mousedown`, stops its click from
3358
+ reaching the cell, and moves the focus to the toggled row when the collapse would hide the focused
3359
+ row. No live region: the ARIA and the moved focus are the announcement. Not modelled: cell and
3360
+ column selection, editing or Tab inside a cell, PageUp / PageDown, `aria-rowcount` / `-colcount` /
3361
+ `-index`, disabled rows, type-ahead and row header cells (every cell is a `gridcell`). The model
3362
+ checks the fixed seven rows and three columns, the parent map is fixed at creation, and row ids
3363
+ pack into a 32-bit mask, so the grid takes at most 32 rows.
3364
+
3365
+ ### Headless: `createWindowSplitter` (`@cssints/headless`)
3366
+
3367
+ The APG window splitter: one focusable `role="separator"` between a primary and a secondary pane,
3368
+ whose value is the primary pane's size. Its behaviour is
3369
+ `packages/apg-specs/specs/apg/windowsplitter.qnt` (the bounds 0..6, a step of 2, a start of 4 and a
3370
+ primary pane that collapses at 0), checked against the real DOM by the differential walk in
3371
+ `packages/headless/test/windowsplitter.browser.test.tsx` (vertical, horizontal, fixed, controlled
3372
+ accepting and holding) and by the 25 ported scenarios in
3373
+ `packages/apg-specs/scenarios/apg/windowsplitter.apg.json`. Two of those stay recorded
3374
+ divergences: they write `"*"` for any IDREF or accessible name, which the replay engine's literal
3375
+ comparison cannot match (`aria-label`, `aria-controls`), and the rest replay.
3376
+
3377
+ **This is the single-separator pattern, not the N-pane group.** `createResizablePanels` is the
3378
+ group of N panes and N-1 separators: the same splitter keyboard, with the layout and its bounds in
3379
+ `packages/apg-specs/specs/patterns/resizable-panels.qnt`, plus the pointer drag this primitive does
3380
+ not have. A two-pane split belongs here; a layout with more than one separator belongs there.
3381
+
3382
+ The value is one number in the host's own unit: `min`, `max` and `step` are in it, `aria-valuenow`
3383
+ prints it as it is, and the primitive knows nothing of pixels, percentages or the layout. There is
3384
+ no `aria-valuetext`, so a percentage reads as its bare number. `collapsed` means "collapsed by
3385
+ Enter", not "the value is `min`": a pane at `min` that was never collapsed is not collapsed. While
3386
+ controlled, `collapsed` is derived from the host's value plus the last collapse request, so call
3387
+ `freezeCollapse()` before the host writes `value` itself (a preset, a saved layout), or a write
3388
+ that happens to land on `min` reads as a collapse. `onCollapsedChange` reports the derived state
3389
+ when it changes.
3390
+
3391
+ The host spreads `separatorProps` on the focusable bar and `primaryProps` / `secondaryProps` on the
3392
+ two panes. The panes are `tabindex="0"`, not `-1`, because F6's cycle lands on them, and their
3393
+ `onFocus` / `onBlur` mirror the DOM focus into `focusPart`; the primitive never calls `focus()`
3394
+ itself, so the host follows `focusPart` with its own effect to make F6 move the focus (the fixture
3395
+ does). `separatorProps["aria-controls"]` names the primary pane's id, and the host must put that id
3396
+ on the primary element, because `primaryProps` carries none: spread the separator first and read
3397
+ it back, or pass `primaryId` and use the same string. `label` or `labelledby` names the separator,
3398
+ and APG wants the primary pane's own label, so `labelledby` is the better one.
3399
+
3400
+ Options are read lazily, so pass the object itself: `orientation`, `fixed`, `min`, `max`, `step`
3401
+ and `value` may be getters, and a host that flips `fixed` or `orientation` on a mounted splitter
3402
+ sees the new bounds and keys at once (the fixture switches both that way).
3403
+
3404
+ ```ts skip
3405
+ createWindowSplitter(options?: {
3406
+ orientation?: "vertical" | "horizontal"; // "vertical": a left/right split; horizontal: top/bottom
3407
+ fixed?: boolean; // false: arrows and Home/End move; true: only Enter's two positions
3408
+ value?: number | undefined; // controlled: the primary pane's size; undefined = uncontrolled
3409
+ defaultValue?: number; // 4, clamped into the range
3410
+ min?: number; // 0
3411
+ max?: number; // 6
3412
+ step?: number; // 2: one arrow press
3413
+ id?: string; // base of the separator and pane ids
3414
+ primaryId?: string; // the id aria-controls names; <id>-primary by default
3415
+ label?: string; // the separator's aria-label
3416
+ labelledby?: string; // the id of the primary pane's visible label; it wins the name
3417
+ onValueChange?(value: number): void; // a request the host may ignore
3418
+ onCollapsedChange?(collapsed: boolean): void; // the derived state, when it changes
3419
+ }): {
3420
+ value(): number;
3421
+ collapsed(): boolean;
3422
+ focusPart(): "none" | "splitter" | "primary" | "secondary";
3423
+ focusSeparator(): void; // sets focusPart only
3424
+ setFocusPart(part: "none" | "splitter" | "primary" | "secondary"): void;
3425
+ keyDown(key: string): void;
3426
+ freezeCollapse(): void; // call before the host writes `value` itself
3427
+ collapse(): void; // only while the separator has focus
3428
+ expand(): void; // only while the separator has focus
3429
+ separatorProps: {
3430
+ id: string;
3431
+ role: "separator";
3432
+ "aria-orientation": "vertical" | "horizontal";
3433
+ "aria-valuenow": number;
3434
+ "aria-valuemin": number;
3435
+ "aria-valuemax": number;
3436
+ "aria-controls": string; // the primary pane's id: set it on the element
3437
+ "aria-label": string | undefined;
3438
+ "aria-labelledby": string | undefined;
3439
+ tabindex: 0;
3440
+ onKeyDown;
3441
+ onFocus;
3442
+ onBlur;
3443
+ };
3444
+ primaryProps: { tabindex: 0; onKeyDown; onFocus; onBlur };
3445
+ secondaryProps: { tabindex: 0; onKeyDown; onFocus; onBlur };
3446
+ };
3447
+ ```
3448
+
3449
+ **Keyboard.** On the bar, ArrowLeft / ArrowRight move a vertical splitter and ArrowUp / ArrowDown
3450
+ a horizontal one, by `step` and clamped at the bounds; Home / End jump to the bounds; Enter
3451
+ collapses the primary pane to `min` and restores the size it had before. A fixed splitter ignores
3452
+ the arrows and Home / End and keeps Enter's two positions; a collapsed one ignores everything but
3453
+ Enter. While a pane has focus those keys do nothing (their presses are `preventDefault`ed), and F6
3454
+ is the only one that acts: it cycles the focus splitter → secondary → primary → splitter. The
3455
+ primitive handles the keys a host's own listener passes to `keyDown`, and nothing else.
3456
+
3457
+ **Limits.** One separator and a two-pane split, no drag (a dragged separator is the host's, or
3458
+ `createResizablePanels`), no nested splits, no `aria-valuetext` and no live region. The spec fixes
3459
+ its bounds and step and does not model a `min` above zero; the options are the primitive's and the
3460
+ walk covers the spec's constants.
3461
+
3462
+ ### Headless: `createResizablePanels` (`@cssints/headless`)
3463
+
3464
+ A group of N panes and N-1 separators: react-resizable-panels' percentage layout on the APG
3465
+ window splitter's keyboard. It is **not `createWindowSplitter`**: that primitive is one separator,
3466
+ the APG pattern only, with no drag, no bounds and no collapse; this one owns the whole layout — a
3467
+ drag with pointer capture, per-pane `min` / `max`, a collapsible pane, a remembered size and
3468
+ persistence. It is not an APG pattern, so its spec lives under `specs/patterns/`:
3469
+ `packages/apg-specs/specs/patterns/resizable-panels.qnt` is the three-pane instance (10% steps,
3470
+ 10/80 bounds, pane 0 collapsible at 0) and the primitive takes N panes and any bounds.
3471
+ `packages/headless/test/resizable-panels.browser.test.tsx` replays its 11 scenarios
3472
+ (`scenarios/patterns/resizable-panels.spec.json`) and walks the generated model in four
3473
+ configurations (uncontrolled, horizontal, a controlled host that accepts, one that holds), then
3474
+ pins the drag, the remembered size, the defaults and persistence, which the model cannot hold. The
3475
+ host renders the group, each pane and a separator between neighbours, and spreads the props: the
3476
+ group is a flex or grid box, and the pane's size is `sizes()[index]`.
3477
+
3478
+ Sizes are percentages of the group and always sum to 100. A move (an arrow, Home/End or a drag)
3479
+ changes only the two panes around its separator, clamped by both bounds, and never drags a pane
3480
+ below its expanded `min` (only Enter or a host write collapses one). Collapsing hands the space to
3481
+ the nearest pane that can take it and cascades outward, right then left; restoring takes it back in
3482
+ the same order from the size the pane left. `defaultSize` is a pane's named start: it is kept, and
3483
+ the panes that name none share what is left equally (changed 2026-11-02); `defaultSizes` states the
3484
+ whole start instead and wins over the per-pane names, and a layout stored under `storageKey` wins
3485
+ over both. Every start is clamped and its residual pushed into the panes in order, so a host's bad
3486
+ sum still lands on 100.
3487
+
3488
+ Uncontrolled by default; `sizes` + `onSizesChange` make it controlled, and the request is the whole
3489
+ layout array. The drag is a snapshot plus a delta: pointerdown remembers the layout, every move
3490
+ re-solves from it, so dragging back past a clamp recovers exactly. A host write of `sizes` ends a
3491
+ drag; the echo of a request the host accepted does not count as one. The newest pointerdown takes
3492
+ the drag over, the keyboard is inert while a drag runs, and only the primary button starts one.
3493
+
3494
+ ```ts skip
3495
+ createResizablePanels(options: {
3496
+ panes: ReadonlyArray<{
3497
+ id?: string; // the pane's id; the separator's aria-controls names it
3498
+ min?: number; // smallest expanded size, percent (10)
3499
+ max?: number; // largest size, percent (80)
3500
+ defaultSize?: number; // the named start; panes that name none share what is left
3501
+ collapsible?: boolean; // Enter collapses to collapsedSize and restores the remembered size
3502
+ collapsedSize?: number; // 0
3503
+ label?: string; // the separator announces "Resize <label>"
3504
+ }>;
3505
+ orientation?: "vertical" | "horizontal"; // "vertical": a vertical bar, panes side by side
3506
+ sizes?: ReadonlyArray<number> | undefined; // controlled; undefined = uncontrolled
3507
+ defaultSizes?: ReadonlyArray<number>; // the uncontrolled start as a whole
3508
+ step?: number; // keyboard step, percent (10)
3509
+ id?: string; // base of the generated ids; generated when absent
3510
+ label?: string | undefined; // the group's aria-label
3511
+ storageKey?: string | undefined; // persist the layout in localStorage
3512
+ onSizesChange?(sizes: ReadonlyArray<number>): void; // a request the host may ignore
3513
+ }): {
3514
+ sizes(): ReadonlyArray<number>; // the committed layout, percent; sums to 100
3515
+ collapsed(index: number): boolean;
3516
+ resizing(): number | null; // the separator being dragged
3517
+ focusSeparator(): number | null;
3518
+ setFocusSeparator(index: number | null): void;
3519
+ keyDown(index: number, key: string): void; // the separator's keys, without an event
3520
+ groupProps: { role: "group"; "aria-label": string | undefined };
3521
+ paneProps(index: number): { id: string };
3522
+ separatorProps(index: number): {
3523
+ id;
3524
+ role: "separator";
3525
+ "aria-orientation": "vertical" | "horizontal";
3526
+ "aria-valuenow": number; // the committed size of the pane on its left
3527
+ "aria-valuemin": number; // that pane's min
3528
+ "aria-valuemax": number; // that pane's max
3529
+ "aria-controls": string; // the pane on its left
3530
+ "aria-label": string; // "Resize <label>", else "Resize"
3531
+ tabindex: 0;
3532
+ onKeyDown;
3533
+ onFocus;
3534
+ onBlur;
3535
+ onPointerDown;
3536
+ onPointerMove;
3537
+ onPointerUp;
3538
+ onPointerCancel;
3539
+ };
3540
+ };
3541
+ ```
3542
+
3543
+ - **The separator is the widget.** It is the only tab stop the primitive adds: `role=separator`,
3544
+ `aria-orientation` the group's, `aria-valuenow` the committed size of the pane on its left,
3545
+ `aria-valuemin` / `-max` that pane's `min` and `max` (10 and 80 by default), `aria-controls` its
3546
+ id, and `aria-label` "Resize <label>" (else "Resize"). A collapsible pane's committed
3547
+ `collapsedSize` (0) therefore reads below its announced minimum.
3548
+ - **Keyboard.** Left/Right move a vertical bar's pair (the default: panes side by side), Up/Down a
3549
+ horizontal one's, Home/End jump the pair to its bounds, and Enter collapses or restores; all six
3550
+ `preventDefault`, and every other key passes. Tab walks the bars in DOM order — there is no
3551
+ arrow navigation between them and no F6. A bar beside a collapsed pane is inert except Enter.
3552
+ - **Nested groups.** The drag measures `event.currentTarget.parentElement`'s box at pointerdown, so
3553
+ an inner group's bar is a share of the inner group, not of the workspace (2026-11-02), and an
3554
+ outer bar resizing the inner group does not re-solve its percentages. Each group keeps its own
3555
+ ids, `aria-controls`, orientation and keyboard.
3556
+ - **Persistence is the host's.** `storageKey` reads and writes `localStorage` best effort: a stored
3557
+ array of another length, or one with a non-number, is ignored, and a blocked or full store is
3558
+ silent. A pane mounted already collapsed restores to its own `min`, not to 0.
3559
+ - **Limits.** Percentages only (no pixel bounds), a fixed pane list, and a persisted layout with no
3560
+ version or namespace: the key belongs to the host's storage. F6 is left out; with three panes a
3561
+ Tab is enough.
3562
+
3563
+ ### Headless: `createCarousel` (`@cssints/headless`)
3564
+
3565
+ A carousel after the APG pattern: a rotator, previous and next controls, an optional tabbed picker,
3566
+ a slide that is a `group` (or `tabpanel`) and a pause policy. **It is not a scroll-snap strip**:
3567
+ every slide but the current one gets `hidden`, so the DOM holds one visible slide and no scroll
3568
+ container, and the primitive owns the slide semantics, the pause on hover and focus, and the live
3569
+ region. It owns no timer either: `tick()` is one rotation interval and the host calls it from its
3570
+ own interval (and passes `reducedMotion`, from its own `matchMedia`). Its behaviour is
3571
+ `packages/apg-specs/specs/apg/carousel.qnt`; `packages/headless/test/carousel.browser.test.tsx`
3572
+ replays the 51 scenarios of `scenarios/apg/carousel.apg.json` in Chromium and walks the model in
3573
+ seven configurations (plain, tabbed, auto-rotation off, started paused, hovering, controlled
3574
+ accepting, controlled pinned).
3575
+
3576
+ The host renders the wrapper with `carouselProps`, the rotation control (hidden when `autoRotate` is
3577
+ false), previous and next, a slide per item with `slideProps`, and for `tabbed` a `tablist` with one
3578
+ tab per slide. The host puts the rotation control first in the tab order, as the pattern asks. It is
3579
+ `hidden` rather than disabled when `autoRotate` is false, so the option removes it. `items` is read
3580
+ once (6, at least 1); `value` + `onValueChange` own the shown slide, `defaultIndex` starts it, and
3581
+ `next`, `prev`, `selectSlide`, `toggleRotation` and `tick` are requests too — `next` and `prev`
3582
+ wrap.
3583
+
3584
+ `autoRotate` (true) is the feature, `playing` (true) is the start and `reducedMotion` (false) starts
3585
+ it paused; `rotating()` is true when the feature is on, the state wants it, and either the user
3586
+ started it or neither focus nor hover is inside. Focus entering the carousel stops the rotation
3587
+ under `stickyFocusPause` (true) and the rotation control starts it again; a pointer press on a
3588
+ control suppresses the focus it would give, so a click does not count as focus entering. Hover is a
3589
+ real `mousemove` (`mouseenter` would mark an element that appeared under a stationary pointer), and
3590
+ it pauses a rotation the user did not start. `onRotatingChange` reports the user's intent, not the
3591
+ momentary pause. `focused()` names the part holding focus (`rotation`, `prev`, `next`, `tab:<i>`,
3592
+ `slide:<i>`), which a host syncs to the DOM as the test fixture does.
3593
+
3594
+ ```ts skip
3595
+ createCarousel(options?: {
3596
+ items?: number; // slides (6, at least 1)
3597
+ value?: number | undefined; // controlled shown slide; undefined = uncontrolled
3598
+ defaultIndex?: number; // the uncontrolled start (0)
3599
+ autoRotate?: boolean; // true; false removes (hides) the rotation control
3600
+ playing?: boolean; // true: rotation starts on
3601
+ reducedMotion?: boolean; // false: the host's prefers-reduced-motion starts it paused
3602
+ tabbed?: boolean; // tablist / tab / tabpanel instead of plain groups
3603
+ stickyFocusPause?: boolean; // true: focus entering stops rotation until Start
3604
+ hovering?: boolean; // the pointer starts over the carousel
3605
+ role?: "region" | "group"; // "group"
3606
+ labelledBy?: string | undefined; // the carousel's accessible name
3607
+ id?: string; // base of the generated ids
3608
+ onValueChange?(index: number): void; // a request the host may ignore
3609
+ onRotatingChange?(rotating: boolean): void; // the user's intent, not a hover/focus pause
3610
+ }): {
3611
+ items: number;
3612
+ current(): number;
3613
+ rotating(): boolean;
3614
+ focused(): "rotation" | "prev" | "next" | "tab:1" | "slide:0" | null; // the part holding focus
3615
+ next(): void; // wraps; a request
3616
+ prev(): void;
3617
+ selectSlide(index: number): void; // out of range is a no-op
3618
+ toggleRotation(): void;
3619
+ tick(): void; // one interval: next() while rotating
3620
+ carouselProps: {
3621
+ role: "region" | "group";
3622
+ "aria-roledescription": "carousel";
3623
+ "aria-live": "off" | "polite"; // off while rotating
3624
+ "aria-labelledby": string | undefined;
3625
+ onMouseMove;
3626
+ onMouseLeave;
3627
+ };
3628
+ rotationProps: {
3629
+ type: "button";
3630
+ id;
3631
+ "aria-label": string; // "Stop automatic slide show" / "Start automatic slide show"
3632
+ "aria-controls": string;
3633
+ hidden: boolean; // true when autoRotate is false
3634
+ onMouseDown;
3635
+ onClick;
3636
+ onFocus;
3637
+ onBlur;
3638
+ };
3639
+ prevProps: { type: "button"; id; "aria-controls": string; onMouseDown; onClick; onFocus; onBlur };
3640
+ nextProps: { type: "button"; id; "aria-controls": string; onMouseDown; onClick; onFocus; onBlur };
3641
+ slideProps(index: number): {
3642
+ id;
3643
+ role: "group" | "tabpanel";
3644
+ "aria-roledescription": "slide";
3645
+ "aria-label": string; // "1 of 6"
3646
+ tabindex: 0 | -1; // the current slide only
3647
+ hidden: boolean; // every other slide
3648
+ onFocus;
3649
+ onBlur;
3650
+ };
3651
+ tablistProps: { role: "tablist" };
3652
+ tabProps(index: number): {
3653
+ id;
3654
+ role: "tab";
3655
+ "aria-label": string; // "Slide 1"
3656
+ "aria-selected": "true" | "false";
3657
+ "aria-controls": string;
3658
+ tabindex: -1 | undefined; // undefined on the current tab (a native button)
3659
+ onClick;
3660
+ onFocus;
3661
+ onBlur;
3662
+ onKeyDown;
3663
+ };
3664
+ };
3665
+ ```
3666
+
3667
+ - **Keyboard.** Rotation, previous and next are native buttons, so Enter and Space are the
3668
+ browser's. A tab's ArrowRight / ArrowDown, ArrowLeft / ArrowUp, Home and End select the next,
3669
+ previous, first and last slide, wrapping, with automatic activation; a slide has no keys. Exactly
3670
+ one slide is in the tab order (`tabindex="0"` on the current, `-1` on the rest), and every
3671
+ non-current one is hidden.
3672
+ - **Live region.** The carousel wrapper is the region: `aria-live="off"` while rotating and
3673
+ `"polite"` otherwise. There is no second status element, and while it is paused a slide change is
3674
+ announced.
3675
+ - **ARIA.** `role` is `group` by default; pass `region` for a named landmark. A slide is a `group`
3676
+ (a `tabpanel` when `tabbed`) with `aria-roledescription="slide"` and "N of M" as its name.
3677
+ The controls' `aria-controls` names `<base>-slides`, so a host that wants the reference to
3678
+ resolve passes `id` and gives its slides wrapper that id. The rotation control's label flips
3679
+ between "Stop automatic slide show" and "Start automatic slide show", with no `aria-pressed`.
3680
+ - **Divergences.** Two ported scenarios use the `*` (any non-empty IDREF) convention, which the
3681
+ replay engine does not implement, and one asserts the raw `hidden=""` that the DOM driver reports
3682
+ as `"true"`; those three stay negative tests. A fourth assumes focus does not pause rotation,
3683
+ while the pattern text says it does, so the first activation restarts and the second stops: that
3684
+ expectation stays negative too, because the pattern text wins (the README's divergence policy).
3685
+ - **Limits.** The host owns the interval, the media query, the slide transition and the slide
3686
+ content. The picker activates automatically (there is no manual mode), and the grouped variant
3687
+ with `aria-disabled` picker buttons is not modelled.
3688
+
3689
+ ### Headless: `createFeed` (`@cssints/headless`)
3690
+
3691
+ The APG feed: a stream of articles that loads more when the reading cursor reaches the last loaded
3692
+ one, announces each article's place with `aria-posinset` / `aria-setsize`, and brackets a load with
3693
+ `aria-busy`. Its behaviour is `packages/apg-specs/specs/apg/feed.qnt`;
3694
+ `packages/headless/test/feed.browser.test.tsx` replays the 29 scenarios of
3695
+ `scenarios/apg/feed.apg.json` in Chromium and walks the model in seven configurations (1, 3 and 6
3696
+ loaded; both `aria-setsize` modes with the total known and unknown; a controlled host accepting or
3697
+ holding).
3698
+
3699
+ The host renders a focusable element before the feed and one after it (`beforeProps`,
3700
+ `afterProps`), the container (`feedProps`), and one article slot per `items` with
3701
+ `articleProps(posinset)`; slots past `loaded()` are hidden. `items` is the number of slots the host
3702
+ renders, not the loaded count: the controlled value is `value`, the uncontrolled start
3703
+ `defaultValue` (1), and `loaded()` is clamped to 1..`items`. `onValueChange` comes from
3704
+ `loadFinish`, so the request is the new count. The primitive never moves DOM focus: it records the
3705
+ cursor from each article's `onFocus`, and the host focuses the article (or sentinel) that `active()`
3706
+ names, as the test fixture does. The cursor starts on the `"before"` zone; an article's
3707
+ `aria-labelledby` / `aria-describedby` name the host's title and content elements
3708
+ (`<base>-article-<n>-title` / `-content`), which the host must create.
3709
+
3710
+ Load lifecycle: the primitive fetches nothing. Focus reaching the last loaded article, and
3711
+ `pageDown` onto it, call `loadStart`, which moves idle to loading only while more articles exist
3712
+ (`totalArticles` unknown or `loaded < total`), and `busy()` becomes `aria-busy="true"`. The skin
3713
+ watches that (or its own trigger), calls `loadStart` itself for a scroll sentinel, runs its loader,
3714
+ then reports back: `loadFinish(count)` requests `loaded + count`, clamped by the total, and returns
3715
+ to idle; `loadFail` returns to idle with the count unchanged. `loadFinish` is ignored when no load
3716
+ is in flight, and a controlled host that holds its `value` keeps the count while the busy state
3717
+ still ends. A host write of `value` also settles an in-flight load and pulls the cursor back into
3718
+ the new range.
3719
+
3720
+ `aria-setsize` depends on `sizeMode`: `"loaded"` (the default) announces the loaded count,
3721
+ `"total"` the server total when `totalKnown` is true, else `-1` (ARIA's undetermined);
3722
+ `totalArticles: null` means the total is not known. `setSizeMode` and `setTotalKnown` change the
3723
+ mode at run time, and `aria-posinset` is the slot's 1-based position.
3724
+
3725
+ ```ts skip
3726
+ createFeed(options?: {
3727
+ items?: number; // article slots the host renders (6, at least 1)
3728
+ totalArticles?: number | null; // the server total; null = undetermined
3729
+ value?: number | undefined; // controlled loaded count; undefined = uncontrolled
3730
+ defaultValue?: number; // uncontrolled start (1, at least 1 and at most items)
3731
+ sizeMode?: "loaded" | "total"; // "loaded": aria-setsize is the loaded count
3732
+ totalKnown?: boolean; // false: total mode announces -1 until known
3733
+ labelledBy?: string | undefined; // the feed's accessible name
3734
+ id?: string; // base of the generated ids
3735
+ onValueChange?(loaded: number): void; // a request the host may ignore
3736
+ }): {
3737
+ items: number;
3738
+ loaded(): number; // the committed count, 1..items
3739
+ busy(): boolean; // a load is in flight (aria-busy)
3740
+ active(): number | "before" | "after"; // the reading cursor: a 1-based posinset or a zone
3741
+ focusArticle(posinset: number): void; // a loaded, rendered article only
3742
+ pageDown(): void;
3743
+ pageUp(): void;
3744
+ ctrlEnd(): void;
3745
+ ctrlHome(): void;
3746
+ loadStart(): void; // idle -> loading, only when more articles exist
3747
+ loadFinish(count: number): void; // a batch arrived: a request to grow the count
3748
+ loadFail(): void; // end the busy state, keep the count
3749
+ setSizeMode(mode: "loaded" | "total"): void;
3750
+ setTotalKnown(known: boolean): void;
3751
+ feedProps: {
3752
+ id;
3753
+ role: "feed";
3754
+ "aria-busy": "true" | undefined;
3755
+ "aria-labelledby": string | undefined;
3756
+ };
3757
+ articleProps(posinset: number): {
3758
+ id;
3759
+ role: "article";
3760
+ "aria-posinset": number;
3761
+ "aria-setsize": number; // the loaded count, the total, or -1
3762
+ "aria-labelledby": string; // <base>-article-<n>-title
3763
+ "aria-describedby": string; // <base>-article-<n>-content
3764
+ tabindex: 0;
3765
+ hidden: boolean; // every slot past the loaded count
3766
+ onFocus;
3767
+ onKeyDown;
3768
+ };
3769
+ beforeProps: { id; tabindex: 0; onFocus }; // an element just before the feed
3770
+ afterProps: { id; tabindex: 0; onFocus }; // an element just after it
3771
+ };
3772
+ ```
3773
+
3774
+ - **Keyboard.** Page Down and Page Up move the reading cursor to the next and previous article and
3775
+ stop at the last loaded one (Page Down there asks for more); Control+End and Control+Home leave
3776
+ the feed for `afterProps` / `beforeProps`, not for the first article. Tab enters the feed on any
3777
+ loaded article and leaves it from any, and the arrows do nothing.
3778
+ - **Divergence.** The APG example focuses the first article on Control+Home; the pattern text says
3779
+ "the first focusable element before the feed", so `beforeProps` gets it and the ported scenario
3780
+ stays a negative test. Two `*`-IDREF scenarios are negative for the replay engine's sake.
3781
+ - **Limits.** `items` is a slot count, so the host decides how many article elements exist; the
3782
+ primitive observes no scroll and owns no focus. A batch only grows the count (a removal is a host
3783
+ write of `value`), and a load is one batch, not a queue.
3784
+
3785
+ ### Headless: `createPlayer` (`@cssints/headless`)
3786
+
3787
+ The media player as a composition: there is no APG player pattern, so the control surface restates
3788
+ the APG Slider (the media seek slider), Toolbar, Button and Menu Button contracts, and the playback
3789
+ machine is the HTML `HTMLMediaElement` state and event contract reduced to the stages a UI can show.
3790
+ `packages/apg-specs/specs/apg/player.qnt` is that composition (the generator imports pure defs
3791
+ only, so the slider, toolbar and button specs are restated, not imported); there is no ported
3792
+ scenario file, and `packages/headless/test/player.browser.test.tsx` is the oracle: four differential
3793
+ walks (uncontrolled at 90 s and 1 s, a controlled host accepting and holding) plus real-DOM checks
3794
+ of the controlled preferences, the `m:ss` value text, Space on the surface, the seek keys and the
3795
+ pressed states. The contract behind it is `packages/headless/docs/player-aria-contract.md`.
3796
+
3797
+ The engine is injected as a `PlaybackAdapter`, so `@cssints/headless` imports no media library
3798
+ (Mediabunny is one adapter). The adapter owns transport and decoding; the primitive owns the stage,
3799
+ position and duration, the four preferences, and the seek slider's ARIA and keys. It subscribes at
3800
+ creation, calls `load(src ?? "")` after the first render, and unsubscribes and disposes on cleanup.
3801
+ Stages are `loading` at mount, `ready` on `loadedmetadata` (which also sets the duration and resets
3802
+ the position to 0), `playing`, `paused`, `buffering` (`waiting` while playing, `playing` back),
3803
+ `ended` (`ended` while playing sets the position to the duration) and `error` (an adapter `error`,
3804
+ or a rejected load or play; the cause is not exposed). `timeupdate` moves the position only while
3805
+ playing; `pause` from the adapter affects only an active stage; `seeking` and `seeked` change no
3806
+ state; a `ratechange` is a request for the rate. `play` is accepted from ready, paused, buffering
3807
+ and ended (from ended it starts at 0), `pause` from playing or buffering, and `seek` does nothing
3808
+ until metadata has arrived; a seek clamps to the duration and leaves the ended stage paused.
3809
+
3810
+ The four preferences follow the request/commit model, each with `<name>`, `default<Name>` and
3811
+ `on<Name>Change`: `volume` is 0..100 from a linear control, applied to the adapter as a squared gain
3812
+ (the perceptual curve); `muted` is a boolean; `rate` is a percent, where `setRate` accepts 50, 100,
3813
+ 150 or 200 and ignores anything else; `fullscreen` is a flag only, and the host applies the
3814
+ Fullscreen API. The adapter follows the committed values, so a request a controlled host rejects
3815
+ never reaches it; a `ratechange` from the adapter is taken as `round(rate × 100)` and, controlled,
3816
+ waits for the host like the other preferences.
3817
+
3818
+ ```ts skip
3819
+ interface PlaybackAdapter {
3820
+ load(src: string): Promise<void>; // emits loadedmetadata once the duration is known
3821
+ play(): Promise<void>;
3822
+ pause(): void;
3823
+ seek(seconds: number): Promise<void>;
3824
+ setVolume(gain: number): void; // 0..1, already muted-resolved
3825
+ setPlaybackRate(rate: number): void; // 1 = normal
3826
+ dispose(): void;
3827
+ subscribe(listener: (event: PlaybackAdapterEvent) => void): () => void;
3828
+ }
3829
+
3830
+ type PlaybackAdapterEvent =
3831
+ | { type: "loadedmetadata"; duration: number }
3832
+ | { type: "canplay" | "playing" | "pause" | "waiting" | "seeking" | "seeked" | "ended" }
3833
+ | { type: "timeupdate"; currentTime: number }
3834
+ | { type: "ratechange"; rate: number }
3835
+ | { type: "error"; cause: unknown };
3836
+
3837
+ createPlayer(options: {
3838
+ adapter: PlaybackAdapter;
3839
+ src?: string; // the load source; "" when absent
3840
+ volume?: number | undefined; // controlled, 0..100; undefined = uncontrolled
3841
+ defaultVolume?: number; // 100
3842
+ onVolumeChange?(volume: number): void; // a request the host may ignore
3843
+ muted?: boolean | undefined;
3844
+ defaultMuted?: boolean; // false
3845
+ onMutedChange?(muted: boolean): void;
3846
+ rate?: number | undefined; // controlled percent: 50, 100, 150 or 200
3847
+ defaultRate?: number; // 100
3848
+ onRateChange?(percent: number): void;
3849
+ fullscreen?: boolean | undefined;
3850
+ defaultFullscreen?: boolean; // false
3851
+ onFullscreenChange?(fullscreen: boolean): void;
3852
+ onPlay?(): void;
3853
+ onPause?(): void;
3854
+ onSeek?(seconds: number): void;
3855
+ onEnded?(): void;
3856
+ }): {
3857
+ stage(): "loading" | "ready" | "playing" | "paused" | "buffering" | "ended" | "error";
3858
+ position(): number; // seconds
3859
+ duration(): number; // 0 until loadedmetadata
3860
+ muted(): boolean;
3861
+ volume(): number; // 0..100
3862
+ rate(): number; // percent
3863
+ fullscreen(): boolean;
3864
+ focus(): "none" | "surface" | "seek" | "control";
3865
+ play(): void;
3866
+ pause(): void;
3867
+ togglePlay(): void;
3868
+ seek(seconds: number): void; // a commit: a no-op until the duration is known
3869
+ setVolume(value: number): void; // clamped to 0..100
3870
+ toggleMute(): void;
3871
+ setRate(percent: number): void; // 50, 100, 150 or 200; anything else is ignored
3872
+ toggleFullscreen(): void;
3873
+ surfaceProps: { tabindex: 0; onKeyDown; onFocus; onBlur };
3874
+ playProps: {
3875
+ type: "button";
3876
+ "aria-label": "Play" | "Pause";
3877
+ onClick;
3878
+ onMouseDown;
3879
+ onFocus;
3880
+ onBlur;
3881
+ };
3882
+ seekProps: {
3883
+ role: "slider";
3884
+ tabindex: 0;
3885
+ "aria-valuemin": 0;
3886
+ "aria-valuenow": number; // the position
3887
+ "aria-valuemax": number; // the duration
3888
+ "aria-valuetext": string; // "m:ss"
3889
+ onKeyDown;
3890
+ onFocus;
3891
+ onBlur;
3892
+ };
3893
+ muteProps: {
3894
+ type: "button";
3895
+ "aria-pressed": "true" | "false";
3896
+ onClick;
3897
+ onMouseDown;
3898
+ onFocus;
3899
+ onBlur;
3900
+ };
3901
+ fullscreenProps: {
3902
+ type: "button";
3903
+ "aria-pressed": "true" | "false";
3904
+ onClick;
3905
+ onMouseDown;
3906
+ onFocus;
3907
+ onBlur;
3908
+ };
3909
+ };
3910
+ ```
3911
+
3912
+ - **Keyboard.** On the surface: Space (or the legacy `Spacebar`), `k` or `K` toggles play and
3913
+ pause; ArrowLeft / ArrowRight seek -5 s / +5 s; `f` / `F` toggles fullscreen; `m` / `M` toggles
3914
+ mute. On the seek slider: ArrowLeft / ArrowDown -1 s, ArrowRight / ArrowUp +1 s, PageUp +15 s,
3915
+ PageDown -15 s, Home 0 and End the duration. A handled key `preventDefault`s; anything else
3916
+ passes.
3917
+ - **Focus.** `focus()` is `surface`, `seek`, `control` or `none`. Every control is its own tab stop
3918
+ (there is no roving toolbar), but a pointer press does not move focus: each button prevents its
3919
+ `mousedown` default, so the surface shortcuts keep their scope after a click.
3920
+ - **Announcement.** There is no live region: the seek slider's `aria-valuetext` (`m:ss`, minutes
3921
+ never folded into hours) is what a screen reader reads while it holds focus, and the play button's
3922
+ label flips between "Play" and "Pause". `aria-valuenow` and `-max` are the position and duration
3923
+ in seconds.
3924
+ - **The host's parts.** The primitive has no volume or rate control: render one from `volume()` and
3925
+ `setVolume` (or `createSlider`) and from `rate()` and `setRate`. The mute and fullscreen buttons
3926
+ carry only `aria-pressed` and no accessible name, so label them after the spread; the surface has
3927
+ no role, so name it. The seek slider has no pointer handlers: a drag is the host's, mapped to
3928
+ `seek` (a commit, with no preview).
3929
+ - **Limits.** `fullscreen` is a flag, not a Fullscreen API call. `src` is read at mount (there is no
3930
+ reload and no retry), a live stream is out of scope (`duration` is taken as finite), and the seek
3931
+ slider does not mirror its arrows under RTL — a time axis does not flip, a deliberate divergence
3932
+ from `slider.qnt`.
3933
+
3934
+ ### Stories: `@cssints/stories` (decided 2026-11-02)
3935
+
3936
+ The CSF 3 loader of a storybook, with no DOM, no styles and no chrome: the modules an app globs in, the entries with the ids
3937
+ Storybook builds out, and the controls a story declares. It serves the starters' site, the reactive lab and (the reason it is
3938
+ a package rather than two copies) a native window.
3939
+
3940
+ ```ts skip
3941
+ // src/registry/button/button.stories.tsx — a Storybook CSF 3 file, unchanged
3942
+ import type { Meta, Story } from "@cssints/stories";
3943
+ import { Button, type ButtonProps } from "./button.tsx";
3944
+
3945
+ const meta = {
3946
+ title: "Button",
3947
+ component: Button,
3948
+ args: { children: "Save", tone: "neutral" },
3949
+ argTypes: {
3950
+ tone: { type: "select", options: ["neutral", "accent", "quiet"] },
3951
+ disabled: { type: "boolean" },
3952
+ },
3953
+ } satisfies Meta<ButtonProps>;
3954
+ export default meta;
3955
+
3956
+ export const Default: Story<ButtonProps> = {};
3957
+ export const Publish: Story<ButtonProps> = { args: { tone: "accent", children: "Publish" } };
3958
+ ```
3959
+
3960
+ ```ts skip
3961
+ import { entries, parseHash, storyOfId, type Module } from "@cssints/stories";
3962
+
3963
+ const modules = import.meta.glob<Module>("../registry/*/*.stories.tsx", { eager: true });
3964
+ const stories = entries(modules); // [{ id: "button--default", item: "button", view, args, argTypes, … }]
3965
+ ```
3966
+
3967
+ - **`entries(modules, itemOf?)`** returns `Entry[]`: `id` (`button--publish`, the item's name and the story's,
3968
+ kebab-cased), `item` (the story file's directory, or whatever `itemOf` reads from the path), `title`, `name`, `args` (the
3969
+ meta's with the story's over them), `argTypes`, and `view(args)` — the meta's `component` called through
3970
+ `createComponent`, or the story's own `render`. Entries are sorted by item and then by id; within a file the module
3971
+ namespace's order is what Storybook lists.
3972
+ - **`storyOfId(stories, id)`** finds one entry, and **`parseHash(hash)`** splits `#button--publish` into the page
3973
+ (`button`) and the story, so an app's hash is a link to a story.
3974
+ - **`argTypes`** is the controls model: `{ type: "boolean" }`, `{ type: "select", options }`, `{ type: "text" }`,
3975
+ `{ type: "number", min?, max?, step? }`, `{ type: "range", min, max, step? }`. Storybook infers a control from a
3976
+ component's props; a Solid component carries none at run time, so a story declares what it wants. **The panel is the
3977
+ app's**: the site draws it on the kit's `Field`, `Select`, `Switch` and `Slider`, and a story with no `argTypes` gets no
3978
+ panel.
3979
+ - **`Node`** is a type parameter: `entries<JSX.Element>(modules)` on the web, `entries<NativeNode>(modules)` in
3980
+ `cssints-native`, and the package never names a renderer. `solid-js` is a peer (for `createComponent` alone).
3981
+
2294
3982
  ### Publishing a library (decided 2026-10-02, cssints-0l3f)
2295
3983
 
2296
3984
  A package written on cssints ships JavaScript that still has the attribute, and its `.d.ts`. The app that installs it evaluates the package with its own engine: one sheet, the app's browserslist and themes, and a token, a kernel or an `@property` of the package is made once however many modules use it. Nothing is declared: the engine recognises a file by `with { type: "cssints" }`, the same marker as in app code.