cssints 0.20.0 → 0.22.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.
package/README.md CHANGED
@@ -223,7 +223,7 @@ cssints is MIT. It stands on other people's work; this is what we took, from who
223
223
 
224
224
  No code is copied from these; each package README says which part follows which.
225
225
 
226
- - **Plugin packages.** [Every Layout](https://every-layout.dev) and [smolcss](https://smolcss.dev) (layout), [nytimes/text-balancer](https://github.com/nytimes/text-balancer) and [react-wrap-balancer](https://github.com/shuding/react-wrap-balancer) (balance), [fluid-tailwind](https://github.com/barvian/fluid-tailwind) (fluid), [postcss-easing-gradients](https://github.com/larsenwork/postcss-easing-gradients) (gradient), [tailwindcss-motion](https://github.com/romboHQ/tailwindcss-motion) and [tailwindcss-animate](https://github.com/jamiebuilds/tailwindcss-animate) (motion), [the supersonic plugin](https://github.com/the-illarionov/the-supersonic-plugin-for-scroll-based-animation) and [lax.js](https://github.com/alexfoxy/lax.js) (reveal), [sync-skeleton](https://github.com/crutchcorn/sync-skeleton) (skeleton), [bramus/sticky-observer](https://github.com/bramus/sticky-observer) (stuck), [unpic-placeholder](https://github.com/ascorbic/unpic-placeholder) and [ThumbHash](https://github.com/evanw/thumbhash) (placeholder), [nutelch](https://github.com/meodai/nutelch) and [Leonardo](https://github.com/adobe/leonardo) (ramp), the resets of Josh Comeau, Jake Lazaroff, Vale and Andy Bell (reset), [Mise en Mode](https://standard.mode.place) and its Mode Contract (mise-en-mode), [brendonovich/vite-plugin-opencode](https://github.com/Brendonovich/vite-plugin-opencode) (the prompt box of devtools).
226
+ - **Plugin packages.** [Every Layout](https://every-layout.dev) and [smolcss](https://smolcss.dev) (layout), [nytimes/text-balancer](https://github.com/nytimes/text-balancer) and [react-wrap-balancer](https://github.com/shuding/react-wrap-balancer) (balance), [fluid-tailwind](https://github.com/barvian/fluid-tailwind) (fluid), [postcss-easing-gradients](https://github.com/larsenwork/postcss-easing-gradients) (gradient), [tailwindcss-motion](https://github.com/romboHQ/tailwindcss-motion) and [tailwindcss-animate](https://github.com/jamiebuilds/tailwindcss-animate) (motion), [the supersonic plugin](https://github.com/the-illarionov/the-supersonic-plugin-for-scroll-based-animation) and [lax.js](https://github.com/alexfoxy/lax.js) (reveal), [sync-skeleton](https://github.com/crutchcorn/sync-skeleton) (skeleton), [bramus/sticky-observer](https://github.com/bramus/sticky-observer) (stuck), [unpic-placeholder](https://github.com/ascorbic/unpic-placeholder) and [ThumbHash](https://github.com/evanw/thumbhash) (placeholder), [nutelch](https://github.com/meodai/nutelch) and [Leonardo](https://github.com/adobe/leonardo) (ramp), the resets of Josh Comeau, Jake Lazaroff, Vale and Andy Bell (reset), [Mise en Mode](https://standard.mode.place) and its Mode Contract (mise-en-mode), [Reactive CSS](https://reactivecss.com) (reactive), [brendonovich/vite-plugin-opencode](https://github.com/Brendonovich/vite-plugin-opencode) (the prompt box of devtools).
227
227
  - **The engine.** The comparisons in [docs/research](docs/research) record what we took from [StyleX](https://stylexjs.com), [vanilla-extract](https://vanilla-extract.style), [Tailwind CSS](https://tailwindcss.com), [Tokenami](https://tokenami.com), [SugarCube](https://sugarcube.sh) and the [React Spectrum](https://react-spectrum.adobe.com) style macros; the first spark was [Haz's post](https://bsky.app/profile/haz.dev/post/3mebm2mx6jk27) on a CSS framework in TypeScript. Tokens follow the [Design Tokens Community Group](https://www.designtokens.org) format.
228
228
  - **apps/csssky** follows the look of Mu and the hue slider of Witchsky.
229
229
 
@@ -70,9 +70,19 @@ interface Runtime {
70
70
  /** The plugin's browser script, a module specifier the app resolves (`"@cssints/balance/runtime"`): imported, never evaluated at build time. */
71
71
  script?: string;
72
72
  }
73
+ /**
74
+ * A value a site ships as a JavaScript object of its own: `ship({ owner, open })` (`@cssints/reactive`'s `state()`, whose
75
+ * owner is a style and whose sources are data). Each `Style` becomes its class string, an array or an object keeps its
76
+ * shape, and anything else is its JSON.
77
+ */
78
+ interface Ship {
79
+ readonly "~ship": {
80
+ readonly [key: string]: unknown;
81
+ };
82
+ }
73
83
  /** A build error when one `cn()` holds the member's marker and a property listed here (a name, or a pattern). */
74
84
  interface Conflict {
75
85
  props: (string | RegExp)[];
76
86
  message: string;
77
87
  }
78
- export { Kernel as a, GlobalRule as i, Conflict as n, Runtime as o, Decl as r, ThemeRule as s, BcdKey as t };
88
+ export { Kernel as a, ThemeRule as c, GlobalRule as i, Conflict as n, Runtime as o, Decl as r, Ship as s, BcdKey as t };
package/dist/clay.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { a as registry } from "./registry-8sYvlzyn.mjs";
2
- import { a as createTokens$1, i as createGlobalTheme$1, j as fail, s as sets } from "./theme-Le0IAI8N.mjs";
3
- import { n as valueOf, t as together } from "./contrast-B1QrUfME.mjs";
2
+ import { a as createTokens$1, i as createGlobalTheme$1, j as fail, s as sets } from "./theme-ClgusumY.mjs";
3
+ import { n as valueOf, t as together } from "./contrast-BB0wimFI.mjs";
4
4
  import { n as parseColor } from "./color-BB8ipynT.mjs";
5
5
  import { Formatter, Predicate } from "effect";
6
6
  import { clampRgb, formatHex } from "culori/fn";
@@ -1,5 +1,5 @@
1
1
  import { a as registry, d as MacroError, o as warn } from "./registry-8sYvlzyn.mjs";
2
- import { l as layerStatements } from "./sheet-BVksF2ST.mjs";
2
+ import { l as layerStatements } from "./sheet-rwl57DmD.mjs";
3
3
  import { Effect } from "effect";
4
4
  // src/contrast.ts
5
5
  const VAR = /var\((--[^\s,)]+)\)/g;
package/dist/index.d.mts CHANGED
@@ -1,6 +1,6 @@
1
- import { a as Kernel } from "./author-types-D9IjEXwn.mjs";
1
+ import { a as Kernel } from "./author-types-X4zGpNpU.mjs";
2
2
  import { n as _cv, r as _cx } from "./runtime-Cv7hGrWf.mjs";
3
- import { n as At, t as CssNode } from "./node-CnkQisiV.mjs";
3
+ import { n as At, t as CssNode } from "./node-CWwn8PaX.mjs";
4
4
  import { CSS, CSSColorValue, CSSImageValue, CSSStyleValue, CSSUnitValue } from "@cssints/css-grammar/values";
5
5
  import { Effect } from "effect";
6
6
  /** What firstThatWorks() builds: its values as text, the wanted one first. */
package/dist/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { a as registry, d as MacroError, f as MemberError, o as warn, r as forgetKernels, t as Registry } from "./registry-8sYvlzyn.mjs";
2
- import { A as camel, C as hashClass, D as settled, E as minWidth, F as probed, M as failIn, N as isNode, O as toDecls, P as node, S as edge, T as merged, _ as PSEUDOS, a as createTokens, b as checkVars, c as tokensOf, d as style, f as token, g as PROPS, h as LAYER, i as createGlobalTheme, j as fail, k as VAR, l as CSSTokenValue, m as FUNCTIONS, n as addTheme, o as isDone, p as CONTEXTS, r as check$1, t as DONE, u as isStyle, v as carried, w as isFallback, x as dropsOf, y as chainDecl } from "./theme-Le0IAI8N.mjs";
3
- import { D as relation, S as addRuntime, _ as addKernel, i as byOrder, n as addGlobal, r as addLayers, y as kernelWarnings } from "./sheet-BVksF2ST.mjs";
2
+ import { A as camel, C as hashClass, D as settled, E as minWidth, F as probed, M as failIn, N as isNode, O as toDecls, P as node, S as edge, T as merged, _ as PSEUDOS, a as createTokens, b as checkVars, c as tokensOf, d as style, f as token, g as PROPS, h as LAYER, i as createGlobalTheme, j as fail, k as VAR, l as CSSTokenValue, m as FUNCTIONS, n as addTheme, o as isDone, p as CONTEXTS, r as check$1, t as DONE, u as isStyle, v as carried, w as isFallback, x as dropsOf, y as chainDecl } from "./theme-ClgusumY.mjs";
3
+ import { D as relation, S as addRuntime, _ as addKernel, i as byOrder, n as addGlobal, r as addLayers, y as kernelWarnings } from "./sheet-rwl57DmD.mjs";
4
4
  import { n as loweredOf } from "./lowered-DOJEDRFM.mjs";
5
5
  import { i as SCV_CALL, l as groups$1, n as CX_CALL, t as CV_CALL } from "./instrument-QGVM1E1B.mjs";
6
6
  import { checkDeclarationNow, checkQuery, checkSelector, checkValueNow, isDeclarationError } from "@cssints/css-grammar/check";
@@ -1285,11 +1285,27 @@ const inlineOf = Effect.fnUntraced(function* ({ __style: { sets, decls } }) {
1285
1285
  });
1286
1286
  });
1287
1287
  const isPluginValue = Predicate.isObjectKeyword;
1288
+ const isShip = (v) => Predicate.hasProperty(v, "~ship");
1289
+ const shippedOf = Effect.fnUntraced(function* (value, at) {
1290
+ if (isNode(value)) return Formatter.formatJson(yield* classesOf(value, at));
1291
+ if (Array.isArray(value)) {
1292
+ const parts = [];
1293
+ for (const v of value) parts.push(yield* shippedOf(v, at));
1294
+ return `[${parts.join(",")}]`;
1295
+ }
1296
+ if (Predicate.isObject(value) && !(value instanceof CSSStyleValue)) {
1297
+ const parts = [];
1298
+ for (const [k, v] of Object.entries(value)) parts.push(`${Formatter.formatJson(k)}:${yield* shippedOf(v, at)}`);
1299
+ return `{${parts.join(",")}}`;
1300
+ }
1301
+ return Formatter.formatJson(value);
1302
+ });
1288
1303
  const replacementOf = Effect.fnUntraced(function* (at, value) {
1289
1304
  if (isNode(value)) return Formatter.formatJson(yield* classesOf(value, at));
1290
1305
  if (isCv(value)) return `${CV_CALL}${yield* cvOf(value.__cv, at)})`;
1291
1306
  if (isScv(value)) return `${SCV_CALL}${yield* scvOf(value.__scv, at)})`;
1292
1307
  if (isGroup(value)) return yield* headOf(value, at);
1308
+ if (isShip(value)) return yield* shippedOf(value["~ship"], at);
1293
1309
  if (isStyle(value)) return yield* inlineOf(value);
1294
1310
  const tokens = tokensOf(value);
1295
1311
  if (tokens !== void 0) return tokens;
@@ -1,4 +1,4 @@
1
- import { a as Kernel, i as GlobalRule, n as Conflict, o as Runtime, s as ThemeRule } from "./author-types-D9IjEXwn.mjs";
1
+ import { a as Kernel, c as ThemeRule, i as GlobalRule, n as Conflict, o as Runtime } from "./author-types-X4zGpNpU.mjs";
2
2
  import { Warning } from "@cssints/css-grammar/support";
3
3
  import { Context } from "effect";
4
4
  import { TokenType } from "@cssints/css-grammar/token";
@@ -74,6 +74,16 @@ interface Runtime {
74
74
  /** The plugin's browser script, a module specifier the app resolves (`"@cssints/balance/runtime"`): imported, never evaluated at build time. */
75
75
  script?: string;
76
76
  }
77
+ /**
78
+ * A value a site ships as a JavaScript object of its own: `ship({ owner, open })` (`@cssints/reactive`'s `state()`, whose
79
+ * owner is a style and whose sources are data). Each `Style` becomes its class string, an array or an object keeps its
80
+ * shape, and anything else is its JSON.
81
+ */
82
+ interface Ship {
83
+ readonly "~ship": {
84
+ readonly [key: string]: unknown;
85
+ };
86
+ }
77
87
  /** A build error when one `cn()` holds the member's marker and a property listed here (a name, or a pattern). */
78
88
  interface Conflict {
79
89
  props: (string | RegExp)[];
@@ -129,12 +139,19 @@ interface ParentVar {
129
139
  write: `--_${string}`;
130
140
  watch?: `--_${string}`;
131
141
  }
132
- /** The browser half, delivered when a target lacks a polyfilled feature (`virtual:cssints/runtime/<scope>.<entry>`); `&` is the marker of the member's first kernel. */
142
+ /**
143
+ * The browser half, delivered when a target lacks a polyfilled feature (`virtual:cssints/runtime/<scope>.<entry>`); `&` is
144
+ * the marker of the member's first kernel. A runtime with a `script` (or `css`) and no `polyfills` is the plugin's own
145
+ * face, not a fallback: every target gets it.
146
+ */
133
147
  interface RuntimeDef {
134
148
  polyfills: BcdKey[];
135
149
  parentVars?: ParentVar[];
136
150
  css?: string;
137
- /** A module specifier the app resolves (a package subpath, `"@cssints/balance/runtime"`): imported when a target lacks a polyfilled feature. */
151
+ /**
152
+ * A module specifier the app resolves (a package subpath, `"@cssints/balance/runtime"`): imported when a target lacks a
153
+ * polyfilled feature, and by every target when `polyfills` is empty.
154
+ */
138
155
  script?: string;
139
156
  }
140
157
  /** A build error when one `cn()` holds the member's marker and a property listed here (a name, or a pattern). */
@@ -377,4 +394,13 @@ export declare const dependsOn: (path: string) => void;
377
394
  * `scope()`; an unknown group is an error.
378
395
  */
379
396
  export declare const inGroup: (group: string, property: string, value: string) => boolean;
380
- export type { ArgsDef, Chain, Conflict, Css, Decl, Entry, GlobalRule, Impl, Kernel, KernelDef, Made, Members, Part, Pending, Runtime, Scope, Style, Table, ThemeRule, Typed };
397
+ /**
398
+ * `ship({ owner, open })`: what a member returns when a site has more to give than a class string — `@cssints/reactive`'s
399
+ * `state()`, whose `owner` is a style and whose sources are data. The client module gets an object literal: each style is
400
+ * its class string, an array or an object keeps its shape, and anything else is its JSON. At build time the object is the
401
+ * entries themselves. Build time, like `scope()`.
402
+ */
403
+ export declare const ship: (entries: {
404
+ readonly [key: string]: unknown;
405
+ }) => Ship;
406
+ export type { ArgsDef, Chain, Conflict, Css, Decl, Entry, GlobalRule, Impl, Kernel, KernelDef, Made, Members, Part, Pending, Runtime, Scope, Ship, Style, Table, ThemeRule, Typed };
package/dist/plugin.d.mts CHANGED
@@ -1,3 +1,3 @@
1
- import { _ as inGroup, a as Entry, b as Part, c as Made, d as Scope, f as Table, g as fn, h as dependsOn, i as Css, l as Members, m as arg, n as ArgsDef, o as Impl, p as Typed, r as Chain, s as KernelDef, u as Pending, v as lacking, x as Style, y as scope } from "./scope-CYtpn6RD.mjs";
2
- import { a as Kernel, i as GlobalRule, n as Conflict, o as Runtime, r as Decl, s as ThemeRule } from "./author-types-D9IjEXwn.mjs";
3
- export { type ArgsDef, type Chain, type Conflict, type Css, type Decl, type Entry, type GlobalRule, type Impl, type Kernel, type KernelDef, type Made, type Members, type Part, type Pending, type Runtime, type Scope, type Style, type Table, type ThemeRule, type Typed, arg, dependsOn, fn, inGroup, lacking, scope };
1
+ import { S as Style, _ as inGroup, a as Entry, b as ship, c as Made, d as Scope, f as Table, g as fn, h as dependsOn, i as Css, l as Members, m as arg, n as ArgsDef, o as Impl, p as Typed, r as Chain, s as KernelDef, u as Pending, v as lacking, x as Part, y as scope } from "./scope-Oxd_stwQ.mjs";
2
+ import { a as Kernel, c as ThemeRule, i as GlobalRule, n as Conflict, o as Runtime, r as Decl, s as Ship } from "./author-types-X4zGpNpU.mjs";
3
+ export { type ArgsDef, type Chain, type Conflict, type Css, type Decl, type Entry, type GlobalRule, type Impl, type Kernel, type KernelDef, type Made, type Members, type Part, type Pending, type Runtime, type Scope, type Ship, type Style, type Table, type ThemeRule, type Typed, arg, dependsOn, fn, inGroup, lacking, scope, ship };
package/dist/plugin.mjs CHANGED
@@ -33,4 +33,17 @@ const dependsOn = (path) => engine().dependsOn(path);
33
33
  * `scope()`; an unknown group is an error.
34
34
  */
35
35
  const inGroup = (group, property, value) => engine().inGroup(group, property, value);
36
- export { arg, dependsOn, fn, inGroup, lacking, scope };
36
+ /**
37
+ * `ship({ owner, open })`: what a member returns when a site has more to give than a class string — `@cssints/reactive`'s
38
+ * `state()`, whose `owner` is a style and whose sources are data. The client module gets an object literal: each style is
39
+ * its class string, an array or an object keeps its shape, and anything else is its JSON. At build time the object is the
40
+ * entries themselves. Build time, like `scope()`.
41
+ */
42
+ const ship = (entries) => {
43
+ Object.defineProperty(entries, "~ship", {
44
+ value: entries,
45
+ enumerable: false
46
+ });
47
+ return entries;
48
+ };
49
+ export { arg, dependsOn, fn, inGroup, lacking, scope, ship };
@@ -1,4 +1,4 @@
1
- import { r as Decl, t as BcdKey } from "./author-types-D9IjEXwn.mjs";
1
+ import { r as Decl, s as Ship, t as BcdKey } from "./author-types-X4zGpNpU.mjs";
2
2
  import { t as ClassValue } from "./runtime-Cv7hGrWf.mjs";
3
3
  import { createRequire } from "node:module";
4
4
  import { CSS, CSS as CSS$1, CSSColorValue, CSSColorValue as CSSColorValue$1, CSSImageValue, CSSImageValue as CSSImageValue$1, CSSStyleValue, CSSStyleValue as CSSStyleValue$1, CSSUnitValue, CSSUnitValue as CSSUnitValue$1 } from "@cssints/css-grammar/values";
@@ -3546,12 +3546,19 @@ interface ParentVar {
3546
3546
  write: `--_${string}`;
3547
3547
  watch?: `--_${string}`;
3548
3548
  }
3549
- /** The browser half, delivered when a target lacks a polyfilled feature (`virtual:cssints/runtime/<scope>.<entry>`); `&` is the marker of the member's first kernel. */
3549
+ /**
3550
+ * The browser half, delivered when a target lacks a polyfilled feature (`virtual:cssints/runtime/<scope>.<entry>`); `&` is
3551
+ * the marker of the member's first kernel. A runtime with a `script` (or `css`) and no `polyfills` is the plugin's own
3552
+ * face, not a fallback: every target gets it.
3553
+ */
3550
3554
  interface RuntimeDef {
3551
3555
  polyfills: BcdKey[];
3552
3556
  parentVars?: ParentVar[];
3553
3557
  css?: string;
3554
- /** A module specifier the app resolves (a package subpath, `"@cssints/balance/runtime"`): imported when a target lacks a polyfilled feature. */
3558
+ /**
3559
+ * A module specifier the app resolves (a package subpath, `"@cssints/balance/runtime"`): imported when a target lacks a
3560
+ * polyfilled feature, and by every target when `polyfills` is empty.
3561
+ */
3555
3562
  script?: string;
3556
3563
  }
3557
3564
  /** A build error when one `cn()` holds the member's marker and a property listed here (a name, or a pattern). */
@@ -3794,4 +3801,13 @@ declare const dependsOn: (path: string) => void;
3794
3801
  * `scope()`; an unknown group is an error.
3795
3802
  */
3796
3803
  declare const inGroup: (group: string, property: string, value: string) => boolean;
3797
- export { inGroup as _, Entry as a, Part as b, Made as c, Scope as d, Table as f, fn as g, dependsOn as h, Css as i, Members as l, arg as m, ArgsDef as n, Impl as o, Typed as p, Chain as r, KernelDef as s, AliasesOf as t, Pending as u, lacking as v, Style as x, scope as y };
3804
+ /**
3805
+ * `ship({ owner, open })`: what a member returns when a site has more to give than a class string — `@cssints/reactive`'s
3806
+ * `state()`, whose `owner` is a style and whose sources are data. The client module gets an object literal: each style is
3807
+ * its class string, an array or an object keeps its shape, and anything else is its JSON. At build time the object is the
3808
+ * entries themselves. Build time, like `scope()`.
3809
+ */
3810
+ declare const ship: (entries: {
3811
+ readonly [key: string]: unknown;
3812
+ }) => Ship;
3813
+ export { Style as S, inGroup as _, Entry as a, ship as b, Made as c, Scope as d, Table as f, fn as g, dependsOn as h, Css as i, Members as l, arg as m, ArgsDef as n, Impl as o, Typed as p, Chain as r, KernelDef as s, AliasesOf as t, Pending as u, lacking as v, Part as x, scope as y };
@@ -130,8 +130,13 @@ const uncovered = Effect.fnUntraced(function* (feature, off, runtimes) {
130
130
  const beyond = yield* unreached(fallback);
131
131
  return off.filter((t) => beyond.has(t));
132
132
  });
133
- /** Whether a target needs the runtime: it lacks a feature in `polyfills` and the runtime reaches it (`unreached`). */
133
+ /**
134
+ * Whether a target needs the runtime: it lacks a feature in `polyfills` and the runtime reaches it (`unreached`). A
135
+ * runtime with a `script` (or `css`) and no `polyfills` is the plugin's own face, not a fallback: no target can decide it,
136
+ * so every target needs it.
137
+ */
134
138
  const needed = Effect.fnUntraced(function* (runtime, ts) {
139
+ if (!runtime.polyfills.length && (runtime.script || runtime.css)) return true;
135
140
  const off = yield* unreached(runtime, ts);
136
141
  for (const key of runtime.polyfills) for (const t of yield* lacking(key, ts)) if (!off.has(t.join(" "))) return true;
137
142
  return false;
@@ -1,4 +1,4 @@
1
- import { t as AliasesOf } from "./scope-CYtpn6RD.mjs";
1
+ import { t as AliasesOf } from "./scope-Oxd_stwQ.mjs";
2
2
  /**
3
3
  * `surface().color(t.color.canvas).lighten(0.06).ink()`: a background that follows from the surface around it, however
4
4
  * many plain wrappers sit between. `color(<color>)` is an absolute base (default: the parent surface); `lighten(step)`,
@@ -1,5 +1,5 @@
1
1
  import { a as registry, d as MacroError, f as MemberError, o as warn } from "./registry-8sYvlzyn.mjs";
2
- import { D as relation, E as covers, d as requireLayer, p as standIn } from "./sheet-BVksF2ST.mjs";
2
+ import { D as relation, E as covers, d as requireLayer, p as standIn } from "./sheet-rwl57DmD.mjs";
3
3
  import { t as fallbackOf } from "./lowered-DOJEDRFM.mjs";
4
4
  import { createRequire } from "node:module";
5
5
  import { checkSelector } from "@cssints/css-grammar/check";
package/dist/tui.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { a as registry } from "./registry-8sYvlzyn.mjs";
2
- import { a as createTokens$1, i as createGlobalTheme$1, j as fail, s as sets } from "./theme-Le0IAI8N.mjs";
3
- import { g as evaluate, h as css } from "./sheet-BVksF2ST.mjs";
4
- import { n as valueOf, t as together } from "./contrast-B1QrUfME.mjs";
2
+ import { a as createTokens$1, i as createGlobalTheme$1, j as fail, s as sets } from "./theme-ClgusumY.mjs";
3
+ import { g as evaluate, h as css } from "./sheet-rwl57DmD.mjs";
4
+ import { n as valueOf, t as together } from "./contrast-BB0wimFI.mjs";
5
5
  import { n as parseColor } from "./color-BB8ipynT.mjs";
6
6
  import { Formatter } from "effect";
7
7
  import { clampRgb, formatHex, formatHex8 } from "culori/fn";
package/dist/vite.d.mts CHANGED
@@ -1,6 +1,6 @@
1
- import { i as GlobalRule } from "./author-types-D9IjEXwn.mjs";
1
+ import { i as GlobalRule } from "./author-types-X4zGpNpU.mjs";
2
2
  import { n as ImportError, r as MacroError } from "./errors-jNEiB3ye.mjs";
3
- import { i as RegistryState, n as At, r as LayersDecl } from "./node-CnkQisiV.mjs";
3
+ import { i as RegistryState, n as At, r as LayersDecl } from "./node-CWwn8PaX.mjs";
4
4
  import { Effect } from "effect";
5
5
  import { ImportDeclaration } from "oxc-parser";
6
6
  import MagicString from "magic-string";
package/dist/vite.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { c as EvalError, d as MacroError, f as MemberError, i as forgetWarnings, l as HookError, n as forget, p as SiteMissingError, t as Registry } from "./registry-8sYvlzyn.mjs";
2
- import { C as fallbackEntries, T as runtimeNames, a as closeNames, b as DERIVED, c as layerEntries, f as themedEntries, l as layerStatements, m as usesDerived, n as addGlobal, o as globalEntries, r as addLayers, s as globalNames, t as LAYERS, u as renderSheet, v as kernelEntries, w as runtimeModule, x as RUNTIME_ID } from "./sheet-BVksF2ST.mjs";
2
+ import { C as fallbackEntries, T as runtimeNames, a as closeNames, b as DERIVED, c as layerEntries, f as themedEntries, l as layerStatements, m as usesDerived, n as addGlobal, o as globalEntries, r as addLayers, s as globalNames, t as LAYERS, u as renderSheet, v as kernelEntries, w as runtimeModule, x as RUNTIME_ID } from "./sheet-rwl57DmD.mjs";
3
3
  import { a as instrument, c as writeSite, o as runtimeImport, s as tagged } from "./instrument-QGVM1E1B.mjs";
4
- import { r as verifyChecks } from "./contrast-B1QrUfME.mjs";
4
+ import { r as verifyChecks } from "./contrast-BB0wimFI.mjs";
5
5
  import { i as serverId, n as gitRoot, o as sessionOf, r as register } from "./devtools-link-DN91p7g5.mjs";
6
6
  import { createRequire } from "node:module";
7
7
  import { targets as targets$1, useTargets } from "@cssints/css-grammar/support";
@@ -804,7 +804,7 @@ function cssints({ inner = false, exclude, devtools = false, opencode = false, e
804
804
  if (!FILE_RE.test(id) || excluded(id)) return void 0;
805
805
  if (!marked(code, id)) {
806
806
  if (ctx.environment.name === "client") yield* drop(id);
807
- return ctx.environment.name === "ssr" && (inner || !build) && /[jt]sx$/.test(id) && !id.includes("node_modules") ? yield* inertJsx(code, id) : void 0;
807
+ return ctx.environment.name === "ssr" && (inner || !build) && /[jt]sx$/.test(id) ? yield* inertJsx(code, id) : void 0;
808
808
  }
809
809
  if (ctx.environment.name === "ssr" && (inner || !build)) {
810
810
  const out = yield* instrument(code, id, false);
package/docs/API.md CHANGED
@@ -954,8 +954,9 @@ export const a = cn(row().gap("2rem"), margin(0), flexGrow(1)); // fine: the mar
954
954
  - **`conflicts: { props, message }`**: one `cn()` that holds the marker and one of the listed properties (a name, or a RegExp) is a build error with the plugin's message and the call site: `cssints: ring: ring() sets the outline: use its methods (this style also sets outline)`. Conditions count. The message is prefixed with the scope's name.
955
955
  - **Derived conflicts.** A kernel written as styles needs no `conflicts` entry: a member that uses it conflicts with every property that meets one the kernel sets, as `cn()` decides overlap: the property itself, its longhands, a shorthand over it, and a logical or physical sibling of the same side. `core: { css: (css) => [css.outline(...), css.outlineOffset("2px")] }` stops `outline`, `outlineColor`, `outlineWidth` and `outlineOffset` in the same `cn()`, under any condition, and lets `color` through: `cssints: ring: kernel core sets outline, outline-offset (this style also sets outline-color)`. The message names the scope, the kernel and the property that was set. A `conflicts` entry among the member's `uses` **replaces** what its kernels derive (a list that allows `outline-color` while the kernel sets `outline`: `{ props: ["outline"], message }`; `props: []` allows everything), and so does a raw kernel, which has nothing to derive from (`frame()` and `surface()` are raw CSS with custom properties and `@container` rules, and `frame()` lets `border-color` through). The check reads every declaration of the style, those a member emits itself included: a member that emits a property its kernel sets lists its own `conflicts`.
956
956
 
957
- - **`runtime: { polyfills, parentVars?, css?, script? }`** is the part for browsers that lack a feature. `polyfills` are BCD keys. A client module that holds a site with the member imports `virtual:cssints/runtime/<scope>.<entry>`, and the module is empty (`export {};`, nothing ships) unless a browserslist target lacks one of the keys and the runtime reaches it. A runtime with `parentVars` is a script that stands on `@property`, so it reaches only the targets that have it; `css` and `script` stand on nothing and reach every target (`@cssints/fonts`' capsize trim in Firefox 120). When it is needed, `parentVars` (`{ marker: "&", read, write, watch? }`, custom properties written `--_x`) configure `cssints/parent-vars`, one shared script and one `MutationObserver` for all plugins, `css` (the fallback rules) is added to the sheet in layer `_.k`, only then, and the module imports `script`.
958
- - **`script`** (decided 2026-10-08, cssints-kuki) is the plugin's own browser code, a module specifier: `"#io": { polyfills: ["css.properties.animation-timeline"], script: "@cssints/reveal/runtime", css: "..." }`. It is a string the engine never evaluates (the script stays out of the build-time graph) and writes into the runtime module as `import "@cssints/reveal/runtime";`, which Vite resolves as the app's own imports are, in dev and in `vite build`. Write a subpath of the plugin's package, with a `development` condition for the source and a default for `dist/` (`exports: { "./runtime": { development: "./src/runtime.ts", default: "./dist/runtime.mjs" } }`): the package's own tests resolve it by its name too. A script reaches every target, so it is not gated: it ships to every browser of a build whose targets lack a key, and probes the feature itself (`CSS.supports`) to stay off where it is native; with `parentVars` beside it the two are decided apart. A script is a module, so the browser runs it once, however many sites, files or runtime entries import it. The plugin's subpath stays public: an app can still import it by hand, which is harmless. Derived tokens are not members; the engine registers their runtime (`cssints/derived`) on this same delivery (see "Themes").
957
+ - **`runtime: { polyfills, parentVars?, css?, script? }`** is the part for browsers that lack a feature. `polyfills` are BCD keys. A client module that holds a site with the member imports `virtual:cssints/runtime/<scope>.<entry>`, and the module is empty (`export {};`, nothing ships) unless a browserslist target lacks one of the keys and the runtime reaches it. A runtime with `parentVars` is a script that stands on `@property`, so it reaches only the targets that have it; `css` and `script` stand on nothing and reach every target (`@cssints/fonts`' capsize trim in Firefox 120). A runtime with `css` or `script` and **no** `polyfills` is not a fallback at all: it is the plugin's own face, so every target gets it (`@cssints/reactive`'s DOM runtime, 2026-10-31, cssints-zrt2). When it is needed, `parentVars` (`{ marker: "&", read, write, watch? }`, custom properties written `--_x`) configure `cssints/parent-vars`, one shared script and one `MutationObserver` for all plugins, `css` (the fallback rules) is added to the sheet in layer `_.k`, only then, and the module imports `script`.
958
+ - **`ship({ owner, open })`** (decided 2026-10-31, cssints-zrt2) is what a member returns when a site has more to give than a class string: `@cssints/reactive`'s `state()` returns its owner style and the sources beside it. The client module gets an object literal — each style is its class string, an array or an object keeps its shape, anything else its JSON — so the owner is a string in the browser and the data is there (`const ui = state({ open: false }); cn(ui.owner, p(4))`). At build time the object is the entries themselves, so `ui.owner` is the style and `ui.open` the source. A value a member returns at a site that is neither a style, a condition, a Typed OM value nor a `ship()` is a build error, unless the member is only ever called from a build-time module (nothing of an `icons()` definition module is in the client graph).
959
+ - **`script`** (decided 2026-10-08, cssints-kuki) is the plugin's own browser code, a module specifier: `"#io": { polyfills: ["css.properties.animation-timeline"], script: "@cssints/reveal/runtime", css: "..." }`. It is a string the engine never evaluates (the script stays out of the build-time graph) and writes into the runtime module as `import "@cssints/reveal/runtime";`, which Vite resolves as the app's own imports are, in dev and in `vite build`. Write a subpath of the plugin's package, with a `development` condition for the source and a default for `dist/` (`exports: { "./runtime": { development: "./src/runtime.ts", default: "./dist/runtime.mjs" } }`): the package's own tests resolve it by its name too. A script reaches every target, so it is not gated: it ships to every browser of a build whose targets lack a key — with no `polyfills` at all it ships to every build — and probes the feature itself (`CSS.supports`) to stay off where it is native; with `parentVars` beside it the two are decided apart. A script is a module, so the browser runs it once, however many sites, files or runtime entries import it. The plugin's subpath stays public: an app can still import it by hand, which is harmless. Derived tokens are not members; the engine registers their runtime (`cssints/derived`) on this same delivery (see "Themes").
959
960
  - **Identity.** A scope name is unique in the process, and a scope is its content (the table, the source of its functions, the scopes it embeds), hashed. A module that runs again with the same content is nothing (every client module that uses a plugin runs it again). Another content under the same name replaces the old one, as an edit does in dev: the files that imported the edited module are forgotten when Vite reports it and run again. It is a build error, `scope "x" is defined twice with different content`, while a file that was not affected still uses the old one: two modules that define one name. `icons()` runs its scope once per collection, with the same table and the same code: one scope, and the collection is the closure of `build`, which the hash does not see.
960
961
  - **Errors** are `MemberError` values (`Data.TaggedError`) with `pluginName` (the scope) and the file, site and offset of the call, so the Vite overlay points at it. What `build`, `value`, a chain's `emit` or a kernel's styles throw is a `MemberError` at the call. A plain function the plugin exports (one that wraps styles, say) may throw an `Error`: while a site runs, anything thrown is located at that site, with its message (2026-10-08, cssints-rf3k).
961
962
  - **`lacking(...keys)`** gives the build's targets that lack one of the BCD keys, as browserslist names them, by the rules of the support warnings (`partial_implementation` lacks), and `[]` when every target has them all: a plugin emits fallback rules only when some target needs them (2026-10-08, cssints-rf3k). It runs at build time, in a member's functions or in the plugin's own:
@@ -1241,6 +1242,7 @@ More plugins on `scope()`, each a package of its own with its rules and limits i
1241
1242
  | [`@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 |
1242
1243
  | [`@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 |
1243
1244
  | [`@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 |
1245
+ | [`@cssints/reactive`](../packages/reactive/README.md) | `state()`, `toggle()`, `set()`, `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 0.9 kB runtime shipped to every target (2026-10-31, cssints-zrt2) |
1244
1246
 
1245
1247
  ### Headless: `createField` and `createConfirm` (`@cssints/headless`)
1246
1248
 
@@ -1296,8 +1298,8 @@ createConfirm(options?: { id?: string | undefined; onOpenChange?(open: boolean):
1296
1298
  Two live regions with no APG pattern and no Quint spec: `createStatus` is one polite message whose text is replaced, `createToast` queues toasts whose countdown pauses. Their oracle is `packages/headless/test/toast.browser.test.tsx`.
1297
1299
 
1298
1300
  ```ts skip
1299
- createStatus(options?: { id?: string | undefined }): {
1300
- message(): string | undefined;
1301
+ createStatus(options?: { id?: string | undefined; from?: (() => string | undefined) | undefined }): {
1302
+ message(): string | undefined; // from() when the host controls the text, else the last show
1301
1303
  show(text: string): void; // replaces the text; nothing is queued
1302
1304
  clear(): void;
1303
1305
  statusProps: { id; role: "status"; "aria-live": "polite"; "aria-atomic": "true" };
@@ -1330,7 +1332,7 @@ createToast(options?: {
1330
1332
  };
1331
1333
  ```
1332
1334
 
1333
- `createStatus` renders one `role=status` region that the host keeps mounted; `show()` replaces its text, so a burst of "Link copied" / "Saved" reads as the last state, not as a queue. A status is never for errors: an error is `createAlert` (`role=alert`), and a titled message with a timeout is a toast.
1335
+ `createStatus` renders one `role=status` region that the host keeps mounted; `show()` replaces its text, so a burst of "Link copied" / "Saved" reads as the last state, not as a queue. A status is never for errors: an error is `createAlert` (`role=alert`), and a titled message with a timeout is a toast. Both take `from: () => text`, which makes the text the host's own signal (`createAlert({ from: () => error() })`): no effect mirrors it into `show()`, and clearing the signal unmounts the alert.
1334
1336
 
1335
1337
  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.
1336
1338
 
@@ -1430,7 +1432,7 @@ A data table on a **native `<table>`**: sortable column headers and row selectio
1430
1432
  ```ts skip
1431
1433
  createTable(options: {
1432
1434
  columns: ReadonlyArray<{ key: string; sortable?: boolean; numeric?: boolean; abbr?: string | undefined }>; // sortable defaults true
1433
- rows: ReadonlyArray<string>; // row ids in render order; the host sorts its own data
1435
+ rows: ReadonlyArray<string> | (() => ReadonlyArray<string>); // row ids in render order; a getter is read lazily
1434
1436
  selection?: "none" | "single" | "multiple" | undefined; // "none"
1435
1437
  sortCycle?: "tri-state" | "toggle" | undefined; // "tri-state": ascending -> descending -> unsorted
1436
1438
  sort?: TableSort | null | undefined; // controlled; null = unsorted, undefined = uncontrolled
@@ -1463,7 +1465,7 @@ createTable(options: {
1463
1465
 
1464
1466
  `aria-sort` is carried by the sorted column only; a sortable header that is not sorted has none (ARIA's "not sorted" default), and a `sortable: false` column gets no button from `headerButtonProps` at all, so it is not a tab stop. The header button cycles `ascending` -> `descending` -> unsorted by default (so the table can be put back in its own order) and `ascending` <-> `descending` with `sortCycle: "toggle"` (what the APG grid example does); a new column always starts `ascending`, and only one is sorted. Sorting is the host's data: the primitive carries the descriptor and announces it, it never reorders a row.
1465
1467
 
1466
- Selection is over ids, so it survives the host's re-sort. `rowProps` carries `aria-selected` and a click that selects; `toggleRow` / `toggleAll` are the programmatic path, and the row's checkbox stops its click from reaching the row so the two controls never toggle the same row twice. `selectAllProps` and `rowCheckboxProps` are `undefined` unless `selection` is `"multiple"` (one control over one selection is meaningless), and the select-all is a `role="checkbox"` rather than a native `input` because the mixed state a screen reader must hear is `aria-checked="mixed"`, not the DOM-only `indeterminate` property. It toggles the rows the table renders: a windowed table toggles its window and keeps the rest of the selection. A table that renders all of its rows and columns carries no `aria-rowcount` / `aria-colcount` (the markup already says it); pass them, and every cell gets its `aria-colindex`, only when the host windows. `numeric` is a layout hint for the host's skin: HTML has no attribute for it.
1468
+ `rows` may be a getter (`() => rows()`): it is read at attribute-read time, so a table whose rows arrive later keeps its sort and its selection instead of being rebuilt, and a selection made before the rows landed applies once they do. Selection is over ids, so it survives the host's re-sort. `rowProps` carries `aria-selected` and a click that selects; `toggleRow` / `toggleAll` are the programmatic path, and the row's checkbox stops its click from reaching the row so the two controls never toggle the same row twice. `selectAllProps` and `rowCheckboxProps` are `undefined` unless `selection` is `"multiple"` (one control over one selection is meaningless), and the select-all is a `role="checkbox"` rather than a native `input` because the mixed state a screen reader must hear is `aria-checked="mixed"`, not the DOM-only `indeterminate` property. It toggles the rows the table renders: a windowed table toggles its window and keeps the rest of the selection. A table that renders all of its rows and columns carries no `aria-rowcount` / `aria-colcount` (the markup already says it); pass them, and every cell gets its `aria-colindex`, only when the host windows. `numeric` is a layout hint for the host's skin: HTML has no attribute for it.
1467
1469
 
1468
1470
  ### Headless: `createTabs` (`@cssints/headless`)
1469
1471
 
@@ -1475,7 +1477,7 @@ Close and rename are an **extension** (bean `cssints-gdv2`, the docs playground'
1475
1477
 
1476
1478
  ```ts skip
1477
1479
  createTabs(options?: {
1478
- ...; // the plain tabs: items, value, defaultSelected, orientation, activation, disabled, focusablePanels, id, onValueChange
1480
+ ...; // the plain tabs: items, value, defaultSelected, orientation, activation, disabled, focusablePanels, id, tabId, panelId, onValueChange
1479
1481
  closable?: boolean; // false: closeProps and the Delete key; the host removes from onClose
1480
1482
  renamable?: boolean; // false: a double click edits the label inline; the host renames from onRename
1481
1483
  label?(index: number): string | undefined; // the tab's name: the buttons' labels and the rename draft
@@ -1499,6 +1501,7 @@ createTabs(options?: {
1499
1501
 
1500
1502
  - **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.
1501
1503
  - **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)`).
1504
+ - **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.
1502
1505
  - **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.
1503
1506
  - 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.
1504
1507
 
@@ -1647,9 +1650,12 @@ createMenu(options: {
1647
1650
  open?: boolean | undefined; // controlled: the host owns the open state
1648
1651
  defaultOpen?: boolean; // uncontrolled starting state
1649
1652
  onOpenChange?(open: boolean): void; // a request the host may ignore
1650
- value?: number | undefined; // controlled checked items, packed (checkboxItem0 * 5 + radioChoice, 1-based)
1651
- defaultValue?: number;
1652
- onCheckedValueChange?(value: number): void;
1653
+ checked?: ReadonlyArray<number> | undefined; // controlled, readable: the checked checkbox item indices
1654
+ choice?: number | undefined; // controlled, readable: the chosen radio item, -1 for none
1655
+ value?: number | undefined; // controlled, packed view of the same state (checkboxItem0 * 5 + radioChoice, 1-based)
1656
+ defaultValue?: number; // the uncontrolled packed state at mount
1657
+ onCheckedValueChange?(value: number): void; // a packed request the host may ignore
1658
+ onCheckedValuesChange?(checked: ReadonlyArray<number>, choice: number): void; // the readable request
1653
1659
  triggerMode?: "outside" | "held" | undefined; // where the focus sits while the menu is closed
1654
1660
  disabled?: ReadonlyArray<number>; // focusable, never activated
1655
1661
  id?: string | undefined;
@@ -1658,6 +1664,9 @@ createMenu(options: {
1658
1664
  open(): boolean;
1659
1665
  focusLevel(): 0 | 1 | 2; // 0 outside, 1 on the trigger, 2 on an item
1660
1666
  focusIndex(): number;
1667
+ checked(): ReadonlyArray<number>; // the readable view of the checked state
1668
+ choice(): number; // the readable view of the radio choice
1669
+ value(): number; // the packed view of the same state
1661
1670
  openMenu(): void; // a request, like every intent
1662
1671
  closeMenu(): void;
1663
1672
  keyDown(key: string): void;
@@ -1665,8 +1674,46 @@ createMenu(options: {
1665
1674
  };
1666
1675
  ```
1667
1676
 
1677
+ **The checked state has one store and two views.** The readable pair (`checked` + `choice`) is the store; `value` is derived from it as `menu-checkable.qnt`'s `checked(wrap, choice)` (one checkbox bit * 5 + the radio). The pack has one checkbox bit, so only the readable pair can name a checkbox beyond item 0. A host may own either view; each has its request (`onCheckedValuesChange(checked, choice)` and `onCheckedValueChange(value)`), and where both views are given the readable pair wins. `defaultChecked` / `defaultChoice` start the pair and `defaultValue` the packed side.
1678
+
1668
1679
  **The open state is a request/commit pair**, like every other controlled primitive: `open` (+ `onOpenChange`) makes the host own it, `defaultOpen` starts it. Every intent that opens or closes — the trigger's click and its keys, Escape, an item's activation, Tab, an outside click in a kit, the programmatic `openMenu` / `closeMenu` — is a request, so a host that holds `open` keeps the menu open and the focus where it was; `menu-button.qnt` reverts the whole transition of a rejected request, focus included. A write the host makes on its own is not a request: it takes the focus to the first item when it opens and to the trigger when it closes, and a write of the value it already has moves nothing (no widget can observe it).
1669
1680
 
1681
+ ### Headless: `createCombobox` (`@cssints/headless`)
1682
+
1683
+ The APG combobox as state and attribute objects: the select-only variant of `specs/apg/combobox-select-only.qnt`, with a listbox popup; `filter: true` turns typing into the autocomplete variant's prefix filter. The grid, tree and dialog popups the other combobox specs model are not this primitive (their models are walked against themselves). Its behaviour is `packages/headless/test/combobox.browser.test.tsx`: DOM focus never leaves the combobox, the popup's active option is announced through `aria-activedescendant`, and navigating never commits — Enter, Space, Tab, blur, Alt+ArrowUp, an option click or `setValue` do.
1684
+
1685
+ ```ts skip
1686
+ createCombobox(options: {
1687
+ options: ReadonlyArray<ComboboxOption | string>; // `{ value, label }`, or a string that is both
1688
+ value?: string | null | undefined; // controlled selection
1689
+ defaultSelectedValue?: string | null; // uncontrolled start (`defaultValue` is its alias)
1690
+ open?: boolean | undefined; // controlled popup
1691
+ defaultOpen?: boolean;
1692
+ autoComplete?: "none" | "list" | "both"; // "list"
1693
+ filter?: boolean; // false: single-letter typeahead; true: a prefix filter
1694
+ disabled?: boolean; // aria-disabled on the combobox and the trigger; a key, click, typed character, filter input and option click change nothing, no popup opens
1695
+ placeholder?: string;
1696
+ id?: string; // base of `<id>-input`, `-trigger`, `-listbox`, `-option-<i>`
1697
+ onOpenChange?(open: boolean): void; // a request the host may ignore
1698
+ onValueChange?(value: string | null): void; // a request the host may ignore
1699
+ onFocusRelease?(): void; // Tab: the host may blur its own DOM
1700
+ }): {
1701
+ open(): boolean;
1702
+ activeIndex(): number; // index into `options()`; -1 = the visual focus on the combobox
1703
+ value(): string | null;
1704
+ focused(): boolean;
1705
+ query(): string; // the typeahead / filter text
1706
+ options(): ReadonlyArray<{ value; label; selected; id; props }>;
1707
+ setOpen(open: boolean): void; // a request
1708
+ setValue(value: string | null): void;
1709
+ comboboxProps: { id; role: "combobox"; value; "aria-autocomplete"; "aria-haspopup"; "aria-expanded"; "aria-controls"; "aria-activedescendant"; "aria-disabled"; onInput; onKeyDown; onFocus; onBlur; onClick };
1710
+ triggerProps: { id; type: "button"; tabindex: -1; "aria-haspopup"; "aria-expanded"; "aria-controls"; "aria-disabled"; onClick; onMouseDown };
1711
+ listboxProps: { id; role: "listbox"; hidden };
1712
+ };
1713
+ ```
1714
+
1715
+ **`disabled` is a widget-level extension** (`combobox-select-only.qnt` lists it under its deliberate omissions and has no `disabled` var, so the DOM test is its oracle): a key, a click, a typeahead character, a filter input and an option click are dropped before they become a request, and no popup opens. It announces `aria-disabled` — not the native `disabled` — because the pattern puts the state on a `role="combobox"` element that is a button in the select-only example; the combobox keeps its tab stop (the pattern's focus contract), as `createButton` and `createSlider`'s thumb do. The trigger announces `aria-disabled` too. `setOpen` / `setValue` stay host requests, so a programmatic write still applies.
1716
+
1670
1717
  ### Headless: `createCheckboxGroup` (`@cssints/headless`)
1671
1718
 
1672
1719
  The APG Checkbox pattern's tri-state group controller: exactly three child values, a controller whose `aria-checked` is derived from them (all / none / mixed) with the optional "remember the last mixed combination" cycle, checked against `specs/apg/checkbox-group.qnt`. Its name, description and error are `createField`'s contract on the `role="group"` element, which the model leaves out on purpose (its header omits labelling), so `packages/headless/test/checkbox-group.browser.test.tsx` is their oracle.
@@ -1678,7 +1725,7 @@ createCheckboxGroup(options: {
1678
1725
  defaultValue?: ReadonlyArray<T>;
1679
1726
  parent?: boolean; // the "all" controller is a focus stop
1680
1727
  remember?: boolean; // the third activation restores the last mixed combination
1681
- id?: string | undefined; // base of `<id>-control` (the group element) and `<id>-error`
1728
+ id?: string | undefined; // base of `<id>-control` (the group element), `<id>-0`..`-2` (the children) and `<id>-error`
1682
1729
  label?: string | undefined; // the group's name, as aria-label
1683
1730
  labelledby?: string | undefined; // the id of the host's own label element; wins over `label`
1684
1731
  describedby?: string | undefined; // host-owned description ids, after the error
@@ -1695,7 +1742,7 @@ createCheckboxGroup(options: {
1695
1742
  };
1696
1743
  ```
1697
1744
 
1698
- The group is not a `<label for>` target: pass `labelledby` naming the host's own label element, or `label` without one. The error id is in `aria-describedby` only while the error is shown, `aria-invalid` only while the group is invalid, and the group element's own id is there for a controller that names it with `aria-controls`. The checked set is unchanged by all of this: the request/commit split and the spec stay as they were.
1745
+ The group is not a `<label for>` target: pass `labelledby` naming the host's own label element, or `label` without one. The error id is in `aria-describedby` only while the error is shown, `aria-invalid` only while the group is invalid, and the group element's own id is there for a controller that names it with `aria-controls`. The children's ids follow the same base (`<base>-0`..`-2`), so two groups on one page do not collide (the model omits ids). The checked set is unchanged by all of this: the request/commit split and the spec stay as they were.
1699
1746
 
1700
1747
  ### Headless: `createSlider` and `createSpinbutton` (`@cssints/headless`)
1701
1748
 
@@ -1796,6 +1843,48 @@ createSpinbutton(options?: {
1796
1843
  - a disabled spinbutton's field announces `aria-disabled` **and carries the native `disabled`** (the field is an `<input>`, so the platform's own form-control rule applies); the spin keys, the typed draft (Enter and blur included) and both steppers change nothing, and **both** steppers announce `aria-disabled="true"` — the at-bound rule of the spec stays, and a disabled widget adds the other side;
1797
1844
  - both read the option on every attribute read, so a host may flip `disabled` on a mounted widget and the attributes and the guards follow in the same tick.
1798
1845
 
1846
+ ### Headless: `createSliderMultiThumb` (`@cssints/headless`)
1847
+
1848
+ The APG Slider (Multi-Thumb) pattern: two `role="slider"` thumbs on one rail, each bounded by its neighbour, so they cannot pass one another; equal values are allowed. Its behaviour is `packages/apg-specs/specs/apg/slider-multithumb.qnt`, checked against the real DOM by the differential walk in `packages/headless/test/slider-multithumb.browser.test.tsx`. The spec fixes two thumbs and one linked pair: the independent-thumbs variant, N thumbs, dragging, a nearest-thumb rail press and `disabled` are deliberate omissions (a press names its thumb). Options are plain and read lazily; `value` + `onValueChange` make the widget controlled.
1849
+
1850
+ ```ts skip
1851
+ createSliderMultiThumb(options?: {
1852
+ min?: number; // 0
1853
+ max?: number; // 10
1854
+ step?: number; // 1
1855
+ largeStep?: number; // 3: the jump of PageUp / PageDown
1856
+ value?: readonly [number, number] | undefined; // controlled; undefined = uncontrolled
1857
+ defaultValue?: readonly [number, number]; // [3, 7], clamped into the range and ordered
1858
+ orientation?: "horizontal" | "vertical"; // "horizontal"
1859
+ direction?: "ltr" | "rtl"; // "ltr": under rtl the horizontal arrows flip
1860
+ id?: string; // base of the thumb ids
1861
+ onValueChange?(values: readonly [number, number]): void; // a request the host may ignore
1862
+ }): {
1863
+ values(): readonly [number, number];
1864
+ focused(): 0 | 1 | null;
1865
+ setValues(values: readonly [number, number]): void;
1866
+ pointerDown(index: 0 | 1, position: number): void; // a press at a 0..1 fraction of the track
1867
+ focusThumb(index: 0 | 1): void;
1868
+ blur(): void;
1869
+ keyDown(key: string): boolean; // true when the key was consumed (preventDefault it)
1870
+ thumbProps(index: 0 | 1): {
1871
+ id;
1872
+ role: "slider";
1873
+ "aria-valuenow"; // the thumb's value
1874
+ "aria-valuemin"; // the lower thumb: the range's minimum; the upper thumb: the lower value
1875
+ "aria-valuemax"; // the lower thumb: the upper value; the upper thumb: the range's maximum
1876
+ "aria-valuetext";
1877
+ "aria-orientation";
1878
+ tabindex: 0;
1879
+ onFocus;
1880
+ onBlur;
1881
+ onKeyDown;
1882
+ };
1883
+ };
1884
+ ```
1885
+
1886
+ Both thumbs are tab stops in a fixed order (lower, then upper), whatever the values are. There is no `trackProps`: the primitive has no rail press, because a press has to name a thumb.
1887
+
1799
1888
  ### Headless: `createKnob` (`@cssints/headless`)
1800
1889
 
1801
1890
  A virtual knob after GarageBand's ([How Apple Designs a Virtual Knob](https://jherrm.github.io/knobs/)): spin around the centre, or slide vertically / horizontally, with one gesture locked per drag. **No APG pattern and no ARIA role exist for a dial**, so there is no Quint spec and no ported scenarios: `packages/headless/test/knob.browser.test.tsx` (real Chromium) is the oracle. The knob is a `role=slider` — the closest role WAI-ARIA has — with a polite live value; a numeric field must sit next to it, because a dial is never the only control. Options are plain and read lazily; `value` + `onValueChange` make the widget controlled.
@@ -1852,8 +1941,10 @@ createDropZone(options: {
1852
1941
  maxSize?: number | undefined; // bytes; a larger file is rejected with "size"
1853
1942
  disabled?: boolean | undefined; // zone aria-disabled, native disabled input and button, drops ignored
1854
1943
  onFiles(files: File[]): void; // the accepted files of one batch; not called when none passed
1855
- onReject?({ file, reason }: { file: File; reason: "type" | "size" }): void; // once per refused file
1856
- labels?: { zone?: string | undefined; browse?: string | undefined };
1944
+ maxFiles?: number | undefined; // files taken from one batch; the rest are rejected with "count"
1945
+ onReject?({ file, reason }: { file: File; reason: "type" | "size" | "count" }): void; // once per refused file
1946
+ labels?: { zone?: string | undefined; labelledby?: string | undefined; browse?: string | undefined };
1947
+ formatMessage?(message: { total: number; accepted: number; rejected: ReadonlyArray<"type" | "size" | "count"> }): string | undefined; // the announcement in the host's language
1857
1948
  }): {
1858
1949
  dragging(): boolean; // a drag is over the zone; a child's dragleave does not clear it
1859
1950
  message(): string | undefined; // "2 files, 1 rejected: too large"; undefined before the first batch
@@ -1869,6 +1960,7 @@ createDropZone(options: {
1869
1960
  zoneProps: {
1870
1961
  role: "group";
1871
1962
  "aria-label": string | undefined;
1963
+ "aria-labelledby": string | undefined; // labels.labelledby: the host's own heading wins over aria-label
1872
1964
  "aria-disabled": "true" | undefined;
1873
1965
  onDragEnter;
1874
1966
  onDragOver;
@@ -1883,10 +1975,10 @@ createDropZone(options: {
1883
1975
  Principles:
1884
1976
 
1885
1977
  - **The zone is never drag-only.** `browseProps` spreads onto a real `<button type="button">` that clicks the hidden input, so the picker is the keyboard path (Enter and Space are the platform's) and a pointer user can click too. The drop handlers add nothing the button cannot do.
1886
- - **Validation happens once per file**, in the order type then size, with a reason the host can show: `onReject` gets the file and `"type" | "size"`, and `onFiles` gets the rest in one batch. A batch with nothing accepted calls `onFiles` not at all — not with an empty array.
1978
+ - **Validation happens once per file**, in the order type then size then count, with a reason the host can show: `onReject` gets the file and `"type" | "size" | "count"` (`maxFiles` limits what one batch takes), and `onFiles` gets the rest in one batch. A batch with nothing accepted calls `onFiles` not at all — not with an empty array.
1887
1979
  - **A folder or an empty `DataTransfer` is a no-op** (Chromium marks a dropped directory as a `file` item whose `webkitGetAsEntry().isDirectory` is true): no callback, no rejection, and `message()` keeps the previous sentence.
1888
1980
  - **`dragleave` counts.** A child's `dragleave` bubbles to the zone, so `dragging()` is a depth counter: it clears when the drag leaves the zone, or on a drop. It is the styling hook; the primitive writes no `data-dragging`.
1889
- - **`message()` is a default sentence for the host's status region** ("2 files, 1 rejected: too large", `undefined` before the first batch): the primitive tracks the counts of the last batch, the host decides the words. Render `statusProps` once and keep it mounted, or a change of its text is not announced.
1981
+ - **`message()` is a default sentence for the host's status region** ("2 files, 1 rejected: too large", `undefined` before the first batch): the primitive tracks the counts of the last batch, the host decides the words. An app with a locale passes `formatMessage`, which gets the same counts (`total`, `accepted`, `rejected`) and returns the sentence to announce. Render `statusProps` once and keep it mounted, or a change of its text is not announced.
1890
1982
  - Picking through the input is the same path as a drop and clears `input.value` afterwards, so choosing the same file twice fires `change` again.
1891
1983
 
1892
1984
  ### Headless: `createTimeline` (`@cssints/headless`)
@@ -1895,7 +1987,7 @@ Two modes over one axis. Without `rows` it is the package's demo tree of five fi
1895
1987
 
1896
1988
  ```ts skip
1897
1989
  createTimeline(options: {
1898
- rows: ReadonlyArray<{ id: string; label: string; start: number; end: number; depth?: number; status?: "ok" | "error" | "unset" }>; // in display order
1990
+ rows: ReadonlyArray<{ id: string; label: string; start: number; end: number; depth?: number; status?: "ok" | "error" | "unset" }> | (() => ...); // in display order; a getter lets a trace arrive after mount
1899
1991
  axis?: { unit?: "ms" | "us" | "s" } | undefined; // "ms": the ruler's labels
1900
1992
  selected?: string | undefined; // controlled; undefined = uncontrolled
1901
1993
  onSelect?(id: string): void; // a request the host may ignore
@@ -1909,6 +2001,7 @@ createTimeline(options: {
1909
2001
  rulerProps: { role: "presentation"; "aria-hidden": "true"; ticks: ReadonlyArray<{ value: number; left: number; label: string }> };
1910
2002
  select(id: string): void;
1911
2003
  selectedId(): string | undefined;
2004
+ focusedId(): string | undefined; // the row with the roving tab stop: the last one focused, else the first
1912
2005
  };
1913
2006
  ```
1914
2007
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cssints",
3
- "version": "0.20.0",
3
+ "version": "0.22.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.20.0",
75
+ "@cssints/css-grammar": "^0.22.0",
76
76
  "culori": "^4.0.2",
77
77
  "effect": "4.0.6",
78
78
  "happy-dom": "^20.14.5",