@menapo/vicss 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,663 @@
1
- # Temporary Holding Version
1
+ # @menapo/vicss
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Typed, design-system-first atomic CSS for Vite.
4
+
5
+ vicss is a Vite 8 plugin and a runtime of about half a kilobyte (about 0.8 KB with recipes). You describe your design system once, in a typed config: tokens, semantic tokens, modes, conditions. You write styles as typed objects that use that system, next to your components. At build time the plugin checks every value, compiles the styles to atomic CSS in one order-proof stylesheet, and replaces each style with a constant that names its classes, short hashes in production. One style is an element's class (`class={card}`) or its inline style (`style={card}`), in every framework. Overrides merge by property and condition, so `sx(component, props.style)` does what you expect without tailwind-merge, `!important` or load-order tricks. Recipes give you tailwind-variants' API (variants, compound variants, slots, `extends`) over style objects, with responsive variants.
6
+
7
+ ```tsx
8
+ import { css, sx } from "@menapo/vicss";
9
+
10
+ const card = css({ padding: { base: 4, _md: 6 }, borderRadius: "lg", background: "surface" });
11
+
12
+ <section className={card}>…</section>; // class={card} outside React and Preact
13
+ // dev: <section class="bg_surface md:p_6 p_4 r_lg">
14
+ // build: <section class="v1x7m v8q1z vk2f9 v0c3d">
15
+
16
+ <section {...sx(card, props.style)}>…</section>; // several styles, or a caller's override
17
+ ```
18
+
19
+ Requires Vite 8.3 or newer and Node 22.18 or newer.
20
+
21
+ ## Contents
22
+
23
+ - [Install](#install)
24
+ - [A two-minute tour](#a-two-minute-tour)
25
+ - [One style: `class=` and `style=`](#one-style-class-and-style)
26
+ - [Principles](#principles)
27
+ - [Frameworks](#frameworks)
28
+ - [Critical CSS for prerendered pages](#critical-css-for-prerendered-pages)
29
+ - [Config reference](#config-reference)
30
+ - [Plugin options](#plugin-options)
31
+ - [Runtime API](#runtime-api)
32
+ - [CLI](#cli)
33
+ - [Lint rules](#lint-rules)
34
+ - [Known limits](#known-limits)
35
+ - [Browser support](#browser-support)
36
+ - [How it compares](#how-it-compares)
37
+ - [Guides](#guides)
38
+
39
+ ## Install
40
+
41
+ ```sh
42
+ vp add @menapo/vicss
43
+ ```
44
+
45
+ Install it as a regular dependency. Compiled modules import `sx` and the recipe resolver from `@menapo/vicss` at runtime, and Vite's server builds (SSR) import dependencies from `node_modules` instead of bundling them, so a server deployed without dev dependencies would fail to start. In an app that Vite bundles entirely, such as an SPA, `vp add -D @menapo/vicss` works too.
46
+
47
+ Add the plugin:
48
+
49
+ ```ts
50
+ // vite.config.ts
51
+ import { defineConfig } from "vite";
52
+ import { vicss } from "@menapo/vicss/vite";
53
+
54
+ export default defineConfig({ plugins: [vicss()] });
55
+ ```
56
+
57
+ Create the config next to it. The plugin, the CLI and the generated types all read this one file:
58
+
59
+ ```ts
60
+ // vicss.config.ts
61
+ import { defineConfig, tailwind } from "@menapo/vicss/config";
62
+
63
+ export default defineConfig({
64
+ presets: [tailwind], // Tailwind v4's default theme: colors, spacing, radii, breakpoints …
65
+ semanticTokens: {
66
+ colors: {
67
+ surface: { base: "{colors.white}", _dark: "{colors.gray.950}" },
68
+ text: { base: "{colors.gray.900}", _dark: "{colors.gray.50}" },
69
+ accent: { DEFAULT: "{colors.blue.600}", hover: "{colors.blue.700}" },
70
+ },
71
+ },
72
+ });
73
+ ```
74
+
75
+ That is all the setup. There is no CSS entry to import, no PostCSS config and no generated folder: the plugin adds the stylesheet to your app.
76
+
77
+ ## A two-minute tour
78
+
79
+ ### The config is the design system
80
+
81
+ Tokens are scales (`colors.blue.600`, `radii.lg`, a spacing step). Semantic tokens name a purpose and take a value per mode, so dark mode and brands are token values, not per-component overrides. A used token becomes a CSS custom property (`--color-accent`), which plain CSS can use too.
82
+
83
+ ```ts
84
+ // vicss.config.ts
85
+ import { defineConfig, tailwind } from "@menapo/vicss/config";
86
+
87
+ export default defineConfig(
88
+ {
89
+ presets: [tailwind],
90
+ tokens: { durations: { fast: "150ms" } },
91
+ conditions: { brandB: '[data-brand="b"] &' },
92
+ },
93
+ // Every token above and in the presets, typed: colors.gray[950] is "{colors.gray.950}".
94
+ ({ colors }) => ({
95
+ semanticTokens: {
96
+ colors: {
97
+ surface: {
98
+ DEFAULT: { base: colors.white, _dark: colors.gray[950] },
99
+ muted: colors.gray[100],
100
+ },
101
+ text: { base: colors.gray[900], _dark: colors.gray[50] },
102
+ accent: {
103
+ DEFAULT: { base: colors.blue[600], _brandB: colors.red[600] },
104
+ hover: colors.blue[700],
105
+ },
106
+ onAccent: colors.white,
107
+ },
108
+ },
109
+ textStyles: { heading: { fontSize: "xl", fontWeight: "semibold", lineHeight: "tight" } },
110
+ globalCss: { body: { background: "surface", color: "text", fontFamily: "sans" } },
111
+ }),
112
+ );
113
+ ```
114
+
115
+ The second argument is optional: a function of the first argument's tokens and its presets', which returns the rest of the config. The editor completes each token, and a misspelt one, or a group such as `colors.gray`, is a type error. Each reference is a brace string, so `"{colors.gray.950}"` written by hand means the same, with one argument or two, and names what the types do not know (the tokens of a `dtcg()` file).
116
+
117
+ Tokens can also come from DTCG files exported by Figma, Tokens Studio, Terrazzo or Style Dictionary: `presets: [dtcg("./tokens.resolver.json")]`. See [Design tokens](./docs/guides/design-tokens.md).
118
+
119
+ ### Styles: `css()`
120
+
121
+ ```tsx
122
+ import { css } from "@menapo/vicss";
123
+
124
+ const card = css({
125
+ display: "flex",
126
+ flexDirection: { base: "column", _md: "row" }, // a value per condition
127
+ gap: 4, // spacing steps: calc(var(--spacing) * 4)
128
+ padding: { base: 4, _md: 6 },
129
+ borderRadius: "lg", // a radius token
130
+ background: "surface", // a semantic color: switches with dark mode
131
+ boxShadow: { base: "sm", _hover: "md" },
132
+ transitionDuration: "fast",
133
+ _focusWithin: { outlineColor: "accent", outlineWidth: "2px", outlineStyle: "solid" }, // a block
134
+ });
135
+
136
+ const title = css({ textStyle: "heading", color: "text" });
137
+ ```
138
+
139
+ Property names are CSS, in camelCase. Each value is checked against the scale of its property: `background` takes a color, `gap` a spacing step, `borderRadius` a radius. CSS keywords (`auto`, `100%`, `fit-content`) and `0` always work. A typo is a build error with a code frame, which the `vicss/valid-styles` lint rule reports in the editor; under `strict: "tokens"` it is a TypeScript error too.
140
+
141
+ `css()` compiles to a constant, an object from each property and condition the style sets to its class (`{ p: "p_4", "md:p": "md:p_6" }`), so you can write it anywhere, including inside a component. Read as a string, it is its class attribute, so one style is an element's class as it is, `className={title}` (`class={title}` elsewhere), and `style={…}` takes one too ([below](#one-style-class-and-style)); several styles go through `sx()`. Its argument must be known at build time; a value only known at runtime goes through a function style (below). The build fails, and says why, rather than drop a style.
142
+
143
+ `css(style, { name: "card" })` adds the class `card` to the style, as it is, for tests, theming hooks and devtools.
144
+
145
+ ### One style: `class=` and `style=`
146
+
147
+ One style → `class={card}`; several styles or overrides → `sx(a, b)`.
148
+
149
+ ```tsx
150
+ import { css } from "@menapo/vicss";
151
+
152
+ const card = css({ padding: 4, background: "surface" });
153
+
154
+ <div className={card} />; // class={card} outside React and Preact
155
+ <div style={css({ padding: 4, color: "text" })} />; // style="padding: calc(var(--spacing) * 4); color: var(--color-text)"
156
+ <div style={css({ color: "text", background: { base: "surface", _hover: "accent" } })} />;
157
+ // style="color: var(--color-text)" class="bg_surface hover:bg_accent"
158
+ ```
159
+
160
+ - **As a class**, a compiled style reads as its class attribute wherever a string is read: `class={card}`, `className={card}`, `` `<p class="${card}">` ``, `element.className = card`, `String(card)`. A recipe's result and a marker are styles too. Vue's `:class` and Svelte's `class` read an object as a map of classes, so the plugin points every compiled component's class calls at a version that takes a style.
161
+ - **As a style**, the plugin writes `style={css({ … })}`, and `style={card}` where `card` is a style it can see (a constant in the module, or imported from one it compiles), as a literal inline style: tokens as their `var(--…)`, an object in React, Preact and Solid, a string in Vue, Svelte, Astro and templates. An inline declaration beats every class, so it cannot carry conditions: a property with one (`_hover`, `_md`, `_dark`, a marker's state) goes to classes whole, its base value included, with the shorthands and longhands around it, since an inline `padding` would beat a hovered `paddingTop`. Those classes join the element's own `class` (`className`), static or dynamic, exactly as `sx(own, style)` merges them, or become its class when it has none. A palette, a theme and a `name` stay classes.
162
+ - **A `style=` value the plugin cannot see**, such as a prop, is the framework's own; in dev, a compiled style there fails where it renders, with the fix: `class={style}`, or `sx()`.
163
+ - A function style (runtime values) and several styles go through `sx()`, whose `style` sets the custom properties.
164
+
165
+ | Framework | One style as a class | As a style |
166
+ | ------------- | ------------------------------------------------ | ---------------------------------------------- |
167
+ | React, Preact | `className={card}` | `style={card}`: `{ paddingTop: "…" }` |
168
+ | Solid | `class={card}` | `style={card}`: `{ "padding-top": "…" }` |
169
+ | Vue | `:class="card"` | `:style="card"`: `"padding-top:…"` |
170
+ | Svelte, Astro | `class={card}` | `style={card}`: `"padding-top:…"` |
171
+ | Plain JS | `` `<p class="${card}">` ``, `.className = card` | `` `<p style="${card}">` ``: `"padding-top:…"` |
172
+
173
+ ### Applying and merging: `sx()`
174
+
175
+ ```tsx
176
+ import { css, sx, type Style } from "@menapo/vicss";
177
+
178
+ export function Card(props: { heading: string; style?: Style; children?: unknown }) {
179
+ return (
180
+ <section {...sx(card, props.style)}>
181
+ <h2 {...sx(title)}>{props.heading}</h2>
182
+ {props.children}
183
+ </section>
184
+ );
185
+ }
186
+
187
+ <Card heading="Plans" style={css({ padding: 8, background: "accent" })} />;
188
+ ```
189
+
190
+ `sx()` returns the attributes the element needs (`className` in React, `class` elsewhere), and `sx(card).className` is the class string alone. It takes styles, recipe results, class names of your own, `false`, `null`, `undefined` and arrays of those.
191
+
192
+ Merging works like a deep `Object.assign` of the style objects, per property and per condition:
193
+
194
+ - The last value of a property wins: the caller's `padding: 8` replaces the card's padding at every breakpoint it sets, and the card's `_md` padding stays unless the caller sets `_md` too.
195
+ - A shorthand clears its longhands: `sx(css({ paddingTop: 2 }), css({ padding: 4 }))` is padding 4 on every side.
196
+ - Neither the order of classes in the attribute nor the order of stylesheets on the page changes the result: every precedence lives in a cascade sub-layer.
197
+ - A string is class names of your own: `sx("card__title", title, isOpen && "is-open")` keeps `card__title` and `is-open` as they are, first, in order and once each, and never merges them.
198
+
199
+ Static calls fold at build time: `sx(title)` becomes `{ className: "c_text fs_xl fw_semibold lh_tight" }`.
200
+
201
+ ### Class names
202
+
203
+ In dev, classes are readable: `p_4`, `md:p_6`, `hover:bg_accent`, the property, the value and the conditions. A build ships short hashes of those names, `v` and four base-36 characters (`vk2f9`), named once from every class the build scans, so the client, the server and every worker agree; two names that would collide get one more character, and so does one that would be a class the app writes itself (`css(style, { name: "vcard" })`, `sx("vcard")`). `classNames` in the config sets the prefix and the length, and `readable: true` keeps readable names in a build, for debugging; `VICSS_READABLE=1` does the same without editing the config.
204
+
205
+ ### Conditions
206
+
207
+ Every condition starts with `_`, and `base` is the value without one.
208
+
209
+ | Kind | Built in |
210
+ | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
211
+ | States | `_hover`, `_focus`, `_focusVisible`, `_focusWithin`, `_active`, `_disabled`, `_enabled`, `_checked`, `_indeterminate`, `_invalid`, `_valid`, `_required`, `_optional`, `_readOnly`, `_placeholderShown`, `_autofill`, `_open`, `_visited`, `_target`, `_empty` |
212
+ | Structure | `_first`, `_last`, `_only`, `_odd`, `_even`, `_firstOfType`, `_lastOfType` |
213
+ | Pseudo-elements | `_before`, `_after`, `_placeholder`, `_selection`, `_marker`, `_file`, `_backdrop`, `_firstLetter`, `_firstLine` |
214
+ | Breakpoints | one per `breakpoints` token, mobile-first (`_md`), plus `_maxMd` and ranges (`_mdToLg`) |
215
+ | Containers | one per `containers` token: `_containerMd` |
216
+ | Modes | `_dark`, `_light`, and every mode a semantic token uses |
217
+ | Preferences | `_motionSafe`, `_motionReduce`, `_contrastMore`, `_contrastLess`, `_portrait`, `_landscape`, `_print`, `_forcedColors`, `_pointerFine`, `_pointerCoarse`, `_ltr`, `_rtl` |
218
+ | Relations | `_groupHover`, `_groupFocus`, `_groupFocusVisible`, `_groupActive`, `_groupDisabled`, `_peerHover`, `_peerFocus`, `_peerFocusVisible`, `_peerChecked`, `_peerDisabled`, `_peerInvalid`, `_peerPlaceholderShown` (an ancestor with the class `group`, a preceding sibling with `peer`), and typed markers |
219
+
220
+ Conditions nest per property (`padding: { base: 4, _md: 6 }`) or as blocks (`_hover: { color: "accent" }`), and they combine (`_md: { _hover: { … } }`). `_hover` only applies on devices that can hover. `_dark` holds under `data-theme="dark"` on any ancestor, or under `prefers-color-scheme: dark` when no ancestor sets `data-theme`.
221
+
222
+ ### Tokens and values outside them
223
+
224
+ ```ts
225
+ import { css } from "@menapo/vicss";
226
+
227
+ const gap = 16;
228
+ css({ padding: 4 }); // calc(var(--spacing) * 4)
229
+ css({ color: "blue.600" }); // var(--color-blue-600)
230
+ css({ width: "100%", height: "auto" }); // keywords and percentages
231
+ css({ maxWidth: "37.5rem", width: `calc(100% - ${gap}px)` }); // CSS outside the scale, as written
232
+ // css({ color: "blu.600" }) fails the build: did you mean `blue.600`?
233
+ ```
234
+
235
+ A string is resolved in three steps. A token of the property's scale comes first: `color: "red"` is the `red` token when the colours have one, and CSS's red otherwise. Then a valid CSS value of the property is written as it is: lengths, percentages, colours (hex, named, `rgb()`, `oklch()`, `color-mix()` …), the property's keywords, functions (`calc()`, `clamp()`, `var()` …) and shorthands made of valid parts. Anything else is a mistake, most likely a mistyped token, and fails the build with the closest tokens: ``color: `blu.600` is not a colors token, nor a CSS value of `color`. Did you mean `blue.600`?``. Properties without a scale (`gridTemplateColumns`) take any string. The check knows each scale's value category rather than all of CSS, and refuses a lone word: a font family alone is quoted (`fontFamily: "'Inter'"`) or followed by a fallback.
236
+
237
+ Values outside the scales are counted: the build's size report and `vicss check` give their number, `vicss check --max-arbitrary <n>` fails above a budget, and the `vicss/no-arbitrary` lint rule names each one. `strict: "tokens"` takes only tokens, for a design system that wants no exception, with per-scale or per-property overrides (`strict: { default: "tokens", maxWidth: "css" }`).
238
+
239
+ ### Recipes
240
+
241
+ `recipe()` is tailwind-variants' API over style objects.
242
+
243
+ ```ts
244
+ import { recipe, type Variants } from "@menapo/vicss";
245
+
246
+ export const button = recipe({
247
+ base: {
248
+ display: "inline-flex",
249
+ alignItems: "center",
250
+ gap: 2,
251
+ borderRadius: "md",
252
+ fontWeight: "medium",
253
+ _focusVisible: {
254
+ outlineColor: "accent",
255
+ outlineWidth: "2px",
256
+ outlineStyle: "solid",
257
+ outlineOffset: "2px",
258
+ },
259
+ _disabled: { opacity: 0.5, pointerEvents: "none" },
260
+ },
261
+ variants: {
262
+ intent: {
263
+ primary: { background: { base: "accent", _hover: "accent.hover" }, color: "onAccent" },
264
+ ghost: { background: { base: "transparent", _hover: "surface.muted" }, color: "text" },
265
+ },
266
+ size: {
267
+ sm: { height: 8, paddingInline: 3, fontSize: "sm" },
268
+ md: { height: 10, paddingInline: 4, fontSize: "base" },
269
+ },
270
+ block: { true: { width: "100%" } },
271
+ },
272
+ compoundVariants: [{ intent: "ghost", size: "sm", style: { paddingInline: 2 } }],
273
+ defaultVariants: { intent: "primary", size: "md" },
274
+ responsive: ["size"],
275
+ });
276
+
277
+ export type ButtonVariants = Variants<typeof button>;
278
+ ```
279
+
280
+ ```tsx
281
+ <button {...sx(button({ intent: "ghost", size: { base: "sm", _md: "md" } }), props.style)}>
282
+ Save
283
+ </button>
284
+ ```
285
+
286
+ - **Responsive variants:** a variant listed in `responsive` takes a value per breakpoint or container size. Compound variants still apply, at each width, to the values in effect there.
287
+ - **Slots** style the parts of a component; a call returns one style per slot:
288
+
289
+ ```ts
290
+ export const dialog = recipe({
291
+ slots: {
292
+ overlay: { position: "fixed", inset: 0, background: "gray.950" },
293
+ panel: { borderRadius: "lg", background: "surface", padding: 6 },
294
+ title: { textStyle: "heading" },
295
+ },
296
+ variants: { size: { sm: { panel: { maxWidth: "sm" } }, lg: { panel: { maxWidth: "2xl" } } } },
297
+ defaultVariants: { size: "sm" },
298
+ });
299
+
300
+ const s = dialog({ size: "lg" }); // { overlay, panel, title }
301
+ ```
302
+
303
+ - **Composition:** `recipe({ extends: [button, focusRing], … })` merges one or more parents.
304
+ - **Props:** `button.variantKeys` lists the variant props, `button.splitProps(props)` returns `[variantProps, rest]`, and `button.schema` describes every variant's values and the defaults (for docs and Storybook).
305
+ - A call with literal props folds to a constant at build time. Every variant of a recipe is compiled, used or not, so renaming a prop or a component never loses CSS.
306
+
307
+ ### Flattened bases
308
+
309
+ A recipe's base is atomic like everything else: one class per declaration. For a component repeated thousands of times, such as a table cell or a list row, `flatten: true` compiles the base to one class instead:
310
+
311
+ ```ts
312
+ import { recipe, sx } from "@menapo/vicss";
313
+
314
+ export const cell = recipe({
315
+ base: {
316
+ paddingBlock: 2,
317
+ paddingInline: { base: 2, _md: 4 },
318
+ whiteSpace: "nowrap",
319
+ color: "text",
320
+ },
321
+ variants: { numeric: { true: { textAlign: "end" } } },
322
+ flatten: true,
323
+ });
324
+
325
+ sx(cell({ numeric: true })); // in dev, class "vx-base_r68syh ta_end", where the atomic base gives "c_text md:p-i_4 p-b_2 p-i_2 ta_end ws_nowrap"; a build ships two short hashes in their place
326
+ ```
327
+
328
+ The class prints one rule per condition chain, each in a sub-layer just below that chain's atoms, so the element computes exactly what it would with the atomic base: variants, compounds and later `sx()` arguments still override it per property and condition, and its `_hover` or `_md` styles keep their rank. Two rules make that hold, and vicss warns, in the build and in dev, when a call breaks them:
329
+
330
+ - **One flattened base per element**, the last, as with themes: `sx(cell(), row())` keeps `row`'s (`DROPPED_BASE`).
331
+ - **The recipe comes first.** A flattened base sits under every other class of its element, so `sx(cell(), props.style)` is exact, while an earlier argument that sets what the base sets wins over it (`BASE_ORDER`).
332
+
333
+ Physical sides and corners (`paddingLeft`, `borderTopLeftRadius`) and a base's own `colorPalette` stay atoms beside the class. In a slot recipe, `flatten: true` flattens every slot's base and `flatten: ["row", "cell"]` those slots; a recipe that `extends` a flattened one stays flattened unless it says `flatten: false`. It is off by default because it only pays where elements repeat by the thousand: on a 1 000-row table it cut classes per element from 10.5 to 1.7 and the table's HTML from 786 KB to 287 KB, but only 10 % gzipped, since repeated class strings compress well ([Performance](./docs/guides/performance.md#flattened-recipe-bases)).
334
+
335
+ ### Themes, markers and runtime values
336
+
337
+ ```tsx
338
+ import { css, group, peer, sx, theme, type Token } from "@menapo/vicss";
339
+
340
+ // A subtree with other token values: one class, no re-render, no provider.
341
+ // Its token names and values are checked by the build (the editor completes the scales).
342
+ const marketing = theme({ colors: { accent: "{colors.red.600}" }, radii: { md: "0.75rem" } });
343
+ <section className={marketing}>…</section>;
344
+
345
+ // Relations to an ancestor or a sibling, typed: a misspelt marker is an error.
346
+ const row = group();
347
+ const icon = css({ opacity: { base: 0, [row.hover]: 1 } });
348
+ <li className={row}>
349
+ <svg className={icon} />
350
+ </li>;
351
+
352
+ const toggle = peer();
353
+ const label = css({ color: { base: "text", [toggle.checked]: "accent" } });
354
+
355
+ // Values known only at runtime: a class reading a custom property, set inline.
356
+ const bar = css((percent: number) => ({ width: `${percent}%` }));
357
+ const swatch = css((tone: Token<"colors">) => ({ background: tone }));
358
+ <div {...sx(bar(progress), swatch("accent"))} />;
359
+ // class="bg_$… w_$…" style="--vx-…: 42%; --vx-…: var(--color-accent, accent)"
360
+ ```
361
+
362
+ A runtime value is written as it is, with no table shipped: a `Token`-typed one reads its token's custom property, with the value itself as the fallback when no token has that name, and anything else is the CSS value. In dev, a runtime value the browser would drop (`CSS.supports()` says no, and no token has its name) warns once in the console.
363
+
364
+ ### Palettes
365
+
366
+ Write a component once against a palette, and choose the colour where it is used (Chakra's `colorPalette`, Radix's accent colour):
367
+
368
+ ```ts
369
+ // vicss.config.ts
370
+ import { defineConfig, tailwind } from "@menapo/vicss/config";
371
+
372
+ export default defineConfig({ presets: [tailwind], palettes: { default: "gray" } });
373
+ ```
374
+
375
+ ```tsx
376
+ import { css, recipe } from "@menapo/vicss";
377
+
378
+ const button = recipe({
379
+ base: { background: { base: "palette.600", _hover: "palette.700" }, color: "white" },
380
+ variants: { tone: { danger: { colorPalette: "red" } } },
381
+ });
382
+
383
+ <section className={css({ colorPalette: "blue" })}>
384
+ <button className={button()}>Blue, from the section</button>
385
+ <button className={button({ tone: "danger" })}>Red</button>
386
+ </section>;
387
+ ```
388
+
389
+ Every top-level colour group is a palette (`palettes.names` chooses), and its keys are the paths under it, raw or semantic: `600`, or a `contrast` the config adds to each group with a value per mode. `palette.600` is a colour token like any other, in styles, recipes, `globalCss`, keyframes, `{colors.palette.600}` references and `token()`. `colorPalette` takes conditions, merges last-wins in `sx()`, works in recipe variants (responsive ones too) and in function styles (`css((tone: Palette) => ({ colorPalette: tone }))`). Palettes nest, and semantic tokens with a value per mode switch inside them, with custom properties only. Elements outside any `colorPalette` get `palettes.default`.
390
+
391
+ Only the tokens of the palettes in use, for the keys styles read, reach the CSS, and a palette in use that lacks one of those keys fails the build (`PALETTE`), naming the missing tokens. A component library's prop typed `Palette` takes the palettes of the app that installs it. [Design tokens](./docs/guides/design-tokens.md#palettes) shows how palettes compile and their limits.
392
+
393
+ ### Public and private tokens
394
+
395
+ Keep raw values out of app code, so components name the design system's semantic tokens rather than the values behind them:
396
+
397
+ ```ts
398
+ // vicss.config.ts
399
+ import { defineConfig, tailwind } from "@menapo/vicss/config";
400
+
401
+ export default defineConfig({
402
+ presets: [tailwind],
403
+ semanticTokens: { colors: { accent: "{colors.blue.600}" } },
404
+ tokenVisibility: { colors: "private", "colors.white": "public", "colors.black": "public" },
405
+ privateAccess: ["src/design-system/**"],
406
+ });
407
+ ```
408
+
409
+ ```ts
410
+ css({ color: "accent" }); // var(--color-accent)
411
+ css({ color: "blue.600" }); // the build fails (a type error too under strict: "tokens"):
412
+ // vicss(PRIVATE_TOKEN): color: `blue.600` is a private colors token: the design system keeps it out of app styles
413
+ // Name `accent`, which aliases it.
414
+ ```
415
+
416
+ `"colors"` hides the scale's raw tokens; a path (`"colors.blue"`, `"colors.*.950"`) hides or shows those tokens, raw or semantic, and the most specific pattern wins. Semantic tokens, text styles, `globalCss`, keyframes, `theme()` values, palettes and what a utility writes itself still read private tokens, and so do the modules `privateAccess` lists. `privateAccess` lifts the build's check, not the types': the generated types leave private tokens out everywhere, so in those modules and in `keyframes()` write a private token as a reference, `"{colors.blue.600}"`, which type-checks and compiles to the same CSS. `vicss check` and the [lint rules](#lint-rules) report every use, `vicss tokens export` marks private tokens, and a library's styles may not name a token the app keeps private, though what its text styles and utilities write may, as in the app ([Design tokens](./docs/guides/design-tokens.md#public-and-private-tokens)).
417
+
418
+ ## Principles
419
+
420
+ 1. **The design system is the source of truth.** Tokens, semantic tokens, modes and conditions live in one config, and every value is checked against it. A value outside the system must be valid CSS for its property, is counted, and can be refused per scale.
421
+ 2. **Objects, not class strings.** Styles are typed objects with CSS property names. TypeScript gives completion and errors; no editor extension is needed.
422
+ 3. **Build time does the work.** The plugin generates all CSS and folds every static style to a constant. The runtime merges those constants by key and reads recipe tables.
423
+ 4. **Last write wins, per property and condition.** `sx(a, b)` behaves like a deep `Object.assign` of the style objects.
424
+ 5. **Every precedence is a cascade sub-layer.** Shorthands, longhands and condition chains sit in sub-layers declared once, so splitting or reordering CSS files never changes a computed style. The build fails if two rules could tie.
425
+ 6. **A style belongs to one element.** No descendant selectors: parts are slots, relations are markers.
426
+ 7. **Exact styles or a build error.** No style is dropped silently and no class ships without its rule.
427
+ 8. **One stylesheet, cached.** One sheet per app, plus a small tokens file in an SPA so that a theme-only deploy changes no JS.
428
+ 9. **The platform does the rest.** Cascade layers, custom properties, `@property`, `:where()`, container queries; Lightning CSS, which Vite ships, lowers and minifies. No PostCSS, no Babel.
429
+ 10. **One API, no framework packages.** The plugin emits the attribute shape each framework expects.
430
+
431
+ ## Frameworks
432
+
433
+ The plugin knows each file's framework from its extension and, for JSX, from its JSX import source (a `@jsxImportSource` pragma, else the Vite config's or tsconfig's), and makes `sx()` return the shape that framework expects. Plain `.ts` and `.js` modules take the `framework` plugin option, else the first of Vue, Svelte, Solid, Preact, React and Astro your `package.json` depends on, else vanilla. Your code is the same everywhere.
434
+
435
+ | Framework | `sx()` returns |
436
+ | ----------------- | -------------------------------------------- |
437
+ | React, Preact | `{ className, style }`, `style` as an object |
438
+ | Solid, Vue | `{ class, style }`, `style` as an object |
439
+ | Svelte, Astro | `{ class, style }`, `style` as a string |
440
+ | Vanilla JS | `{ class, style }`, `style` as a string |
441
+ | Plain `.ts`/`.js` | the project's default shape |
442
+
443
+ `vicss.d.ts` registers the project's framework, so `sx()`'s result is typed with that shape (`SxAttributes<"vue">` names any one of them). `.vue`, `.svelte` and `.astro` files are compiled after their framework's compiler; write styles in the component's script (or Astro's frontmatter) and use them in the template, since a style written in the template fails the build with a hint to move it. In dev, `sx()` adds `data-vx-src="src/Card.vue:12"`, the component's own line, in every framework's shape.
444
+
445
+ One style needs no `sx()`: `class={card}`, `style={card}` ([One style](#one-style-class-and-style)). `sx()`'s `style` is present only when a runtime value sets a custom property. Spread the result (`{...sx(a)}` in JSX, `v-bind="sx(a)"` in Vue, `{...sx(a)}` in Svelte and Astro), or read the class where you need a string:
446
+
447
+ ```ts
448
+ element.className = sx(card, isActive && active).class; // .className in React and Preact
449
+ ```
450
+
451
+ Server rendering needs nothing: class names are constants, named once per build for every environment, so the server and the client render the same strings. There is no style registry, no `<style>` injection, no provider and no `"use client"`.
452
+
453
+ ## Critical CSS for prerendered pages
454
+
455
+ A prerendered page can paint without waiting for any stylesheet:
456
+
457
+ ```ts
458
+ // vite.config.ts
459
+ import { defineConfig } from "vite";
460
+ import { vicss } from "@menapo/vicss/vite";
461
+
462
+ export default defineConfig({ plugins: [vicss({ critical: "prerendered" })] });
463
+ ```
464
+
465
+ In every HTML page the build emits, each stylesheet holding vicss's CSS becomes an inline `<style>` in its place, with what the page's markup uses: its atoms (responsive, dark-mode and marker ones included), the tokens they read in every mode, global CSS and reset, fonts, keyframes, and the app's own CSS bundled with the sheet. The stylesheet itself loads from the end of `<body>`, where it no longer blocks the first paint, and its `<link>` stays in `<head>`, disabled, for frameworks that hydrate the whole document. The inline CSS is cut from the built sheet, in the same cascade layers, so the full sheet changes no computed style when it arrives; your scripts still start once it has, so hydration and the markup scripts render find it.
466
+
467
+ No inline script is involved. Each page's style hashes go into `vicss-critical.json` for your Content-Security-Policy, and a policy the page carries in a `<meta>` gets them by itself. The pages of the benchmarks inline 2.4–4.1 KB gzip each; the build warns when one goes over 14 KB (`critical: { budget }`), about the first round trip of a new connection, and inlines it all the same.
468
+
469
+ Frameworks that write their pages after `vite build` (Astro, SvelteKit, Vike, React Router prerendering) run the same step on their output, which reads only the built HTML and CSS:
470
+
471
+ ```sh
472
+ vp exec vicss critical dist
473
+ ```
474
+
475
+ or call `critical("dist")` from `@menapo/vicss/vite` in a build hook, such as an Astro integration's `astro:build:done`. A page the build hook sees with no vicss class (a client-rendered shell, a template a server renders into) is left as it was. Dark mode set by a class from a script in `<head>` keeps working: the mode's tokens and the page's `_dark` atoms, the palettes they choose included, are always inlined. What it cannot know is a class an `async` script, or a classic script in the middle of `<body>` such as Astro's island runtime, adds before the sheet arrives; [Performance](./docs/guides/performance.md#critical-css-for-prerendered-pages) has the details and the measurements.
476
+
477
+ ## Config reference
478
+
479
+ `vicss.config.ts` (or `.mts`, `.js`, `.mjs`) in the Vite root exports `defineConfig({ … })`, or `defineConfig({ … }, (tokens) => ({ … }))`: the callback receives the first argument's tokens and its presets' as typed references (`tokens.colors.gray[900]` is `"{colors.gray.900}"`) and returns any key but `tokens` and `presets`; a key it returns replaces the first argument's. Every key is optional.
480
+
481
+ | Key | What it does | Example |
482
+ | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
483
+ | `presets` | Configs merged under this one, in order: a design system package, `tailwind`, `shorthands`, `reset`, `dtcg()` | `presets: [tailwind, shorthands]` |
484
+ | `tokens` | Raw scales. Groups nest; `DEFAULT` names the group itself; `spacing` may be a linear `step` | `tokens: { colors: { blue: { 600: "oklch(54.6% 0.245 263)" } }, spacing: { step: "0.25rem" } }` |
485
+ | `semanticTokens` | Tokens that name a purpose, aliasing other tokens with `{scale.path}` or, in the callback, a typed reference (`colors.white`), with a value per mode | `semanticTokens: { colors: { surface: { base: "{colors.white}", _dark: "{colors.gray.950}" } } }` |
486
+ | `textStyles` | Composite tokens, used as `textStyle: "heading"` | `textStyles: { heading: { fontSize: "xl", fontWeight: "semibold" } }` |
487
+ | `conditions` | Named conditions on top of the built-ins: an ancestor selector, `&` plus a state, `@media`, `@container`, `@supports`, or a list of alternatives | `conditions: { brandB: '[data-brand="b"] &', hocus: ["&:hover", "&:focus-visible"] }` |
488
+ | `utilities` | Properties of your own: a function from one value to a style object, typed by a `scale` or as `boolean` | `utilities: { focusRing: { boolean: true, transform: () => ({ _focusVisible: { outlineStyle: "solid", outlineColor: "accent" } }) } }`, then `css({ focusRing: true })` |
489
+ | `globalCss` | Element styles by selector, with tokens and conditions, in the `vicss.global` layer | `globalCss: { body: { background: "surface", color: "text" } }` |
490
+ | `keyframes` | Named `@keyframes`. A preset's print in a build only when a style's `animation` or `animationName` names them; the config's own always print | `keyframes: { spin: { to: { rotate: "360deg" } } }` |
491
+ | `fontFaces` | `@font-face` rules; URLs are root-relative or absolute, and Vite bundles them | `fontFaces: [{ fontFamily: "Inter", src: "url(/fonts/inter.woff2) format('woff2')", fontDisplay: "swap" }]` |
492
+ | `darkMode` | How `_dark` is selected: `"attribute-or-media"` (default), `"attribute"`, `"media"` or an ancestor `{ selector }` | `darkMode: { selector: ".dark" }` |
493
+ | `strict` | What a value outside a scale may be: `"css"` (default: valid CSS for the property, a typo fails), `"warn"` (the same, with a warning each), `"tokens"` (tokens only) or `"off"` (anything, unchecked), for every scale, or by scale, property or utility with `default` for the rest | `strict: { default: "tokens", maxWidth: "css" }` |
494
+ | `classNames` | The classes a build ships: `prefix` (default `"v"`), `minLength` hash characters (default 4, 6 in a library; a warning below 3), `readable: true` for readable names in builds too. Dev always serves readable names. Hashed names skip the app's own classes the build sees (`css(style, { name })`, strings written in `sx()`); a class name built at runtime that is the prefix followed by base-36 characters (`v` + `card`) may still collide, so give such names another start or change the prefix | `classNames: { prefix: "ds-", minLength: 5 }` |
495
+ | `reset` | Adds a modern CSS reset in the `vicss.reset` layer | `reset: true` |
496
+ | `include` | Globs of the modules the build scans, from the Vite root. Default `src/**/*.{js,jsx,ts,tsx,mjs,mts,cjs,cts,vue,svelte,astro}` and the same under `app/` (Nuxt 4, React Router); keep the framework extensions you use when you widen it | `include: ["src/**/*.{ts,tsx,vue}", "lib/**/*.ts"]` |
497
+ | `exclude` | Globs left out of the scan. Default: tests, specs and stories, `node_modules`, `dist` | `exclude: ["**/*.stories.*"]` |
498
+ | `importSources` | Import specifiers of modules that re-export the runtime (a design system), followed like `@menapo/vicss`. Matched exactly as modules write them, not resolved: use a Vite alias or a package name | `importSources: ["~/ds"]` with `resolve.alias` mapping `~/ds` to `src/ds/index.ts` |
499
+ | `cssVarPrefix` | Prefix of every token custom property | `cssVarPrefix: "ds-"` gives `--ds-color-accent` |
500
+ | `palettes` | Turns on [palettes](#palettes): `default` (required) is the palette of elements no `colorPalette` applies to; `names` the colour groups `colorPalette` may name, by default every top-level one | `palettes: { default: "gray", names: ["gray", "red", "blue"] }` |
501
+ | `tokenVisibility` | [Private tokens](#public-and-private-tokens): a token pattern → `"private"` or `"public"`. `"colors"` is the scale's raw tokens, `"colors.blue"` a token and every token under it, `*` one path segment; the most specific pattern wins, and presets mark their own | `tokenVisibility: { colors: "private", "colors.white": "public" }` |
502
+ | `privateAccess` | Globs, from the Vite root, of the modules that belong to the design system and may name private tokens; an installed library's directory lets its manifest name them | `privateAccess: ["src/design-system/**", "node_modules/@acme/ui/**"]` |
503
+
504
+ **Scales:** `colors`, `spacing`, `sizes`, `radii`, `fonts`, `fontSizes`, `fontWeights`, `lineHeights`, `letterSpacings`, `shadows`, `textShadows`, `durations`, `easings`, `zIndices`, `opacities`, `blurs`, `aspectRatios`, `breakpoints`, `containers`. A token's custom property is named after its scale and path: `colors.blue.600` is `--color-blue-600`, `radii.lg` is `--radius-lg`, `fontSizes.sm` is `--text-sm`, `textShadows.sm` is `--text-shadow-sm`, the spacing step is `--spacing`.
505
+
506
+ **Presets** (from `@menapo/vicss/config`):
507
+
508
+ - `tailwind`: Tailwind CSS v4.3's default theme, so `padding: 4`, `color: "blue.500"` and `_md` mean what a Tailwind user expects. Each text size is also a text style with its line height; `textShadow: "sm"` reads its text shadows, and `animationName: "spin"` its `spin`, `ping`, `pulse` and `bounce` keyframes, which a build prints only when a style names them.
509
+ - `shorthands`: Tailwind-like short names (`p`, `px`, `m`, `bg`, `rounded`, `shadow`, `w`, `h` …) as utilities, for teams that want them. CSS names stay canonical.
510
+ - `reset`: the reset `reset: true` adds.
511
+ - `dtcg(file, options?)`: reads DTCG 2025.10 `.tokens.json` and resolver files into `{ tokens, semanticTokens, textStyles }`.
512
+ - `definePreset({ … })` types a preset of your own, keeping its token names for the callback of `defineConfig`.
513
+
514
+ ## Plugin options
515
+
516
+ The config file describes the design system; the plugin options only describe delivery.
517
+
518
+ ```ts
519
+ vicss({
520
+ config: "./design/vicss.config.ts", // or a config object; default: vicss.config.* in the root
521
+ layers: ["reset", "vicss", "app"], // outer cascade-layer order; must contain "vicss"
522
+ tokensFile: "auto", // SPA builds link used tokens as their own file; false keeps them in the sheet
523
+ framework: "vue", // the sx() shape of plain modules; default: from package.json, else "vanilla"
524
+ types: { output: "src/vicss.d.ts" }, // where the generated types go; false writes none
525
+ critical: "prerendered", // inline each prerendered page's CSS; default: off
526
+ });
527
+ ```
528
+
529
+ | Option | Default | What it does |
530
+ | ------------ | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
531
+ | `config` | `vicss.config.{ts,mts,js,mjs}` | A path relative to the Vite root, or the config object itself |
532
+ | `layers` | none | The outer layer order, declared first; put your own layers around `"vicss"`. Unlayered CSS always beats layered CSS |
533
+ | `tokensFile` | `"auto"` | In builds with an HTML entry, the layer statement, used tokens and global rules go into `assets/vicss-tokens-[hash].css`, linked first in `<head>`. `true` warns when there is no HTML entry; `false` puts them at the start of the sheet |
534
+ | `framework` | from `package.json` | The `sx()` shape of plain `.ts`/`.js` modules, and the framework `vicss.d.ts` registers: `"react"`, `"preact"`, `"solid"`, `"vue"`, `"svelte"`, `"astro"` or `"vanilla"`. Framework files and JSX modules go by their own framework |
535
+ | `types` | `{ output: "src/vicss.d.ts" }` | Where the plugin writes `vicss.d.ts` whenever the config loads: `src/vicss.d.ts`, else `app/vicss.d.ts` when there is no `src/` but an `app/`, else the root, with the [lint rules'](#lint-rules) `vicss.lint.json` next to it; `false` writes neither. `vicss types` reads this option from your Vite config |
536
+ | `critical` | off | [Critical CSS](#critical-css-for-prerendered-pages) for the HTML pages the build emits: `"prerendered"`, or `{ budget, hashes }`: the gzip bytes of a page's inline CSS above which the build warns (default `14_000`) and the CSP hashes file, relative to the output directory (default `"vicss-critical.json"`; `false` writes none). Builds only |
537
+
538
+ ## Runtime API
539
+
540
+ Everything below comes from `@menapo/vicss`. In an app, the plugin replaces every function except `sx` and recipe calls with constants; only those ship to the browser. In React and Preact that is 496 B gzip for `sx` and the prototype every compiled style shares (its `toString`), 835 B with the recipe resolver and 1,071 B with responsive recipes (Solid and Vue: the same). Svelte, Astro and vanilla JS get `style` as a string, which costs more: 536, 876 and 1,109 B. Figures as the size tests measure them; see [the benchmarks](./docs/benchmarks.md#2-runtime-bytes).
541
+
542
+ | Function | Returns | Notes |
543
+ | ----------------------- | ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
544
+ | `css(style)` | `Style`: an object from merge id to class (`{ p: "p_4" }`), its class attribute as a string | The argument must be static; one style is `class={style}` or `style={style}`, several go through `sx()` |
545
+ | `css(style, { name })` | `Style`, with the class `name` as it is | A stable class for tests, theming hooks and devtools |
546
+ | `css((…args) => style)` | `(…args) => DynamicStyle` | A runtime value becomes a custom property the class reads |
547
+ | `sx(...styles)` | The attributes of your framework (see [Frameworks](#frameworks)) | Takes styles, recipe results, class names of your own (kept as they are, first), `false`/`null`/`undefined` and arrays; later wins per property and condition, and one theme and one flattened recipe base per element |
548
+ | `recipe(definition)` | `Recipe`: `(props?) => Style`, or an object of styles with slots | Plus `variantKeys`, `splitProps(props)`, `schema`; `flatten: true` compiles the base to one class ([Flattened bases](#flattened-bases)) |
549
+ | `theme(overrides)` | `Style`: a class that overrides token values for a subtree | Token names and values are checked by the build, with a suggestion; `sx()` keeps the last theme on an element, and warns when it drops one |
550
+ | `group(name?)` | `Marker`: a style whose states are condition keys for descendants (`[row.hover]`) | Apply it with `sx(row)` |
551
+ | `peer(name?)` | `Marker`: the same, for following siblings (`[toggle.checked]`) | |
552
+ | `keyframes(frames)` | `string`: the generated animation name | `css({ animationName: fadeIn })` |
553
+ | `token(path)` | `string`: `var(--…)` | `token("colors.accent")` is `"var(--color-accent)"`, for inline styles and third-party APIs |
554
+
555
+ | Type | Is |
556
+ | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
557
+ | `Style<Props?>` | A compiled style, typed as a string that records what it sets, so `className={css(…)}` and `style={css(…)}` type-check (at runtime it is an object whose `toString` is its classes). `Style<"margin" \| "width">` limits what a caller may pass: `type CardProps = { style?: Style<"margin" \| "maxWidth"> }` |
558
+ | `Token<Scale>` | A token path of one scale: `Token<"colors">` is `"accent" \| "blue.600" \| …` |
559
+ | `Palette` | A palette name, for a runtime `colorPalette`: `css((tone: Palette) => ({ colorPalette: tone }))` |
560
+ | `Variants<typeof recipe>` | A recipe's props: `{ intent?: "primary" \| "ghost"; size?: "sm" \| "md" \| { base?, _md?, … }; block?: boolean }` |
561
+ | `SxAttributes<F?>` | What `sx()` returns: in the framework `vicss.d.ts` registers, or in framework `F` (`SxAttributes<"svelte">`) |
562
+
563
+ The precise types come from `vicss.d.ts`, which the plugin and `vicss types` write from your config; commit it or generate it in CI. It leaves private tokens out of `Token<Scale>` and the style types.
564
+
565
+ Without the plugin, `css()`, `recipe()` and the other compiled functions throw "vicss: css() was not compiled", so a missing plugin never ships unstyled markup.
566
+
567
+ ## CLI
568
+
569
+ Run it with `vp exec vicss <command>`.
570
+
571
+ | Command | Does |
572
+ | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
573
+ | `vicss init` | Writes `vicss.config.ts` (the `tailwind` preset, a small starting design system, or one read from a DTCG file), `vicss.d.ts` and `vicss.lint.json`, and prints the plugin registration when the Vite config lacks it |
574
+ | `vicss types` | Regenerates `vicss.d.ts` and the lint rules' `vicss.lint.json` from the config, with the framework and the `types.output` of the plugin in your Vite config |
575
+ | `vicss check` | The CI gate: evaluation errors, unknown and private tokens, palettes lacking a key styles read, the values outside the scales against a budget (`--max-arbitrary`), unused tokens (the config's own, not its presets') and the ordering invariant, over `.vue`, `.svelte` and `.astro` scripts too and the installed libraries' manifests with their contract; it counts flattened recipe bases |
576
+ | `vicss inspect <file>` | Prints the atoms and the CSS a file produces, a flattened base's declarations under its class; a framework file's by its scripts |
577
+ | `vicss tokens export` | Writes the resolved tokens as CSS, JSON or a DTCG file that `dtcg()` reads back with its values per mode (text styles stay in the config); JSON and DTCG mark private tokens, and `dtcg()` reads the mark back |
578
+ | `vicss critical <dir>` | Gives the prerendered pages under `<dir>` [critical CSS](#critical-css-for-prerendered-pages) and writes their CSP hashes, for pages a framework writes after `vite build`. Options `--budget <bytes>`, `--hashes <file>` (inside `<dir>`), `--no-hashes`, `--dry-run`, `--json`; it reads neither config |
579
+
580
+ ## Lint rules
581
+
582
+ `@menapo/vicss/lint` reports in your editor what the build would refuse, in the build's words, and a few mistakes the build cannot see. It is one plugin for Oxlint, which Vite+ runs as `vp lint`, and for ESLint. In a Vite+ project, add it to the `lint` block of the root `vite.config.ts`:
583
+
584
+ ```ts
585
+ // vite.config.ts
586
+ import vicss from "@menapo/vicss/lint";
587
+ import { defineConfig } from "vite-plus";
588
+
589
+ export default defineConfig({
590
+ lint: {
591
+ jsPlugins: ["@menapo/vicss/lint"],
592
+ rules: vicss.configs.recommended.rules,
593
+ },
594
+ });
595
+ ```
596
+
597
+ Plain Oxlint takes the same `jsPlugins` and `rules` in `.oxlintrc.json`; either way, Oxlint runs them on the scripts of `.vue`, `.svelte` and `.astro` files too. With ESLint, add the recommended config to `eslint.config.js` (TypeScript files need typescript-eslint's parser, as for any rule):
598
+
599
+ ```js
600
+ // eslint.config.js
601
+ import vicss from "@menapo/vicss/lint";
602
+
603
+ export default [vicss.configs.recommended];
604
+ ```
605
+
606
+ | Rule | Reports | Recommended |
607
+ | ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
608
+ | `vicss/static-styles` | Arguments of `css()`, `recipe()`, `theme()`, `keyframes()`, `token()`, `group()` and `peer()` the compiler cannot evaluate, with the fix (a function style) | error |
609
+ | `vicss/valid-styles` | Properties, conditions, tokens, values, palettes, recipe definitions and literal recipe calls, themes, keyframes and marker states the design system refuses, with "did you mean" suggestions; `{ warnings: true }` also reports what the build only warns about | error |
610
+ | `vicss/no-private-tokens` | Private tokens outside the modules `privateAccess` lists, where the build refuses them, suggesting the public tokens that alias them | error |
611
+ | `vicss/no-uncompiled-styles` | A style object passed to `sx()` without `css()`, which fails at runtime; `--fix` wraps it and imports `css` | error |
612
+ | `vicss/no-arbitrary` | Every value outside its property's scale that the build writes as it is, naming its property; `{ allowProperties: ["maxWidth"] }` allows them on those properties | off |
613
+
614
+ The entry's default export is the plugin (`meta`, `rules`, `configs.recommended`); `recommendedRules`, `rules` and `configs` are named exports too, and its types import nothing from Vite, so a lint config type-checks where Vite is not installed. The rules run the compiler's own checks, so a message is the build's, word for word, and a state under a marker reads as you wrote it (`[row.hover].color: …`). Their suggestions (a "did you mean" name, the public token that aliases a private one) are editor quick fixes, which Oxlint applies with `--fix-suggestions`. They read the design system from `vicss.lint.json`, which the plugin writes next to `vicss.d.ts` whenever the config loads; rules find it from the linted file up to its package root, or at `settings.vicss.snapshot`, and `settings.vicss.importSources` adds modules that re-export the runtime. Commit it with `vicss.d.ts`, or write both in CI first: `vp exec vicss types && vp lint`. Without it, the rules check only what needs no config; a snapshot written by another version of vicss is reported once per file, with how to write it again. Under Vite+, Oxlint reads the `lint` block of `vite.config.ts` and ignores `.oxlintrc.json`. Values imported from other modules, and styles written in Vue, Svelte or Astro templates, are left to the build, which checks them. After the design system changes, files ESLint's `--cache` kept are not linted again until they change.
615
+
616
+ ## Known limits
617
+
618
+ - **A nested theme cannot re-point an outer theme's alias.** A custom property inherits its resolved value, so a nested `theme()` that overrides `colors.blue.600` updates the config's aliases of it, not an alias the outer theme set (a CSS limit; [Design tokens](./docs/guides/design-tokens.md)).
619
+ - **One theme and one palette per element.** `sx()` keeps the last theme it is given (and warns); a palette is chosen per element, so one declaration cannot mix two.
620
+ - **`style=` is rewritten where the plugin reads it.** JSX attributes, Vue's vnode props and server template, Svelte's setters and server template, Astro's template and plain template literals (`` `<p style="${card}">` ``). A style set another way (a spread, `v-bind="attrs"`, `element.style.cssText`) is the framework's, and kit's own templates (`@menapo/markup`, `@menapo/og`) take a style object of literal CSS, so they are left alone. In a plain HTML string, quote the attribute: an inline style holds spaces.
621
+ - **A class list that holds a style is `sx()`'s job.** Vue's `:class="[card, extra]"`, Svelte's `class={[card, extra]}` and Astro's `class:list` read the objects inside as maps of classes; one style goes in as it is (`:class="card"`), several through `sx()`.
622
+ - **An element's own class beside a split `style=` is what `sx()` takes:** a string, a style or an array of them. Vue's and Svelte's object form (`{ active: isOpen }`) there reads as a style; write `sx()` or a string.
623
+ - **A library compiled by vicss 1.0** ships plain objects: `sx()` takes them as before, and `class={…}` needs the library built with 1.1.
624
+ - **Folding happens only in modules the plugin compiles.** An `sx()` call anywhere else runs at runtime, and `css()` and `recipe()` work only in compiled modules.
625
+ - **A library's recipe calls are not folded in the app.** The library ships its compiled table, which the resolver runs at runtime.
626
+ - **Templates are invisible to the CLI.** `vicss check` and the lint rules read the scripts of `.vue`, `.svelte` and `.astro` files; a style written in a template is checked by the build alone.
627
+ - **Vue dev keeps the template's `sx()` and recipe calls as runtime calls.** Vue's dev render function reads the setup through `$setup`; the build inlines the template and folds them.
628
+ - **SSR pages keep one render-blocking stylesheet.** [Critical CSS](#critical-css-for-prerendered-pages) inlines what a page uses only for pages the build prerenders.
629
+ - **`experimental.bundledDev` is not supported.** The plugin warns under it: CSS updates may arrive late or not at all.
630
+ - **Oxlint's JS plugins are alpha.** The [lint rules](#lint-rules) run under Oxlint through them, and under ESLint.
631
+
632
+ ## Browser support
633
+
634
+ Chrome and Edge 111, Safari 16.4, Firefox 128 and later: the same floor as Tailwind CSS v4. vicss relies on cascade layers, custom properties, `@property` and `:where()`, and prints flat rules (no CSS nesting). It never emits `light-dark()`: modes are custom properties switched by selectors and media queries. Lightning CSS, through Vite, lowers what your `build.cssTarget` needs.
635
+
636
+ ## How it compares
637
+
638
+ | | Tailwind v4 + tailwind-variants | StyleX | Panda CSS 2 | vanilla-extract | vicss |
639
+ | ----------------------------- | ----------------------------------------------------------- | ------------------------------------------ | ------------------------------------------ | ---------------------------------- | --------------------------------------------------------------------------------- |
640
+ | You write | Class strings | Typed objects in `stylex.create` | Typed objects, or JSX style props | Objects in `.css.ts` files | Typed objects in any module |
641
+ | Design tokens and modes | Yes, `@theme` in CSS | No scales or semantic tokens; `defineVars` | Yes | Theme contracts | Yes, TS or DTCG, checked in the editor and the build; palettes and private tokens |
642
+ | An invalid value | Silently has no CSS | Rejected shorthands are dropped silently | Class without CSS in some cases | A CSS type error; tokens unchecked | Fails the build with a code frame; lint rules report it in the editor |
643
+ | Overrides | tailwind-merge guesses from class names (8.5 KB) | `props()`, last wins per property | `css()` merges objects; `cx` only joins | CSS order | `sx()`, last wins per property and condition |
644
+ | Variants | `tv()`: slots, compounds; responsive variants removed in v1 | None built in | Recipes; responsive in config recipes only | Recipes, no slots | Recipes with slots, compounds, `extends` and responsive variants |
645
+ | Runtime (gzip) | `tv` 12.6 KB with merge, 3.6 KB lite | 0.8 KB (`props`) | ~0.4–4 KB | ~0 | 0.44–0.48 KB, 0.78–0.82 KB with recipes |
646
+ | Merge cost, uncached / cached | 9–14 µs / 0.5 µs | 0.6–1.2 µs / 0.3 µs | — | — | 5–13 µs / 0.04–0.06 µs |
647
+ | Bundlers | Any (Vite, PostCSS, CLI) | Babel-based plugins | PostCSS, CLI, Vite | Most bundlers | Vite 8 only |
648
+ | Maturity | Huge ecosystem | Used at Meta scale | Mature, v2 just shipped | Mature | New |
649
+
650
+ Merge timings and runtime sizes are from [the benchmarks](./docs/benchmarks.md) on one machine. On the same 23-component UI, vicss and Tailwind ship the same CSS bytes within 1 % (with the same dark-mode strategy), and vicss ships 10–12 % less with its reset. Those merge figures predate the style objects of PLAN D31, which read merge keys from object keys instead of class names: on one machine, uncached merges take 0.4–0.7× the time they took (canonical class order still costs a sort), and cached ones about the same. StyleX merges uncached styles several times faster than vicss; once a combination has been merged, vicss is the fastest of the three, including when a function style brings a new value (about 0.12 µs).
651
+
652
+ ## Guides
653
+
654
+ - [Coming from Tailwind](./docs/guides/from-tailwind.md): utilities, variants, `@theme`, tailwind-merge, tailwind-variants and lint rules, mapped to vicss.
655
+ - [Design tokens](./docs/guides/design-tokens.md): TS tokens, semantic tokens and modes, palettes, public and private tokens, DTCG files and resolvers, themes.
656
+ - [Building a component library](./docs/guides/libraries.md): presets, `importSources` and the manifest contract, palettes and private tokens across it.
657
+ - [Performance](./docs/guides/performance.md): what vicss does for size and speed, critical CSS and flattened recipe bases, with numbers.
658
+ - [Benchmarks](./docs/benchmarks.md): method, machine, versions and results.
659
+ - [Design and plan](./docs/PLAN.md): every decision and why.
660
+
661
+ ## License
662
+
663
+ MIT