cssints 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +188 -2
- package/dist/author-types-C1AhsnrT.d.mts +80 -0
- package/dist/check.d.mts +22 -0
- package/dist/check.mjs +37 -0
- package/dist/color-BYIxVLlS.mjs +29 -0
- package/dist/color-C8OzjAeg.mjs +2 -0
- package/dist/contrast-DhK69Xak.mjs +83 -0
- package/dist/derived.d.mts +14 -0
- package/dist/derived.mjs +391 -0
- package/dist/errors-D4ZbcwRo.d.mts +32 -0
- package/dist/frame-BoBKjgBB.mjs +92 -0
- package/dist/frame.d.mts +72 -0
- package/dist/frame.mjs +2 -0
- package/dist/group-common-9zpBxfUF.d.mts +1 -0
- package/dist/group-types-9ttphdku.d.mts +55 -0
- package/dist/groups/compositing-2.d.mts +20 -0
- package/dist/groups/css-align-3.d.mts +29 -0
- package/dist/groups/css-anchor-position-1.d.mts +49 -0
- package/dist/groups/css-anchor-position-2.d.mts +50 -0
- package/dist/groups/css-animations-1.d.mts +28 -0
- package/dist/groups/css-animations-2.d.mts +29 -0
- package/dist/groups/css-backgrounds-3.d.mts +27 -0
- package/dist/groups/css-backgrounds-4.d.mts +29 -0
- package/dist/groups/css-borders-4.d.mts +95 -0
- package/dist/groups/css-box-4.d.mts +38 -0
- package/dist/groups/css-break-4.d.mts +23 -0
- package/dist/groups/css-cascade-5.d.mts +18 -0
- package/dist/groups/css-cascade-6.d.mts +18 -0
- package/dist/groups/css-color-4.d.mts +29 -0
- package/dist/groups/css-color-5.d.mts +32 -0
- package/dist/groups/css-color-6.d.mts +32 -0
- package/dist/groups/css-color-adjust-1.d.mts +20 -0
- package/dist/groups/css-color-hdr-1.d.mts +19 -0
- package/dist/groups/css-conditional-3.d.mts +17 -0
- package/dist/groups/css-conditional-4.d.mts +17 -0
- package/dist/groups/css-conditional-5.d.mts +22 -0
- package/dist/groups/css-contain-2.d.mts +19 -0
- package/dist/groups/css-content-3.d.mts +23 -0
- package/dist/groups/css-counter-styles-3.d.mts +16 -0
- package/dist/groups/css-display-4.d.mts +22 -0
- package/dist/groups/css-easing-2.d.mts +18 -0
- package/dist/groups/css-env-1.d.mts +16 -0
- package/dist/groups/css-flexbox-1.d.mts +24 -0
- package/dist/groups/css-fonts-4.d.mts +46 -0
- package/dist/groups/css-fonts-5.d.mts +44 -0
- package/dist/groups/css-forms-1.d.mts +19 -0
- package/dist/groups/css-gaps-1.d.mts +21 -0
- package/dist/groups/css-gcpm-3.d.mts +20 -0
- package/dist/groups/css-gcpm-4.d.mts +20 -0
- package/dist/groups/css-grid-2.d.mts +34 -0
- package/dist/groups/css-grid-3.d.mts +34 -0
- package/dist/groups/css-images-3.d.mts +25 -0
- package/dist/groups/css-images-4.d.mts +32 -0
- package/dist/groups/css-images-5.d.mts +33 -0
- package/dist/groups/css-inline-3.d.mts +28 -0
- package/dist/groups/css-lists-3.d.mts +26 -0
- package/dist/groups/css-logical-1.d.mts +35 -0
- package/dist/groups/css-masking-1.d.mts +37 -0
- package/dist/groups/css-multicol-2.d.mts +24 -0
- package/dist/groups/css-overflow-3.d.mts +28 -0
- package/dist/groups/css-overflow-4.d.mts +29 -0
- package/dist/groups/css-overflow-5.d.mts +31 -0
- package/dist/groups/css-overscroll-1.d.mts +22 -0
- package/dist/groups/css-page-3.d.mts +18 -0
- package/dist/groups/css-page-floats-3.d.mts +19 -0
- package/dist/groups/css-position-3.d.mts +29 -0
- package/dist/groups/css-position-4.d.mts +30 -0
- package/dist/groups/css-rhythm-1.d.mts +18 -0
- package/dist/groups/css-round-display-1.d.mts +16 -0
- package/dist/groups/css-ruby-1.d.mts +21 -0
- package/dist/groups/css-scroll-anchoring-1.d.mts +18 -0
- package/dist/groups/css-scroll-snap-1.d.mts +42 -0
- package/dist/groups/css-scroll-snap-2.d.mts +43 -0
- package/dist/groups/css-scrollbars-1.d.mts +19 -0
- package/dist/groups/css-shapes-1.d.mts +27 -0
- package/dist/groups/css-shapes-2.d.mts +27 -0
- package/dist/groups/css-size-adjust-1.d.mts +18 -0
- package/dist/groups/css-sizing-3.d.mts +34 -0
- package/dist/groups/css-sizing-4.d.mts +33 -0
- package/dist/groups/css-speech-1.d.mts +18 -0
- package/dist/groups/css-tables-3.d.mts +22 -0
- package/dist/groups/css-text-3.d.mts +36 -0
- package/dist/groups/css-text-4.d.mts +40 -0
- package/dist/groups/css-text-decor-3.d.mts +30 -0
- package/dist/groups/css-text-decor-4.d.mts +31 -0
- package/dist/groups/css-transforms-1.d.mts +31 -0
- package/dist/groups/css-transforms-2.d.mts +48 -0
- package/dist/groups/css-transitions-1.d.mts +22 -0
- package/dist/groups/css-transitions-2.d.mts +23 -0
- package/dist/groups/css-ui-4.d.mts +35 -0
- package/dist/groups/css-values-4.d.mts +38 -0
- package/dist/groups/css-values-5.d.mts +43 -0
- package/dist/groups/css-variables-2.d.mts +6 -0
- package/dist/groups/css-view-transitions-2.d.mts +20 -0
- package/dist/groups/css-viewport.d.mts +18 -0
- package/dist/groups/css-will-change-1.d.mts +18 -0
- package/dist/groups/css-writing-modes-4.d.mts +22 -0
- package/dist/groups/css2.d.mts +19 -0
- package/dist/groups/fill-stroke-3.d.mts +27 -0
- package/dist/groups/filter-effects-1.d.mts +32 -0
- package/dist/groups/filter-effects-2.d.mts +33 -0
- package/dist/groups/limited-availability.d.mts +2 -0
- package/dist/groups/mediaqueries-4.d.mts +16 -0
- package/dist/groups/mediaqueries-5.d.mts +16 -0
- package/dist/groups/motion-1.d.mts +24 -0
- package/dist/groups/newly-available.d.mts +631 -0
- package/dist/groups/not-discouraged.d.mts +727 -0
- package/dist/groups/scroll-animations-1.d.mts +30 -0
- package/dist/groups/selectors-4.d.mts +54 -0
- package/dist/groups/selectors-5.d.mts +54 -0
- package/dist/groups/widely-available.d.mts +616 -0
- package/dist/index.d.mts +48 -0
- package/dist/index.mjs +1269 -0
- package/dist/instrument-gCazujmb.mjs +1624 -0
- package/dist/lowered-D2KusPLp.mjs +28 -0
- package/dist/node-OJ_w_Usj.d.mts +233 -0
- package/dist/parent-vars.d.mts +13 -0
- package/dist/parent-vars.mjs +65 -0
- package/dist/plugin-types.d.mts +377 -0
- package/dist/plugin.d.mts +3 -0
- package/dist/plugin.mjs +30 -0
- package/dist/registry-CTumHB8p.mjs +73 -0
- package/dist/runtime-Dt4Tm0Q7.d.mts +19 -0
- package/dist/runtime.d.mts +18 -0
- package/dist/runtime.mjs +25 -0
- package/dist/scope-ZVhUxQH1.d.mts +2101 -0
- package/dist/sheet-BKsMTa0c.mjs +757 -0
- package/dist/surface.d.mts +228 -0
- package/dist/surface.mjs +101 -0
- package/dist/theme-BsP2E4LB.mjs +929 -0
- package/dist/theme-types-Bqv0Ggz_.d.mts +1808 -0
- package/dist/trace-otlp-CbKFDsn9.mjs +14 -0
- package/dist/tui-types.d.mts +181 -0
- package/dist/tui.d.mts +23 -0
- package/dist/tui.mjs +427 -0
- package/dist/types.d.mts +2 -0
- package/dist/vite.d.mts +134 -0
- package/dist/vite.mjs +977 -0
- package/docs/API.md +1193 -0
- package/package.json +85 -4
- package/skills/cssints/SKILL.md +203 -0
- package/skills/cssints/references/errors.md +86 -0
- package/skills/cssints/references/plugins.md +91 -0
- package/skills/cssints/references/tokens.md +91 -0
- package/skills/cssints-plugin/SKILL.md +177 -0
- package/skills/cssints-plugin/references/table.md +124 -0
- package/skills/cssints-plugin/references/testing.md +122 -0
package/docs/API.md
ADDED
|
@@ -0,0 +1,1193 @@
|
|
|
1
|
+
# CSS in TS
|
|
2
|
+
|
|
3
|
+
The 0.x API, **alpha**: it can still change between minor versions. Setup (the Vite plugin, the stylesheet import) is in the [README](../README.md). Decisions and findings are in [wayfinder.md](wayfinder.md).
|
|
4
|
+
|
|
5
|
+
Every `ts` block here is type-checked against the workspace `cssints` types by `packages/cssints/test/docs.mjs` (`bun run test`). A line that ends in `// error` must fail to type-check. Blocks fenced `ts skip` (output) are not checked. A few blocks show a build-time error that the types cannot see; their comments give the message.
|
|
6
|
+
|
|
7
|
+
### Authoring surface (decided 2026-09-29, cssints-5dbi)
|
|
8
|
+
|
|
9
|
+
- **Import.** `with { type: "cssints" }` marks code that runs at build time: cssints itself, plugins such as icons, and your own helper modules. The plugin finds call sites by this attribute. Nothing is imported without it: the few runtime lines a module needs (`__cx` for a `cn()` with runtime arguments, `__cv` for a `cv()`) are imported by the plugin. Using a build-time member without the attribute is a build error.
|
|
10
|
+
- **Names.** Every mdn property as camelCase (`paddingLeft`, `alignItems`, `justifyContent`), plus a short set: `p pt pr pb pl`, `m mt mr mb ml`, `bg`. Everything is available both as named exports and through the namespace (`css.p`). There is no `items` or `justify`: they were ambiguous (`align-items` or `align-content`, `justify-content` or `justify-items`).
|
|
11
|
+
- **Numbers.** A bare number is a spacing step of 0.25rem in `padding*`, `margin*`, `gap*` and `inset*` (`p(4)` is `1rem`). In any other property it is a CSS `<number>`: `opacity(0.5)`, `zIndex(3)`.
|
|
12
|
+
- **Values.** A property takes one or more parts joined by a space. Each part is a string or a Typed OM value: `border("1px solid", accent)`, `padding(CSS.rem(1), CSS.rem(2))`, `padding("1rem 2rem")`. Parts are checked against the mdn grammar in types and again at build time. `CSS.px`, `rem`, `em`, `percent`, `number`, `CSSUnitValue` and `CSSColorValue` come from `cssints`, imported with the attribute.
|
|
13
|
+
- **Editor.** A bad part is marked on that part: `padding("2px", "red")` puts the error on `"red"`, with the message of the whole value. While a string is not a value yet, the editor offers the keywords of the property that the word under the cursor begins (`display("fl…")` gives `flex`, `flow`, `flow-root`; `border("1px", "so…")` gives `solid`), and all of them in an empty part (`border("1px", "")`; an empty part is an error). The offer is the keywords of the whole grammar, not only those that fit at that point, it stops once the string is valid, and functions such as `rgb(` are not offered.
|
|
14
|
+
- **Math.** `calc()`, `min()`, `max()`, `clamp()` and the other CSS Values 4 math functions are accepted wherever their type is, as strings (`width("calc(100% - 2rem)")`, checked by the build: `calc(1px + 2s)` is an error) and as calls that take tokens (`calc(t.space.md, "*", 2)`, see "Functions").
|
|
15
|
+
- **Layout contexts.** `flex()` and `grid()` without arguments set `display` and chain only their container properties. Names drop only the context prefix: `flex().direction("column").wrap("wrap").alignItems("center").justifyContent("space-between")`, `grid().templateColumns("1fr 2fr").gap(4)`. Plain styles do not chain; merge them with `cn(...)`. Item properties (`flexGrow`, `alignSelf`, `gridColumn`, `order`) are plain functions.
|
|
16
|
+
- **`flex(...parts)` and `grid(...parts)` are the shorthand properties.** With at least one argument the call is the `flex` (or `grid`) property, checked by its grammar like any member: `flex("1")`, `flex(1)` and `flex("1 1 0")` are `flex: 1`, `flex: 1` and `flex: 1 1 0`, `grid("auto / 1fr 1fr")` is `grid: auto / 1fr 1fr`. A bare number is a `<number>` here, not a spacing step. The result is a plain style: it sets no `display` and does not chain. The types tell the two forms apart by overload.
|
|
17
|
+
- **`container` is the condition.** `container("sidebar (width < 30rem)")` is a query; the `container` shorthand property is written with its longhands `containerName` and `containerType`.
|
|
18
|
+
- **Arguments are build-time values.** The plugin runs your module at build time and replaces each call with a class string. A literal, a `const`, a token or a value from another attributed module works. A value that exists only at runtime (a prop, state) does not: `p(n)` with `n` a parameter is a build error (`css arguments must be known at build time: n is not defined where the call is evaluated`) that points at the call, also when the local is named like a global (`open`, `name`) or like a module binding. The types do not catch it: a helper module may take parameters (below), so a `string` or `number` is a fine argument there. Choose between styles with `cn(base, on && p(4))` (see "Runtime: `cn`, `cv`"), a `cv` variant, or a token set in a `style` prop. A helper module imported with the attribute may take parameters, because each call of it is a site with its own arguments: `stack(4)` below is a build-time call in the file that makes it.
|
|
19
|
+
- **Top-level code runs at build time.** A call the run does not reach (`false && p(4)`, a branch not taken, a ternary arm) still gets its class string: it is evaluated after the module has loaded, like a call inside a function, so its arguments may use the bindings of the module but not the locals of the branch. A `cv(...)` at the top of a module works too: during the build it is the same function the browser gets, so `button({ size: "lg" })` at the top of a module gives its classes.
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
// stack.ts
|
|
23
|
+
import * as css from "cssints" with { type: "cssints" };
|
|
24
|
+
|
|
25
|
+
export const stack = (gap: number) => css.flex().direction("column").gap(gap);
|
|
26
|
+
|
|
27
|
+
// elsewhere: import { stack } from "./stack.ts" with { type: "cssints" }; ... className={stack(4)}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### Basic usage
|
|
31
|
+
|
|
32
|
+
```tsx
|
|
33
|
+
import { alignItems, cn, justifyContent, p } from "cssints" with { type: "cssints" };
|
|
34
|
+
import * as css from "cssints" with { type: "cssints" };
|
|
35
|
+
|
|
36
|
+
export const A = () => (
|
|
37
|
+
<div className={cn(css.display("flex"), alignItems("center"), justifyContent("center"), prop && p(4))}>Hello, world!</div>
|
|
38
|
+
);
|
|
39
|
+
// or with a layout context
|
|
40
|
+
export const B = () => (
|
|
41
|
+
<div className={cn(css.flex().alignItems("center").justifyContent("center"), prop && css.p(4))}>Hello, world!</div>
|
|
42
|
+
);
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Output for `A`:
|
|
46
|
+
|
|
47
|
+
```tsx skip
|
|
48
|
+
import { _cx as __cx } from "cssints";
|
|
49
|
+
|
|
50
|
+
export const A = () => <div className={__cx([], "_1s47490 _1ob5z5m _cn8dee", prop && "_lwugp7")}>Hello, world!</div>;
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The static arguments of `cn` are one class string; `prop && p(4)` depends on a runtime value, so it stays an argument of the runtime call, and the `p(4)` in it is already its class.
|
|
54
|
+
|
|
55
|
+
```css
|
|
56
|
+
@layer _.k, _.a, _.b, _.c, _.d, _.e;
|
|
57
|
+
@layer _.a {
|
|
58
|
+
._lwugp7 { padding: 1rem; }
|
|
59
|
+
._1ob5z5m { align-items: center; }
|
|
60
|
+
._1s47490 { display: flex; }
|
|
61
|
+
._cn8dee { justify-content: center; }
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
- **The class names** are a hash of the declaration (and its conditions), so the same declaration gives the same class in every file.
|
|
66
|
+
- **One sheet.** All rules are in `virtual:cssints.css`, each once, sorted by layer, then condition, then shorthand before longhand (`border` before `border-top`, counted from Chrome's own expansion of each shorthand), then class name. So a longhand wins over a shorthand that contains it, whatever the order of the arguments: `cn(pl(2), p(4))` keeps `padding-left: 0.5rem`.
|
|
67
|
+
- **`cn()` keeps the later of the same property.** `cn(p(4), p(2))` is `p(2)`: the earlier declaration is dropped at build time (also for an alias: `wordWrap` is `overflowWrap`). Under different conditions both stay, because they are in different layers: `cn(md(p(6)), p(2))`. Two properties that overlap without one containing the other are a build error, because the sheet cannot order them by the arguments: `cn(borderTop("1px solid red"), borderColor("blue"))` both set `border-top-color`; so do a physical and a logical side (`marginLeft` and `marginInline`, `width` and `inlineSize`), since which physical side a logical one is depends on the writing mode. A logical property inside a physical shorthand that sets every side is contained, not overlapping: `cn(border(...), borderInline(...))` is fine, and `borderInline` wins. A style in a runtime argument is compared with the static arguments at build time, and replaces the classes it overrides while it is active (see "Runtime: `cn`, `cv`").
|
|
68
|
+
- **A layout context** gives one string: `flex().alignItems("center")` is `"_1s47490 _1ob5z5m"`.
|
|
69
|
+
- **Also exported:** `css` (the namespace as a value) and `cn`, `cv`, `firstThatWorks`, `media`, `container`, `supports`, `attr`, `within`, `group`, `global`, `token`, `style`, `createTokens`, `createGlobalTheme`, `check`. Nothing is exported for the browser: the runtime is the plugin's.
|
|
70
|
+
|
|
71
|
+
### Without runtime
|
|
72
|
+
|
|
73
|
+
```tsx
|
|
74
|
+
import * as css from "cssints" with { type: "cssints" };
|
|
75
|
+
|
|
76
|
+
export const C = () => <div className={css.cn(css.flex().alignItems("center").justifyContent("center"), css.p(4))}>Hello, world!</div>;
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Output:
|
|
80
|
+
|
|
81
|
+
```tsx skip
|
|
82
|
+
export const C = () => <div className="_1s47490 _1ob5z5m _cn8dee _lwugp7">Hello, world!</div>;
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
- **`cn(...)`** merges styles at build time into one class string. With arguments that are all known at build time nothing is left at runtime and the runtime is not imported.
|
|
86
|
+
|
|
87
|
+
### Conditions and layers (decided 2026-09-29, cssints-kpq1)
|
|
88
|
+
|
|
89
|
+
Conditions are wrapper functions. A wrapper takes styles and returns them under its condition. Wrappers nest.
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
import * as css from "cssints" with { type: "cssints" };
|
|
93
|
+
|
|
94
|
+
const md = css.media("(width >= 40rem)");
|
|
95
|
+
const [tablet, wide] = css.media("(width >= 40rem)", "(width >= 60rem)");
|
|
96
|
+
const { sm, lg } = css.media({ sm: "(width >= 30rem)", lg: "(width >= 60rem)" });
|
|
97
|
+
const dark = css.media("(prefers-color-scheme: dark)");
|
|
98
|
+
const narrow = css.container("sidebar (width < 30rem)");
|
|
99
|
+
const grid = css.supports("(display: grid)");
|
|
100
|
+
|
|
101
|
+
export const styles = css.cn(
|
|
102
|
+
css.p(2),
|
|
103
|
+
md(css.pl("8ch"), css.pt(4)),
|
|
104
|
+
css.hover(css.bg("#eee")),
|
|
105
|
+
md(css.hover(css.bg("#ddd"))),
|
|
106
|
+
prop && lg(css.p(8)),
|
|
107
|
+
);
|
|
108
|
+
export const forms = css.cn(dark(css.bg("#111")), narrow(css.m(0)), grid(css.display("grid")), tablet(css.m(1)), wide(css.m(2)), sm(css.m(3)));
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
- **Pseudo-classes** are ready-made wrappers named after the selector: `hover`, `focus`, `focusVisible`, `active`, `disabled`, and so on: 49 of them, from mdn-data `selectors.json` (standard pseudo-classes without arguments). Functional ones (`:nth-child()`) are not wrappers.
|
|
112
|
+
- **Query strings are checked** against the mdn-data grammar (`<media-query-list>`, `<container-condition>`, `<supports-condition>`), in types and at build time, like property values. A range needs parentheses: `(width > 40rem)`, not `min-width > 40rem`. `supports()` takes property declarations; a typo in the property (`(display: grdi)`) is a build error, not a type error.
|
|
113
|
+
- **Layer = number of conditions.** A style with no condition goes to `_.a`, one condition to `_.b`, two to `_.c`, and so on up to `_.e`. A fifth condition is a build error. A media query, a container query, `supports` and a pseudo-class each count as one. `_.k` comes first and holds kernels, global rules, token and theme rules. A theme or a plugin rule may name a **top-level** layer instead (`createGlobalTheme(t, selector, overrides, { layer: "app" })`, a plugin's `globals: [{ css, layer: "app" }]`): the layers you declare with `layers()` (see "Themes") come after the class layers, so a look beats the engine's `:root` fallbacks (Mise en Mode's `@layer app, author, viewer`), and those declared with `{ before: true }` come before `_.k`, so every style beats them (`@cssints/reset`'s `@layer reset`).
|
|
114
|
+
- **Order inside a layer** comes from the sorted stylesheet. Each condition has a rank: a pseudo-class by its place in the list (`link`, `visited`, `any-link`, `focus-within`, `hover`, `focus`, `focus-visible`, `active` first), a media or container query by its min-width in px (`(min-width: X)` or `(width >= X)` in px, rem or em; 0 when none can be read). Rules sort by the ranks of their chain. With equal layers, `hover(...)` beats `md(...)` by specificity.
|
|
115
|
+
- **Class names** hash the declaration together with its condition chain.
|
|
116
|
+
- **Bindings.** `const md = media(...)`, and its tuple and object forms, are build-time only: the client has `null` in their place, so keep them in the module that uses them or import them from a module with the attribute.
|
|
117
|
+
- Attributes and parent states are conditions too: `attr()`, `group()` and `within()` (below). Pseudo-elements (`::before`, `::placeholder`) are not part of 0.1.
|
|
118
|
+
- **A condition wraps declarations, not a kernel's marker** (cssints-q83n). A plugin member's kernel is global CSS keyed to its marker class, so the marker applies wherever the class is, whatever the condition. A member with declarations of its own is fine under a condition: `hover(frame().padding(2))` makes the custom property conditional, and the kernel reads it. A member with none, `pretty()` of `@cssints/balance`, `frame()` with no method, `group()`, gives the condition nothing to wrap, so it is a build error at the call, also inside `cn()`, beside other styles, nested or in a `cv()` variant: `cssints: :hover wraps _balance-prettyCore, the marker of kernel balance.prettyCore, with no declaration of its own: a kernel is global CSS keyed to its marker class and cannot be conditional, …`. Put the member outside the condition. The types do not catch it (a member is a class string there).
|
|
119
|
+
|
|
120
|
+
Output of `styles`; each form in `forms` gives one more rule in `_.b` (`dark`, `narrow` and `grid` in a block of their own before `:hover`):
|
|
121
|
+
|
|
122
|
+
```css
|
|
123
|
+
@layer _.a {
|
|
124
|
+
._1t8gqy5 { padding: 0.5rem; }
|
|
125
|
+
}
|
|
126
|
+
@layer _.b {
|
|
127
|
+
._16f8jrr:hover { background-color: #eee; }
|
|
128
|
+
@media (width >= 40rem) {
|
|
129
|
+
._1q848uz { padding-top: 1rem; }
|
|
130
|
+
._t70zhd { padding-left: 8ch; }
|
|
131
|
+
}
|
|
132
|
+
@media (width >= 60rem) {
|
|
133
|
+
._1khcbr2 { padding: 2rem; }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
@layer _.c {
|
|
137
|
+
@media (width >= 40rem) {
|
|
138
|
+
._ronnd2:hover { background-color: #ddd; }
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Global rules, attributes and parent states (decided 2026-10-03, cssints-635k, cssints-eaob)
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
import * as css from "cssints" with { type: "cssints" };
|
|
147
|
+
|
|
148
|
+
const dark = css.media("(prefers-color-scheme: dark)");
|
|
149
|
+
|
|
150
|
+
// a reset: rules with no class
|
|
151
|
+
css.global("*, *::before, *::after", css.boxSizing("border-box"));
|
|
152
|
+
css.global("body", css.margin("0"), css.margin("8px")); // the later margin wins
|
|
153
|
+
css.global(":root", css.colorScheme("light"), dark(css.colorScheme("dark")));
|
|
154
|
+
css.global("a[href^='http'] > b", css.color("red"));
|
|
155
|
+
css.global("a[href", css.color("red")); // error: a bracket is open
|
|
156
|
+
css.global("a >", css.color("red")); // selector: unexpected end in "a >", expected <complex-selector-list>
|
|
157
|
+
css.global("a", css.hover(css.color("red"))); // global() takes media(), container() and supports() conditions, not :hover: write the state in the selector
|
|
158
|
+
|
|
159
|
+
// an attribute state, and the state of a marked ancestor
|
|
160
|
+
export const row = css.cn(css.group(), css.p(2));
|
|
161
|
+
export const link = css.cn(
|
|
162
|
+
css.color("gray"),
|
|
163
|
+
css.attr("aria-current", "page")(css.color("blue")),
|
|
164
|
+
css.within(css.hover)(css.textDecoration("underline")),
|
|
165
|
+
);
|
|
166
|
+
export const icon = css.attr("href", ".pdf", "$=")(css.opacity(0.8));
|
|
167
|
+
export const nested = css.cn(css.group("card"), css.within(css.focusVisible, "card")(css.outline("2px solid")));
|
|
168
|
+
css.attr("a b"); // error: a name has no space
|
|
169
|
+
css.attr("a", "x", "!="); // error: not an operator
|
|
170
|
+
css.within(css.media("(width >= 40rem)")); // error: a media query is not a state
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
- **`css.global(selector, ...styles)`** is a rule with no class: `body{margin:0}`. The styles are member calls (`margin("0")`, `cn(...)`), checked by the grammar of each property like a class's (types at the member call, the build at the call of `global()`, with the support warnings). One property twice is the later one, as in `cn()`; two that overlap without one containing the other are the same build error. A `media()`, `container()` or `supports()` condition around a style makes an at-rule block around the rule (`@media (prefers-color-scheme: dark){:root{color-scheme:dark}}`); `hover`, `attr()` and `within()` are errors there, since a global rule has the selector for that (`a:hover`). A marker or kernel of a plugin member (`frame()`, `icons()`) is an error too: they need a class. A `firstThatWorks()` prints as in a class. The call returns nothing (`void`; the client has `undefined` in its place).
|
|
174
|
+
- **The selector** is a string checked at build time against `<complex-selector-list>` (css-selectors-4, mdn-data's grammar; `css-grammar/src/selector.ts` is a recogniser written over it, since the value twin drops the whitespace a selector is made of): type, `*`, `#id`, `.class`, `[attr]` with its operators, pseudo-classes and pseudo-elements, the combinators (descendant, `>`, `+`, `~`, `||`) and a list. The arguments of `:is()`, `:not()`, `:nth-child()` are balanced but not read (the grammar says `<any-value>`). `&` and a leading combinator are errors. A unknown pseudo-class is fine, as in the grammar. In the types the check is cheap: not empty, brackets and quotes pair up (about 180 instantiations a call); the build reads the rest and puts the message on the call.
|
|
175
|
+
- **The sheet.** A global rule is in the sheet of the module that calls it, once, in layer `_.k`: after the kernels, before the token sets and themes, in source order (by file, then call). It sets declarations on elements by selector, so it carries no hash class and no layer number; classes (`_.a` to `_.e`) are in later layers and win over it, so a reset in `_.k` never fights a component. Import a module with its `global()` calls (`import "./reset.ts"`) to have them, as for a theme. A token that a global rule reads has its `@property` rule in the same sheet.
|
|
176
|
+
- **`@font-face` and the other at-rules stay in CSS** (`keyframes()` and `positionTry()` are the local ones): `global()` takes a selector and declarations, and an at-rule with no selector is not one.
|
|
177
|
+
- **`attr(name, value?, op?)`** is a condition like `hover`: `attr("aria-current", "page")(color(...))` is `._x[aria-current="page"]{...}`. No value is presence (`[disabled]`). The value is always quoted for you (`"` and `\` escaped); `op` is `=` (default), `~=` (a word of a list), `|=`, `^=`, `$=` or `*=`. The name is an identifier (`aria-current`, `data-state`; no namespaces, no `[`, `=` or spaces), the value a string, and the case flag (`i`) is not offered. It counts as one condition (a layer, as `hover`). Among the rules of a layer it sorts after every pseudo-class, so an attribute state beats `hover` on the same property. Two `attr()` conditions that can both match and set one property sort by class name: keep them exclusive (`[aria-current=page]` and `[aria-current=false]`).
|
|
178
|
+
- **`group(name?)` and `within(state, name?)`** are the state of a marked ancestor. `group()` is a style that adds the fixed class `_g` (`group("card")` adds `_g-card`); it has no rule of its own, so the ancestor needs nothing else. `within(hover)(color("red"))` is `:where(._g:hover) ._x{color:red}`; `within(hover, "card")` reads the `_g-card` ancestor (any ancestor with that class, not the nearest one; use names to tell nested groups apart). A plugin member with `group: true` is the group named after its scope (`within(hover, "stuck")` reads `sticky()`, see "Plugins"). `state` is a pseudo-class wrapper or `attr(...)`, not a `media()` and not another `within()`. It counts as one condition.
|
|
179
|
+
- **Specificity of `within()` is one class.** `:where()` has none, so `:where(._g:hover) ._x` is as specific as `._x:hover` and `._x[aria-current="page"]`. Without it the ancestor's class would make `within(hover)` (0,3,0) beat `hover` (0,2,0) whatever the order, and a nested `md(within(hover)(...))` would fight the sheet's own order. Now the order decides, as for every condition: in a layer the ancestor's state comes first, then the pseudo-classes, then attributes, so an element's own state wins over its group's.
|
|
180
|
+
- **`:has()` is not offered** (`has(...)`): it is a selector on the ancestor that looks down, `:where(._x:has(...))` would be a rule on another element than the class, and `within()` plus `group()` already say what it is used for in a card. It is a pseudo-class with arguments, like `:nth-child()`.
|
|
181
|
+
|
|
182
|
+
```css
|
|
183
|
+
@layer _.k {
|
|
184
|
+
*, *::before, *::after{box-sizing:border-box}
|
|
185
|
+
body{margin:8px}
|
|
186
|
+
:root{color-scheme:light}@media (prefers-color-scheme: dark){:root{color-scheme:dark}}
|
|
187
|
+
a[href^='http'] > b{color:red}
|
|
188
|
+
}
|
|
189
|
+
@layer _.b {
|
|
190
|
+
:where(._g:hover) ._72037{text-decoration:underline}
|
|
191
|
+
._fc1pxh[aria-current="page"]{color:blue}
|
|
192
|
+
._pl3vkk[href$=".pdf"]{opacity:0.8}
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Tokens (decided 2026-09-29, cssints-o00x)
|
|
197
|
+
|
|
198
|
+
A token is a Typed OM value whose `toString()` is `var(--name)`. It fits anywhere a value of its type fits: `color(blue)`, `border("1px solid", blue)`. In math and in other CSS functions it is a part of a call: `calc(t.space.md, "*", 2)`, `var(t.space.sm, "4px")` (see "Functions").
|
|
199
|
+
|
|
200
|
+
```tsx
|
|
201
|
+
import * as css from "cssints" with { type: "cssints" };
|
|
202
|
+
|
|
203
|
+
const blue = css.token.color("blue", "color-primary"); // --color-primary, can be set
|
|
204
|
+
const gap = css.token.length("16px", { var: "gap", inherit: false });
|
|
205
|
+
const muted = css.token.color("#6b7280"); // --_<hash>, private
|
|
206
|
+
|
|
207
|
+
export const A = () => <div className={css.cn(css.color(blue), css.gap(gap), css.color(muted))} />;
|
|
208
|
+
export const B = () => <div style={css.style(css.color(blue), css.token.set(blue, "red"))} />; // { "--color-primary": "red", color: "var(--color-primary)" }
|
|
209
|
+
css.token.set(muted, "red"); // error: private tokens cannot be set
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
- **Types:** `token.<type>` exists for the types that `@property` `syntax` allows: `color`, `length`, `percentage`, `lengthPercentage`, `number`, `integer`, `angle`, `time`, `customIdent`, `resolution`, `url`, `image`, `transformList`, and two that it does not: `easingFunction` (`<easing-function>`) and `shadow` (a list of box shadows, `<shadow>#`). Those two have `syntax: "*"`: the browser takes any value and does not check or animate it, and the initial value is still checked by the grammar; the alternative, no `@property` rule, would lose `inherits` and the default outside `:root`. A token fits only the typed parts of its type, with two exceptions: an `integer` also fits `<number>`, and a `url` also fits `<image>` (a `url()` is an image). A `url` token in a `<url>`-only position (`markerStart`) is fine, an `image` token there is an error. A relative `url()` in a `<url>` token resolves against the page, not the stylesheet. The initial value is checked against that type's grammar, in types and at build time. A browser drops an `@property` rule whose initial value depends on a font size (`1rem`, `1em`, `1ch`), so such a value is the initial value in px at a 16px font (`1rem` is `16px`; `1ex` and `1ch` are `8px`, `1lh` is `19.2px`, the fallbacks of CSS) and the value itself goes on `:root` (`:where(*)` when the token does not inherit): `@property --gap{…initial-value:16px}` and `:root{--gap:1rem}`, so the token follows the user's font size. On `:root` an `em` is the root's font size, as `rem`; a token that does not inherit takes the `em` of each element.
|
|
213
|
+
- **Names:** a name (a string, or `{ var, inherit }`) gives `--name`, and the token can be set. Without a name the variable is `--_<hash>` of the file and site, and the token is private. `inherits` defaults to `true`.
|
|
214
|
+
- **Output:** one `@property` per token at the top of the global sheet, after the layer statement and outside the layers, once however many modules use it. Two tokens with one name are a build error at the second.
|
|
215
|
+
|
|
216
|
+
```css
|
|
217
|
+
@property --color-primary {
|
|
218
|
+
syntax: "<color>";
|
|
219
|
+
inherits: true;
|
|
220
|
+
initial-value: blue;
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
- **`style()` and `token.set()`** are build-time and take static values; the result is a style object, custom properties first. A dynamic value is set with a plain `style={{ "--color-primary": value }}`; a typed runtime setter is not part of 0.1.
|
|
225
|
+
- **A token of the wrong type** is an error that names the token: `color: unexpected "1px" in "1px", expected <color> ("var(--gap)" is checked as "1px": a token stands for a value of its type)`.
|
|
226
|
+
- **A raw `env()` or `if()`** in a string part (`margin("env(safe-area-inset-top)", "auto")`) stands for any run of values, commas included, in any property, and the rest of the value is still checked: `margin("env(x)", "red")` is an error. Only the call's shape is checked (the conditions of an `if()`). **A raw `var(--name)`** is for a token, and a token is already its `var()`: a string that writes the `var()` of a declared token is a build warning that names the token to pass (`padding: "var(--space-sm)" is the token space.sm: pass the token itself, and css.var(token, fallback) for a fallback`). Any other custom property is left alone (a library's like `--shiki-light`, a plugin's own after namespacing like `--motion-o`, one an inline style or a script sets), and the scope's own `--_x` of a `scope()` kernel stays a string.
|
|
227
|
+
- Design-token constraints and math (DTCG, Ariakit Tailwind) are researched in `docs/research/design-tokens.md`.
|
|
228
|
+
|
|
229
|
+
#### Themes (decided 2026-09-29, cssints-exof)
|
|
230
|
+
|
|
231
|
+
Tokens come from an inline DTCG-shaped `const` object. Every leaf is a token (see "Tokens") reached by property access. Values are DTCG values or CSS strings checked by the grammar of their `$type`. Aliases use DTCG `{path}` references.
|
|
232
|
+
|
|
233
|
+
```tsx
|
|
234
|
+
import * as css from "cssints" with { type: "cssints" };
|
|
235
|
+
|
|
236
|
+
const t = css.createTokens({
|
|
237
|
+
color: {
|
|
238
|
+
$type: "color",
|
|
239
|
+
bg: { $value: "#ffffff" },
|
|
240
|
+
fg: { $value: "#111827" },
|
|
241
|
+
accent: { $value: "oklch(62% 0.19 259)" },
|
|
242
|
+
link: { $value: "{color.accent}" },
|
|
243
|
+
},
|
|
244
|
+
space: { $type: "dimension", md: { $value: "16px" } },
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const dark = css.media("(prefers-color-scheme: dark)");
|
|
248
|
+
css.createGlobalTheme(t, dark, { color: { bg: "#0b0b0f", fg: "#f3f4f6" } });
|
|
249
|
+
css.createGlobalTheme(t, ".light", { color: { bg: "#fff" } });
|
|
250
|
+
css.createGlobalTheme(t.color, css.container("(width < 30rem)"), { bg: "#eee" });
|
|
251
|
+
|
|
252
|
+
css.check.contrast(t.color.fg, t.color.bg, { min: 4.5 });
|
|
253
|
+
|
|
254
|
+
export const A = () => <div className={css.cn(css.bg(t.color.bg), css.color(t.color.fg), css.p(t.space.md))} />;
|
|
255
|
+
css.createGlobalTheme(t, dark, { color: { nope: "red" } }); // error: no token at this path
|
|
256
|
+
css.createGlobalTheme(t, css.hover, { color: { bg: "red" } }); // error: a pseudo-class is not a theme condition
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
- **Types:** `$type` is inherited through groups, and a token needs one (it is not guessed from the value). It maps to a token type: `color` to `color`, `dimension` to `length`, `number` to `number`, `duration` to `time`, `fontWeight` to `number` (keywords such as `bold` become 700), `cubicBezier` to `easingFunction`, `shadow` to `shadow`. `fontFamily`, `strokeStyle` and the other composite types (`border`, `transition`, `gradient`, `typography`) are an error. A value is a CSS string of the type's grammar, or the DTCG form: `{ colorSpace, components, alpha? }`, `{ value, unit }` (`px` or `rem`, `ms` or `s`), a number, `[x1, y1, x2, y2]` (`cubic-bezier(x1, y1, x2, y2)`), `{ color, offsetX, offsetY, blur, spread, inset? }` or an array of them (the parts are DTCG values or CSS strings, without references). The initial value follows the rule of "Tokens": `{ value: 1, unit: "rem" }` is `initial-value:16px` and `:root{--space-md:1rem}`.
|
|
260
|
+
- **Names:** the variable name is the kebab-case path (`--color-bg`). A second argument to `createTokens` maps a path (an array of names) to a name, or to `{ var, inherit }`.
|
|
261
|
+
- **Support warnings** (cssints-tuyw): a token value goes through the same support check as a property value, since it reaches the same browsers: the initial value of `token.<type>()` and of a `createTokens()` leaf, an override of `createGlobalTheme()` (its references replaced by their literals) and a `token.set()` value. It is checked as a value of a property that takes its type (`color` for a colour, `width` for a length, `transform` for a transform list; `custom-ident` and `resolution` have none), so `oklch()` warns for a target without it, like `color: oklch(...)`. A warning is given once per token (or theme value) and BCD feature per build, and a dev server gives it again after each edit, as for properties. It names the token path and, for a theme, the theme: `createTokens(): color.accent value: "oklch()" in "oklch(0.62 0.19 259)" is not supported by chrome 109 (css.types.color.oklch)`, `createGlobalTheme(): color.accent value in .light: "oklch()" in ...`, `token.color() initial value of --name: ...`, `token.set() value of --name: ...`. An alias has the same warning for its own token, since its `@property` rule holds the same literal. A value that goes into the sheet gets no warning when a fallback below handles it for the targets (the bullet "No warning for what LightningCSS lowers" below says what LightningCSS lowers); an inline `token.set()` value never goes through LightningCSS and keeps the warning, as does a value LightningCSS cannot lower (`color-mix()` over a token in a class).
|
|
262
|
+
- **Fallbacks for older targets** (cssints-33pa): a browser that cannot parse the initial value of an `@property` rule (`oklch()` in Chrome 109) drops the rule, and the token has no value at all. When some target lacks a value, the engine writes it for that target (transforming `--x:value` through LightningCSS with the lacking targets and taking the first declaration, `#db4aa7` for `oklch(0.637 0.203 345)`) and nothing changes for a target set that has it, byte for byte:
|
|
263
|
+
- **A plain token** (`token.color("oklch(0.637 0.203 345)", "accent")`, a `createTokens()` leaf): `@property --accent{…initial-value:#db4aa7}` and `:root{--accent:oklch(0.637 0.203 345)}`, which LightningCSS lowers like any custom property (`--accent:#db4aa7`, then the wide-gamut `lab()` under `@supports (color: lab(0% 0 0))`). A browser that parses the value gets the wide one, so a modern browser reads the same colour as before, written as `lab()` (`:where(*)` stands for `:root` when the token does not inherit).
|
|
264
|
+
- **A derived token or a theme value that holds `var()`** (`oklch(0.637 0.203 {hue.accent})`, `color-mix()` over a token, `{path}` references): LightningCSS cannot lower a value with `var()`. Its default is the lowered literal (the references replaced by the values the tokens start with) and the live value comes after it under `@supports` of the literal itself, the feature the value needs and no table of features: `:root{--accent:#db4aa7}` then `@supports (color:oklch(0.637 0.203 345)){:root{--accent:oklch(0.637 0.203 var(--hue-accent))}}`. A browser that parses the literal reads the live value, so the hue slider works; one that does not keeps the default, unless the runtime of the next bullet computes the value. A theme does the same inside its own rule, `@supports (…){@media (…){:root{…}}}` or `@supports (…){.dark{…}}`, after the default of that theme. An alias (`{color.accent}` as the whole value) stays live, since it needs no feature of its own, and reads the default of its target. A theme that sets a token a derived token depends on declares that derived token again (live, as above), and an older browser keeps the default of the derived token there.
|
|
265
|
+
- The support warning for those values is gone: the fallback handles it. A token value that LightningCSS leaves alone keeps its warning and its single declaration, as today.
|
|
266
|
+
- Checked in Chromium 109 (`apps/sky/test/old-browser.mjs`: Mu pink in real colours, light and dark, before the runtime of the next bullet moves anything) and Chrome 154 (the colours stay `oklch()` and follow the slider). The cost on sky is one more `:root` declaration per token and one `@supports` rule per derived value: 27.97 kB to 29.58 kB of CSS with the default targets.
|
|
267
|
+
- **Derived tokens in older browsers: a runtime** (cssints-i9xo). Where a target cannot parse a derived colour (`oklch(0.637 0.203 var(--hue-accent))`, `lab(from color(from var(--color-accent) srgb-linear …) …)`), the slider would do nothing. The engine then ships a small browser module, `cssints/derived`, that computes the live values in JavaScript. Tokens are not members, so this is not a plugin you import: the engine registers the runtime itself when a derived colour needs it, and delivers it the way it delivers a plugin's runtime (`virtual:cssints/runtime/derived`, empty, `export {};`, unless a browserslist target lacks a feature the literals need and has `@property`; the same `needed()` decision). The client modules that use such a token import it. With targets that parse every value nothing is written or shipped: sky built for `chrome 154` is byte-identical to a build without the runtime.
|
|
268
|
+
- **The data** is one custom property on `:root` in layer `_.k` (`--_derived`, JSON in a string) with only what the sheet uses: `p`, the literals a browser must parse for the runtime to stay idle (the same literals as the `@supports` tests); `b`, the template of each derived colour of the default theme (`oklch(0.637 0.203 var(--hue-accent))`); `t`, per theme in sheet order, its condition (a selector, or `@media (…)`) and the templates it sets, `0` for a token it sets to a literal. A token that a theme overrides with a plain `{path}` alias stays live and is released by the runtime.
|
|
269
|
+
- **The runtime** does nothing without a `document`, and nothing when the browser parses every `p` (Chrome 154 gets the same CSS as an old one and writes nothing on `<html>`). Otherwise it picks the themes that apply to `<html>` (`matches()` of the selector, `matchMedia()` of the condition), evaluates the templates over the computed values of the inputs (`--hue-accent` and any other token a template reads; the runtime's own results are used as they are, not read back), and writes the results as inline custom properties on `<html>`, as `rgb()`. It runs again on any attribute change of `<html>` (class, style: the slider, the theme classes) and when a theme's media query changes (`prefers-color-scheme`), and once the stylesheet has loaded if it was not there at the start. It reads every input before the first write, so one input costs one style recalculation. A token it cannot compute at the moment (an input is missing) keeps its static default.
|
|
270
|
+
- **The subset** is hex, `rgb()`/`rgba()`, `oklch()`, `oklab()`, `lab()`, `color(srgb | srgb-linear …)`, `color-mix()` in `srgb`, `srgb-linear`, `oklab` or `lab` (shares that add up to 100%), the names `black`, `white` and `transparent`; relative colour (`from`) with the channel keywords, numbers, percentages, `deg`, `+ - * /`, parentheses, `calc()`, `clamp()`, `min()`, `max()` and `var()`. A colour outside sRGB is clipped to it (an old browser has no wider gamut). Anything else (`light-dark()`, `hsl()`, a polar `color-mix()`, a wide-gamut `color()`, another unit), a derived token that is not a colour, one that does not inherit, and a value in a `container()` theme stays at its static default, and the build warns, naming the token and the target: `tf.color.mixed: "color-mix(in oklch, …)" stays at its static default in chrome 109: the runtime for derived tokens does not evaluate "oklch"`. The themes it reads are those of `<html>` (`:root`, a selector that matches it, a media query), not of an element inside the page. A template should read plain tokens and derived tokens, not an alias of a derived token (it would be read one update late).
|
|
271
|
+
- **Cost.** `cssints/derived` is 7.2 kB minified, 3.1 kB gzipped; sky's CSS grows by the data (29.6 kB to 31.4 kB with the default targets, about 0.6 kB gzipped). The colours of the default theme take 10 µs of JavaScript per input; in Chromium 109 a slider input costs 1.2 ms against 0.6 ms for the style recalculation alone, which the browser pays on any change of the hue. Checked in `apps/sky/test/old-browser.mjs`: in Chromium 109 the four accent steps, the label's onAccent and the page background at hues 0, ±90 and ±180 are within 1/255 of the pixels Chrome 154 paints, in light, dim, dark and system under a light and a dark scheme, and the system scheme changing recomputes them; `packages/cssints/test/derived-runtime.mjs` holds the evaluator to culori (the maths of `check.contrast`) on the sky templates and random inputs.
|
|
272
|
+
- **Themes:** `createGlobalTheme(tokens, condition | selector, overrides, { layer?, scope? }?)` re-declares the overridden tokens under a `media()` condition on `:root`, or under a selector. Under `container()` it declares them on `:where(*)`, since `:root` has no container above it and would never match. `layer` names a top-level layer for the rule (`{ layer: "app" }` puts an app look in `@layer app`, above the engine's `:root` fallbacks) that `layers()` has declared, in this module or one it imports: an undeclared layer is a build error at the call. Without `layer` the rule is in `_.k`. `scope` (with a selector) confines the rule to a subtree, `@scope (article.post) { [data-mode="hero"] { ... } }`, so a foreign look cannot repaint the rest of the page; it is a selector, checked by the build like the one of `global()` and balanced in the types, and it is an error with a condition (`:root` is not inside a scope). Overrides are checked against the token types, and an unknown path is a type error; they take the same values as tokens, and `tokens` may be a group (`t.color`). `supports()` and pseudo-classes are not themes: `createGlobalTheme(t, hover, …)` is a type error, and a build error when the types are bypassed. A module that uses a token gets the theme rules that set it, with the `@property` rules of the tokens it uses and of those their values refer to, not of every token the theme sets.
|
|
273
|
+
- **`layers(...names)`** declares top-level layers in order. The sheet prints `@layer app, author, viewer;` once, right after the engine's `@layer _.k, _.a, …;`, however many modules declare it (a module's sheet has the statements of its own `layers()` calls and of the layers it uses). The same names again are fine; names in another order (`layers("viewer", "app")` after the one above), or sharing layers without one statement holding the other (`layers("viewer", "base")`), are a build error at the second call, since the sheet could not say which comes first. A statement that holds another is printed first; statements that share nothing are printed longest first. A name is letters, digits, `_` and `-`.
|
|
274
|
+
- **`layers(...names, { before: true })`** declares them before the engine's: the sheet prints `@layer reset;` above `@layer _.k, _.a, …;`, so `_.k`, the classes and every layer declared after rank above what is in them (a reset that any style beats). The rules above hold among the statements before, in the same order; a layer declared both before and after (`layers("reset")` once `layers("reset", { before: true })` has run, or the other way) is a build error at the second call. A stylesheet's own layer goes between a reset and cssints with `layers("reset", "base", { before: true })`.
|
|
275
|
+
|
|
276
|
+
```ts
|
|
277
|
+
import * as css from "cssints" with { type: "cssints" };
|
|
278
|
+
|
|
279
|
+
css.layers("app", "author", "viewer");
|
|
280
|
+
const t = css.createTokens({ color: { $type: "color", bg: { $value: "#fff" } } });
|
|
281
|
+
css.createGlobalTheme(t, '[data-mode="dark"]', { color: { bg: "#000" } }, { layer: "viewer" });
|
|
282
|
+
css.createGlobalTheme(t, '[data-mode="hero"]', { color: { bg: "#123" } }, { layer: "author", scope: "article.post" });
|
|
283
|
+
css.createGlobalTheme(t, ".x", { color: { bg: "#000" } }, { scope: "a[href" }); // error: unbalanced brackets in the scope
|
|
284
|
+
css.layers(); // error: a declaration has a name
|
|
285
|
+
css.layers("reset", { before: true }); // @layer reset; before @layer _.k, _.a, …;
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
- **The value in the type:** a token carries the value of its definition as text, inferred: `t.color.base` is `Token<"color", true, "#545434">` on hover. An alias carries its reference (`"{color.base}"`: a theme can change the base), a number or `{ value, unit }` its CSS text (`"150ms"`), a DTCG colour object and anything from a JSON import (`$value` widens to `string` there) `string`. It is the `:root` definition, not what a theme shows. A `Token<K, S, V>` fits wherever a `Token<K>` does.
|
|
289
|
+
- **A key that starts with a digit** is read as `_` and the key: `"2xs"` in the data is `t.space._2xs` in code, and the overrides of a theme follow the set (`{ space: { _2xs: "3px" } }`). The variable (`--space-2xs`), references (`{space.2xs}`) and the DTCG data keep the key; `2xs` and `_2xs` in one group is a build error.
|
|
290
|
+
- **Aliases:** `@property initial-value` must be computationally independent, so it gets the flattened literal. The alias stays live as a `var()` declaration on `:root`. A theme re-declares the aliases that depend on an overridden token (all of them, through chains), since `var()` is substituted where it is declared. A `{path}` must name a token of the same `$type`; a cycle is an error.
|
|
291
|
+
- **Derived tokens** (cssints-5y5a): a `$value` may refer to other tokens inside a CSS value, `"color-mix(in oklab, {color.accent}, black 15%)"` or `"calc({space.md} * 2)"`. It stays live CSS: the `@property` initial value takes the targets' literals, the `:root` declaration keeps `var(--color-accent)`, and a theme that sets `color.accent` declares every token derived from it again (through chains), so a hover colour follows the brand colour. A derived token needs its `$type` (or its group's); in the editor its value is checked as the type's grammar with each `{path}` in place as a value of that token's type, so a missing path or a value that is not of the type is a type error.
|
|
292
|
+
|
|
293
|
+
```ts
|
|
294
|
+
import * as css from "cssints" with { type: "cssints" };
|
|
295
|
+
|
|
296
|
+
export const t = css.createTokens({
|
|
297
|
+
color: {
|
|
298
|
+
$type: "color",
|
|
299
|
+
accent: { $value: "#2563eb" },
|
|
300
|
+
hover: { $value: "color-mix(in oklab, {color.accent}, black 15%)" },
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
css.createGlobalTheme(t, ".brand", { color: { accent: "#db2777" } }); // hover follows
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
- **References in overrides** (cssints-q6l0): an override takes `{path}` too, as the whole value (`"{radius.sm}"`) or inside a CSS value (`"oklch(0.7 0.1 {hue.accent})"`). The theme declares `var(--target)`, so the value is live; tokens derived from the overridden token are declared again as above. Order does not matter: the declarations of one rule belong to one element, and a `var()` reads that element's own value of the target, so a theme that sets `hue.accent` and a colour made of it needs no particular order. A reference to no token, a whole-value reference to a token of another type, a value that is not of the token's type with each reference replaced by its target's value (a literal at build time, a value of the target's type in the editor), and a cycle through the base tokens and the theme are errors. Paths are from the root of the tokens, so give the root (`t`) to a theme that refers to another group; the editor rejects a reference outside the group it is given.
|
|
307
|
+
|
|
308
|
+
```ts
|
|
309
|
+
import * as css from "cssints" with { type: "cssints" };
|
|
310
|
+
|
|
311
|
+
export const t = css.createTokens({
|
|
312
|
+
hue: { $type: "number", accent: { $value: 345 } },
|
|
313
|
+
radius: { $type: "dimension", sm: { $value: "4px" }, md: { $value: "8px" } },
|
|
314
|
+
color: { $type: "color", accent: { $value: "oklch(0.6 0.2 {hue.accent})" } },
|
|
315
|
+
});
|
|
316
|
+
css.createGlobalTheme(t, ".square", { radius: { md: "{radius.sm}" } });
|
|
317
|
+
css.createGlobalTheme(t, ".dim", { hue: { accent: 12 }, color: { accent: "oklch(0.7 0.1 {hue.accent})" } });
|
|
318
|
+
css.createGlobalTheme(t, ".x", { radius: { md: "{hue.accent}" } }); // error: a number, not a length
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
- **JSON input:** `createTokens` takes a DTCG file imported as JSON, `import tokens from "./tokens.json" with { type: "json" }`. The editor knows the tree (`t.color.bg`, and a wrong path is a type error) but not the types, since a JSON import widens `$type` to `string`: each token is a token of any type there, and its values, references and use in a property (and in `check.contrast`) are checked at build time.
|
|
322
|
+
- **JSON from Figma** (cssints-ow16): the token file of a Figma tool is DTCG with a few additions, and createTokens reads what is DTCG in it:
|
|
323
|
+
- **Figma's own export** (Variables view, Export modes) writes one DTCG file per mode. The default mode's file is the tokens, and the file of another mode is a theme as it is: `createGlobalTheme(t, dark, darkJson)` takes `{ $value }` leaves and skips `$type`, `$description` and `$extensions` (`com.figma.*`, the root's `com.figma.modeName`); a key that starts with a digit (`500`) is the set's `_500`. A theme declares every token of the file, the unchanged ones too. Figma writes a FLOAT variable as a `number` token (`5`), which a length property does not take: `calc(t.radius, "*", "1px")`, or `$type: "dimension"` with a `{ value, unit }` in the file. A colour is `{ colorSpace: "srgb", components, alpha, hex }`, read as `color(srgb …)`.
|
|
324
|
+
- **TokensBrücke** with "DTCG 2025.10 format" is DTCG too. Its modes in `$extensions.mode` are not read: with "Split modes into separate files" each mode is a file, as above. Each file has the collection as its top-level key and refers across collections by it (`{Primitives.color.blue}`), so the files of all collections go into one createTokens: `createTokens({ ...primitives, ...semantic })`.
|
|
325
|
+
- **Tokens Studio** is not read directly: its legacy format has `value` and `type` without `$`, its own types (`spacing`, `fontSizes`, `borderRadius`, `boxShadow`, …), unitless dimensions, math (`{spacing.base} * 2`) and colour modifiers in `$extensions`. Style Dictionary with `@tokens-studio/sd-transforms` resolves all of that; a format of six lines writes the result as DTCG, which createTokens takes (checked with style-dictionary 5.6 and sd-transforms 2.0; the aliases are resolved to their values, and a theme is a second build over the sets of a theme of `$themes.json`):
|
|
326
|
+
|
|
327
|
+
```js
|
|
328
|
+
import StyleDictionary from "style-dictionary";
|
|
329
|
+
import { register } from "@tokens-studio/sd-transforms";
|
|
330
|
+
|
|
331
|
+
register(StyleDictionary);
|
|
332
|
+
const DTCG = { fontSize: "dimension", lineHeight: "number", opacity: "number" }; // the types sd-transforms leaves
|
|
333
|
+
StyleDictionary.registerFormat({
|
|
334
|
+
name: "cssints/dtcg",
|
|
335
|
+
format: ({ dictionary }) => {
|
|
336
|
+
const out = {};
|
|
337
|
+
for (const t of dictionary.allTokens) {
|
|
338
|
+
let group = out;
|
|
339
|
+
for (const key of t.path.slice(0, -1)) group = group[key] ??= {};
|
|
340
|
+
const type = t.$type ?? t.type;
|
|
341
|
+
group[t.path.at(-1)] = { $type: DTCG[type] ?? type, $value: t.$value ?? t.value };
|
|
342
|
+
}
|
|
343
|
+
return JSON.stringify(out, null, 2);
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
await new StyleDictionary({
|
|
347
|
+
source: ["tokens/**/*.json"],
|
|
348
|
+
preprocessors: ["tokens-studio"],
|
|
349
|
+
platforms: { cssints: { transformGroup: "tokens-studio", files: [{ destination: "tokens.json", format: "cssints/dtcg" }] } },
|
|
350
|
+
}).buildAllPlatforms();
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
- **Build errors** for what would be misread: `value`/`type` without `$` (`global.Main.0 has value and type without $, the legacy format of Tokens Studio …`), `$themes` or `$metadata` in the tree, a Tokens Studio type (the error names the DTCG type: `"spacing" is a Tokens Studio type: its DTCG type is "dimension"`), math outside `calc()` (`write "calc({spacing.base} * 2)"`), a Tokens Studio colour modifier (`$extensions["studio.tokens"].modify`), a Figma boolean (`com.figma.type: "boolean"` on a number), and more than one mode in `$extensions` (`mode`, or `modes` of `com.figma`). `string`, `boolean`, `fontFamily` and the composite types are errors as before (no token type), so leave them out of the file (Figma: a collection without them; TokensBrücke: no styles). Samples of each tool are in `packages/cssints/test/fixtures/importers`.
|
|
354
|
+
- **The sheet:** token sets and themes are rules of layer `_.k`, after the global rules of `css.global()` and before the class layers. They set custom properties only, and no class does, so the layer order never decides between them; `_.k` is the lowest layer, so your own unlayered `--color-bg` rule wins over a theme. The `:root` rules of aliases come first, then themes under a condition, then themes under a selector, each in source order, so a selector theme beats a condition theme on the same element. A module has the rules of the tokens it uses, from wherever they were made, and of the tokens those are made of (the references of a derived token or of a theme value, through chains: a `hue.accent` that no class uses still gets its `@property` rule), and its own; import a theme module (`import "./theme.ts"`) to have all of it.
|
|
355
|
+
- **`css.check.contrast(fg, bg, { min })`** computes the WCAG 2.x contrast ratio (`min` from 1 to 21) for the default values and for every theme that sets either token or one they are aliases of. It reads themes one at a time, except those of one selector: themes with the same selector (and scope) meet on one element, so they are read together, laid over each other in the order of the cascade (no layer first, then the declared layers in order, then source order). A viewer look that sets only the text colour is checked on the background of the app look under the same selector. Themes of different selectors, or a selector and a condition, are still read one at a time, not in their combinations. A value that is set at run time (a slider that sets `--hue-accent` on `<html>`) belongs to no theme and is checked at its default only; a theme per value would put each rule in the sheet and hold it to the thresholds of the other themes, since a check applies to every theme that touches its tokens, so measure a range outside the build (apps/sky `test/contrast.mjs`). A ratio below `min` is a build error that names the theme and both resolved values (the ratio is cut, not rounded: 4.499 is 4.49). A value the check cannot evaluate (`color-mix()`, a relative colour, `light-dark()`, `currentColor`, a system colour) or a translucent one is a warning that names the token; a build says it once, and a dev server says it again after each edit. A derived token (or a theme value with `{path}` references) is read in each theme by putting that theme's values, or the defaults, in place of its references, through a chain of derived tokens: a ratio when the result is a colour (`oklch(0.6 0.2 {hue.accent})`), a warning that names the theme when it is not (`color-mix()` over another token), and a theme that sets any token a checked colour depends on, directly, through an alias or through references, is checked. The colour library (culori, `culori/fn`: 27 kB minified, 9 kB gzipped) is build-only and never ships. The check runs when its module has run, and again at the end of a build over the themes of every module.
|
|
356
|
+
|
|
357
|
+
Output (the `@property` rules of `space-md` and `color-fg` are not shown):
|
|
358
|
+
|
|
359
|
+
```css
|
|
360
|
+
@layer _.k, _.a, _.b, _.c, _.d, _.e;
|
|
361
|
+
@property --color-accent { syntax: "<color>"; inherits: true; initial-value: oklch(62% 0.19 259); }
|
|
362
|
+
@property --color-bg { syntax: "<color>"; inherits: true; initial-value: #ffffff; }
|
|
363
|
+
@property --color-link { syntax: "<color>"; inherits: true; initial-value: oklch(62% 0.19 259); }
|
|
364
|
+
@layer _.k {
|
|
365
|
+
:root { --color-link: var(--color-accent); }
|
|
366
|
+
@media (prefers-color-scheme: dark) {
|
|
367
|
+
:root { --color-bg: #0b0b0f; --color-fg: #f3f4f6; }
|
|
368
|
+
}
|
|
369
|
+
@container (width < 30rem) {
|
|
370
|
+
:where(*) { --color-bg: #eee; }
|
|
371
|
+
}
|
|
372
|
+
.light { --color-bg: #fff; }
|
|
373
|
+
}
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
Derived tokens (`contrastColor()`, `derive()`), JSON input with generated types and slot variants are not part of 0.1.
|
|
377
|
+
|
|
378
|
+
### Fallbacks: `firstThatWorks` (decided 2026-10-01, cssints-bogz)
|
|
379
|
+
|
|
380
|
+
`firstThatWorks(wanted, ...fallbacks)` is a part of any property: the value you want comes first, then what to use where it fails.
|
|
381
|
+
|
|
382
|
+
```ts
|
|
383
|
+
import { border, color, firstThatWorks, minHeight, token } from "cssints" with { type: "cssints" };
|
|
384
|
+
|
|
385
|
+
const brand = token.color("#0057b8", "brand");
|
|
386
|
+
|
|
387
|
+
export const a = minHeight(firstThatWorks("100dvh", "100vh"));
|
|
388
|
+
export const b = color(firstThatWorks("oklch(55% 0.18 250)", brand));
|
|
389
|
+
export const c = border("1px", firstThatWorks("solid", "dashed"), "red");
|
|
390
|
+
export const d = minHeight(firstThatWorks("100dvh", "100vhh")); // error: the fallback is not a min-height
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
- **One class.** `a` is one class, `.x { min-height: 100vh; min-height: 100dvh }`: the declarations in CSS order, the wanted value last, so `cn()` and the sheet treat it as one rule. With three values the last is printed first.
|
|
394
|
+
- **Checked per value.** Every value is checked for the property, in types and at build time, as the whole declaration with that value in its place. The type error is on the `firstThatWorks(...)` part and the message names the bad value.
|
|
395
|
+
- **Values** are strings and Typed OM values (tokens); `var()` is fine. A number is not (`p(4)` has no fallback), nor is a style.
|
|
396
|
+
- **In several parts** the whole declaration is repeated with each value (`c`: `border: 1px dashed red; border: 1px solid red`). One `firstThatWorks` per declaration; two are an error.
|
|
397
|
+
- **Support warnings.** A target that lacks the wanted value gets no warning if it supports a fallback. The warning stays, for the targets that lack every value, and says so: `color: "color-mix()" in "color-mix(in srgb, var(--brand), blue)" is not supported by chrome 100 (css.types.color.color-mix), and no fallback of its firstThatWorks covers them` (a literal `oklch()` would not warn at all: LightningCSS lowers it).
|
|
398
|
+
- **Minify.** Vite's CSS minify decides which of the declarations LightningCSS may drop, from `build.cssTarget` (set it to `cssTarget` of `cssints/vite`, see the README), not from `css.lightningcss.targets`. With the targets it keeps `min-height: 100vh; min-height: 100dvh` where a target lacks `dvh` and merges them where none does; with `cssTarget: false` it always merges.
|
|
399
|
+
- **Limits.** It is not a Typed OM value: a typed position (`token.length(firstThatWorks(...))`) rejects it, and so does `style()` (an inline style holds one value per property). `const f = firstThatWorks(...)` is build-time only, like a condition: the client has `null`. The fallback is for values a browser drops; `@supports` is for styles that are not one declaration.
|
|
400
|
+
|
|
401
|
+
### Local at-rules: `keyframes` and `positionTry` (decided 2026-10-02, cssints-lirm)
|
|
402
|
+
|
|
403
|
+
`keyframes()` and `positionTry()` define an at-rule next to the styles that use it and return its name, a typed value that fits where the grammar wants a name.
|
|
404
|
+
|
|
405
|
+
```ts
|
|
406
|
+
import { animation, cn, hover, keyframes, margin, opacity, positionTry, positionTryFallbacks, top, transform } from "cssints" with { type: "cssints" };
|
|
407
|
+
|
|
408
|
+
export const fade = keyframes({ from: opacity(0), to: opacity(1) });
|
|
409
|
+
export const wiggle = keyframes({ from: transform("rotate(0deg)"), "50%": cn(transform("rotate(8deg)"), opacity(0.5)), to: transform("rotate(0deg)") });
|
|
410
|
+
export const above = positionTry(cn(top("anchor(bottom)"), margin(2)));
|
|
411
|
+
|
|
412
|
+
export const a = animation(fade, "200ms", "ease-out");
|
|
413
|
+
export const b = hover(animation(wiggle, "1s", "infinite"));
|
|
414
|
+
export const c = positionTryFallbacks(above, "flip-inline");
|
|
415
|
+
export const d = keyframes({ from: opacity("lots") }); // error: opacity: unexpected "lots"
|
|
416
|
+
export const e = keyframes({ halfway: opacity(0) }); // error: a step is from, to or a percentage
|
|
417
|
+
export const f = positionTryFallbacks(fade); // error: a keyframes name is not a dashed ident
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
- **Steps are styles.** A step of `keyframes()` and the argument of `positionTry()` are made of member calls (`opacity(0)`, `cn(...)`), as `cn()` and `cv()` take them: they are checked by the grammar of each property, at the member call in the types and at the call of `keyframes()` at build time, with the support warnings of a class. A step is `from`, `to` or one percentage (`"50%"`, 0 to 100); a list (`"0%, 100%"`) is not supported. A condition, a `firstThatWorks()` or a member with a marker class (a plugin's) inside a step is a build error, since an at-rule step has none.
|
|
421
|
+
- **`positionTry()` takes what `@position-try` takes:** inset, margin and sizing properties, `align-self`, `justify-self`, `place-self`, `position-anchor` and `position-area` (CSS Anchor Positioning §6.4). Any other property (`positionTry(cn(padding(2)))`) is a build error at the call; the types cannot see which property a style holds. `position-try` is a property as well, so with value parts `positionTry("flip-block")` is still that shorthand; one style is the rule.
|
|
422
|
+
- **The name** is a hash of the rule's content: `kf-1a2b3c4d` for keyframes, `--pt-1a2b3c4d` for `@position-try`, so two blocks with the same steps are one name and one rule, from any module. It is a Typed OM value: `CSSStyleValue<"custom-ident">` for keyframes (`animation`, `animationName`), `<dashed-ident>` for `positionTry()` (`positionTryFallbacks`); it fits nowhere else. A `keyframes()` in a client module ships as its name string.
|
|
423
|
+
- **Where the rule goes.** The name carries its rule; a style that holds the name (`animation(fade, ...)`, inside `cn()`, a condition, a `cv()` or a plugin's kernel written as styles) puts the rule in the sheet of its module, once however many use it. A rule that nothing uses is in no sheet, so there is nothing to shake: a module that imports a helper but does not use a name has no rule. It is a kernel (see "Plugins"), keyed by the content, in layer `_.k`, with `requires` set to `css.at-rules.keyframes` or `css.at-rules.position-try`: a target that lacks it gets a support warning, never an error.
|
|
424
|
+
- **The layer.** A name is global and a rule of a layer wins over another of the same name by layer order (css-cascade-5, css-anchor-position-1 §6.4: the same cascade as `@keyframes`). Names are hashes of the content, so no two rules of different content share one and the order never decides; the rules go in `_.k`, below every class layer, and the layer statement stays as it is.
|
|
425
|
+
- **In a helper module.** `export const fade = keyframes(...)` in a module imported with the attribute (like a token) is a definition site: it ships nothing, and the components that import it and use it carry the rule.
|
|
426
|
+
- **Limits.** The name is not a part of `firstThatWorks()` or `style()` (an inline style has no sheet): both reject it with a message. A value of `custom-ident` kind is accepted by the grammar as a keyframes name whichever way it was made, so a typo in a hand-written `animationName("fadee")` is not caught.
|
|
427
|
+
|
|
428
|
+
```css
|
|
429
|
+
@layer _.k {
|
|
430
|
+
@keyframes kf-0e6af964{from{opacity:0}to{opacity:1}}
|
|
431
|
+
@position-try --pt-23965b44{top:anchor(bottom);margin:0.5rem}
|
|
432
|
+
}
|
|
433
|
+
@layer _.a {
|
|
434
|
+
._44ecsn{animation:kf-0e6af964 200ms ease-out}
|
|
435
|
+
}
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
### Runtime: `cn`, `cv` (decided 2026-09-29, cssints-lo9x; `cv` without `_cv` 2026-10-07, cssints-ep2l; `cx` folded into `cn` 2026-10-11, cssints-4e9f)
|
|
439
|
+
|
|
440
|
+
- **One runtime package: `cssints` itself.** You import nothing for the browser. A module with a `cn()` that has runtime arguments, or a `cv()`, gets `import { _cx as __cx, _cv as __cv } from "cssints"` from the plugin, and those few lines are the only code that ships. `@cssints/variants` is gone.
|
|
441
|
+
- **`cn(...)` is the one function that merges.** Arguments known at build time merge into one class string, as above. An argument that depends on a runtime value is joined when the code runs, and the `css.*` calls inside it are still class strings made at build time: only the choice is left.
|
|
442
|
+
|
|
443
|
+
```ts
|
|
444
|
+
import { bg, cn, hover, p } from "cssints" with { type: "cssints" };
|
|
445
|
+
|
|
446
|
+
export const box = (props: { on: boolean; class?: string }) =>
|
|
447
|
+
cn(p(2), bg("white"), props.on && p(4), hover(bg("#eee")), props.class);
|
|
448
|
+
export const row = (kind: "a" | "b", items: boolean[]) => cn(p(1), kind === "a" ? p(2) : bg("red"), items.map((x) => x && p(3)));
|
|
449
|
+
export const bad = (n: number) => cn(p(2), p(n)); // the build refuses it: css arguments must be known at build time
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
```tsx skip
|
|
453
|
+
import { _cx as __cx } from "cssints";
|
|
454
|
+
|
|
455
|
+
export const box = (props) => __cx([["_lwugp7", "_1t8gqy5"]], "_1t8gqy5 _1ebepn0 _16f8jrr", props.on && "_lwugp7", props.class);
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
- **What is a runtime argument.** One that needs a binding the build does not have: it names a parameter, a prop, a signal or any other local of the function or block around the call (`on && p(4)`, `cond ? a : b`, `state() && p(4)`, `props.class`, `items.map(...)`), or a string or template literal, or a call of something that is not a `css.*` member (`button({ size })`, a `cv` result: a class string at build time too). Everything else is known at build time, a module `const` too, and merges. A spread (`...list`) is judged by what it spreads. Strings, `false`, `null`, `undefined` and `0` are skipped, arrays are flattened; there are no clsx-style objects (`cond && style`). A `false`, `null` or `undefined` that is known at build time (`dev && p(4)` with a module `const dev = false`) is no style and is skipped there.
|
|
459
|
+
- **What the build settles.** Of two parts, static or runtime, the later one replaces the earlier one for a property it sets again under the same conditions (the same property, an alias, or a shorthand over it, as in `cv`): the plugin lists, per class, the classes it drops, and the runtime leaves them out while that class is active. So `cn(p(2), on && p(4))` gives `p-4` when `on` and `p-2` otherwise, and `cn(on && p(4), p(2))` always gives `p-2`. The sheet order never decides. Under another condition (`hover`, a media query) a class replaces nothing: `cn(p(2), on && hover(p(4)))` keeps both, as in a build-time `cn`. A longhand after a shorthand needs no drop. Two properties that overlap without one containing the other, in parts that can be active together, are a build error as above (`cssints: cn() padding-inline and padding-left overlap ...`); both arms of `cond ? a : b` count as active together.
|
|
460
|
+
- **Opaque strings are only appended.** `props.class`, `"legacy"` or a style held in a variable inside a runtime argument (`on && blurred`) has no declarations in the argument, so nothing is dropped for it or by it; write the call there (`on && p(4)`) to have it settled.
|
|
461
|
+
- **A style whose own argument is a runtime value is a build error.** `cn(p(2), p(n))` with `n` a parameter stays `css arguments must be known at build time: n is not defined where the call is evaluated`; choose between styles (`n > 1 ? p(2) : p(4)`), or use a `cv` variant or a token set in `style`.
|
|
462
|
+
- **The call.** `__cx(drops, ...parts)`: `drops` is a list of `[class, "classes it replaces"]`, empty when nothing conflicts; the parts are class strings, runtime values and arrays. With no static argument there is no static string: `cn(props.class)` is `__cx([], props.class)`. Nothing is imported when every argument is static.
|
|
463
|
+
- **`cx` is gone.** `import { cx } from "cssints"` is a build error in a module the plugin handles, with or without the attribute: `cssints: <file> imports cx from "cssints", which is gone: cn(...) takes runtime values now. Write cn(base, on && p(4)) with cn from "cssints" with { type: "cssints" }`, and a type error (the name is now the SVG property `cx`, which you write `css.cx(...)`). The fix is mechanical: add `cn` to the attributed import and write `cn(` for `cx(`. Arguments that were only joined are now merged and checked at build time, so `cx(p(4), p(2))` (both classes, the sheet decides) is `cn(p(4), p(2))` (`p(2)`).
|
|
464
|
+
- **`cv(config)`** takes `css.*` values, so types check them, and is the function you call: one call, no wrapper. The plugin replaces the site by a call of the small runtime (and imports it for you; `_cv` is not exported any more) with a config of class strings. The props are typed from `variants`: a name or a value that is not there is a type error, in the props and in `defaultVariants` and `compoundVariants` alike.
|
|
465
|
+
|
|
466
|
+
```ts
|
|
467
|
+
import { cn, cv, fontSize, opacity, p } from "cssints" with { type: "cssints" };
|
|
468
|
+
|
|
469
|
+
const button = cv({
|
|
470
|
+
base: cn(p(2), fontSize("1rem")),
|
|
471
|
+
variants: {
|
|
472
|
+
size: { sm: p(1), lg: cn(p(4), fontSize("1.25rem")) },
|
|
473
|
+
disabled: { true: opacity(0.5), false: opacity(1) },
|
|
474
|
+
},
|
|
475
|
+
defaultVariants: { size: "sm", disabled: false },
|
|
476
|
+
compoundVariants: [{ size: "lg", disabled: true, class: opacity(0.3) }],
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
export const classes = () => button({ size: "lg" }); // "_lwugp7 _z6yj9k _1t0bw2x"
|
|
480
|
+
export const off = () => button({ size: "lg", disabled: true });
|
|
481
|
+
export const bad = () => button({ size: "xl" }); // error: not a size
|
|
482
|
+
export const bad2 = cv({ base: "padding: 1rem" }); // error: a base is a css value, not a CSS string
|
|
483
|
+
```
|
|
484
|
+
|
|
485
|
+
- **What ships** for `button` is `__cv({ base: "_1t8gqy5 _6tpir7", variants: { size: { sm: "_lqk467", lg: "_lwugp7 _z6yj9k" }, ... }, defaultVariants: { size: "sm", disabled: false }, ... })` with `import { _cv as __cv } from "cssints"` added to the module: class strings only. `cv()` works in a function, at the top of a module and in a library.
|
|
486
|
+
- **Old code.** `import { _cv } from "cssints"` is a type error (no such export) and, in a module the plugin handles, a build error at the import: `cssints: <file> imports _cv from "cssints", which is gone: cv({ ... }) is the function now. Write const button = cv({ base, variants }) ...`. It does not keep working for a release: `_cv(cv(...))` would hand a function to the runtime, and a shim would keep two spellings of one thing. The fix is mechanical: drop `_cv(` and its closing bracket. A library built the old way fails with the same error, naming its file.
|
|
487
|
+
- **Boolean variants.** A variant whose keys are `true` and `false` (or only one of them) takes a boolean: `button({ disabled: true })`, and a default is a boolean too. `false` is a value, not a missing one: `button({ disabled: false })` selects the `false` key even when the default is `true`. `undefined` is the default.
|
|
488
|
+
- **`compoundVariants`** as in cva: each entry names values (any of the variants, a boolean for a boolean one) and the style that is added when all of them are active. The key is `class`, as in cva, since what the entry adds is a class (a `cn` or `style()` key would suggest an inline style, and cssints has `style()` for that; `css` is the namespace). Its value is a `css.*` style like the others, not a string. The values of an entry are one value each, not a list of values.
|
|
489
|
+
- **Values** of `base` and of each variant are one property, `cn(...)`, or a `flex()`/`grid()` chain. A CSS string is a build error that shows the new form: `cv() base must be a css.* value (cn(...), a flex() chain or one property), got "padding: 1rem". CSS strings are gone: write cn(p(4), fontSize("1rem"))`.
|
|
490
|
+
|
|
491
|
+
- **The variant wins.** The function joins the base and the variants that are active at runtime, so the build compares them. Under one condition, a variant that sets what the base sets (the same property, an alias, or a shorthand over a base longhand: `pl(2)` in the base, `p(4)` in the variant) replaces it: the base class is dropped from the string while that value is active, which the runtime does from a `drops` entry in the config (`drops: { size: { lg: "_77ujlx" } }`, only where there is one). Which rule comes later in the sheet does not decide. A variant longhand inside a base shorthand needs no drop, since the sheet puts a longhand after its shorthand: base `border(...)` with `active: { yes: borderBottomColor(...) }` is fine. Under different conditions both stay, as in `cn()`.
|
|
492
|
+
- **Overlap is an error, as in `cn()`.** A base property and a variant property that overlap without one containing the other are a build error at the `cv()` call: `cssints: cv() base and variant active.yes: border-bottom and border-color overlap without one containing the other, so the sheet cannot order them: set one of them, or the longhands` (both set `border-bottom-color`; write `borderBottomColor` in the variant). The same holds for two variants of different keys, which can be active together. Two such variants that set the same property are an error too, since nothing says which wins: `cssints: cv() variants a.x and b.x both set color and can be active together, so nothing says which wins: set it in one of them, or in the base`. Values of one key are never compared, since only one is active.
|
|
493
|
+
- **A compound wins over what it combines.** It is the most specific style, so under one condition it replaces what it sets again in the base and in the variant values it names (the same rule as a variant over the base): `compoundVariants: [{ size: "lg", tone: "loud", class: p(6) }]` drops the base's padding, `size.lg`'s padding and `tone.loud`'s padding while both are active, in the `compound` entry of the config (`{ when: { size: "lg", tone: "loud" }, class: "_x", drops: "_a _b" }`). The sheet order never decides. What a compound shares with other things that can be active together is compared as for variants: an overlap without containment is an error, and so is the same property in a compound and a variant of a key it does not name (it is active with any of its values), or in two compounds that can be active together (no key they both name with different values). Write the property in one of them, or in the base.
|
|
494
|
+
|
|
495
|
+
Slot variants (`scv`) are not part of 0.1.
|
|
496
|
+
|
|
497
|
+
### Errors
|
|
498
|
+
|
|
499
|
+
A bad value fails in the types, where the call is, and again at build time with the file and the place of the call, so the Vite overlay points at it. The types catch most of it.
|
|
500
|
+
|
|
501
|
+
```ts
|
|
502
|
+
import * as css from "cssints" with { type: "cssints" };
|
|
503
|
+
css.padding("1rem", "solid"); // error: not a padding value
|
|
504
|
+
css.width(4); // error: a bare number is a length only in spacing properties
|
|
505
|
+
css.flex().padding(4); // error: flex() does not chain padding
|
|
506
|
+
css.flex("1").gap(2); // error: flex("1") is a plain style, it does not chain
|
|
507
|
+
css.flex("sideways"); // error: not a flex value
|
|
508
|
+
css.padding(css.p(1)); // error: a style is not a value
|
|
509
|
+
css.items("center"); // error: there is no items
|
|
510
|
+
css.media("min-width > 40rem"); // error: a range needs parentheses
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
The build checks more than the types see:
|
|
514
|
+
|
|
515
|
+
```ts
|
|
516
|
+
import * as css from "cssints" with { type: "cssints" };
|
|
517
|
+
|
|
518
|
+
css.p(-1); // padding: unexpected "-0.25rem" in "-0.25rem", expected <length-percentage [0,∞]>
|
|
519
|
+
css.width("calc(1px + 2s)"); // width: unexpected "calc(1px" ... (cannot add length and time)
|
|
520
|
+
css.supports("(display: grdi)"); // @supports: unexpected "grdi" in "(display: grdi)", expected <supports-in-parens>
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
- **A bad argument of a CSS function is named** (not `calc` and the other math functions), as the types name it: `light-dark(): unexpected "1px" in "light-dark(white, 1px)", expected <light-dark()> (argument 2)`; a call that ends too soon says `(after argument 1)`, one argument too many `(argument 3)`. The build finds it as the types do, by prefixes with a `var()` standing for the rest, only when the call is already an error. The error is located at the site, the `css.*` call that holds the function call: a site knows its own offset, not those of the arguments inside it (a nested call is part of its site).
|
|
524
|
+
- A value that a browser in your browserslist does not support is a warning, not an error: `cursor: pointer` warns for iOS Safari. A token or theme value is checked the same way (see Tokens). The browserslist is the `BROWSERSLIST` variable, else the standard config looked up from Vite's `root` and upward (`"browserslist"` in `package.json`, `.browserslistrc`, with the section of `BROWSERSLIST_ENV`, `NODE_ENV` or `production`), else `defaults`. Each evaluation checks with the targets of its own build, so two servers of two roots in one process (a tool, a test) each warn for their own (2026-10-09, cssints-p3d2). `targets` and `cssTarget` of `cssints/vite` are computed when `vite.config.ts` imports them, before the root is known, so they read the config of `process.cwd()`: the same when Vite runs from the app's directory (see the README).
|
|
525
|
+
- **A warning is located and said once per file per build** (2026-10-09, cssints-p3d2). Vite gets it at the `css.*` call that made it (`loc`: the file, line and column, and a code frame), as an error. A build says each warning once per file of its site, property and BCD feature: two files that use `lineClamp(3)` give two warnings, each at its call; a module evaluated again (every module that imports it runs it) says nothing new. A dev server has no end, so a warning is said again when the file of its site is edited and the modules that run it are transformed again, and not on the update of an unrelated module. A tool that wants every warning of a transform calls `api.forgetWarnings(files?)` of the plugin first (the files' warnings, or all): `@cssints/mcp`'s `compile` does.
|
|
526
|
+
- **No warning for what LightningCSS lowers.** The build runs the sheet through LightningCSS with those targets, which gives `color: oklch(…)` a fallback (`color: #db4aa7; color: lab(…)`), prefixes `backdrop-filter` and folds a static `round()`. When a value warns, cssints asks LightningCSS: it transforms `x{prop: value}` (`:root{--x: value}` for a token value) for the targets that lack the feature, and the warning is dropped if the feature is gone from the first declaration of the output. It stays for what LightningCSS leaves: a `var()` inside the value (`oklch(from var(--x) l c h)`, `color-mix(in oklab, var(--x), …)`), a property it cannot prefix (`text-wrap`, `line-clamp`), and an inline `style()` or `token.set()` value, which never go through it. An `@property` initial value is not lowered by it either, but the engine writes the lowered form itself ("Fallbacks for older targets" under Themes). One transform per distinct value that already warns, cached per value and targets.
|
|
527
|
+
- Clamping text to N lines is the WebKit box, in every engine today: `display("-webkit-box")` (the `display` grammar takes `-webkit-box` and `-webkit-inline-box`), `WebkitBoxOrient("vertical")` and `WebkitLineClamp(N)`, with `overflow("hidden")`. The three are quiet: BCD files them under prefixed standard names, which the support lookup does not follow. LightningCSS passes them through and adds nothing. The standard `lineClamp(N)` (css-overflow-4) is a member too, and warns for the default targets: no engine ships it unprefixed (Chrome 154 rejects it) and LightningCSS does not prefix it.
|
|
528
|
+
|
|
529
|
+
```ts
|
|
530
|
+
import { cn, display, overflow, WebkitBoxOrient, WebkitLineClamp } from "cssints" with { type: "cssints" };
|
|
531
|
+
|
|
532
|
+
export const clamp2 = cn(
|
|
533
|
+
display("-webkit-box"),
|
|
534
|
+
WebkitBoxOrient("vertical"),
|
|
535
|
+
WebkitLineClamp(2),
|
|
536
|
+
overflow("hidden"),
|
|
537
|
+
);
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
- Validity rules written only in prose are errors too (`transition: none, opacity 1s`, an unquoted `font-family` with a generic keyword inside a name).
|
|
541
|
+
- Keywords and function names are written in their canonical case, on purpose: `color("RED")` and `rgb(` as `RGB(` are errors, so each value has one spelling (completions and class names stay the same). The message says the spelling: `color: unexpected "RED" in "RED" ("RED" is red in CSS; write "red"), expected <color>`. The types say it for keywords (`color("RED")`); the build says it for function names too. The CSS-wide keywords `inherit`, `initial`, `unset`, `revert` and `revert-layer` are a value of every property, whatever its grammar: `fontFamily("inherit")` is fine.
|
|
542
|
+
|
|
543
|
+
### Plugins (decided 2026-09-30, cssints-zho2; `scope()` 2026-10-05, cssints-8ogz)
|
|
544
|
+
|
|
545
|
+
A plugin is a module that exports the members of a `scope()` from `cssints/plugin`: `scope("name", table).build(impl).export()`. The table says what the plugin has (syntax names, kernels, a runtime, conflicts, token sets, other scopes, and its members); the members are typed from the table, with no second file of types. User code imports the plugin with the attribute, like `cssints` itself: `import { ring } from "./ring.ts" with { type: "cssints" }`. There is no registration in `vite.config` and no type augmentation; the engine finds the sites by the attribute, and learns kernels, runtimes and conflicts from the style each member returns. In user code a member returns a class string, so it goes into `cn()` and the conditions like any `css.*` value.
|
|
546
|
+
|
|
547
|
+
```ts
|
|
548
|
+
import { cn, p } from "cssints" with { type: "cssints" };
|
|
549
|
+
import { scope } from "cssints/plugin";
|
|
550
|
+
|
|
551
|
+
export const { ring, dot, webkitFontSmoothing } = scope("ring", {
|
|
552
|
+
// a local syntax name: `<width>` in a grammar of this scope (it does not leak into mdn's names)
|
|
553
|
+
width: "<length [0,∞]> | thin | thick",
|
|
554
|
+
// a kernel: global CSS, once. `&` is the class the engine mints for it, `--_w` is the custom property `--ring-w`
|
|
555
|
+
"#core": {
|
|
556
|
+
css: "& { outline: var(--_w) solid var(--_c); outline-offset: 2px }",
|
|
557
|
+
atProperty: '@property --_w { syntax: "<length>"; inherits: false; initial-value: 2px; }',
|
|
558
|
+
requires: ["css.at-rules.property"],
|
|
559
|
+
},
|
|
560
|
+
"#noOutline": { props: ["outline", /^outline-/], message: "ring() sets the outline: use its methods" },
|
|
561
|
+
// a chain member, like flex(): a call gives the marker and the kernel, each method emits custom properties
|
|
562
|
+
ring: {
|
|
563
|
+
uses: ["core", "noOutline"],
|
|
564
|
+
chain: {
|
|
565
|
+
width: { args: "<width>", spacing: true, emit: (v) => [["--_w", v]] },
|
|
566
|
+
color: { args: "<color>", emit: (v) => [["--_c", v]] },
|
|
567
|
+
},
|
|
568
|
+
},
|
|
569
|
+
// a property of your own, typed by its grammar (it is not in mdn-data); the key starts with `-`
|
|
570
|
+
"-webkit-font-smoothing": "auto | none | antialiased | subpixel-antialiased",
|
|
571
|
+
// a call member: its function is the entry of the same name in build()
|
|
572
|
+
dot: { args: "small | large" },
|
|
573
|
+
})
|
|
574
|
+
.build({ dot: (size) => ({ decls: [["--_size", size === "small" ? "4px" : "8px"]] }) })
|
|
575
|
+
.export();
|
|
576
|
+
|
|
577
|
+
export const card = cn(p(2), ring().width("thin").color("red"), webkitFontSmoothing("antialiased"), dot("small"));
|
|
578
|
+
export const twice = ring().width("1px").width("2px"); // error: a method is used once
|
|
579
|
+
export const wrong = ring().width("red"); // error: not a width
|
|
580
|
+
export const bad = webkitFontSmoothing("bold"); // error: not in the grammar
|
|
581
|
+
export const no = dot("medium"); // error: not in the grammar
|
|
582
|
+
export const hidden = ring().core; // error: no such method
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
- **The table** holds entries, by their shape. A string is a syntax name (`width: "<length [0,∞]> | thin"`), or, for a key that starts with `-`, a typed property (`-webkit-font-smoothing`, exported camel-cased as `webkitFontSmoothing`; a CSS property of mdn-data is an error, use `css.<name>`). `{ css, atProperty?, requires? }` is a kernel, `{ rule, layer? }` a global rule, `{ layers: [...], before? }` a declaration of top-level layers (`before: true` prints them before the engine's), `{ polyfills, parentVars?, css? }` a runtime, `{ props, message }` conflicts, `{ tokens }` a token set, a `scope(...)` another scope, `{ chain }` a chain member, `{ args }` a call member (a function in `build()`), `{ args, value, label? }` a value member (`fn({ args, value })` when `args` is a tuple or `arg<T>()`, see "Functions"). A key that starts with `#` is private: other entries use it, it is not exported, and an embedding scope does not see it. `uses` names it without the `#`, so `#trim` beside a public `trim` is a type error and a build error (`cssints: x: #trim and trim share a name: rename one`): a name is one key, private or not.
|
|
586
|
+
- **Local syntax names.** `<width>` in a grammar of the table is the grammar written for `width`, in place, as a group (`[ <length [0,∞]> | thin | thick ]`); a name that is not in the table is left to mdn-data. The types and the build expand them with the same rule, so what the editor accepts is what the build accepts. A scope embedded as a module gives its public names as `<mod.name>`, a token set as `<tokens.color.accent>` (the type of that token), so a grammar can say "a colour or this token".
|
|
587
|
+
- **Namespaces.** Everything a scope declares is the scope's own. In the CSS of a kernel, a runtime or a global, and in what `emit` and `build` return, `--_x` is the custom property `--<scope>-x`; in a kernel or a runtime `&` is the class the engine mints for the kernel (`_<scope>-<entry>`, `_ring-core`) and `&(mod.key)` that of the kernel `key` of an embedded scope. A runtime or conflicts entry takes the class of the first kernel in the `uses` of the member. Scope names are lowercase letters, digits and `-`; `cssints` and `derived` are the engine's.
|
|
588
|
+
- **`uses`** lists what a member needs, by key: kernels, globals, layers, runtimes and conflicts of the scope, or `mod.key` of an embedded one (the types reject a name that is none of these, or a private one). A call member may add more in what `build` returns.
|
|
589
|
+
- **`group: true`** on a chain or call member makes every style of the member carry the group marker `_g-<scope>`, after the markers of its kernels: `sticky: { uses: ["core"], group: true, chain: {...} }` in scope `stuck` gives `_stuck-core _g-stuck _x`, and `within(attr("data-stuck", "top", "~="), "stuck")(...)` reads that ancestor (2026-10-08, cssints-sbs8). The group's name is the scope's, unique in the process, so two plugins never share one; a user's `group("stuck")` is the same group, so do not name your own groups after a scope you use. It is a marker like a kernel's: a condition around the member with no declaration of its own is a build error (see "Conditions and layers").
|
|
590
|
+
- **Kernels** are global CSS, registered once per scope entry (key `<scope>.<entry>`) on first use: the CSS goes into layer `_.k`, below every class layer, and `atProperty` (the `@property` rules) at the top of the sheet. So a kernel cannot be conditional per site: a condition around a member that has only its marker is a build error (see "Conditions and layers"); give the member a method or arguments that set custom properties, which a condition can wrap. A kernel can also be written as styles, `css: (css) => [css.outline("var(--_w)", "solid", "var(--_c)"), css.outlineOffset("2px")]`, which prints the declarations under the kernel's class: they are checked like a user's (grammar, prose rules), conditions, `nest()`, `vars()` and `keyframes()` names are allowed (see below), `firstThatWorks()` and a member's marker are not. Build errors, at the call site: two keys that declare one custom property, two keys that print one marker, and two kernels written as styles in one member's `uses` that set properties of the element that meet (the same property, a shorthand and its longhand, a logical and a physical side, as in `cn()`): `cssints: x: kernels run and view both set animation, animation-timeline (they are one element's styles and print in no set order): write those in one kernel`. Kernels are printed in `_.k` sorted by key (`<scope>.<entry>`), whatever the order of `uses` or of the modules, so two of one member never decide between themselves; in one kernel the sheet's order holds (a shorthand before its longhands). A raw kernel derives no properties and is not compared (2026-10-08, cssints-life). A kernel's `requires` (BCD keys) is a build warning for a target that lacks one, never an error. A key that is also in the `polyfills` of a runtime in the member's `uses` is covered: it warns only for the targets that runtime does not reach (a `parentVars` runtime below `@property`, see below), and a fallback of CSS or a `script` covers it everywhere. The support warning of a declaration in a kernel written as styles is covered by the same rule, by its BCD key (the one in parentheses at the end of the warning): `core: { css: (css) => [css.borderRadius("var(--_r)"), css.cornerShape("squircle")] }` beside `mask: { polyfills: ["css.properties.corner-shape"], css: "..." }` in `uses` warns for nobody, so a plugin keeps derived conflicts without a raw kernel (2026-10-08, cssints-rp0e); a warning or an error of such a declaration prints its value as the sheet does, `var(--squircle-r)`, not `var(--_r)`. As in every support warning, a BCD `partial_implementation` counts as support, because most partial entries are edge cases; an entry `{ key, exact: true }` counts it as lacking, for a feature whose partial form gives other results (`surface()` asks for `{ key: "css.types.color.oklch.relative_syntax", exact: true }`), and the warning says so: `(a partial implementation counts as lacking)`.
|
|
591
|
+
- **Chain members** (`chain: { method: { args, spacing?, excludes?, emit } }`) give `() => chain`. `args` is the grammar of the method's parts, checked in the types (the error is on the bad argument and says `ring.width: unexpected "red" ...`) and again at build time. `spacing` makes a lone number a step of the spacing scale (0.25rem), as in `padding`. `emit` returns declarations; a custom property is allowed and its value is not checked, any other property is checked like a `css.*` one. A method is used once: the chain leaves it out. `excludes: ["darken"]` leaves other methods out after this one too, and `args: ""` makes a method that takes no value (`ink()`). A grammar must be a literal type (a `const` variable is fine; if TypeScript has widened it to `string`, the method says so: write `as const`).
|
|
592
|
+
- **Call members** (`{ args, uses? }` and a function in `build()`) take their arguments by `args`: a grammar (the parts, joined by a space, as a property takes them: `args: "small | large"`), `arg<T>()` (one value of a TypeScript type, which TypeScript checks and `build` gets as it is: `arg<"home" | "star">()`), or a **tuple of positional arguments**, each a grammar (one part), `"$tokens"` (a token set from `createTokens`), `"$overrides"` (values by path for the tokens of the `$tokens` before it, checked in the types against that token set), `"$selector"` (a CSS selector list, checked in the types for brackets and quotes and at build time as a whole), `arg<T>()`, or at the end one object of named leaves (`{ "size?": "<width>", on: "<color>" }`, a key ending in `?` is optional). `build` gets them as written, grammar parts as checked text. The positional form type-checks within the per-call budget; its cost is that an error lands on the call, with the position and the reason in the message, not on the argument. `build` returns `{ decls?, uses?, globals?, themes?, layers? }`.
|
|
593
|
+
- **`globals`** are global rules that are not classes (`{ rule, layer? }` in the table, or `{ css, layer? }[]` from `build`). A global is identified by its content, not by a name: the same rule from two styles is one, and editing a value replaces it. It is in the sheet of the modules whose styles carry it (as kernels are), and leaves it with them. `layer` sends a rule to a top-level layer of that name (`@layer app`); it must be declared by a `layers` entry (or `layers` from `build`: a list, or `{ layers: [...], before: true }` for layers below the engine's), or by `layers()` in a module imported before. No layer puts it in `_.k`. Unlike a kernel, two globals **may** declare the same custom property; that is what lets a look set the same `--color-*` in several source layers.
|
|
594
|
+
- **`themes`** — `themes: { tokens, overrides, selector, layer?, scope? }[]` from `build` is `createGlobalTheme(tokens, selector, overrides, { layer, scope })` made by a member, through the same path: the paths and values of `overrides` are checked against the types of `tokens` at build time, the aliases that depend on an override are declared again, and `check.contrast` reads the theme with the others. With `args: ["$tokens", "$overrides", ...]` the member takes the token set from its caller and the overrides are checked in the types as well.
|
|
595
|
+
|
|
596
|
+
```ts
|
|
597
|
+
import { scope } from "cssints/plugin";
|
|
598
|
+
|
|
599
|
+
// A member that sets tokens in a look: `mode(t, "dark", { color: { bg: "#000" } })` is `[data-mode="dark"] { --color-bg: #000 }` in @layer viewer
|
|
600
|
+
export const { mode } = scope("mode", {
|
|
601
|
+
layers: { layers: ["app", "viewer"] },
|
|
602
|
+
mode: { args: ["$tokens", "<custom-ident>", "$overrides"], uses: ["layers"] },
|
|
603
|
+
})
|
|
604
|
+
.build({
|
|
605
|
+
mode: (tokens, name, overrides) => ({
|
|
606
|
+
themes: [{ tokens, overrides, selector: `[data-mode="${name}"]`, layer: "viewer" }],
|
|
607
|
+
}),
|
|
608
|
+
})
|
|
609
|
+
.export();
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
- **Conditions in a kernel written as styles.** `hover` and the other pseudo-classes, `attr()`, `within()`, `media()`, `container()` and `supports()` wrap the kernel's styles as they wrap a user's: `css.hover(css.opacity(0.8))`, `css.media("(width >= 40rem)")(css.padding("1rem"))`. The rules are printed under the kernel's class, in layer `_.k`, in the order of the sheet (fewer conditions first, then queries by width and pseudo-classes by place, a shorthand before its longhands), so the order the author writes them in does not matter; the declarations of one selector share a block. At most four conditions, as everywhere.
|
|
613
|
+
|
|
614
|
+
```ts
|
|
615
|
+
import { cn, paddingLeft } from "cssints" with { type: "cssints" };
|
|
616
|
+
import { scope } from "cssints/plugin";
|
|
617
|
+
|
|
618
|
+
export const { chip } = scope("chip", {
|
|
619
|
+
core: {
|
|
620
|
+
css: (css) => [
|
|
621
|
+
css.padding("var(--_p)"),
|
|
622
|
+
css.border("1px solid"),
|
|
623
|
+
css.hover(css.opacity(0.8)),
|
|
624
|
+
css.media("(width >= 40rem)")(css.padding("1rem")),
|
|
625
|
+
],
|
|
626
|
+
},
|
|
627
|
+
chip: { uses: ["core"], chain: { padding: { args: "<length>", emit: (v) => [["--_p", v]] } } },
|
|
628
|
+
}).export();
|
|
629
|
+
|
|
630
|
+
// layer _.k: ._chip-core{border:1px solid;padding:var(--chip-p)} ._chip-core:hover{opacity:0.8} @media (width >= 40rem){._chip-core{padding:1rem}}
|
|
631
|
+
export const a = chip().padding("4px");
|
|
632
|
+
// build error: cssints: chip: kernel core sets padding, border, opacity (this style also sets padding-left)
|
|
633
|
+
export const b = cn(chip(), paddingLeft("1px"));
|
|
634
|
+
```
|
|
635
|
+
|
|
636
|
+
- **Children, custom properties and keyframes in a kernel written as styles** (decided 2026-10-08, cssints-b4q6). The `css` a kernel is handed has two members user code has not. `css.nest(selector)` is a condition that puts the kernel's class in a selector: one complex selector with at least one `&` (the class), anywhere: `"& > *"`, `"& > * + *"`, `"& p"`, `":root:has(&)"`, `".dark &"` (2026-10-09, cssints-l8nq: `&` was first only at the start, before a combinator). It is checked at build time as a `<complex-selector>` with `&` as a class (a list, `"& a, & b"`, is an error; the types check that there is an `&`). A condition inside it applies to the rule's subject, one around it to the kernel's element, which stands for `&`: `css.nest("& > *")(css.hover(...))` is `._k > *:hover`, `css.hover(css.nest("& > *")(...))` is `._k:hover > *`, `css.hover(css.nest(":root:has(&)")(...))` is `:root:has(._k:hover)` and `css.nest(":root:has(&)")(css.hover(...))` is `:root:has(._k):hover`. It counts as one condition and sorts after the element's own states. `css.vars({ "--_gap": "1rem" })` sets custom properties on the element (`--_x` is `--<scope>-x`, as everywhere), values not checked, as in a chain's `emit`. A `keyframes()` or `positionTry()` name is a value of any declaration of the kernel, `vars()` included: its rule is a kernel of its own that goes wherever the kernel goes, so a member names keyframes without raw CSS. Made inside the kernel's `css`, its steps name `--_x` as the kernel does, `--<scope>-x` (`css.keyframes({ from: css.vars({ "--_p": "100%" }) })` animates `--skeleton-p` in `@cssints/skeleton`), and such a rule's name ends with the scope's (`kf-1a2b3c4d-skeleton`), so two scopes with the same steps get two rules (2026-10-09, cssints-ge5k). **Derived conflicts read the element's properties only:** a custom property and what a `nest()` sets on another subject are left out, since a child's `margin` is not the element's, nor is the `animation` of `:root:has(&)`. The subject is the element when `&` is in the last compound, outside parentheses (`".dark &"`, `"&.on"`): those count, for derived conflicts and for two kernels of one member alike.
|
|
637
|
+
|
|
638
|
+
```ts
|
|
639
|
+
import { cn, flexGrow, keyframes, margin, opacity } from "cssints" with { type: "cssints" };
|
|
640
|
+
import { scope } from "cssints/plugin";
|
|
641
|
+
|
|
642
|
+
const fade = keyframes({ from: opacity(0) });
|
|
643
|
+
|
|
644
|
+
export const { row } = scope("row", {
|
|
645
|
+
core: {
|
|
646
|
+
css: (css) => [
|
|
647
|
+
css.vars({ "--_gap": "1rem", "--_fade": fade }),
|
|
648
|
+
css.display("flex"),
|
|
649
|
+
css.animation("var(--_fade) 200ms"),
|
|
650
|
+
css.nest("& > * + *")(css.marginInlineStart("var(--_gap)")),
|
|
651
|
+
],
|
|
652
|
+
},
|
|
653
|
+
row: { uses: ["core"], chain: { gap: { args: "<length>", emit: (v) => [["--_gap", v]] } } },
|
|
654
|
+
}).export();
|
|
655
|
+
|
|
656
|
+
// layer _.k: @keyframes kf-…{from{opacity:0}} ._row-core{animation:var(--row-fade) 200ms;--row-gap:1rem;--row-fade:kf-…;display:flex} ._row-core > * + *{margin-inline-start:var(--row-gap)}
|
|
657
|
+
export const a = cn(row().gap("2rem"), margin(0), flexGrow(1)); // fine: the margin is the children's
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
- **`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.
|
|
661
|
+
- **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`.
|
|
662
|
+
|
|
663
|
+
- **`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`.
|
|
664
|
+
- **`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").
|
|
665
|
+
- **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.
|
|
666
|
+
- **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).
|
|
667
|
+
- **`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:
|
|
668
|
+
|
|
669
|
+
```ts
|
|
670
|
+
import { attr, cn, color, container, type Style, within } from "cssints" with { type: "cssints" };
|
|
671
|
+
import { lacking } from "cssints/plugin";
|
|
672
|
+
|
|
673
|
+
const old = lacking("css.at-rules.container.scroll-state_queries").length > 0; // ["firefox 140"] or []
|
|
674
|
+
export const stuck = (...styles: Style[]) =>
|
|
675
|
+
cn(
|
|
676
|
+
container("scroll-state(stuck: top)")(...styles),
|
|
677
|
+
...(old ? [within(attr("data-stuck", "top", "~="), "stuck")(...styles)] : []),
|
|
678
|
+
);
|
|
679
|
+
export const title = stuck(color("red"));
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
- **`dependsOn(path)`** tells the engine that the module being evaluated read a file (an image, an SVG, a font; `path` relative to the working directory) (2026-10-09, cssints-04u8). Call it where the file is read, in a member's functions or in the plugin's own, before or after the read. The file becomes a dependency of the client module whose evaluation ran the call, as a module it imports is: in dev, an edit of the file runs that module again and the sheet changes, the module untouched; the dev server's watcher watches the file, outside the root too. In `vite build --watch` the file is a watched file (`addWatchFile`), so an edit builds again. `@cssints/placeholder`, `@cssints/icons` (`fs`) and `@cssints/fonts` (`fs`) call it for every file they read.
|
|
683
|
+
|
|
684
|
+
```ts
|
|
685
|
+
import { readFileSync } from "node:fs";
|
|
686
|
+
|
|
687
|
+
import { color } from "cssints" with { type: "cssints" };
|
|
688
|
+
import { dependsOn } from "cssints/plugin";
|
|
689
|
+
|
|
690
|
+
export const swatch = (file: string) => {
|
|
691
|
+
dependsOn(file);
|
|
692
|
+
return color(readFileSync(file, "utf8").trim());
|
|
693
|
+
};
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
A dependency is a file, not a directory: a file added to a directory is not seen. Vite's watcher skips `node_modules`, so a file there (a collection of `@iconify-json`, `@fontsource`) is not watched. An evaluation that fails records nothing: after a build error, edit the module or reload.
|
|
697
|
+
|
|
698
|
+
- **Types.** `cssints/plugin` points at `author.ts` and `scope.ts`: the signatures and the type-level code (`Members`, `Chain`, `Expand`, the call checks) and no code that runs, because a program that imports a plugin must not need Node's types or a newer `lib`. The engine is `scope-engine.ts`; `macro.ts` registers it when the library loads.
|
|
699
|
+
- **Not in 0.2:** `keyword` members, completions of keywords inside plugin grammar strings (the message still names the grammar), and a plugin that is a package of members you install other than `@cssints/icons`.
|
|
700
|
+
|
|
701
|
+
### Functions: every CSS function is a call (cssints-67qf)
|
|
702
|
+
|
|
703
|
+
Every CSS function that mdn-data has a grammar for is a member of `css` and a named import, camelCased: `calc`, `min`, `clamp`, `round`, `sin`, `rgb`, `oklch`, `colorMix`, `lightDark`, `linearGradient`, `cubicBezier`, `steps`, `translateX`, `url`, `env`, `var`, ... 110 of them (`bun run extract` writes the list from `mdn-data/css/functions.json` and the `name()` grammars of mdn-data's syntaxes, `bun run members` the types). A call takes parts: a token, a number, a string, another call, or an array of those joined by a space (`[t.color.bg, "25%"]`, a colour stop). It is computed at build time, checked against the grammar of its function by the twin, and replaced by its text: nothing ships. What it returns is a Typed OM value of the function's type, a part of any property that takes one.
|
|
704
|
+
|
|
705
|
+
```ts
|
|
706
|
+
import * as css from "cssints" with { type: "cssints" };
|
|
707
|
+
|
|
708
|
+
const t = css.createTokens({
|
|
709
|
+
color: { $type: "color", accent: { $value: "oklch(62% 0.19 259)" }, bg: { $value: "white" } },
|
|
710
|
+
space: { $type: "dimension", sm: { $value: "8px" }, md: { $value: "16px" } },
|
|
711
|
+
time: { $type: "duration", fast: { $value: "100ms" } },
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
export const a = css.width(css.calc(t.space.md, "*", 2)); // width: calc(var(--space-md) * 2)
|
|
715
|
+
export const b = css.padding(css.calc("1rem", "+", 2, "*", t.space.md)); // padding: calc(1rem + 2 * var(--space-md))
|
|
716
|
+
export const c = css.padding(css.clamp(t.space.md, "2vw", "2rem")); // padding: clamp(var(--space-md), 2vw, 2rem)
|
|
717
|
+
export const d = css.maxWidth(css.min("48rem", "100%"));
|
|
718
|
+
export const e = css.color(css.lightDark("white", t.color.bg)); // color: light-dark(white, var(--color-bg))
|
|
719
|
+
export const f = css.color(css.colorMix("in oklab", t.color.accent, "black 15%"));
|
|
720
|
+
export const g = css.color(css.oklch(0.7, 0.1, 200, "/", 0.5)); // color: oklch(0.7 0.1 200 / 0.5)
|
|
721
|
+
export const h = css.backgroundImage(css.linearGradient("to right", [t.color.accent, "25%"], "blue"));
|
|
722
|
+
export const i = css.transitionTimingFunction(css.cubicBezier(0.1, 0.7, 1, 0.1));
|
|
723
|
+
export const img = css.backgroundImage(css.url('"a.png"')); // url("a.png"): the quotes are yours
|
|
724
|
+
export const j = css.transform(css.translateFn(t.space.md, 0), css.rotateFn("10deg"));
|
|
725
|
+
export const k = css.width(css.calc(t.space.md, "+", t.time.fast)); // error: cannot add length and time
|
|
726
|
+
export const l = css.width(css.calc(t.space.md, "*", t.space.md)); // error: cannot multiply length by length
|
|
727
|
+
export const m = css.color(css.lightDark("white", t.space.md)); // error: a length is not a colour
|
|
728
|
+
export const n = css.width(css.rgb(255, 0, 0)); // error: a colour is not a length
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
- **Parts and operators.** The parts of a call are its arguments, joined by `", "`, or by a space when the function's grammar has no comma (`oklch`, `hwb`, `lab`, `color`, `inset`, `circle`, `dropShadow`). An operator is a string part, `"+"`, `"-"`, `"*"` or `"/"`, and joins its neighbours with a space instead: `calc(t.space.md, "*", 2)` is `calc(var(--space-md) * 2)`, `min(t.space.md, "*", 2, "4px")` is `min(var(--space-md) * 2, 4px)`, `oklch(0.7, 0.1, 200, "/", 0.5)` is `oklch(0.7 0.1 200 / 0.5)`. A number is its text. `rgb(255, 0, 0)` is the comma syntax; the space syntax is `rgb("255 0 0", "/", 0.5)` or `rgb("from", t.color.accent, "r", "g", "b")` (a relative colour, with a token).
|
|
732
|
+
- **Math types.** The kind of each argument is read (a token's type, a string such as `"2vw"` or `"50%"`, a number) and folded with the precedence of CSS: `*` and `/` before `+` and `-`; a length times a number is a length, a length over a length a number, a length plus a percentage a `length-percentage`. The result has the kind of its arguments (`min`, `max`, `clamp`, `calc`, `round`, `mod`, `rem`, `hypot`, `abs`), or is a number (`sign`, `sin`, `cos`, `tan`, `sqrt`, `exp`, `pow`, `log`) or an angle (`asin`, `acos`, `atan`, `atan2`). `calc(t.space.md, "+", t.time.fast)` is a type error on `t.time.fast` (`cannot add length and time`), as is a colour in a calculation. A string the types cannot read (`"var(--x)"`, `"(1px + 2px)"`, `"pi"`) is of any kind and the result is a wild typed value. `round("up", x, y)` skips its mode.
|
|
733
|
+
- **Other types.** The call is checked as the text it will be, against `<name()>`: `lightDark("white", t.space.md)` is an error because a length is not a `<color>`, `colorMix("in rgb", ...)` because `rgb` is not a colour space. The result is a `CSSStyleValue<"name()">`, a value where the grammar of the property reaches `<name()>` through its types (`<color>` reaches `<light-dark()>`, `<image>` the gradients). The error is on the argument where the text stops matching the grammar, with the message of the call and the position (`light-dark(): unexpected "1px" in "light-dark(white, 1px)", grammar <light-dark()> (argument 2)`); a call that ends too soon says `unexpected end ... (after argument 1)` on its last argument. The recognizer does not say where it stopped inside a call, so the types find the argument by prefixes: the first one after which `name(parts so far, var(--x))` (a `var()` stands for any run of values) is no `<name()>`. It costs nothing on a valid call. A function a property writes inline in its grammar (`fit-content()` and `minmax()` in the grid and size properties) is not reached by its type: write it as a string.
|
|
734
|
+
- **The build** checks every call with the twin and the usual message, at the call: `min(): unexpected "min(1rem" in "min(1rem, 2s)" (min() cannot take length and time)`. Any other function names the bad argument as the types do: `light-dark(): unexpected "1px" in "light-dark(white, 1px)", expected <light-dark()> (argument 2)` (see "Errors"). A token is checked as a literal of its type (`calc(): unexpected "calc(1px" in "calc(1px + 1s)" (cannot add length and time) ("calc(var(--space-md) + var(--time-fast))" is checked as "calc(1px + 1s)": ...)`). What the types leave to the build: the number of arguments (`clamp` takes three, `light-dark` two), numeric ranges (a share over 100%), and `sqrt` of a length.
|
|
735
|
+
- **Names that clash** keep the member they had, and the function gets `Fn` after its name: `attrFn` (`attr()` the value; `attr` is the attribute condition), and the functions that are also properties, `colorFn` (`color()` the colour; `color` is the property), `insetFn`, `opacityFn`, `perspectiveFn`, `rotateFn`, `scaleFn`, `translateFn`. A name that is a pseudo-class or another builder would be treated the same way; the list is computed (`FUNCTIONS` in lib.ts), not written. `var` is a reserved word: `css.var(...)` and `import { var as v } from "cssints"`.
|
|
736
|
+
- **`colorMix`, `min`, `max`, `clamp` are generated now.** `colorMix` takes the grammar's arguments, the interpolation method with its `in` (`colorMix("in oklab", a, b)`, was `colorMix("oklab", a, b)`). `min`, `max` and `clamp` infer their kind from their arguments (`clamp<"number">(0, x, 1)` is `clamp(0, x, 1)`).
|
|
737
|
+
|
|
738
|
+
#### `var()`
|
|
739
|
+
|
|
740
|
+
A token is its `var()` already, so `var(--name)` is not written. `css.var(token, fallback?)` is the call that adds a fallback: `css.var(t.space.sm, "4px")` is `var(--space-sm, 4px)`. The result has the token's type, and the fallback is checked as a value of it (a string or a Typed OM value, in the types and at build time). Its first argument is a token, never a name.
|
|
741
|
+
|
|
742
|
+
```ts
|
|
743
|
+
import * as css from "cssints" with { type: "cssints" };
|
|
744
|
+
|
|
745
|
+
const t = css.createTokens({
|
|
746
|
+
color: { $type: "color", accent: { $value: "red" } },
|
|
747
|
+
space: { $type: "dimension", sm: { $value: "8px" } },
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
export const a = css.padding(css.var(t.space.sm, "4px")); // padding: var(--space-sm, 4px)
|
|
751
|
+
export const b = css.color(css.var(t.color.accent, css.rgb(0, 0, 0)));
|
|
752
|
+
export const c = css.padding(css.var(t.space.sm, "red")); // error: the fallback is a length
|
|
753
|
+
export const d = css.color(css.var(t.space.sm)); // error: a length is not a colour
|
|
754
|
+
```
|
|
755
|
+
|
|
756
|
+
A string that writes `var(--space-sm)` is a build warning that names the token to pass; the `var()` of a custom property that is no token is left alone (see "Tokens"). `--_x` custom properties of a scope's kernels are not checked.
|
|
757
|
+
|
|
758
|
+
A plugin author makes a function of their own with a value member of a `scope()` (`{ args, value }`):
|
|
759
|
+
|
|
760
|
+
```ts
|
|
761
|
+
import { CSSColorValue } from "cssints";
|
|
762
|
+
import { scope } from "cssints/plugin";
|
|
763
|
+
|
|
764
|
+
// `args` is the grammar of the parts, joined by a space: `value` gets each one as text, a token as its var(--name).
|
|
765
|
+
export const { tint, step } = scope("units", {
|
|
766
|
+
tint: {
|
|
767
|
+
args: "<color> <percentage>",
|
|
768
|
+
value: ([color, share]) => new CSSColorValue(`color-mix(in oklab, ${color}, white ${share})`),
|
|
769
|
+
},
|
|
770
|
+
step: { args: "<number>", value: ([n]) => `${Number(n) * 4}px` },
|
|
771
|
+
}).export();
|
|
772
|
+
|
|
773
|
+
tint("red", "20%");
|
|
774
|
+
tint("red", "solid"); // error: not a percentage
|
|
775
|
+
step(2);
|
|
776
|
+
```
|
|
777
|
+
|
|
778
|
+
- **Positional arguments** (2026-10-08, cssints-pnnw). `args` takes what a call member's does: a grammar, `arg<T>()`, or a tuple of positional arguments with an object of named leaves at the end, checked by the same types and at build time by the same code. A table cannot type `value` from the `args` beside it, so a member with a tuple or `arg<T>()` is written `fn({ args, value })` (from `cssints/plugin`), and `value` gets an array of the arguments as `build` would: grammar parts as checked text, `arg<T>()` as T, the object as an object (`{}` when left out). The types reject such a member written without `fn()`. A boolean option is `arg<boolean>()`.
|
|
779
|
+
- **`label`** names the member in its errors in place of its key: a key is a name (no dots), so a member exported as `fluid.container` is a key of its own, attached by hand, with `label: "fluid.container"`.
|
|
780
|
+
|
|
781
|
+
```ts
|
|
782
|
+
import { arg, fn, scope } from "cssints/plugin";
|
|
783
|
+
|
|
784
|
+
export const { shade } = scope("shades", {
|
|
785
|
+
shade: fn({
|
|
786
|
+
args: ["<color>", { "by?": "<percentage>", "dark?": arg<boolean>() }],
|
|
787
|
+
label: "tint.shade",
|
|
788
|
+
value: ([color, { by = "10%", dark }]) => `color-mix(in oklab, ${color}, ${dark ? "black" : "white"} ${by})`,
|
|
789
|
+
}),
|
|
790
|
+
}).export();
|
|
791
|
+
|
|
792
|
+
shade("red");
|
|
793
|
+
shade("red", { by: "20%", dark: true });
|
|
794
|
+
shade("red", { by: "red" }); // error: tint.shade(2).by: unexpected "red"
|
|
795
|
+
```
|
|
796
|
+
|
|
797
|
+
- **Typed or opaque.** What `value` returns is what the call returns. A Typed OM value (`CSSColorValue`, `CSS.rem(1)`) keeps its type: `tint(...)` is a `<color>` in the types and goes only where a colour goes. A string, a literal or a template literal too (`` `color-mix(in oklab, ${c}, white)` ``), is opaque to the types, which accept it anywhere, and it is checked at build time in the declaration it lands in, as any value is. Return a Typed OM value when you know the type.
|
|
798
|
+
- **The Typed OM values** of `cssints` are `CSS` (`CSS.px`, `rem`, `em`, `percent`, `number`), `CSSUnitValue`, `CSSColorValue` and `CSSImageValue` (an `<image>`: a gradient, a `url()`), each holding its CSS text, and `CSSStyleValue<T>`, the base to subclass for another type (`class Clamp extends CSSStyleValue<"length">` with a `toString()`). A module that only runs at build time, as a plugin's does, imports them from `"cssints"` without the attribute; a module that reaches the client imports them with it, and each constructor there becomes its CSS text.
|
|
799
|
+
- **Build time only.** `value` runs in the Module Runner, once per call, and the call is replaced by its CSS text (`"_1hjzi3b"` for a class, `"color-mix(...)"` for a call of its own); the function and its module do not reach the client.
|
|
800
|
+
- **Errors** are `MemberError` values with the file, site and offset of the call, and `pluginName` is the scope: `tint: unexpected "solid" in "red solid", expected <percentage>` for the parts (the label is the member's `label`, else its key; a tuple's message names the position, `tint.shade(2).by: ...`); a `value` that throws (`cssints: boom: no luck`, any `Error`) or returns something else than a string or a Typed OM value (`cssints: tint() value must return a string or a Typed OM value`). A result that does not fit the property is that property's error, at the same site (`width: unexpected "color-mix(in" in "color-mix(in oklab, red, blue)"`): the engine cannot know the property a function is used for.
|
|
801
|
+
- **Limits.** With a grammar as `args`, the parts are joined with a space, so it cannot tell the arguments apart (`<color> <color>` takes `"red", "blue"` and `"red blue"`; check `parts.length` in `value`, or use a tuple). A token is checked as the values of its type, so a wrong one is named by its value: `unexpected "1px" in "oklab 1px red"`. The css functions above are not value members: `lightDark(a, b)` is the text `light-dark(a, b)`, whose choice is the browser's at the element.
|
|
802
|
+
|
|
803
|
+
### `frame()`: concentric radius (`cssints/frame`, cssints-q2vh)
|
|
804
|
+
|
|
805
|
+
The first plugin, written only on `scope()` above. A frame passes its inner radius down the tree; a nested frame's radius is the parent's inner radius less its own margin, so a rounded field in a rounded card stays concentric with no wiring, however many plain wrappers sit between them.
|
|
806
|
+
|
|
807
|
+
```ts
|
|
808
|
+
import * as css from "cssints" with { type: "cssints" };
|
|
809
|
+
import { frame } from "cssints/frame" with { type: "cssints" };
|
|
810
|
+
|
|
811
|
+
const radius = css.token.length("12px", "radius-lg");
|
|
812
|
+
|
|
813
|
+
// radius 24px, padding 16px, border 1px: the field inside gets 24 - 16 - 1 = 7px
|
|
814
|
+
export const card = css.cn(frame().radius("1.5rem").padding(4).border("1px"), css.bg("white"), css.borderColor("#9ca3af"));
|
|
815
|
+
export const field = css.cn(frame().padding(2).border("1px"), css.borderColor("#9ca3af"));
|
|
816
|
+
// a token is a length; a condition just sets the custom property, and the whole subtree follows
|
|
817
|
+
export const panel = css.cn(frame().radius(radius).padding(2), css.media("(width >= 40rem)")(frame().padding(6)));
|
|
818
|
+
|
|
819
|
+
export const noNumber = frame().radius(4); // error: a bare number is a spacing step in padding and margin only
|
|
820
|
+
export const noPercent = frame().padding("5%"); // error: a length, not a percentage
|
|
821
|
+
export const once = frame().radius("1px").radius("2px"); // error: a method is used once
|
|
822
|
+
export const own = frame().color("red"); // error: a frame chains its own methods only
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
- **Methods**, each once and in any order: `radius(<length>)`, `padding(<length> | step)`, `border(<length>)` (a width; the style is `solid`), `margin(<length> | step)`. `radius` is the radius of the outermost frame; inside a frame it is ignored (the radius is computed). A lone number in `padding` and `margin` is a step of the spacing scale (0.25rem). Tokens go anywhere a length does.
|
|
826
|
+
- **Build errors** (at the call site): a negative radius, padding or border, a percentage, and a `cn()` that holds `frame()` and a property it sets: `padding*`, `margin*`, `border-radius` and its corners, `border`, `border-width`, `border-style` and the border sides (`cssints: frame: frame() sets padding, margin, border-radius and the border: use its methods (this style also sets padding)`). Border colours (`borderColor`, `borderTopColor`) are plain styles and do not conflict. Conflicts with an opaque runtime string in `cn()` are not seen, as for any plugin.
|
|
827
|
+
- **What it emits:** the class `_frame-core` and one class per method (`._x{--frame-p:0.5rem}`, in `_.a`, or `_.b` inside a condition). The kernel `frame.core` (about 1.2 kB minified with its registrations and fallback, once, in `_.k`) is two `@container style(--frame-c: ...)` rules that alternate two names, because a property cannot read itself; the `@property` rules of its custom properties (`--frame-*`) are at the top of the sheet. The names are the scope's own (`--_x` in the source is `--frame-x`, `&` is `_frame-core`), which is why they are longer than the `--fr-*` and `_f` of the helpers it was first written on. The inputs are registered lengths, so `em`, `calc()` and tokens reach the child as exact lengths (an `em` padding is the parent's `em`, not the child's). The 24-line kernel for px and rem only was not chosen: `@property` is the floor of the runtime anyway, and the registered one costs about 0.2 kB more.
|
|
828
|
+
- **Browsers.** The kernel needs `@container style()` on custom properties (Chrome 111, Safari 18, Firefox 151) and `@property` (Chrome 85, Safari 16.4, Firefox 128). For a target that has `@property` and lacks style queries the build ships the fallback instead: `polyfills: ["css.at-rules.container.style_queries_for_custom_properties"]` and `cssints/parent-vars` (about 1 kB gzip, shared with other plugins), which copies each frame's inner radius to its frame children, and the fallback CSS. The script probes the browser and stays off where style queries work. Below `@property` (Firefox 127, Safari 16.3) the kernel is a build warning and nothing is shipped: a frame keeps no concentric radius there.
|
|
829
|
+
- **Limits of the runtime.** It costs about two or three times the style-query kernel on a first pass (190 ms for 5 500 nested frames in Firefox 128); a `transition` of your own on a frame replaces the sentinel that lets `:hover`, media and container conditions reach the script (the script still follows class and style changes); shadow roots are not crossed; a first paint before the script runs shows declared radii.
|
|
830
|
+
|
|
831
|
+
### `surface()`: parent-relative colour (`cssints/surface`, cssints-vsc9)
|
|
832
|
+
|
|
833
|
+
The second plugin on `scope()` and the same runtime. A surface passes its colour down the tree; a nested surface is that colour with its OKLCH lightness moved, through any number of plain wrappers. **These are tonal steps, not a contrast guarantee**: `contrast()` and `ink()` pick a direction by lightness and never compute a ratio.
|
|
834
|
+
|
|
835
|
+
```ts
|
|
836
|
+
import * as css from "cssints" with { type: "cssints" };
|
|
837
|
+
import { surface } from "cssints/surface" with { type: "cssints" };
|
|
838
|
+
|
|
839
|
+
const canvas = css.token.color("#2b3a67", "canvas");
|
|
840
|
+
|
|
841
|
+
// a base, then a lighter card on it (a token or a colour), a field a step darker, a button pushed away from its parent
|
|
842
|
+
export const page = surface().color(canvas);
|
|
843
|
+
export const card = css.cn(surface().lighten(0.06), css.hover(surface().lighten(0.1)));
|
|
844
|
+
export const field = surface().darken(0.03);
|
|
845
|
+
export const cta = surface().color("oklch(from #e11d48 l c h)").contrast(0.15).ink();
|
|
846
|
+
|
|
847
|
+
export const both = surface().lighten(0.1).darken(0.1); // error: lighten, darken and contrast do not go together
|
|
848
|
+
export const wide = surface().color("wide"); // error: not a colour
|
|
849
|
+
export const noValue = surface().ink("black"); // error: ink() takes no value
|
|
850
|
+
export const own = surface().radius("4px"); // error: a surface chains its own methods only
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
- **Methods**, each once and in any order: `color(<color> | token)` is an absolute base (without it the base is the nearest surface around, or `Canvas` for the outermost); `lighten(step)`, `darken(step)` and `contrast(step)` move the lightness of the base by a `<number [0,1]>`, and only one of the three goes in a chain; `ink()` sets the text colour to black or white by the lightness of the surface (above 0.5636 black, else white). `contrast` moves *away* from the parent: a light parent is darkened, a dark one lightened. The base may be a relative colour (`oklch(from var(--brand) l c h)`).
|
|
854
|
+
- **Build errors** (at the call site): a step outside 0 to 1, a bad colour, a method used twice or with another step, and a `cn()` that holds `surface()` and `background` or `background-color`, `bg()` included (`cssints: surface: surface() sets the background colour: use its methods (this style also sets background-color)`). `backgroundImage`, `color` and the border colours do not conflict. Conflicts with an opaque runtime string in `cn()` are not seen, as for any plugin.
|
|
855
|
+
- **What it emits:** the class `_surface-core` and one class per method (`._x{--surface-d:0.06}`, in `_.a`, or `_.b` inside a condition: a `hover()` or a media query moves every surface below). The kernel `surface.core` (about 1 kB minified with its registrations and fallback, once, in `_.k`) is two `@container style(--surface-p: ...)` rules that alternate two registered `<color>` properties, `--surface-e` and `--surface-o`, because a property cannot read itself; the step `--surface-d` is not registered, since it may contain `l`, which only means something inside `oklch(from …)`. Its names are `--surface-*`; frame's are `--frame-*`, so a frame and a surface can be one element, or nest in each other, with no collision.
|
|
856
|
+
- **Not a contrast guarantee.** A lightness threshold of 0.5636 gives 4.6:1 at the worst on greys, but 3.7:1 on 3 000 random chromatic colours (research, Chrome). `ink()` on a saturated surface can fall short of WCAG AA. For a ratio, declare the colours as tokens and use `css.check.contrast` (see Themes): it checks the declared pair at build time, which the kernel cannot do because it never sees the parent.
|
|
857
|
+
- **Browsers.** The kernel needs `@container style()` on custom properties (Chrome 111, Safari 18, Firefox 151), `@property` (Chrome 85, Safari 16.4, Firefox 128) and relative colour (Chrome 122, Safari 18, Firefox 128). Chrome 119 to 121 and Safari 16.4 to 17.6 have an early relative colour with other channel scales: the steps come out wrong (in Chrome `l` is a number from 0 to 100), so the kernel's `requires` asks for the whole feature (`exact`, see Plugins) and a target set that holds one of them gets the warning. Safari 17 has no style queries, so it gets the runtime as well, which does not change the steps; Chrome 120 has style queries and gets no runtime, only the warning. For a target that has `@property` and relative colour but lacks style queries (Firefox 128 to 150) the build ships the same fallback as `frame()`: the shared `cssints/parent-vars` script (one observer however many plugins use it) copies each surface's colour to its surface children. A surface that is also a frame needs both transition sentinels, so the fallback has a rule for `._surface-core._frame-core` (the surface scope embeds the frame scope for that class: `&(frame.core)`). Below `@property` or relative colour the kernel is a build warning: a surface keeps no tonal step there.
|
|
858
|
+
- **Limits of the runtime** are frame's: about two or three times the style-query kernel on a first pass, shadow roots are not crossed, a first paint before the script runs shows the `canvas`-based colours, and a `transition` of your own on a surface replaces the sentinel that lets `:hover` and media conditions reach the script.
|
|
859
|
+
|
|
860
|
+
### Icons: `@cssints/icons` (decided 2026-10-02, cssints-1x1o)
|
|
861
|
+
|
|
862
|
+
An icon is a style written on `scope()` (one scope, "icons", whatever the collection): a marker class, a mask kernel and one atomic class per icon that holds the SVG as a data URL. There is no runtime. Install `@cssints/icons` and a collection (`@iconify-json/mdi`), or point it at a folder of `.svg` files.
|
|
863
|
+
|
|
864
|
+
```ts
|
|
865
|
+
import { cn, color, width } from "cssints" with { type: "cssints" };
|
|
866
|
+
import { icons } from "@cssints/icons" with { type: "cssints" };
|
|
867
|
+
|
|
868
|
+
// icons.ts: a build-time module (it runs while the build runs, and never reaches the browser)
|
|
869
|
+
export const mdi = icons<typeof import("@iconify-json/mdi/icons.json")>({
|
|
870
|
+
provider: "iconify-json",
|
|
871
|
+
collection: "mdi",
|
|
872
|
+
});
|
|
873
|
+
export const brand = icons({ provider: "fs", dir: "src/icons" });
|
|
874
|
+
|
|
875
|
+
// a component imports them from there: import { mdi, brand } from "./icons.ts" with { type: "cssints" }
|
|
876
|
+
export const close = cn(mdi("close"), color("crimson"), width("1.5em"));
|
|
877
|
+
export const logo = brand("logo");
|
|
878
|
+
export const typo = mdi("clos"); // error: not an icon of mdi
|
|
879
|
+
```
|
|
880
|
+
|
|
881
|
+
```css
|
|
882
|
+
/* kernel icons.mask, layer _.k; LightningCSS adds -webkit-mask for your targets */
|
|
883
|
+
._icons-mask { display: inline-block; width: 1em; height: 1em; background-color: currentColor; mask: var(--icons-svg) no-repeat center / 100% 100%; }
|
|
884
|
+
/* the only per-icon rule */
|
|
885
|
+
._x1 { --icons-svg: url("data:image/svg+xml,%3Csvg …"); }
|
|
886
|
+
```
|
|
887
|
+
|
|
888
|
+
- **`icons({ provider: "iconify-json", collection })`** reads `@iconify-json/<collection>/icons.json` from the `node_modules` of the working directory, while the module runs. **`icons({ provider: "fs", dir })`** reads `<dir>/<name>.svg` (`dir` is relative to the working directory; the SVG needs a `viewBox`). The result is a function of the icon name: `mdi("home")` is a style that goes into `cn()` and the conditions like any member. Call `icons()` in a module of its own, imported with the attribute, as above.
|
|
889
|
+
- **Names.** TypeScript cannot read a module type from a string, so the names of a collection come from the type argument: `icons<typeof import("@iconify-json/mdi/icons.json")>(…)` types `mdi(name)` as the icons and aliases of that collection, and a typo is an error in the editor. Without the type argument, and for `fs`, a name is any string. In every case a name that does not exist is a build error at the call, with the nearest name when it is close: `cssints: icons: no icon "hom" in iconify-json mdi, did you mean home?`.
|
|
890
|
+
- **Mask or image.** An icon that paints with `currentColor` (every one-colour icon of a collection) gets the marker `_icons-mask` and takes the text `color`. Any other (a logo with its own colours) gets `_icons-image`, a background image. This is iconify's own rule. A non-square icon is centred on a square view box, so `width` and `height` set its size (`1em` by default).
|
|
891
|
+
- **Cost.** The kernel is printed once. Each icon adds one class, and the same icon is the same class everywhere. The kernel has no `requires`: LightningCSS adds `-webkit-mask` for targets that have only the prefixed form.
|
|
892
|
+
- **Dev.** An edited SVG of `fs` updates the CSS of the modules that use it, without a touch or a restart (`dependsOn`, see "Plugins"; 2026-10-09, cssints-04u8). A new SVG in `dir` is not seen until the module runs again.
|
|
893
|
+
- **Not yet:** the iconify HTTP API (it would need a lockfile), names generated for `fs`, and a name transform.
|
|
894
|
+
|
|
895
|
+
### Plugin packages (cssints-dk1q)
|
|
896
|
+
|
|
897
|
+
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`.
|
|
898
|
+
|
|
899
|
+
| Package | Members | What it does |
|
|
900
|
+
|---|---|---|
|
|
901
|
+
| [`@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 |
|
|
902
|
+
| [`@cssints/layout`](../packages/layout/README.md) | `stack()`, `cluster()`, `sidebar()`, `switcher()`, `cover()`, `autoGrid()` | Every Layout primitives as chain members |
|
|
903
|
+
| [`@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 |
|
|
904
|
+
| [`@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 |
|
|
905
|
+
| [`@cssints/tokens`](../packages/tokens/README.md) | `/open-props`, `/radix`, `/flexoki`, `/reasonable-colors` | DTCG data for `createTokens` |
|
|
906
|
+
| [`@cssints/gradient`](../packages/gradient/README.md) | `gradient(kind, direction, from, to, easing?, stops?)` | Eased colour stops mixed in oklab at build time |
|
|
907
|
+
| [`@cssints/motion`](../packages/motion/README.md) | `motion().preset().duration().delay().easing()` | Animation presets over `keyframes()`, with reduced motion |
|
|
908
|
+
| [`@cssints/squircle`](../packages/squircle/README.md) | `squircle().radius()` | `corner-shape: squircle`, else a CSS `mask` of the same shape |
|
|
909
|
+
| [`@cssints/reveal`](../packages/reveal/README.md) | `reveal().preset().range().duration().easing()` | motion's entrance presets on `animation-timeline: view()`; for other targets a script plays it once on entry (delivered by the runtime) |
|
|
910
|
+
| [`@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) |
|
|
911
|
+
| [`@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) |
|
|
912
|
+
| [`@cssints/masonry`](../packages/masonry/README.md) | `masonry().columns().gap()` | `display: grid-lanes`, else CSS `columns` (top to bottom per column) |
|
|
913
|
+
| [`@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 |
|
|
914
|
+
| [`@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 |
|
|
915
|
+
| [`@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 |
|
|
916
|
+
| [`@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 |
|
|
917
|
+
|
|
918
|
+
### Publishing a library (decided 2026-10-02, cssints-0l3f)
|
|
919
|
+
|
|
920
|
+
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.
|
|
921
|
+
|
|
922
|
+
```ts skip
|
|
923
|
+
// src/index.ts of the library
|
|
924
|
+
import { bg, cn, cv, hover, p } from "cssints" with { type: "cssints" };
|
|
925
|
+
|
|
926
|
+
import { t } from "./tokens.ts" with { type: "cssints" };
|
|
927
|
+
|
|
928
|
+
export { t };
|
|
929
|
+
export const card = cn(p(4), bg(t.color.brand));
|
|
930
|
+
export const button = cv({ base: cn(p(2), hover(bg("#1d4ed8"))) });
|
|
931
|
+
```
|
|
932
|
+
|
|
933
|
+
```ts skip
|
|
934
|
+
// in the app: the style is a class string, the token is imported with the attribute to be used or themed
|
|
935
|
+
import { createGlobalTheme } from "cssints" with { type: "cssints" };
|
|
936
|
+
import { button, card } from "my-ui";
|
|
937
|
+
import { t } from "my-ui" with { type: "cssints" };
|
|
938
|
+
|
|
939
|
+
createGlobalTheme(t, ".dark", { color: { brand: "#ef4444" } });
|
|
940
|
+
```
|
|
941
|
+
|
|
942
|
+
- **Build with tsdown** (`vp pack`), with `cssints` external and `dts: true`. tsdown keeps the import attributes, so there is no option to set. The built `.d.ts` types `button` as `(props?: …) => string`, with the props of its variants. Do not minify, bundle or transform the JS further with a tool that drops import attributes, and do not bundle `cssints` into it.
|
|
943
|
+
- **Keep in the package:** `cssints` in `peerDependencies` (the app needs it to build; a library that imports it must list it), the JS with the attributes, the `.d.ts` (it types `cssints` members as class strings and tokens as tokens, so the app is checked across the boundary), and no `.css` file: the library has no sheet of its own.
|
|
944
|
+
- **The app needs cssints and its plugin.** The package has no classes until the app's `cssints()` plugin has evaluated it, so an app without cssints cannot use it. Pre-compiled output (class strings plus a sheet fragment for apps without cssints) is not supported yet; it waits for a first user.
|
|
945
|
+
- **The package must be a direct dependency of the app.** `cssints/vite` reads the `package.json` of the app and of each of its dependencies, and for a dependency that lists `cssints` it adds the package to `optimizeDeps.exclude` (the dev optimizer would pre-bundle it with esbuild and drop the attributes) and to `ssr.noExternal` (the engine's runner would otherwise load it as native Node, which does not know the attribute). This is a few file reads at startup, one per dependency, and nothing else. A package that is only a dependency of another package is not found; add it to the app's own dependencies (or to those two options).
|
|
946
|
+
- **Dev and HMR.** The package's files go through the engine like app files, one evaluation per module, and are served as `/node_modules/…/index.js` with the classes replaced. They are never watched, so a package does not change during `vite dev`: restart the server after reinstalling it.
|
|
947
|
+
- A file of a package that does not carry the attribute is never parsed by the engine.
|
|
948
|
+
|
|
949
|
+
### Leaving files alone: `exclude` (decided 2026-10-10, cssints-ci7n)
|
|
950
|
+
|
|
951
|
+
A file outside `node_modules` is a site module when its code mentions `cssints` (a name, a comment, `cssints/plugin`), with or without the attribute: that is how a forgotten attribute is found and told as an error. So the source of a plugin package, or of the engine, that a project bundles as code is also read as a site module, and fails (`import { CSSStyleValue } from "cssints"` is "without the attribute"). `exclude` names such files; the plugin does not transform, evaluate or drop them, in a build, on the dev server and in the private server of a build.
|
|
952
|
+
|
|
953
|
+
```ts skip
|
|
954
|
+
cssints({ exclude: ["**/packages/*/src/**", /\/vendor\//] });
|
|
955
|
+
```
|
|
956
|
+
|
|
957
|
+
- **Vite's filter style.** `string | RegExp | ReadonlyArray<string | RegExp>`, read as `createFilter` reads `exclude`: a picomatch glob (a relative one from the working directory) or a RegExp, matched on the module's file. There is no `include`: the rule of the attribute and of the mention already says which files are sites, and a list of the files to keep would only repeat it.
|
|
958
|
+
- **Nothing changes without it.** The default is no file excluded, so an app, a plugin package's own tests and the packages in `node_modules` behave as before, and a build pays one empty check per module. The attribute is not made the only marker: that would turn the forgotten-attribute error into a `css` that is `undefined` in the browser.
|
|
959
|
+
- **An excluded file stays plain code.** If it also carries `with { type: "cssints" }` imports, it is not evaluated either; exclude only files that are not sites.
|
|
960
|
+
|
|
961
|
+
### Debugging: from a class to its call (dev source map, cssints-0a3p)
|
|
962
|
+
|
|
963
|
+
In dev the sheet has a source map: each rule with a class points at the first `css.*` call that made the class, so Chrome's Styles pane names that file and line beside `._tlfnko` and opens it in Sources. Vite drops CSS maps in dev unless you turn them on:
|
|
964
|
+
|
|
965
|
+
```ts skip
|
|
966
|
+
export default defineConfig({ plugins: [cssints()], css: { devSourcemap: true } });
|
|
967
|
+
```
|
|
968
|
+
|
|
969
|
+
A class is a hash of its declaration, so a class that many calls share points at its first call in the module that comes first by path. The place is the whole site (`cn(...)`, a member call), not the argument inside it. Token, theme and `@property` rules carry no class and map nowhere.
|
|
970
|
+
|
|
971
|
+
A build maps the sheet too, when the app asks for maps (`build: { sourcemap: true }`, `"hidden"` or `"inline"`, as for its JS): the CSS asset that holds the sheet gets `<file>.css.map` and a `sourceMappingURL` comment (`"hidden"`: the file only; `"inline"`: a data URL). Vite writes no CSS map in a build, so cssints maps the asset as written, after LightningCSS and the minify: each rule with a class points at its call, the rest maps nowhere. Bundle analysers read it to split the sheet's bytes by module (docs/research/devtools.md: Sonda). Without `build.sourcemap` nothing is recorded and the output is unchanged. Devtools that read the CSSOM (atomic-css-devtools) see the sheet as any other; what they miss is in docs/research/devtools.md.
|
|
972
|
+
|
|
973
|
+
### Debugging: the devtools overlay (`@cssints/devtools`, decided 2026-10-09, cssints-cncb)
|
|
974
|
+
|
|
975
|
+
An inspector in the page, built on what the dev server knows of every class. In dev only, unless the app asks for it in a build too (below). Install `@cssints/devtools` as a dev dependency of the app and turn it on:
|
|
976
|
+
|
|
977
|
+
```ts skip
|
|
978
|
+
export default defineConfig({ plugins: [cssints({ devtools: true })] });
|
|
979
|
+
```
|
|
980
|
+
|
|
981
|
+
Alt+Shift+C, or the `cssints` button in the bottom right corner, opens it. Hovering an element previews it, a click picks it; ↑ is the parent, ↓ the first child, ← and → the siblings; Esc stops picking, then closes. For the demo's action button, hovered in a 1300 px window, the panel shows:
|
|
982
|
+
|
|
983
|
+
```text skip
|
|
984
|
+
button pick ↑ ×
|
|
985
|
+
CLASSES BY CALL
|
|
986
|
+
src/components/Showcase.tsx:72 ↗
|
|
987
|
+
._1t8gqy5 padding: 0.5rem _.a
|
|
988
|
+
._1r802m6 background-color: color-mix(…, black 15%) :hover _.b
|
|
989
|
+
._pb21ov background-color: color-mix(…, black 30%) @media (width >= 60rem) :hover _.c
|
|
990
|
+
._tw1yer outline: 2px solid var(--color-accent) :focus-visible (not now) _.b
|
|
991
|
+
PROPERTIES
|
|
992
|
+
background-color color-mix(in oklab, var(--color-accent), black 30%) ✓
|
|
993
|
+
._pb21ov:hover _.c @media (width >= 60rem) :hover
|
|
994
|
+
background-color: var(--color-accent) ._ijw0jd _.a (struck through)
|
|
995
|
+
background-color: color-mix(…, black 15%) ._1r802m6:hover _.b (struck through)
|
|
996
|
+
outline nothing applies now
|
|
997
|
+
outline: 2px solid var(--color-accent) ._tw1yer:focus-visible _.b :focus-visible
|
|
998
|
+
TOKENS
|
|
999
|
+
--color-accent rgb(29, 78, 216) color.accent
|
|
1000
|
+
the initial value of its @property
|
|
1001
|
+
```
|
|
1002
|
+
|
|
1003
|
+
- **Classes by call.** The element's classes, grouped by the `css.*` call that made them (`cn(...)`, a member), the call that covers most of them first; the link opens the file at that line through Vite's `/__open-in-editor`, and the call's source is shown under it. Each class shows its declarations, its conditions (green: holds now) and its layer; a marker shows its kernel (`marker of kernel frame.core`), a member's class its scope (`(frame)`); a class that is not cssints' is listed as such.
|
|
1004
|
+
- **Properties.** Per property, the winning declaration, what it beat (struck through) and what does not apply now, with the condition that fails. The winner is the highest by **layer, then specificity, then the sheet's order**, among the rules that apply: a rule applies when its selector matches the element now and each of its at-rules holds for it, which the browser decides (a probe sheet around the element, so `@container`, `@container style()`, `@supports` and `@scope` are evaluated where the element is). A shorthand counts for each of its longhands: `padding` beside a `padding-left` of a higher layer says `except padding-left`. Kernels and global rules in `_.k`, themes and looks in their layers are rules like the others.
|
|
1005
|
+
- **Checked against the browser.** Each winner is set inline on a childless clone of the element, in its place, and the clone's computed value must equal the element's: ✓. When it does not (✗), the panel says why: the element's `style` attribute, a rule of another sheet (a rule outside every layer beats every layer, cssints' too: `.mine { color: … } in src/inspect.css beats it`), or a running animation. A value that depends on the element's content (`width: auto`, grid tracks) is `?`, not checked. A custom property is checked with its declaration as the page's sheet has it, since LightningCSS reprints it (`0.04` as `.04`) and an unregistered one computes to its tokens as written (cssints-g3u1).
|
|
1006
|
+
- **Tokens.** Each custom property the element's rules read: its value on the element, its path (`color.surface`), and the rule that sets it on the element or the nearest ancestor (a theme, a look in `@scope`, a member's class).
|
|
1007
|
+
- **How it gets the data.** Over Vite's HMR channel, no endpoint: the overlay asks for the table (`cssints:devtools:get`) and the server builds it from its own records: the sheet as it serves it, each class's rule, plugin and tokens, and the place of every call. After each transform the server says the table is stale, and an open overlay asks again.
|
|
1008
|
+
- **Nothing in a build, unless asked.** `devtools: true` adds a plugin with `apply: "serve"`; `vite build` has no entry, no script and no event of it (packages/devtools/test/build.mjs builds the demo with the option on and searches the output).
|
|
1009
|
+
- **In a build: `devtools: "build"`.** The same overlay on the built site, for a project whose code is public or whose readers want to see how a page is styled (this site has it on). The build writes the table once every module is transformed, as a JSON asset (`assets/cssints-devtools-<hash>.json`: the sheet, the classes, the tokens, the sites), puts the overlay in a chunk of its own, and adds a loader to the entry: a `cssints` button and Alt+Shift+C, under 1 kB. Nothing else loads until the first open; then the overlay and the table do, and the panel opens. The table is static, so there is no stale state and no update. A site is its file relative to the project, the line and the call's source, shown as text: there is no link to an editor, and none to a repository. This site's table is 70 kB (12 kB gzip), the overlay 190 kB (64 kB gzip), the entry grows by 0.85 kB (0.4 kB gzip). The option needs `@cssints/devtools` installed like the dev one; without it the page logs a warning in the console on the first open.
|
|
1010
|
+
- **Ask OpenCode: `opencode` (dev only, off by default, cssints-ro4k, 2026-10-10).** With a picked element the panel can have a prompt box: type a change, Enter sends. It is a second option, because it starts a coding agent in your repository and must be asked for by name:
|
|
1011
|
+
|
|
1012
|
+
```ts skip
|
|
1013
|
+
cssints({ devtools: true, opencode: true });
|
|
1014
|
+
// or: opencode: { agent: "build", skills: ["cssints"] }
|
|
1015
|
+
```
|
|
1016
|
+
|
|
1017
|
+
The dev server, never the page, talks to the OpenCode V2 service that is running on the machine (the one `opencode serve --service` and the desktop app register; found with `Service.discover` of `@opencode/client`, so the page holds no address and no credentials). It creates a session in the workspace root (the nearest folder up from the Vite root with a `.git`), sends the prompt, waits for the session to end, and tells the overlay the status over the same HMR channel (`cssints:devtools:ask` from the page, `cssints:devtools:asked` back: no route that another origin could post to). The box shows `sending`, then `running · ses_…`, then `completed`, `failed` or `interrupted`; with no service running it says so and keeps your text. `@opencode/client` is an optional peer dependency: install it in the app (the message names it when it is missing). The prompt is made of cssints' own data, not source markers added to the markup, and is bounded (8 call sites, 24 properties, 3 beaten declarations each, 12 tokens, a call cut at 400 characters; the server cuts the whole at 16000):
|
|
1018
|
+
|
|
1019
|
+
```text skip
|
|
1020
|
+
Change the UI of the running app as the instruction says.
|
|
1021
|
+
The styles are written with cssints: each `css.*` call below produced the classes of the element,
|
|
1022
|
+
so edit those calls (or the markup that uses them), not the generated class names.
|
|
1023
|
+
|
|
1024
|
+
Instruction: make it bigger
|
|
1025
|
+
|
|
1026
|
+
Element: <p#p>
|
|
1027
|
+
DOM path: html > body > p#p
|
|
1028
|
+
Text: hello
|
|
1029
|
+
Classes: _aasmd7 _1t8gqy5
|
|
1030
|
+
|
|
1031
|
+
Styled by (file:line, the call as written):
|
|
1032
|
+
- main.ts:5
|
|
1033
|
+
color("rebeccapurple")
|
|
1034
|
+
- main.ts:5
|
|
1035
|
+
p(2)
|
|
1036
|
+
|
|
1037
|
+
Properties now (the winner, then what it beat):
|
|
1038
|
+
- color: rebeccapurple from ._aasmd7 (_.a)
|
|
1039
|
+
- padding: 0.5rem from ._1t8gqy5 (_.a)
|
|
1040
|
+
```
|
|
1041
|
+
|
|
1042
|
+
A build has none of it: the server side is in the `apply: "serve"` plugin, and `devtools: "build"` with `opencode` on still gives a loader without a way to ask (the dormant box is part of the overlay chunk, about 1 kB). The idea and the V2 client calls follow brendonovich/vite-plugin-opencode (MIT).
|
|
1043
|
+
- **Its own styles do not touch yours.** The overlay is a custom element with a shadow root and its own cssints sheet, built with the package: the page's rules do not reach it, and its classes are not in your sheet or in the table.
|
|
1044
|
+
- **Limits.** The proximity of `@scope` is not ranked (no two looks set one property on one element), and a logical and a physical property (`margin-inline-start`, `margin-left`) are two properties, as `cn()` treats them; an element inside another shadow root is picked as its host; a page that Vite does not serve as HTML (server rendering) gets no entry.
|
|
1045
|
+
|
|
1046
|
+
### Debugging: traces of the engine (`CSSINTS_TRACE`, cssints-r2qq)
|
|
1047
|
+
|
|
1048
|
+
Opt-in. Set `CSSINTS_TRACE=otlp` (or `otlp:http://host:port`; the default is `http://localhost:4318`) before `vite dev` or `vite build`, and the plugin sends one trace per Vite hook to an OpenTelemetry backend over OTLP/HTTP (JSON). Unset, nothing is loaded and nothing runs: the hooks use Effect's own `runSync` and `runPromiseExit`, no tracer, no exporter, no HTTP client. The variable is read once, when the plugin loads.
|
|
1049
|
+
|
|
1050
|
+
```bash
|
|
1051
|
+
docker run -d --rm --name cssints-jaeger -p 16686:16686 -p 4318:4318 jaegertracing/all-in-one:latest
|
|
1052
|
+
CSSINTS_TRACE=otlp bun run build # or: CSSINTS_TRACE=otlp bun run dev, then edit a file
|
|
1053
|
+
open http://localhost:16686 # service "cssints"; docker stop cssints-jaeger when done
|
|
1054
|
+
```
|
|
1055
|
+
|
|
1056
|
+
Jaeger takes traces only, which is all there is: a warning or an error is an event on the span of its hook, not a log. Another OTLP backend (Grafana Tempo, `grafana/otel-lgtm`, any collector) works the same way through `CSSINTS_TRACE=otlp:<its OTLP/HTTP base URL>`.
|
|
1057
|
+
|
|
1058
|
+
- **One root span per hook**, with the file as the `file` attribute (and `environment` for `vite.transform`): `vite.transform` (a module; the `ssr` ones are the Module Runner loading helpers and the library), `vite.hotUpdate`, `vite.load` (the sheet in dev), `vite.renderChunk`, `vite.buildStart`, `vite.buildEnd`, `vite.closeBundle`.
|
|
1059
|
+
- **Under it**, the engine's own spans: `cssints.instrument` (the parse), `cssints.clientTransform`, `cssints.evaluate` (waiting for the evaluation lock, then the run) with `cssints.runner.import` (the module running in the Vite Module Runner: its time is the app's code and the engine's work per site), `cssints.renderSheet`, `cssints.lightningcss` and `cssints.cssPost` (Vite's CSS pipeline on the sheet: LightningCSS, then minify), `cssints.locate`.
|
|
1060
|
+
- **Events:** `cssints.warning` (message, file of the transform; Vite gets the warning's own site) and `cssints.error` (message, file, offset). An error that reaches Vite has `traceId` and `spanId`, and ends its message with `(trace <id>)`: search for that id in the backend.
|
|
1061
|
+
- **Not traced:** the work per site and per declaration (`__at`, `__site`, the twin, the hash): at a few µs each, a span per call would be the cost it is meant to find. Read the `cssints.runner.import` span for their total.
|
|
1062
|
+
|
|
1063
|
+
The process must end the exporter or Node stays alive: `vite build` and `server.close()` do it (the `closeBundle` hook flushes and stops it); a script that exits by itself calls `closeTrace()` from `cssints/src/trace.ts` first. A backend that is not there costs a retry of up to three seconds at the end and loses the trace, nothing else.
|
|
1064
|
+
|
|
1065
|
+
`packages/cssints/test/trace.mjs` (in `bun run test`) checks the spans, the file attribute and the error's trace id against an in-process collector. `node packages/cssints/test/trace-live.mjs` builds apps/sky and runs its HMR test traced, then reads both traces back through Jaeger's API (`GET /api/traces?service=cssints`) and prints span counts and the slowest hooks; it skips when no backend answers. Effect's DevTools (the VS Code extension, `effect/devtools`) is not wired: it needs the editor's extension listening, so neither a script nor an agent can read it.
|
|
1066
|
+
|
|
1067
|
+
### Checks for tools: `cssints/check` (2026-10-09, cssints-p3d2)
|
|
1068
|
+
|
|
1069
|
+
The engine's own checks, as functions, for a tool that answers as a build would (`@cssints/mcp`). Node only (culori, LightningCSS): nothing here reaches a browser, and user code has no reason to import it. Each returns an Effect.
|
|
1070
|
+
|
|
1071
|
+
```ts
|
|
1072
|
+
import { contrast } from "cssints/check";
|
|
1073
|
+
import { Effect } from "effect";
|
|
1074
|
+
|
|
1075
|
+
const { ratio, text } = await Effect.runPromise(contrast("#777", "white"));
|
|
1076
|
+
// ratio 4.478…, text "4.47": cut, not rounded, as the message of css.check.contrast prints it
|
|
1077
|
+
```
|
|
1078
|
+
|
|
1079
|
+
- **`contrast(foreground, background)`** is the WCAG 2.x ratio of `css.check.contrast` (see Themes): culori reads the colour, one outside sRGB is clipped to it. A colour it cannot read (`currentColor`, a `var()`) or a translucent one fails with a `ContrastError` (`colour`, `message`).
|
|
1080
|
+
- **`loweredOf(property, value, warning)`** answers what a build asks before it says a support warning ("No warning for what LightningCSS lowers", under Errors): the value of the first declaration LightningCSS writes for the targets of the warning, or `undefined` when it leaves the feature in place (`text-wrap`, a `var()` inside the value). `warning` is one of `support()` of `@cssints/css-grammar/support`; a custom property is `--name`. For `color: oklch(70% 0.2 330)` and chrome 100 it is a hex colour, so a build does not warn.
|
|
1081
|
+
|
|
1082
|
+
### TUI: `cssints/tui` (decided 2026-10-10, cssints-3ff7)
|
|
1083
|
+
|
|
1084
|
+
A terminal app writes its styles with `cssints/tui`, and the same Vite plugin compiles them to **prop objects** for terminal renderables, not to classes: there is no sheet and no cascade. `@cssints/tui` applies them (states, the mouse pointer, the caret, themes); its adapters are [OpenTUI](https://opentui.com) and [@bomb.sh/tty](https://bomb.sh/docs/tty/api/). The design, the full property table and what is out of scope are in [research/tui.md](research/tui.md).
|
|
1085
|
+
|
|
1086
|
+
```ts
|
|
1087
|
+
import * as css from "cssints/tui" with { type: "cssints" };
|
|
1088
|
+
|
|
1089
|
+
const t = css.createTokens({ color: { $type: "color", bg: { $value: "#1e1e2e" }, accent: { $value: "#89b4fa" } } });
|
|
1090
|
+
css.createGlobalTheme(t, "light", { color: { bg: "#eff1f5", accent: "#1e66f5" } });
|
|
1091
|
+
|
|
1092
|
+
export const button = css.cn(
|
|
1093
|
+
css.border("rounded", "#585b70"),
|
|
1094
|
+
css.p(0, 1),
|
|
1095
|
+
css.width(12),
|
|
1096
|
+
css.bg(t.color.bg),
|
|
1097
|
+
css.cursor("pointer"),
|
|
1098
|
+
css.hover(css.borderColor(t.color.accent)),
|
|
1099
|
+
css.active(css.borderStyle("double")),
|
|
1100
|
+
);
|
|
1101
|
+
css.width("10rem"); // error: a terminal counts cells
|
|
1102
|
+
css.boxShadow("0 0 1px red"); // error: no such member in a terminal
|
|
1103
|
+
css.color("blu"); // error: not a colour
|
|
1104
|
+
```
|
|
1105
|
+
|
|
1106
|
+
```ts skip
|
|
1107
|
+
export const button = { border: true, borderStyle: "rounded", borderColor: "#585b70", paddingTop: 0, paddingRight: 1, paddingBottom: 0, paddingLeft: 1, width: 12, backgroundColor: "#1e1e2e", cursor: "pointer", ":hover": { borderColor: "#89b4fa" }, ":active": { border: true, borderStyle: "double" }, themes: { light: { backgroundColor: "#eff1f5", ":hover": { borderColor: "#1e66f5" } } }, vars: { backgroundColor: "color-bg", ":hover": { borderColor: "color-accent" } } };
|
|
1108
|
+
```
|
|
1109
|
+
|
|
1110
|
+
```tsx skip
|
|
1111
|
+
import { attach, setTheme, use } from "@cssints/tui/opentui";
|
|
1112
|
+
|
|
1113
|
+
<box {...use(button)}><text>Save</text></box>; // Solid or React: the spread is a ref that calls attach
|
|
1114
|
+
<text {...use(() => (active() ? tabActive : tab))}>Home</text>; // a style that changes: an accessor
|
|
1115
|
+
attach(new BoxRenderable(renderer, {}), button); // core
|
|
1116
|
+
setTheme("light");
|
|
1117
|
+
```
|
|
1118
|
+
|
|
1119
|
+
- **Opt-in** is the import: an attributed import of `cssints/tui` gives the terminal members; a site that holds one of their values becomes an object literal. A web module is untouched (its sites never reach the terminal case), and one file may hold both. A Bun app is an SSR build (`vite build --ssr`), or a build of an environment of its own: the plugin replaces the sites in the `ssr` environment of a build too, since it evaluates them on a server of its own. That server resolves as the dev server's `ssr` environment does: with the app's `resolve.alias` and the conditions of its `ssr` environment, so a style module may import what an alias names (2026-10-10, cssints-qnif).
|
|
1120
|
+
- **Members** are the properties a terminal has: `color`, `backgroundColor`/`bg`, `borderColor`, `border(style, color?)`, `borderStyle` (`solid`, `double`, `rounded`, `heavy`, `none`), `fontWeight` (`bold`, `normal`, `lighter` for dim), `fontStyle`, `textDecoration`, `textAlign`, padding and margin (with `p`, `m` and the side names), `gap`, sizes, flex (Yoga), `position` and its edges, `overflow`, `display` (`flex`, `none`), `opacity`, `zIndex`, `cursor`, `caretShape`, `caretColor`, `caretAnimation`, and `cn`, `hover`, `focus`, `active`, `createTokens`, `createGlobalTheme`. Any other property is not a member, so a type error.
|
|
1121
|
+
- **Cells.** A number is a cell (columns across, rows down); `N%` is of the parent; `auto` where Yoga takes it. A CSS length is a type error and a build error (`cssints: width: a terminal counts cells: write a number, not "10rem"`): no unit is converted.
|
|
1122
|
+
- **Colours** are checked by the web's `color` grammar in the types, read at build time and written as `#rrggbb` (`#rrggbbaa` when translucent). `currentColor`, system colours and `light-dark()` are build errors.
|
|
1123
|
+
- **`cn()`** merges in argument order (a later prop wins). States wrap styles and do not nest; the runtime lays `:hover`, then `:focus`, then `:active` over the props.
|
|
1124
|
+
- **Themes.** A theme is named by its selector argument (`"light"`). Every colour token is resolved at build time, per theme, aliases and derived tokens included; the site holds the default values and, under `themes`, what each theme changes. `setTheme(name)` swaps them on every attached renderable; `""` is the default. A theme under `media()` is not read.
|
|
1125
|
+
- **Terminal tokens are not the sheet's** (cssints-0s1z, 2026-10-11). The `createTokens` and `createGlobalTheme` of `cssints/tui` keep their tokens and themes in tables of their own: no `@property` rule, no `:root` rule and no theme reaches a stylesheet, and a web token of the same name in the same build (`--color-bg` of the site and of a terminal app) is another token, with no clash. A terminal style reads terminal tokens and themes only; a web style reads web ones only. Two terminal modules that give one token name two values are an error, as on the web (`--color-bg is already declared with another @property`). A set of one `createTokens` given to the other's `createGlobalTheme` is a build error at the call that names the right import (`these are web tokens of "cssints": theme them with the createGlobalTheme of "cssints"`, and the other way round; 2026-10-11, cssints-v872). A token set still ships as `var(--name)` strings, which the system theme reads.
|
|
1126
|
+
- **The adapter.** `attach(renderable, style)` sets the props (`color` is OpenTUI's `fg`, the text attributes are the bits of `attributes`, the caret is an editable's `cursorStyle` and `cursorColor`), follows `over`/`out`, `down`/`up` and `focused`/`blurred`, and sets the mouse pointer of `cursor` on over and `default` on out. A prop the renderable lacks is not set. A focused renderable draws colours of its own (a box `focusedBorderColor`, also while a descendant has the focus; an editable or a select `focusedBackgroundColor` and `focusedTextColor`): each follows the style's `borderColor`, `backgroundColor` and `color` in the current state, theme included, so a focused box draws its `:focus` border, not OpenTUI's `#00AAFF` (2026-10-10, cssints-pkpg). On an editable or a select, `color` is `textColor`. A text node (`<span>`, `<a>`, `<b>` of `@opentui/solid`: a `TextNodeRenderable`) takes the props (`fg`, `bg`, `attributes`) and the theme, not the states: it has no mouse events and no focus, so `:hover` belongs on the `<text>` around it; its theme listener stops when it is destroyed, as `@opentui/solid` does with what it removes (2026-10-10, cssints-5vnf). To change the style of a renderable, attach it again: `attach(r, next)` on a renderable it has attached swaps the style and keeps the states, the theme and the hooks; a prop only the old style set goes back to the renderable's own value (OpenTUI's default, or the element's own prop), the `focused*` colours follow, and under the mouse the pointer turns to the new `cursor` (or `default`). `use()` takes a style or an accessor, `use(() => (active() ? a : b))`: Solid calls a ref in a render effect, so the signals the accessor reads run it again and each run swaps the style; React calls the new ref of each render, which swaps it too and no longer stacks the hooks. The adapter imports no framework (2026-10-10, cssints-332g). `resolve(style, state, theme)` of `@cssints/tui` is the renderer-neutral part, for other adapters.
|
|
1127
|
+
- **The bomb.sh adapter** (cssints-kotl, 2026-10-10). `@cssints/tui/bomb` is for [@bomb.sh/tty](https://bomb.sh/docs/tty/api/), where a frame is a list of ops: `createTracker()` gives `ui.open(id, style, extra?)` and `ui.text(content, style, id?, extra?)`, the ops with the options of the style in the state of that element id, and `ui.render(term, build, { pointer })`, which reads the frame's `pointerenter`/`pointerleave` events and a press of `pointer.down`, draws the frame again when a state changed, and adds the OSC 22 pointer of `cursor` and the caret (DECSCUSR, OSC 12) of the element of `ui.focus(id)`; `ui.reset()` gives them back on exit. Sizes are `fixed`, `percent`, `fit`, and `grow` for `flexGrow`; `rounded` is a corner radius; `double` and `heavy` are drawn single; margins, `flexShrink`, `flexBasis`, `flexWrap`, `alignSelf`, `opacity` and `space-*` are not drawn (the table is in research/tui.md). `openProps(props)` and `textProps(props)` are the plain mapping of `resolve()`.
|
|
1128
|
+
|
|
1129
|
+
```ts skip
|
|
1130
|
+
import { close, createTerm } from "@bomb.sh/tty";
|
|
1131
|
+
import { createTracker } from "@cssints/tui/bomb";
|
|
1132
|
+
|
|
1133
|
+
const term = await createTerm({ width: 80, height: 24 });
|
|
1134
|
+
const ui = createTracker();
|
|
1135
|
+
const build = () => [ui.open("save", button), ui.text("Save", label, "save"), close()];
|
|
1136
|
+
process.stdout.write(ui.render(term, build, { pointer: { x, y, down } }).output);
|
|
1137
|
+
```
|
|
1138
|
+
|
|
1139
|
+
- **The system theme** (cssints-q1j3, 2026-10-10). A theme computed at run time from the terminal's own colours, after [Pi's](https://earendil.com/posts/system-theme/). `createSystemTheme(tokens, roles, options?)` of `@cssints/tui/system` makes each colour token a **role**: a hue source (`hue`: an ANSI name, `black` … `brightWhite`, or the terminal's `fg`/`bg`) and contrast rules (`on`: the tokens it sits on, `contrast`: the WCAG 2 ratio). A text role keeps its source colour when it meets every rule, else takes the lightness of the strictest one (the reversed WCAG ratio, bisected in OKHSL at the source's hue and saturation, the saturation falling off toward black and white, the OKLCH chroma capped at the source's); a `surface` is placed at exactly `contrast` from its first `on`, toward the foreground, or is the terminal's background without one. `followSystemTheme` of each adapter asks the terminal (OSC 10, 11 and 4 for the 16 colours, the colour-scheme report), sets the theme with the fallback at once, resolves after the replies or 100 ms, applies a late reply when it comes, and rebuilds on an appearance change (mode 2031). Light or dark: the reported background, else the scheme report (`CSI ? 997 n`; OpenTUI's `themeMode`), else `COLORFGBG`, else dark. A terminal that reports its background only gets the app's hues (`options.hues`) placed for it; one that reports nothing gets ANSI indices and its default colours on OpenTUI (`ansi:N`, `default`, drawn by the terminal; a role's `ansi` picks the index), and the static theme of its appearance on bomb.sh, which has no indexed colour. A token without a role keeps the value of the static theme `options.themes[appearance]`.
|
|
1140
|
+
|
|
1141
|
+
```ts skip
|
|
1142
|
+
// theme.ts: run time, not a cssints module
|
|
1143
|
+
import { createSystemTheme } from "@cssints/tui/system";
|
|
1144
|
+
import { t } from "./styles.ts";
|
|
1145
|
+
|
|
1146
|
+
export const system = createSystemTheme(t, {
|
|
1147
|
+
color: {
|
|
1148
|
+
bg: { surface: true },
|
|
1149
|
+
card: { surface: true, on: [t.color.bg], contrast: 1.1 },
|
|
1150
|
+
text: { hue: "fg", on: [t.color.bg, t.color.card], contrast: 7 },
|
|
1151
|
+
accent: { hue: "blue", on: [t.color.bg, t.color.card], contrast: 3 },
|
|
1152
|
+
},
|
|
1153
|
+
}, { themes: { light: "light" } });
|
|
1154
|
+
|
|
1155
|
+
// OpenTUI: renderer.getPalette() and its theme_mode event
|
|
1156
|
+
await followSystemTheme(await createCliRenderer(), system);
|
|
1157
|
+
// @bomb.sh/tty: the queries are written, the replies taken out of stdin before input.scan()
|
|
1158
|
+
const follow = followSystemTheme(system, { write: (s) => process.stdout.write(s) });
|
|
1159
|
+
process.stdin.on("data", (bytes) => dispatch(input.scan(follow.feed(bytes)).events));
|
|
1160
|
+
```
|
|
1161
|
+
|
|
1162
|
+
- **The theme table** (cssints-q1j3). A site holds, besides its values and static `themes`, `vars`: the token of each colour prop set to a lone token (`vars: { backgroundColor: "color-bg" }`, the name without `--`). `defineTheme(name, values, base)` of `@cssints/tui` registers a run-time theme: a colour per token name, laid over the static theme `base` for every prop with a token; `setTheme(name)` selects it, and `defineTheme` of the theme in use resolves every attached renderable again. The system theme is one; static themes stay inline, since they need no registration and no import order. A colour that is not a lone token (`color-mix()` over one) keeps its static value.
|
|
1163
|
+
- **Live in the browser** (cssints-ey13, 2026-10-11). [The TUI page](https://cssints.dev/tui) of this site runs apps/sky-tui's styles on `@cssints/tui/bomb` in a real terminal: Ghostty's emulator compiled to WebAssembly (ghostty-web) draws sky's feed and thread from its recorded fixtures, with keys and the mouse. The page is the terminal: it answers the system theme's queries (OSC 10, 11, 4, `CSI ? 996 n`) from the site's display settings and reports a change of them (mode 2031), so the theme is solved again as the palette or the mode changes. OpenTUI is not shown: its renderer is native Zig over `bun:ffi`. On a change notification, `followSystemTheme` of `@cssints/tui/bomb` asks the colours again (`COLOURS` of `@cssints/tui/system`), not the scheme report: its reply is the same sequence as a notification, and asking it looped with a terminal that answers it.
|
|
1164
|
+
- **Cost.** A colour member costs what the same web member costs in the types (the `color` grammar); `bench/tui.ts` is 184k instantiations (tsc) for 16 calls and a token set. No terminal HMR in this version. The system theme brings culori's rgb, oklab, oklch and okhsl modes to the app (45 kB unminified in sky-tui's bundle).
|
|
1165
|
+
|
|
1166
|
+
### Export groups: `cssints/widely-available`, `cssints/css-grid-2`, … (decided 2026-10-11, cssints-n7z3)
|
|
1167
|
+
|
|
1168
|
+
A group is a subpath of `cssints` that exports the members of `cssints` which a set of CSS features has, and refuses a value that holds a feature outside the set. It is the same import (`with { type: "cssints" }`), the same members, the same classes:
|
|
1169
|
+
|
|
1170
|
+
```ts
|
|
1171
|
+
import { color, display, media } from "cssints/widely-available" with { type: "cssints" };
|
|
1172
|
+
|
|
1173
|
+
export const card = [display("grid"), color("rebeccapurple"), media("(width >= 40rem)")(color("black"))];
|
|
1174
|
+
|
|
1175
|
+
display("grid-lanes"); // error: display: "grid-lanes" is not in cssints/widely-available
|
|
1176
|
+
color("light-dark(white, black)"); // error: color: "light-dark()" is not in cssints/widely-available
|
|
1177
|
+
media("(prefers-reduced-data: reduce)"); // error: @media: "prefers-reduced-data" is not in cssints/widely-available
|
|
1178
|
+
```
|
|
1179
|
+
|
|
1180
|
+
- **Baseline groups** (web-features 3.42, the status of each BCD key): `widely-available` is Baseline high, `newly-available` is high and low, `limited-availability` is everything (it is `cssints` itself, under its Baseline name) and `not-discouraged` is everything web-features does not mark `discouraged` (`clip`, the `page-break-*` aliases, `ime-mode`, `word-break: break-word`, `text-decoration-line: blink`, device media features). A feature web-features does not know is limited availability.
|
|
1181
|
+
- **Spec groups** are named by the W3C shortname that @webref/css uses: `cssints/css-grid-2`, `cssints/css-color-4`, `cssints/mediaqueries-4`, `cssints/selectors-4`, … (92 of them). A group holds what the spec defines, and **a level includes the levels of its series before it**: `css-color-5` has what Color 3, 4 and 5 define, `css-color-4` has Color 3 and 4. A level is a delta (Color 5 over Color 4) or a rewrite (Media Queries 4 over 3), and "I write for Color 5" means both. What only a later level defines is refused: `css-color-4` has no `colorMix` and refuses `color-mix()`, `mediaqueries-4` refuses `prefers-color-scheme`. A group has the properties, functions, pseudo-class wrappers and conditions its spec defines, so `display` is in `css-display-4` and `gridTemplateColumns` in `css-grid-2`: import from several groups in one file.
|
|
1182
|
+
- **What is a member:** the properties (and their short names), the functions, the pseudo-class wrappers, `media`, `container`, `supports`, `keyframes` and `positionTry`. Everything else (`cn`, `cv`, `firstThatWorks`, `token`, `createTokens`, `createGlobalTheme`, `check`, `style`, `global`, `layers`, `attr`, `within`, `group`, the Typed OM values) is in every group. A group has no `css` object: write `import * as css from "cssints/widely-available"`.
|
|
1183
|
+
- **What is refused**, in the types (an error on the argument) and in the build (an error at the call), with the same words: a keyword of the property, a function (as a call of a member, or as text, in a `firstThatWorks` too), a unit (`10rch`), a media feature, a `scroll-state()` or `style()` container query and a `selector()` support query, and a property, function or condition the group does not have (`flex().gap()` is checked as `gap`). A value that is not valid CSS is still the usual error.
|
|
1184
|
+
- **What is not:** the types a spec only references (`<color>` in `accent-color` is any colour of any group), a feature of a module that is not in the series of the group (`css-ui-4`'s `accentColor` takes `color-mix()` as text), and what nothing attributes to a feature: the grammar of a value that no BCD key names is not narrowed. A function with no BCD key counts as limited availability. A keyword of `cursor` has the status of `cursor` (web-features counts it limited for iOS Safari, which has no pointer).
|
|
1185
|
+
- **Cost:** a program that does not import a group pays nothing. In one that does, a call costs about 250 instantiations more (a 105-call bench: +2.9%), and the 96 declaration files add 340 KB to `dist/` (about 240 KB of `src/groups`, the tarball gains about 100 KB gzip).
|
|
1186
|
+
|
|
1187
|
+
`bun run groups` (in `packages/cssints`) regenerates `src/groups/<id>.ts` and `src/groups-data.ts` from web-features, @webref/css 6 (one file per level; 8 keeps the latest level of a series only) and BCD. Research and numbers: [research/groups.md](research/groups.md).
|
|
1188
|
+
|
|
1189
|
+
### Browser support and what 0.1 does not have
|
|
1190
|
+
|
|
1191
|
+
- **Types and checks for every property** are generated from [mdn-data](https://github.com/mdn/data) and @webref/css by `packages/css-grammar` (`bun run extract`), not written by hand, so a new property or function needs no change here. 536 members: 525 properties (every standard and experimental one, except the three shorthands above) and the 11 short names.
|
|
1192
|
+
- **Browser support** comes from [`@mdn/browser-compat-data`](https://github.com/mdn/browser-compat-data) against browserslist, as build warnings (see above). There is no per-property table in the types.
|
|
1193
|
+
- **Not in 0.1:** pseudo-elements, `:has()`, derived tokens, slot variants, a typed runtime token setter, icons (the plugin API they are written on is above; `frame()` is in `cssints/frame`, `surface()` in `cssints/surface`), polyfills of its own for missing browser features. The map and the reasons are in [wayfinder.md](wayfinder.md).
|