cssints 0.24.0 → 0.25.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 +120 -23
  2. package/package.json +2 -2
package/docs/API.md CHANGED
@@ -1223,27 +1223,27 @@ 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()`, `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.2 kB runtime shipped to every target; `value()`/`checked()` take the field's value, calls of `on()`, `bind()` or `state()` in one `cn()` append, and `persist(value, key)` keeps a state across a reload (2026-10-31, cssints-674d) |
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) |
1247
1247
 
1248
1248
  ### Headless: `createField` and `createConfirm` (`@cssints/headless`)
1249
1249
 
@@ -1592,7 +1592,104 @@ createCommandPalette(options: {
1592
1592
  };
1593
1593
  ```
1594
1594
 
1595
- Focus stays in the field and `aria-activedescendant` names the highlighted option: ↓ on the last and ↑ on the first return the highlight to the field, and ↓/↑ from there re-enter at the first/last row. Home/End move the caret while the field has text and are list keys only while it is empty. Enter runs the highlight, Escape closes through the dialog, Tab is not intercepted. Filtering is a plain case-insensitive substring match over `label` and `keywords` — no fuzzy scoring, because a palette is short and ranking (recents, scopes) is the host's policy. `results()` keeps host order; `group` is what the host groups by; `emptyProps.hidden` and `labels.empty` are the empty state; the count is written to `statusProps` once per query, not on every arrow. The query and highlight reset on close, focus returns to the element that had it, and `runActive()` closes before `run()` so a command may open its own UI. Host's: rendering, grouping and CSS; async commands and their errors; every visible string via `labels`; any close other than Escape or a command. The filterable menu and the date picker are later compositions on the same research.
1595
+ Focus stays in the field and `aria-activedescendant` names the highlighted option: ↓ on the last and ↑ on the first return the highlight to the field, and ↓/↑ from there re-enter at the first/last row. Home/End move the caret while the field has text and are list keys only while it is empty. Enter runs the highlight, Escape closes through the dialog, Tab is not intercepted. Filtering is a plain case-insensitive substring match over `label` and `keywords` — no fuzzy scoring, because a palette is short and ranking (recents, scopes) is the host's policy. `results()` keeps host order; `group` is what the host groups by; `emptyProps.hidden` and `labels.empty` are the empty state; the count is written to `statusProps` once per query, not on every arrow. The query and highlight reset on close, focus returns to the element that had it, and `runActive()` closes before `run()` so a command may open its own UI. Host's: rendering, grouping and CSS; async commands and their errors; every visible string via `labels`; any close other than Escape or a command. The date picker is a later composition on the same research; the filterable menu is the next section.
1596
+
1597
+ ### Headless: `createFilterableMenu` (`@cssints/headless`)
1598
+
1599
+ The second **composition**, not a new primitive and not a new spec: `createDialogModal` + `createTextInput` + `createStatus` with one filtered `role=menu`, per Base UI's [Building a filterable menu that works for everyone](https://base-ui.com/blog/building-a-filterable-menu) (settled in `docs/research/compositions-2026-10.md`). Its oracle is `packages/headless/test/filterable-menu.browser.test.tsx`. The structure is a role promise: choosing a row runs a command, so the popup is a `role=dialog` whose trigger says `aria-haspopup="dialog"`, the field is `input[type=text][role=searchbox]` with no `aria-autocomplete`, `aria-haspopup` or `aria-expanded`, and the list is a `role=menu`.
1600
+
1601
+ ```ts skip
1602
+ createFilterableMenu(options: {
1603
+ items:
1604
+ | ReadonlyArray<{
1605
+ id: string;
1606
+ label: string;
1607
+ group?: string | undefined; // the heading the host renders
1608
+ submenu?: ReadonlyArray<{ id: string; label: string; run?(): void | Promise<void> }>;
1609
+ run(): void | Promise<void>;
1610
+ }>
1611
+ | (() => ReadonlyArray<FilterableMenuItem>);
1612
+ open?: boolean | undefined; // controlled: the host owns the open state
1613
+ onOpenChange?(open: boolean): void;
1614
+ labels?: Partial<{
1615
+ placeholder: string; // "Filter actions"
1616
+ empty: string; // "No actions found"; the host renders it
1617
+ count(count: number): string; // "5 actions"
1618
+ clear: string; // "Clear"; the clear button's name
1619
+ }> | undefined;
1620
+ id?: string | undefined;
1621
+ }): {
1622
+ open(): boolean;
1623
+ openMenu(): void;
1624
+ closeMenu(): void; // closes every level; Escape pops one first
1625
+ query(): string;
1626
+ setQuery(text: string): void;
1627
+ active(): string | null; // the highlighted row, or null while the field holds it
1628
+ setActive(id: string | null): void;
1629
+ results(submenuId?: string): ReadonlyArray<{
1630
+ id: string;
1631
+ label: string;
1632
+ group: string | undefined;
1633
+ hasSubmenu: boolean;
1634
+ }>;
1635
+ labels: FilterableMenuLabels;
1636
+ status(): string | undefined;
1637
+ // triggerProps (aria-haspopup="dialog", needs nothing from the field),
1638
+ // inputProps, menuProps, groupProps(name), groupLabelProps(name),
1639
+ // itemProps(id), submenuTriggerProps(id), submenuProps(id), emptyProps
1640
+ // ({ hidden, text }), statusProps, clearProps (tabindex -1), dialogProps,
1641
+ // backdropProps and titleProps: the attribute objects.
1642
+ };
1643
+ ```
1644
+
1645
+ Focus stays in the field and `aria-activedescendant` names the highlighted row; `data-highlighted` sits on the field while the highlight is there. ↓ on the last row and ↑ on the first return it to the field, and ↓/↑ from the field enter at the first/last row. Home/End are menu keys only while the query is empty **and** a row is highlighted; otherwise they move the caret. Enter runs the row or enters its submenu. **Tab closes and leaves** (not prevented: the dialog is hidden on the next task, after focus moved, so the menu never traps; a native `showModal()` wrapper keeps its own trap and moves first). **Escape pops one submenu level** and closes at the top level; Backspace on an empty submenu field goes back too.
1646
+
1647
+ A submenu is a level: each level keeps its query and highlight and there is one field, so Enter on a trigger moves the field to that level at once, hover shows the level without moving focus, and coming back never loses the caret (`submenuProps` is the pointer-into-the-submenu hook; `results(triggerId)` gives a submenu's rows for a panel the host draws). Filtering is a case-insensitive substring on the label; typing inside an IME composition updates the text but filters only on `compositionend` (`event.isComposing` is honoured on the keyboard too). `groupProps` / `groupLabelProps` (`aria-hidden`: the group is already named through `aria-labelledby`) are the groups; `emptyProps.hidden` + `emptyProps.text` are the empty state; the count is one polite write per query change, not per arrow; `clearProps` has `tabindex: -1`, so Clear is a pointer affordance and not a tab stop. The host mounts the dialog and field only while `open()`; `triggerProps` names `aria-haspopup="dialog"` and needs nothing from the field, so the trigger works before it exists. Host's: rendering the groups and the submenu geometry, CSS, async commands and their errors and closing, and every visible string through `labels`.
1648
+ Focus stays in the field and `aria-activedescendant` names the highlighted option: ↓ on the last and ↑ on the first return the highlight to the field, and ↓/↑ from there re-enter at the first/last row. Home/End move the caret while the field has text and are list keys only while it is empty. Enter runs the highlight, Escape closes through the dialog, Tab is not intercepted. Filtering is a plain case-insensitive substring match over `label` and `keywords` — no fuzzy scoring, because a palette is short and ranking (recents, scopes) is the host's policy. `results()` keeps host order; `group` is what the host groups by; `emptyProps.hidden` and `labels.empty` are the empty state; the count is written to `statusProps` once per query, not on every arrow. The query and highlight reset on close, focus returns to the element that had it, and `runActive()` closes before `run()` so a command may open its own UI. Host's: rendering, grouping and CSS; async commands and their errors; every visible string via `labels`; any close other than Escape or a command. The filterable menu is a later composition on the same research.
1649
+
1650
+ ### Headless: `createDatePicker` (`@cssints/headless`)
1651
+
1652
+ The second **composition**, not a new primitive and not a new spec: `createPopover` (a non-modal trigger and popup) + `createGrid` (a 6x7 month) + `createStatus` (the selected day). Its oracle is `packages/headless/test/date-picker.browser.test.tsx`. The popup is not modal on purpose — Tab leaves it, nothing behind it is `inert` — and the calendar maths is integer day numbers (`Date.UTC(y, m, d) / 86_400_000`), not a date library.
1653
+
1654
+ ```ts skip
1655
+ createDatePicker(options: {
1656
+ value?: string | undefined; // controlled, ISO YYYY-MM-DD
1657
+ defaultValue?: string | undefined;
1658
+ onValueChange?(iso: string): void; // a request; a controlled host may ignore it
1659
+ min?: string | undefined; // inclusive
1660
+ max?: string | undefined;
1661
+ today?: string | undefined; // injected; the local date by default
1662
+ open?: boolean | undefined; // controlled
1663
+ onOpenChange?(open: boolean): void;
1664
+ labels?: Partial<{
1665
+ trigger: string; // "Choose date"
1666
+ previousMonth: string; // "Previous month"
1667
+ nextMonth: string; // "Next month"
1668
+ month(year: number, month: number): string; // 0-11; "October 2026"
1669
+ weekdays: ReadonlyArray<string>; // 7, Sunday first (getUTCDay order)
1670
+ day(iso: string): string; // the announcement; the ISO string by default
1671
+ }> | undefined;
1672
+ id?: string | undefined;
1673
+ firstDayOfWeek?: number | undefined; // 0 = Sunday .. 6 = Saturday; 1
1674
+ }): {
1675
+ value(): string | undefined;
1676
+ open(): boolean;
1677
+ openPicker(): void;
1678
+ closePicker(): void;
1679
+ month(): { year: number; month: number }; // 0-11
1680
+ setMonth(year: number, month: number): void; // the roving day at the value, today or the 1st
1681
+ today(): string;
1682
+ select(iso: string): void; // a no-op outside min/max; the host closes
1683
+ monthDays(): ReadonlyArray<ReadonlyArray<string>>; // 6 rows of 7 ISO dates, neighbours included
1684
+ labels: DatePickerLabels;
1685
+ status(): string | undefined;
1686
+ // statusProps, triggerProps, popoverProps, headerProps, previousProps,
1687
+ // nextProps, gridProps, weekdayProps(index) and cellProps(iso): the attribute objects.
1688
+ attach(trigger: HTMLElement, content: HTMLElement): () => void; // the popover wiring; returns the teardown
1689
+ };
1690
+ ```
1691
+
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.
1596
1693
 
1597
1694
  ### Headless: `createPanZoom` (`@cssints/headless`)
1598
1695
 
@@ -1642,7 +1739,7 @@ The host owns the box and the look: the viewport's size and `overflow: hidden`,
1642
1739
 
1643
1740
  ### Headless: `createMenu` (`@cssints/headless`)
1644
1741
 
1645
- The APG menu family (`createMenu` for the menu button and the checkable menus, `createMenubar` for the roving bar) as state and attribute objects. Its behaviour is `specs/apg/menu-button.qnt` and `menu-checkable.qnt`, and `packages/headless/test/menu-button.browser.test.tsx` replays the generated models, ported scenarios and seeded differential walks against it.
1742
+ The APG menu family (`createMenu` for the menu button and the checkable menus, `createMenubar` for the roving bar) as state and attribute objects. Its behaviour is `specs/apg/menu-button.qnt`, `menu-checkable.qnt` and `menu-menubar.qnt`, and `packages/headless/test/menu-button.browser.test.tsx` and `packages/headless/test/menu.browser.test.tsx` replay the generated models, ported scenarios and seeded differential walks against it.
1646
1743
 
1647
1744
  ```ts skip
1648
1745
  createMenu(options: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cssints",
3
- "version": "0.24.0",
3
+ "version": "0.25.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.24.0",
75
+ "@cssints/css-grammar": "^0.25.0",
76
76
  "culori": "^4.0.2",
77
77
  "effect": "4.0.6",
78
78
  "happy-dom": "^20.14.5",