cssints 0.25.0 → 0.27.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 +151 -25
  2. package/package.json +2 -2
package/docs/API.md CHANGED
@@ -1223,27 +1223,66 @@ export const typo = mdi("clos"); // error: not an icon of mdi
1223
1223
 
1224
1224
  More plugins on `scope()`, each a package of its own with its rules and limits in its README. Import them with the attribute, like `cssints`.
1225
1225
 
1226
- | Package | Members | What it does |
1227
- | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1228
- | [`@cssints/fluid`](../packages/fluid/README.md) | `fluid(min, max, { from?, to?, zoom? })`, `fluid.container(...)` | A `clamp()` that grows between two widths, computed at build time; a range that fails WCAG 1.4.4 is a build error |
1229
- | [`@cssints/layout`](../packages/layout/README.md) | `stack()`, `cluster()`, `sidebar()`, `switcher()`, `cover()`, `autoGrid()` | Every Layout primitives as chain members |
1230
- | [`@cssints/fonts`](../packages/fonts/README.md) | `font(family, { provider, ... })` → `.family()`, `.trim()` | `@font-face` and a metric-matched fallback face; cap-height trimming by `text-box`, else capsize's margins |
1231
- | [`@cssints/reset`](../packages/reset/README.md) | `reset({ media?, prose? })` | A modern reset in a top-level `@layer reset` declared before the engine's, below every style; `@cssints/reset/<baseline group>` keeps only the declarations of that group |
1232
- | [`@cssints/tokens`](../packages/tokens/README.md) | `/open-props`, `/radix`, `/flexoki`, `/reasonable-colors`, `/madoromi` | DTCG data for `createTokens` |
1233
- | [`@cssints/gradient`](../packages/gradient/README.md) | `gradient(kind, direction, from, to, easing?, stops?)` | Eased colour stops mixed in oklab at build time |
1234
- | [`@cssints/gradient-2d`](../packages/gradient-2d/README.md) | `meshGradient(mesh)`, `freeformGradient(points)` | `mesh-gradient()` / `freeform-gradient()` where the browser has them, else a runtime's canvas (this package's algorithms; lines and shapes not yet) |
1235
- | [`@cssints/color-names`](../packages/color-names/README.md) | `named(name)`, `colorNames.<sector>(name)`, `/nearest` | The Color Name List (31,939 names) as hex colours; the ten sector members keep an editor's completion short, `nearest()` is the build-time reverse lookup |
1236
- | [`@cssints/motion`](../packages/motion/README.md) | `motion().preset().duration().delay().easing()` | Animation presets over `keyframes()`, with reduced motion |
1237
- | [`@cssints/squircle`](../packages/squircle/README.md) | `squircle().radius()` | `corner-shape: squircle`, else a CSS `mask` of the same shape |
1238
- | [`@cssints/reveal`](../packages/reveal/README.md) | `reveal().preset().range().duration().easing()`, `appear().preset().range().duration().easing().inOut()` | motion's entrance presets on `animation-timeline: view()` (reveal) or on `timeline-trigger` + `animation-trigger` (appear); for other targets a script plays it once on entry (delivered by the runtime) |
1239
- | [`@cssints/stuck`](../packages/stuck/README.md) | `sticky().top()`, `stuck(styles)`, `stuck("bottom")(styles)` | Styles inside a sticky element while it is stuck, by `@container scroll-state()`; for other targets an `IntersectionObserver` (delivered by the runtime) |
1240
- | [`@cssints/balance`](../packages/balance/README.md) | `balance()`, `pretty()` | `text-wrap: balance` / `pretty`; for targets without balance a script balances the lines (delivered by the runtime) |
1241
- | [`@cssints/masonry`](../packages/masonry/README.md) | `masonry().columns().gap()` | `display: grid-lanes`, else CSS `columns` (top to bottom per column) |
1242
- | [`@cssints/placeholder`](../packages/placeholder/README.md) | `placeholder(path, { mode? })` | An `<image>` from a JPEG or PNG read at build time: a gradient of its colours laid out as the image, or a tiny thumbhash PNG |
1243
- | [`@cssints/ramp`](../packages/ramp/README.md) | `ramp(base, { mode, steps \| ratios, background })`, `overrides(ramp)` | An OKLCH palette as DTCG data for `createTokens`; the contrast mode meets each ratio against its background |
1244
- | [`@cssints/rybitten`](../packages/rybitten/README.md) | `color(hue, { cube })`, `wheel(steps, { cube })`, `shades(hue, lightness)`, `overrides(group)` | RYBitten's red-yellow-blue colours (36 historical cubes) as DTCG data for `createTokens`: a hue, a wheel of hues, a hue at several lightnesses |
1245
- | [`@cssints/skeleton`](../packages/skeleton/README.md) | `skeleton().radius().color().highlight()` | Loading placeholders whose shimmer is in phase across the page (one clock on `:root`), static under reduced motion |
1246
- | [`@cssints/reactive`](../packages/reactive/README.md) | `state()`, `toggle()`, `set()`, `value()`, `checked()`, `persist()`, `equals()`, `either()`, `on()`, `bind()`, `when()`, `inside()` | State as custom properties, events as data on the element: writes only in events, conditions and DOM output in plain CSS, wired by a 1.3 kB runtime shipped to every target; `value()`/`checked()` take the field's value, calls of `on()`, `bind()` or `state()` in one `cn()` append, `persist(value, key)` keeps a state across a reload, and `bind()` takes a source, a comparison (`equals()`), a choice of two literals (`either()`) or a literal — the one expression the runtime evaluates, because an attribute on the element cannot be computed in CSS (2026-10-31, cssints-ac3o) |
1226
+ | Package | Members | What it does |
1227
+ | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1228
+ | [`@cssints/fluid`](../packages/fluid/README.md) | `fluid(min, max, { from?, to?, zoom? })`, `fluid.container(...)` | A `clamp()` that grows between two widths, computed at build time; a range that fails WCAG 1.4.4 is a build error |
1229
+ | [`@cssints/layout`](../packages/layout/README.md) | `stack()`, `cluster()`, `sidebar()`, `switcher()`, `cover()`, `autoGrid()` | Every Layout primitives as chain members |
1230
+ | [`@cssints/fonts`](../packages/fonts/README.md) | `font(family, { provider, ... })` → `.family()`, `.trim()` | `@font-face` and a metric-matched fallback face; cap-height trimming by `text-box`, else capsize's margins |
1231
+ | [`@cssints/reset`](../packages/reset/README.md) | `reset({ media?, prose? })` | A modern reset in a top-level `@layer reset` declared before the engine's, below every style; `@cssints/reset/<baseline group>` keeps only the declarations of that group |
1232
+ | [`@cssints/tokens`](../packages/tokens/README.md) | `/open-props`, `/radix`, `/flexoki`, `/reasonable-colors`, `/madoromi` | DTCG data for `createTokens` |
1233
+ | [`@cssints/gradient`](../packages/gradient/README.md) | `gradient(kind, direction, from, to, easing?, stops?)` | Eased colour stops mixed in oklab at build time |
1234
+ | [`@cssints/gradient-2d`](../packages/gradient-2d/README.md) | `meshGradient(mesh)`, `freeformGradient(points)` | `mesh-gradient()` / `freeform-gradient()` where the browser has them, else a runtime's canvas (this package's algorithms; lines and shapes not yet) |
1235
+ | [`@cssints/color-names`](../packages/color-names/README.md) | `named(name)`, `colorNames.<sector>(name)`, `/nearest` | The Color Name List (31,939 names) as hex colours; the ten sector members keep an editor's completion short, `nearest()` is the build-time reverse lookup |
1236
+ | [`@cssints/motion`](../packages/motion/README.md) | `motion().preset().duration().delay().easing()` | Animation presets over `keyframes()`, with reduced motion |
1237
+ | [`@cssints/squircle`](../packages/squircle/README.md) | `squircle().radius()` | `corner-shape: squircle`, else a CSS `mask` of the same shape |
1238
+ | [`@cssints/reveal`](../packages/reveal/README.md) | `reveal().preset().range().duration().easing()`, `appear().preset().range().duration().easing().inOut()` | motion's entrance presets on `animation-timeline: view()` (reveal) or on `timeline-trigger` + `animation-trigger` (appear); for other targets a script plays it once on entry (delivered by the runtime) |
1239
+ | [`@cssints/stuck`](../packages/stuck/README.md) | `sticky().top()`, `stuck(styles)`, `stuck("bottom")(styles)` | Styles inside a sticky element while it is stuck, by `@container scroll-state()`; for other targets an `IntersectionObserver` (delivered by the runtime) |
1240
+ | [`@cssints/balance`](../packages/balance/README.md) | `balance()`, `pretty()` | `text-wrap: balance` / `pretty`; for targets without balance a script balances the lines (delivered by the runtime) |
1241
+ | [`@cssints/masonry`](../packages/masonry/README.md) | `masonry().columns().gap()` | `display: grid-lanes`, else CSS `columns` (top to bottom per column) |
1242
+ | [`@cssints/placeholder`](../packages/placeholder/README.md) | `placeholder(path, { mode? })` | An `<image>` from a JPEG or PNG read at build time: a gradient of its colours laid out as the image, or a tiny thumbhash PNG |
1243
+ | [`@cssints/ramp`](../packages/ramp/README.md) | `ramp(base, { mode, steps \| ratios, background })`, `overrides(ramp)` | An OKLCH palette as DTCG data for `createTokens`; the contrast mode meets each ratio against its background |
1244
+ | [`@cssints/rybitten`](../packages/rybitten/README.md) | `color(hue, { cube })`, `wheel(steps, { cube })`, `shades(hue, lightness)`, `overrides(group)` | RYBitten's red-yellow-blue colours (36 historical cubes) as DTCG data for `createTokens`: a hue, a wheel of hues, a hue at several lightnesses |
1245
+ | [`@cssints/skeleton`](../packages/skeleton/README.md) | `skeleton().radius().color().highlight()` | Loading placeholders whose shimmer is in phase across the page (one clock on `:root`), static under reduced motion |
1246
+ | [`@cssints/reactive`](../packages/reactive/README.md) | `state()`, `toggle()`, `set()`, `value()`, `checked()`, `persist()`, `equals()`, `either()`, `on()`, `emit()`, `bind()`, `when()`, `inside()` | State as custom properties, events as data on the element: writes only in events, conditions and DOM output in plain CSS, wired by a 1.4 kB runtime shipped to every target; `value()`/`checked()` take the field's value, calls of `on()`, `bind()` or `state()` in one `cn()` append, `persist(value, key)` keeps a state across a reload, and `bind()` takes a source, a comparison (`equals()`), a choice of two literals (`either()`) or a literal — the one expression the runtime evaluates, because an attribute on the element cannot be computed in CSS (2026-10-31, cssints-ac3o). `emit(name)` is the one hand-off to JavaScript: `on({ submit: emit("save") })` writes no state and dispatches a bubbling `reactive:save` from the element the native event fired on, with `{ event, owner }` in its detail, which `listen(target, name, handler)` from `@cssints/reactive/runtime` hears and whose disposer it returns (2026-10-31, cssints-4s8d) |
1247
+
1248
+ ### A state machine for UI protocols: `@cssints/machine` (cssints-im52)
1249
+
1250
+ A signal holds state, not a protocol. `@cssints/machine` is the protocol: a state machine for the behaviour a component grows the moment it animates — a `closing` and an `opening` in one frame, a cancelled animation that still reports back, an interrupted close that reverses from where the box is, a burst of pointer events, a debounce that must not swallow the last intent. It has no dependencies; `./solid` is the only file that imports anything, and solid-js is its optional peer. The README has the animated-disclosure example and the five properties; `test/machine.mjs` asserts them in plain node.
1251
+
1252
+ ```ts skip
1253
+ defineMachine(config: {
1254
+ initial: Context; // the context, inferred from this object
1255
+ states: { [name]: {
1256
+ on?: { [event]: transition | transition[] }; // the event union; the first guard that passes wins
1257
+ after?: { [ms]: transition }; // cleared when the state is left
1258
+ effect?: EffectSpec[]; // issued on entry, cancelled on exit
1259
+ }};
1260
+ start?: StateName; // the first key of states by default
1261
+ debounce?: { [event]: ms };
1262
+ }): MachineDefinition;
1263
+
1264
+ defineMachine<Payloads>()(config) // typed payloads: { close: undefined; finish: { id: number } }
1265
+ createMachine(definition, { clock?, autoStart?, onEffect?, onCancel?, onTransition? }): Machine
1266
+
1267
+ transition = StateName | { target, guard?(context, event), effect?, assign? }
1268
+
1269
+ Machine: getState() · subscribe(listener) · subscribe(selector, listener) · send(event, { debounce? }) ·
1270
+ flush() · cancel(type?) · effects() · onEffect/onCancel/onTransition · start() · destroy()
1271
+
1272
+ harness(definition): { machine, clock, log, send, enqueue, deliver, deliverAll, advance, race, interleave,
1273
+ pendingEffects, flushEffects, cancelled }
1274
+
1275
+ createMachineStore(machine, { settled?, intent? }): { state, optimistic, projection, send } // ./solid
1276
+ ```
1277
+
1278
+ - **Events are a union derived from the `on` keys**, the context is inferred from `initial`, and a `send` the states do not accept is a type error (`test/types/machine.ts`). A payload map, declared once as the type argument, types the payloads; a guard or an `assign` narrows on `event.type`.
1279
+ - **Effects are data**, not callbacks: `{ type: "animate", ... }`. The machine issues a handle per effect (`{ id, type, spec, state }`) and cancels it when the state is left or the transition is superseded. A completion is an event carrying the handle's numeric `id`: a stale id is dropped, a live one completes without a cancellation. `effects()` is what the host should be running now.
1280
+ - **Debounce** coalesces a burst by event type; `flush()` delivers the final intent, `cancel(type)` drops a superseded one. The same clock drives `after` and a transition's timestamp.
1281
+ - **The clock is injected** (`systemClock` is the default); the harness's fake clock moves only on `advance(ms)`, and the harness logs `{ at, event, from, to, effect, context, cancelled }` per delivery (and per effect it issued).
1282
+ - **The state is an external store**: `getState()`, `subscribe(listener)` and `subscribe(selector, listener)`, the last one firing only when the slice changes.
1283
+ - **`./solid`**: `state` is a `createStore` of `{ ...context, phase }` written once per transition, mutating only the changed fields; `optimistic` is a derived `createOptimisticStore` whose intent, written inside a Solid `action`, reverts to the committed snapshot at settle (a superseded intent rolls back); `projection` is a `createProjection` over `state`, so a reader of a view's field does not re-run when another field changes. A projection reads the committed frame, not an optimistic override, so the visual phase is read off `optimistic` directly. One store write per transition is the atomicity the reactive runtime gets from its one flush (cssints-tm9j).
1284
+
1285
+ Limits: one state at a time (no parallel states, history states or nested machines), effects are data the host runs, and a numeric event `id` is reserved for an effect handle. There is no Quint spec for this runtime yet; the harness is the oracle.
1247
1286
 
1248
1287
  ### Headless: `createField` and `createConfirm` (`@cssints/headless`)
1249
1288
 
@@ -1319,8 +1358,10 @@ createToast(options?: {
1319
1358
  description?: string | undefined;
1320
1359
  tone?: "info" | "success" | "error" | undefined; // "info"
1321
1360
  }): string; // the id
1322
- dismiss(id: string): void;
1361
+ dismiss(id: string): void; // removes now, skipping whatever a renderer would animate
1323
1362
  dismissAll(): void;
1363
+ requestDismiss(id: string): void; // asks the renderer to remove it; removes at once when none claims it
1364
+ onDismissRequest(handler: (id: string) => boolean): () => void; // the expiry: true when this row owns it, then dismiss
1324
1365
  regionProps: { role: "region"; "aria-label": string }; // the F6 target
1325
1366
  toastProps(id: string): {
1326
1367
  role: "alert" | "status"; // "alert" for tone "error", "status" otherwise
@@ -1337,6 +1378,8 @@ createToast(options?: {
1337
1378
 
1338
1379
  A toast is its own live region inside the `role=region` landmark: `role=alert` for `tone: "error"` (assertive) and `role=status` otherwise (polite). The host renders the `title` and `description` handed to `show` inside that element, so the toast's own text is the announcement — `aria-atomic` reads all of it, a close button's label included, so keep a toast to its title and description and give a close button no more than an icon with a label. The role sits on the toast rather than on one container per tone: the container is the `region` landmark, so it cannot also be the live region, and a role per toast needs no second region from the host. One timer per toast honours `timeout`; hover pauses it when `pauseOnHover` is true and focus always pauses, and both resume with the time that was left rather than restarting the deadline. `max` drops the oldest toast past the ceiling, and `onDismiss` fires for every toast that leaves. F6 into the region is the host's handler (hotkeys are a separate bean, cssints-h0d5): give `regionProps` an element with `tabindex="-1"` and focus it, remembering where focus was.
1339
1380
 
1381
+ The countdown does not remove a toast itself: its expiry calls every `onDismissRequest` handler and the removal is the renderer's — a handler returns `true` when it owns that row and calls `dismiss(id)` once the row has finished leaving, so a departure can be animated. With no handler, none that claims the id (a row that has not mounted yet), and for `requestDismiss(id)`, the toast leaves at once, as `dismiss(id)` always does; `dismissAll()` does too, and so does the ceiling, which replaces the dropped row with the new one.
1382
+
1340
1383
  ### Headless: `createPopover` (`@cssints/headless`)
1341
1384
 
1342
1385
  **No APG pattern exists** for a popover (APG has Dialog, which is modal), so it has no Quint spec and no ported scenarios: `packages/headless/test/popover.browser.test.tsx` is its oracle. The behaviour is copied from Base UI's Popover (a trigger click toggles, Enter / Space / ArrowDown open, an outside `pointerdown` light-dismisses without stealing focus) and React Aria's `usePopover` (focus into the content on open and back to the trigger on close, `role="dialog"` without `aria-modal`, a placement that flips at the viewport edge).
@@ -1478,7 +1521,7 @@ Close and rename are an **extension** (bean `cssints-gdv2`, the docs playground'
1478
1521
 
1479
1522
  ```ts skip
1480
1523
  createTabs(options?: {
1481
- ...; // the plain tabs: items, value, defaultSelected, orientation, activation, disabled, focusablePanels, id, tabId, panelId, onValueChange
1524
+ ...; // the plain tabs: items, value, defaultSelected, orientation, dir, activation, disabled, focusablePanels, id, tabId, panelId, onValueChange
1482
1525
  closable?: boolean; // false: closeProps and the Delete key; the host removes from onClose
1483
1526
  renamable?: boolean; // false: a double click edits the label inline; the host renames from onRename
1484
1527
  label?(index: number): string | undefined; // the tab's name: the buttons' labels and the rename draft
@@ -1503,6 +1546,7 @@ createTabs(options?: {
1503
1546
  - **Close.** `closeProps(index)` is the tab's close button: `aria-label="Close <label>"` and `tabindex=-1`, because a tablist's tab stops are its tabs; the keyboard path is Delete on the focused tab. Focus after a close goes to the tab that slid into the closed one's place, or to the previous tab when the last one closed: `focusAfterClose(index)` names that index in the shortened list (call it before the host removes the tab). Closing the last remaining tab is refused, as the spec refuses an empty tablist; a closed tab that was selected asks for the tab that takes the focus.
1504
1547
  - **Rename.** A double click begins; the tab renders `inputProps` instead of its label. Enter commits, Escape reverts, blur commits, and a label that trims to nothing cancels. While editing, the input owns the keys: the tablist's roving does not move. `begin(index)` is the programmatic start, for a tab the host has just added (append it, then `focusTab(index)` and `begin(index)`).
1505
1548
  - **Ids.** `tabId(index)` and `panelId(index)` replace the generated `${id}-tab-<index>` / `${id}-tabpanel-<index>`, and `aria-controls` / `aria-labelledby` follow them. Use them when the host's markup or a deep link already names a panel (`#logs`): the alternative is a second id on the same element, which no attribute can carry.
1549
+ - **Direction.** `dir: "rtl"` swaps ArrowLeft / ArrowRight on a horizontal tablist (a vertical one keeps Up / Down); the host still sets `dir` on its element for the visual order. The model `tabs.qnt` is ltr only, so the ported `direction` scenarios stay skipped; `packages/headless/test/tabs-requirements.browser.test.tsx` holds the swap.
1506
1550
  - **The host's list.** `items` is read from the option on every read, so the bounds, the roving tab stop and `firstEnabled` follow a host that shrinks its list. `label` is read lazily too, so a renamed tab renames its buttons with it.
1507
1551
  - The spec models a `deletable` flavour (Delete, focus to the next tab, the last refused) but neither a rename nor the callbacks, so the browser test, not the model, holds the extension.
1508
1552
 
@@ -1552,6 +1596,40 @@ createScrollspy(options: {
1552
1596
 
1553
1597
  f812d87e (headless: a data table with sortable headers and row selection)
1554
1598
 
1599
+ ### Headless: `createVirtualizer` (`@cssints/headless`)
1600
+
1601
+ A window over a list of thousands: the host renders the visible rows plus an overscan, and two spacer boxes stand in for the rest, so the declared DOM, the content height and the scroll's clamp and thumb are the whole list's. It ports the rules the native renderer had (`native/solid/src/virtual.ts`: `fixedWindow`, `measuredWindow`, `OVERSCAN` and the gap-compensated spacers); the DOM adds the scrollport (`attach`), a `ResizeObserver` (the native renderer reads a row's box from `ui.bounds` after layout, which the DOM has no equivalent of) and the ARIA pair on every declared row. **No APG pattern exists for virtualisation and no Quint machine**: the state is pixels, not items, so `packages/headless/test/virtual.browser.test.tsx` is the oracle.
1602
+
1603
+ ```ts skip
1604
+ createVirtualizer(options: {
1605
+ count: number | (() => number); // the whole list, read on every window calculation
1606
+ itemSize?: number | ((index: number) => number) | undefined; // a fixed px height, or a per-row estimate; omit to measure
1607
+ gap?: number | undefined; // the column's gap, px; 0
1608
+ overscan?: number | undefined; // declared either side of the viewport; OVERSCAN (2)
1609
+ estimate?: number | undefined; // one height for the unmeasured rows; the mean of the measured, else 190
1610
+ }): {
1611
+ window(): { first: number; last: number; above: number; below: number; content: number };
1612
+ rangeCount(): number; // last - first + 1, or 0 (nothing to declare)
1613
+ scrollProps: { onScroll(event: Event): void }; // spread on the scrollport; attach already listens
1614
+ itemProps(index: number): {
1615
+ "aria-setsize": number; // the whole count
1616
+ "aria-posinset": number; // index + 1
1617
+ ref(element: HTMLElement): void; // the measured mode's observer wiring; a no-op when fixed
1618
+ };
1619
+ spacerProps(position: "start" | "end"): { "aria-hidden": "true"; style: { height: string } };
1620
+ scrollToIndex(index: number): void; // the least scroll that shows the row
1621
+ attach(element: HTMLElement): () => void; // the scrollport and the observers; returns the teardown
1622
+ };
1623
+ ```
1624
+
1625
+ A number `itemSize` is the arithmetic window: every row is that tall and row `n` starts at `n·(itemSize + gap)`. A function, or no `itemSize` at all, is the measured mode: a height per index arrives from the `ResizeObserver` on the declared rows, and an unmeasured row is the per-row estimate, else `estimate`, else the mean of what has been measured, else 190. The measured mode **measures once, then virtualises**: while any row's height is unknown the whole column is declared (one frame pays the whole list's layout), and the frame after it has every height, so the content height, the clamp and the thumb do not move as rows are first seen. `count` shrinking drops only the cached heights past the new count; growing it again leaves the new indexes unknown and the pass runs for them.
1626
+
1627
+ The items and the spacers are children of **one gap-spaced column** (`display: flex; flex-direction: column; row-gap: gap`, or a grid with `row-gap`), and a spacer's height is already the gap short (`above = first·(itemSize + gap) - gap`), so the first declared row starts at its real place. At an end there is **no** spacer: a zero-height box would still add the column's gap, so a host renders one only while its height is positive — `window().above > 0` / `window().below > 0`, or the `spacerProps` height.
1628
+
1629
+ Every declared row carries `aria-setsize` (the whole count) and `aria-posinset` (index + 1), so a screen reader announces "row 4,321 of 10,000" while only a screenful exists. A table host adds `aria-rowindex` itself: it counts the header row, which this primitive cannot know (and `createTable` already carries `aria-rowcount`). **Focus is the host's**: a focused row that leaves the window unmounts, so the host calls `scrollToIndex` when its keys move focus, before the new row must exist — the list declares the target, the host focuses it. `scrollToIndex` is the least scroll that shows the row (no move when it is already fully visible). In the measured mode its first offset is only an estimate for a row not measured yet, so the primitive keeps the target and lands again as each height arrives, until every height is in; a scroll the host makes by hand cancels the pending jump, because the reader has taken over.
1630
+
1631
+ `attach` only records the element, adds a passive `scroll` listener, observes the scrollport's size and (measured mode) the rows' heights, and returns the teardown: it **creates no computation**, so a host may call it from a `ref`, as `createPopover` learned to allow. The measured mode requires it; the fixed mode works from `scrollProps` alone but then a resized scrollport is not seen. `scrollProps.onScroll` is the same sync as a prop, for a host that wires scrolling itself. The scrollport and its column must have no padding (the arithmetic starts at the content's 0), horizontal virtualisation and a sticky header are not here, and the measured window rebuilds a prefix sum per call (fine for a feed, not for a dataset).
1632
+
1555
1633
  ### Headless: `createCommandPalette` (`@cssints/headless`)
1556
1634
 
1557
1635
  The first **composition**, not a new primitive and not a new spec: `createDialogModal` + `createTextInput` + `createStatus` wired into a palette. Its oracle is `packages/headless/test/command-palette.browser.test.tsx`. A palette is a dialog, **not a combobox**: choosing a row runs a command, so the field is `input[type=text][role=searchbox]` (no `aria-autocomplete`, no `aria-haspopup`), the container is `role=dialog` and the list is a `role=listbox`.
@@ -1683,13 +1761,14 @@ createDatePicker(options: {
1683
1761
  monthDays(): ReadonlyArray<ReadonlyArray<string>>; // 6 rows of 7 ISO dates, neighbours included
1684
1762
  labels: DatePickerLabels;
1685
1763
  status(): string | undefined;
1764
+ contentStyle(): { position: "absolute"; top: string; left: string }; // the popup's last measure, in page space
1686
1765
  // statusProps, triggerProps, popoverProps, headerProps, previousProps,
1687
1766
  // nextProps, gridProps, weekdayProps(index) and cellProps(iso): the attribute objects.
1688
1767
  attach(trigger: HTMLElement, content: HTMLElement): () => void; // the popover wiring; returns the teardown
1689
1768
  };
1690
1769
  ```
1691
1770
 
1692
- Every date is an ISO `YYYY-MM-DD`, read as a UTC midnight; `month()` and `labels.month` use 0-11. `monthDays()` is **six weeks around the shown month**, so a row can start in the previous month and end in the next one: every cell is a real focusable date (a blank cannot hold the roving focus), and a cursor that leaves the month pages the grid to where it landed. `cellProps(iso)` marks the chosen day `aria-selected`, `today` `aria-current="date"`, a day outside `min`/`max` `aria-disabled`, and gives `tabindex=0` to the roving day only: the selected day, else today when it is in the shown month, else the first day the bounds allow. Keys on the focused day: ←/→ a day, ↑/↓ a week, Home/End the week's first/last day, PageUp/PageDown a month, Shift+PageUp/PageDown a year (the day clamps, 29 February included), Enter/Space selects and closes, Escape closes without changing the value, Tab leaves. The month arrows move real DOM focus. `attach()` is the popover's positioning, light dismissal and focus return, plus focus into the roving day on open. The host renders the cells, the CSS and the trigger's visible text; a segmented text field is a host composition of `createField` + `createSpinbutton` over the same day numbers, not part of this API.
1771
+ Every date is an ISO `YYYY-MM-DD`, read as a UTC midnight; `month()` and `labels.month` use 0-11. `monthDays()` is **six weeks around the shown month**, so a row can start in the previous month and end in the next one: every cell is a real focusable date (a blank cannot hold the roving focus), and a cursor that leaves the month pages the grid to where it landed. `cellProps(iso)` marks the chosen day `aria-selected`, `today` `aria-current="date"`, a day outside `min`/`max` `aria-disabled`, and gives `tabindex=0` to the roving day only: the selected day, else today when it is in the shown month, else the first day the bounds allow. Keys on the focused day: ←/→ a day, ↑/↓ a week, Home/End the week's first/last day, PageUp/PageDown a month, Shift+PageUp/PageDown a year (the day clamps, 29 February included), Enter/Space selects and closes, Escape closes without changing the value, Tab leaves. The month arrows page the month and move the roving tab stop; the DOM focus stays on the arrow, so a host that wants the next key to land on a day moves it after the cells re-rendered (the kit's nav buttons do). `attach()` is the popover's light dismissal and focus return, plus focus into the roving day on open; `contentStyle()` is its placement — the same page-space `position: absolute` box `createPopover` gives, so a popup that escapes a positioned or clipping ancestor is placed by the initial containing block, not by the trigger's parent. The host renders the cells, the CSS and the trigger's visible text; a segmented text field is a host composition of `createField` + `createSpinbutton` over the same day numbers, not part of this API.
1693
1772
 
1694
1773
  ### Headless: `createPanZoom` (`@cssints/headless`)
1695
1774
 
@@ -2049,7 +2128,7 @@ createKnob(options?: {
2049
2128
 
2050
2129
  ### Headless: `createDropZone` (`@cssints/headless`)
2051
2130
 
2052
- A file drop target. **There is no APG pattern and no Quint spec for a drop zone** — the contract is HTML's file input plus WAI-ARIA's live region — so `packages/headless/test/dropzone.browser.test.tsx` (real Chromium) is its oracle. It is the replacement for the bare `<input type="file">` fields of cms-admin's image field and kocharin's dashboard upload.
2131
+ A file drop target. **There is no APG pattern for a drop zone** — the contract is HTML's file input plus WAI-ARIA's live region — but the drag depth and the last delivery change over time, so they have a machine (`packages/apg-specs/specs/patterns/dropzone.qnt`). `packages/headless/test/dropzone.browser.test.tsx` (real Chromium) is the DOM oracle; `dropzone-spec.browser.test.tsx` replays the spec's scenarios and the differential walk, and a property test checks the per-file decision (`accept`, `maxSize`, `maxFiles`). It is the replacement for the bare `<input type="file">` fields of cms-admin's image field and kocharin's dashboard upload.
2053
2132
 
2054
2133
  ```ts skip
2055
2134
  createDropZone(options: {
@@ -2165,6 +2244,53 @@ createDiagramFocus(options: {
2165
2244
  - **The lists are getters.** Pass `() => nodes()` and the arrays are read at attribute-read time: an async model arrives without rebuilding the primitive, and the tab stop keeps its node while that node exists (else it falls back to the first). A node id that is not in `nodes` is a no-op in `nodeProps` and `focus`; an edge whose endpoint is missing is ignored everywhere.
2166
2245
  - **The host owns the DOM.** Nothing is measured, nothing is positioned and nothing is focused by the primitive; the host renders the nodes where its layout put them, the edges on its SVG layer, and the `role="status"` paragraph.
2167
2246
 
2247
+ ### Headless: `createRearrangeableListbox` (`@cssints/headless`)
2248
+
2249
+ The APG rearrangeable listbox: two lists that share one option set, with the option's index as its identity. Single-select, selection follows the focus and Alt+Up / Alt+Down reorder the focused list; multi-select adds Space to toggle and Enter / Delete to transfer the selected options to the other list, where they arrive deselected. Its behaviour is `packages/headless/test/listbox-rearrangeable.browser.test.tsx`, a differential walk against `specs/apg/listbox-rearrangeable.qnt` in six configurations (single and multi, uncontrolled, controlled accepting and controlled holding a request).
2250
+
2251
+ ```ts skip
2252
+ createRearrangeableListbox(options: {
2253
+ items: ReadonlyArray<string>; // labels; the option id is the index, and every option starts in list A
2254
+ multi?: boolean; // false: one selection, which follows the focus
2255
+ value?: ReadonlyArray<number> | undefined; // controlled selection (option ids); undefined = uncontrolled
2256
+ defaultValue?: ReadonlyArray<number>; // the selection at mount
2257
+ id?: string; // base of `<id>-option-<i>`; generated when absent
2258
+ labels?: Readonly<Record<"a" | "b", string>>; // each list's aria-label
2259
+ onValueChange?(selected: ReadonlyArray<number>): void; // a request the host may ignore
2260
+ }): {
2261
+ items: ReadonlyArray<string>;
2262
+ order(list: "a" | "b"): ReadonlyArray<number>; // the option ids a list holds, in order
2263
+ active(list: "a" | "b"): number | null; // the active option, null when the list is empty
2264
+ focusedList(): "a" | "b" | null;
2265
+ selected(): ReadonlyArray<number>;
2266
+ multi(): boolean;
2267
+ focus(list: "a" | "b"): void;
2268
+ blur(): void;
2269
+ keyDown(key: string, modifiers?: { shift?: boolean; alt?: boolean; ctrl?: boolean; meta?: boolean }): void;
2270
+ move(direction: "up" | "down"): void; // swap the focused list's active option with its neighbour
2271
+ transfer(): void; // move the focused list's selected options to the other list
2272
+ setMulti(flag: boolean): void;
2273
+ listProps(list: "a" | "b"): {
2274
+ role: "listbox";
2275
+ tabindex: 0;
2276
+ "aria-multiselectable": "true" | "false";
2277
+ "aria-activedescendant": string | undefined; // only while this list has the primitive's focus
2278
+ "aria-label": string | undefined;
2279
+ onFocus;
2280
+ onBlur;
2281
+ onKeyDown;
2282
+ };
2283
+ optionProps(optionId: number): { id: string; role: "option"; "aria-selected": "true" | "false"; onClick };
2284
+ };
2285
+ ```
2286
+
2287
+ - **One option set, two orders.** `items` is read once; `order("a")` and `order("b")` give the two lists. An option is never copied: a transfer removes it from one order and appends it to the other. A transferred option arrives deselected.
2288
+ - **Selection follows the focus in single-select.** Focusing a list makes its active option the selection; ArrowUp / ArrowDown move the active option (and the selection with it), Home / End jump to the ends, and Alt+ArrowUp / Alt+ArrowDown swap the active option with its neighbour. `move` is that swap as a method, for a host's own button or drag.
2289
+ - **Multi-select is the APG's second example.** `Space` toggles the active option, Enter or Delete transfers every selected option of the focused list, and the orders survive the move.
2290
+ - **Controlled, a transfer waits for the host.** With `value` set, the list surgery is applied only when the `value` prop comes back equal to the request's intent; a host that holds the value moves nothing. Reordering (`move`) is not gated: it changes the primitive's own order arrays, not the selection.
2291
+ - **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
+ - **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
+
2168
2294
  ### Publishing a library (decided 2026-10-02, cssints-0l3f)
2169
2295
 
2170
2296
  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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cssints",
3
- "version": "0.25.0",
3
+ "version": "0.27.0",
4
4
  "description": "CSS in TypeScript: typed css.* calls compiled to atomic CSS at build time (a Vite plugin)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -72,7 +72,7 @@
72
72
  }
73
73
  },
74
74
  "dependencies": {
75
- "@cssints/css-grammar": "^0.25.0",
75
+ "@cssints/css-grammar": "^0.27.0",
76
76
  "culori": "^4.0.2",
77
77
  "effect": "4.0.6",
78
78
  "happy-dom": "^20.14.5",