rainbowindex 0.5.0 → 0.5.1

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/CHANGELOG.md CHANGED
@@ -5,6 +5,203 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.1] - 2026-08-28
9
+
10
+ ### Fixed
11
+
12
+ - **Inline SVG path data is no longer scanned for classes.** Raising the
13
+ line-length guard to 10,000 characters in 0.5.0 let a multi-KB `d="…"`
14
+ attribute reach the whole-file token scan, where path data tokenizes
15
+ cleanly against the class grammar: one 10 KB icon component went from 33
16
+ candidates to 845, the extra 812 being fragments like `9.17-57.2` and
17
+ `40c-.35-1.1-1.04`. They matched no utility and carried the `plain` origin
18
+ editors skip, so nothing rendered wrong and nothing was reported — each
19
+ one just cost a compile lookup and a cache entry on every build. `d` and
20
+ `points` values are now blanked before extraction, in every file type.
21
+ Only quoted values are matched, so `d={expr}` bindings still yield their
22
+ classes.
23
+ - **A JavaScript term is no longer reported as a class.** Candidate origins
24
+ were assigned purely by span containment, so any token the whole-file scan
25
+ matched inside a helper call or class attribute inherited that context's
26
+ origin. The token scan's grammar also matches bare identifiers, so
27
+ `ri(mode === "default" ? "fill-white" : "fill-black")` reported `mode` with
28
+ `origin: "helper"` — and editors, which read a certain origin as "this is a
29
+ class", flagged it as an unknown class. A candidate now inherits a context's
30
+ origin only when a context-aware collector tokenized it; containment still
31
+ decides which context wins. Extracted values are unchanged, so no generated
32
+ CSS moves.
33
+ - **Bare unquoted class attributes keep their `attribute` origin.**
34
+ `collectAssignedValues` now tokenizes an undelimited value (`class=flex` in
35
+ HTML, Vue and Svelte) the same way it already tokenized a quoted one. Without
36
+ this they would have lost provenance and demoted to `plain`.
37
+ - **`@apply` no longer emits a rule with no selector.** A rule carrying both
38
+ the `group` marker and a `group-*` variant resolved its group root to
39
+ itself, so stripping the root prefix off the resolved selector left nothing:
40
+ `.self { @apply group group-hover:underline; }` emitted a bare
41
+ ` { text-decoration-line: underline; }`, which a browser discards as a parse
42
+ error, taking the declarations with it. The group root is the element, so
43
+ the variant now targets the element — `.self:hover`. The same shape reached
44
+ by climbing rather than matching in place, a nested `&` block inside the
45
+ group root, is fixed with it.
46
+ - **RI-1002 no longer fires for a bracket token the scanner merely read.** An
47
+ unresolved arbitrary value is a typo worth reporting when the author wrote
48
+ the class, but the scanner reads whole files, comments and prose included,
49
+ where `min-[437px]` is just text — and every such token warned. Classes
50
+ written by hand still warn: `@source inline(...)`, `@apply`, and a
51
+ caller-supplied `classNames` list. Provenance is not baked into the compile
52
+ memo, so a class that is both scanned and authored warns once rather than
53
+ never.
54
+
55
+ ### Added
56
+
57
+ - **RI-1412 — whitespace in an arbitrary value now warns.** A class name
58
+ cannot contain whitespace: `class`, `@a`/`@apply`, and `safelist()` all
59
+ split on it, so `bg-[url('a b')]` reaches the browser as the two tokens
60
+ `bg-[url('a` and `b')]` and matches nothing. The scanner has always dropped
61
+ these, silently, leaving no CSS and no reason why. The warning names the
62
+ class and points at the `_` escape (`bg-[url('a_b')]` emits `url('a b')`).
63
+ It fires only where a collector treats its input as a class list — an
64
+ attribute value, a helper argument, a `safelist()` argument — so ordinary
65
+ JS and prose stay quiet, `styles["my class"]` in a `className` expression
66
+ included. Extracted values are unchanged.
67
+ - **`"expression"` candidate origin.** A string literal that is an operand of
68
+ `==`/`!=` (so also `===`/`!==`) cannot be a class list: in
69
+ `mode === "default"`, `"default"` is a value being compared. Such literals
70
+ are still extracted — dropping them would change generated CSS — but they
71
+ now report `origin: "expression"` instead of `"helper"`/`"attribute"`.
72
+ Editors should treat it like `"plain"` and never report it as a bad class.
73
+ Assignment is deliberately not matched: `const base = "px-2"` is an ordinary
74
+ class list.
75
+ - **`candidate-origin-provenance` capability.** Feature-detect both behaviours
76
+ above through `editorCapabilities`, never a version compare.
77
+ - **Functional custom utilities — `@utility name-* { … }`.** The body reads
78
+ `var(--value)` and the class suffix replaces it, nested blocks included:
79
+ `@utility glow-* { box-shadow: 0 0 var(--value) gold; }` answers `glow-4`
80
+ and `glow-[3px]`, with `[2px_4px]` decoding to `2px 4px`. An exact static
81
+ name beats a functional match, so `@utility card` and `@utility card-*`
82
+ coexist; the longest root wins between functional entries (`a-b-*` over
83
+ `a-*` for `a-b-4`); and neither a bare root (`glow`) nor a negated class
84
+ (`-glow-4`) matches. A suffix carrying `;`, `{`, or `}` is rejected rather
85
+ than allowed to break out of the declaration. `ri()` treats two suffixes of
86
+ one root as conflicting, so `ri("glow-4 glow-8")` keeps `glow-8`.
87
+ - **Consumer documentation in [`docs/`](docs/README.md).** One page per
88
+ subject — getting started, class syntax, utilities, theming, fonts, source
89
+ scanning, class merging, diagnostics, environment variables — and one per
90
+ integration surface — CLI, PostCSS plugin, Vite plugin, Vite+, Node API,
91
+ editor API. The README keeps the overview and links out.
92
+ - **Vite+ support.** The Vite plugin now adds every stylesheet that activates
93
+ Rainbow Index to `fmt.ignorePatterns`, so `vp check` no longer stops at
94
+ `Syntax error: component value is expected` before it can lint or type
95
+ check. Directive bodies are not valid CSS — a `@font` entry carries a block
96
+ after a declaration, a scale removes a token with `!name;`, `@fluid` takes
97
+ bare keywords, and `@apply` takes variant groups — and Oxfmt parses CSS
98
+ strictly. Vite+ reads its `fmt` block off the resolved Vite config, so the
99
+ plugin contributes the patterns from its `config` hook; plain Vite ignores
100
+ the extra key. Projects without the Vite plugin add the patterns by hand.
101
+ See [docs/vite-plus.md](docs/vite-plus.md).
102
+ - **`rainbowindex/oxlint`.** A new entry point with one Oxlint rule,
103
+ `prefer-ri`, which reports an import of `clsx`, `classnames`, or
104
+ `tailwind-merge`. Each merges classes against a Tailwind utility table, so
105
+ it resolves conflicts against the wrong utility set and never sees the
106
+ theme. The rule is off until a project enables it, and the plugin has no
107
+ dependencies.
108
+
109
+ ### Changed
110
+
111
+ - **`ri()` caches results in two generations instead of an LRU.** The old
112
+ cache moved every hit to the end of a `Map` so insertion order tracked
113
+ recency, which made the steady state — the same class lists on every render
114
+ — pay a delete and a re-insert per call. The new cache keeps a current and a
115
+ previous generation: a hit in the current one is a single `Map.get`, and
116
+ when the current fills it becomes the previous and a fresh one starts. A hit
117
+ on a previous entry promotes it, so hot keys survive the swap while cold
118
+ ones age out with the dropped generation. The cache now holds at most twice
119
+ `RI_CACHE_MAX` (500) entries instead of evicting the oldest quarter at the
120
+ cap, and `evictLRU` is gone with the design that needed it.
121
+ - **The per-class compile memo is bounded.** Keyed on theme identity, it grew
122
+ for the life of the process, so a long dev session could hold every class
123
+ ever compiled against a still-live theme. It now clears wholesale at 50,000
124
+ entries. Steady state is the project's whole scanned vocabulary, which sits
125
+ far below the cap — a lower one would clear mid-compile on every rebuild and
126
+ defeat the memo.
127
+ - **`@apply` resolution is cached per theme, not per invocation.** Every
128
+ rebuild re-resolved every `@apply` class from scratch, although the scan
129
+ analysis and pipeline memos keep the theme object stable across rebuilds, so
130
+ the old results were still valid. The cache is a `WeakMap` keyed on the
131
+ theme and dies with it. The `[RI-1005]` walk of a custom utility body is
132
+ cached alongside: its warnings are now replayed into every class that hits
133
+ the utility rather than emitted once per compile, so they survive a rebuild
134
+ in which the class that first triggered the walk is gone. The plugin still
135
+ dedupes repeats downstream.
136
+ - **The resolved source-file list is cached while a watcher runs.** Each
137
+ rebuild re-globbed the project to find the same files. A cached list is only
138
+ correct while something reacts to file adds and deletes, so caching is
139
+ opt-in: the Vite plugin arms it and clears it from the dev-server watcher
140
+ (`add`, `unlink`, `unlinkDir`). One-shot builds and `postcss-cli --watch`,
141
+ which has no watcher hook, keep the always-fresh glob. A glob already in
142
+ flight when an invalidation arrives is not cached.
143
+ - **A source edit that changes no class no longer re-transforms the CSS.** The
144
+ Vite plugin now keeps a sorted candidate list per source file and compares it
145
+ on every hot update. An edit to logic, comments, or copy leaves that list
146
+ identical, and identical candidates produce byte-identical CSS — so the
147
+ Rainbow Index stylesheets stay out of the update and only the edited module
148
+ reloads. A first sighting or an unreadable file invalidates conservatively.
149
+ - **`preload-fonts --help` names the faces that produce a tag.** Only local
150
+ file and raw-URL faces marked `preload` do. Google serves CSS rather than the
151
+ font binary, so a Google or system slot never emits one — but the help text
152
+ claimed `@font-face` was covered too. Text only; the command is unchanged.
153
+
154
+ ### Removed
155
+
156
+ - **The named radius scale.** `rounded-2xs` through `rounded-xl`, the bare
157
+ `rounded` / `rounded-t` / `rounded-tl` shorthands, the `--rounded-*`
158
+ variables, the `--rounded-roof` anchor, and the token body of `@rounded` are
159
+ all gone. A radius is now a spacing multiple: `rounded-4` is
160
+ `calc(var(--spacing) * 4 * var(--ri-rounded-scale, 1))`. `rounded-none`,
161
+ `rounded-full`, `rounded-scale-*`, arbitrary values, and every logical side
162
+ and corner (`rounded-t-4`, `rounded-ss-2`) are unchanged.
163
+
164
+ Two scales set the same shape, so they drifted apart: the named tokens hung
165
+ off `--rounded-roof` while the numeric forms hung off `--spacing`, and
166
+ `rounded-lg` next to `rounded-4` mixed two rhythms in one component. One
167
+ scale, anchored to spacing, keeps the whole system in step.
168
+
169
+ Migration: replace each named token with the spacing step you want. A
170
+ `@rounded` body that sets radius tokens now warns with `[RI-1122]`; keep
171
+ `--corner-scale` and drop the rest. `--roof` is gone — set the radius you
172
+ want on the element.
173
+
174
+ ### Notes for integrators
175
+
176
+ `ResolvedTheme` lost `rounded` and `roundedRoof`, `Theme` lost `rounded`,
177
+ `listThemeTokens()` no longer returns a `rounded` record, and
178
+ `CompilationResult` lost `usedRounded`. Nothing prunes `--rounded-*` any more,
179
+ because the token layer no longer emits it. `roundedShape` and
180
+ `roundedShapeScale` stay — `@rounded <shape>` is unchanged.
181
+
182
+ `CandidateOrigin` gained a member. Code that switches on it exhaustively must
183
+ handle `"expression"`; code that tests `origin !== "plain"` to mean "this is a
184
+ class" was already wrong and is now wrong in a new way — test for the origins
185
+ you trust instead.
186
+
187
+ `CompilationSnapshot` gained `customFunctionalProps`, the roots of functional
188
+ `@utility` entries and the properties each one claims. `createThemeSnapshot()`
189
+ fills it in; a snapshot built by hand needs the field.
190
+
191
+ `createCompiler().compile()` takes an optional third argument, the set of
192
+ classes the caller wrote by hand. Omit it and every class counts as authored,
193
+ which is what a caller assembling its own list wants. `compileProject()`
194
+ applies the same split on its own: `classNames` is authored, `sources` content
195
+ is scanned.
196
+
197
+ `getCustomUtility(theme, name)` is replaced by
198
+ `matchCustomUtility(utility, value, negative, theme)`, which returns the
199
+ matching entry and the text a functional body substitutes for `var(--value)`.
200
+ The `@apply` walk and the declaration expansion both go through it, so they
201
+ can never disagree about which utility a class hit. The old function resolved
202
+ static entries only. Neither name is exported from the package entry, so only
203
+ code importing the internal module path is affected.
204
+
8
205
  ## [0.5.0] - 2026-08-27
9
206
 
10
207
  ### Added
package/README.md CHANGED
@@ -20,6 +20,8 @@ The product is not a visual design tool, a component library, or a framework abs
20
20
 
21
21
  When tradeoffs arise, we consistently prioritize composability, user control, predictable performance, explicit behavior, and correctness over convenience or familiarity.
22
22
 
23
+ Full consumer documentation lives in [docs/](docs/README.md).
24
+
23
25
  ## Install
24
26
 
25
27
  ```sh
@@ -67,15 +69,21 @@ export default defineConfig({
67
69
  ```css
68
70
  /* src/styles.css */
69
71
  @import "rainbowindex";
72
+
73
+ @color {
74
+ brand: 0.18 330;
75
+ }
70
76
  ```
71
77
 
72
78
  ```tsx
73
79
  // src/App.tsx
74
80
  export default function App() {
75
- return <div className="flex gap-4 px-6 py-3 bg-blue-500 text-white">Hello</div>;
81
+ return <div className="flex gap-4 px-6 py-3 bg-brand-500 text-white">Hello</div>;
76
82
  }
77
83
  ```
78
84
 
85
+ The default palette ships only the neutral `theme` color (plus `black`, `white`, `paper`, `ink`, `transparent`, `current`, `inherit`) — declare your palette with `@color`.
86
+
79
87
  To scaffold a fresh app instead, use the CLI:
80
88
 
81
89
  ```sh
@@ -86,14 +94,15 @@ pnpm dlx rainbowindex init
86
94
 
87
95
  ## CLI
88
96
 
89
- The `rainbowindex` binary exposes five subcommands. The default is `build`.
97
+ The `rainbowindex` binary exposes six subcommands. The default is `build`.
90
98
 
91
99
  ```
92
100
  rainbowindex <glob> [options] Generate CSS from source files
93
101
  rainbowindex init Wire Rainbow Index into the current Vite app
94
102
  rainbowindex create <dir> Scaffold a Vite app with Rainbow Index ready
95
103
  rainbowindex generate-types Generate TypeScript types for ri() autocomplete
96
- rainbowindex preload-fonts Generate <link rel="preload"> tags for resolved fonts
104
+ rainbowindex preload-fonts Print <link rel="preload"> tags for local faces marked preload
105
+ rainbowindex scan <glob> Print the class names the scanner extracts from files
97
106
  ```
98
107
 
99
108
  Common flags:
@@ -103,7 +112,7 @@ Common flags:
103
112
  | `-o`, `--output <file>` | Output CSS file path. Required with `--watch`. |
104
113
  | `--watch` | Re-run on source-file changes (chokidar). |
105
114
  | `--minify` | Minification + browser-fallback passes via LightningCSS. `--optimize` is an accepted alias. |
106
- | `--css <file>` | CSS input with directives. Auto-detected if omitted. |
115
+ | `--css <file>` | CSS input with directives. Auto-detected if omitted. For `init`/`create`: the stylesheet to create or patch (default `src/index.css`). |
107
116
  | `--strict` | Drop the string escape hatch in generated types. |
108
117
  | `--template <name>` | Vite template to scaffold (default: `react-ts`). |
109
118
 
@@ -130,7 +139,7 @@ Options:
130
139
 
131
140
  | Option | Type | Description |
132
141
  | --- | --- | --- |
133
- | `sources` | `string[]` | Glob patterns for files to scan. Can also be declared via `@source` in CSS. |
142
+ | `sources` | `string[]` | Glob patterns for files to scan. Can also be declared via `@source` in CSS. Any positive glob (here or via `@source`) replaces the default scan patterns. |
134
143
  | `cwd` | `string` | Working directory. Defaults to `process.cwd()`. |
135
144
 
136
145
  ## Vite plugin
@@ -139,7 +148,25 @@ Options:
139
148
  import rainbowindex from "rainbowindex/vite";
140
149
  ```
141
150
 
142
- Auto-detects your CSS entry, injects a PostCSS config if none exists, supports HMR with file versioning. No options required for the common case.
151
+ Auto-detects your CSS entry, injects a PostCSS config if none exists, supports HMR with file versioning. The plugin takes no options to pass PostCSS options, create a `postcss.config.js` and register the PostCSS plugin there yourself.
152
+
153
+ ## Vite+
154
+
155
+ [Vite+](https://viteplus.dev) works out of the box. Directive syntax is not valid CSS, so Oxfmt — the formatter behind `vp fmt` and `vp check` — cannot parse a stylesheet that holds directives, and the whole check fails before it lints or type checks. The Vite plugin prevents that: it adds every activated stylesheet to `fmt.ignorePatterns`, and leaves the rest of the project formatted. Without the Vite plugin, add the pattern yourself.
156
+
157
+ One more integration is opt-in:
158
+
159
+ ```ts
160
+ // vite.config.ts — lint rule: merge classes with ri(), not clsx/tailwind-merge
161
+ export default defineConfig({
162
+ lint: {
163
+ jsPlugins: [{ name: "rainbowindex", specifier: "rainbowindex/oxlint" }],
164
+ rules: { "rainbowindex/prefer-ri": "error" },
165
+ },
166
+ });
167
+ ```
168
+
169
+ See [docs/vite-plus.md](docs/vite-plus.md).
143
170
 
144
171
  ## Class syntax
145
172
 
@@ -152,7 +179,7 @@ Roughly the same surface area as Tailwind: spacing, sizing, typography, color, l
152
179
  Prefix any utility with one or more variants, separated by `:`.
153
180
 
154
181
  ```html
155
- <button class="bg-blue-500 hover:bg-blue-600 dark:bg-blue-400 sm:px-6">…</button>
182
+ <button class="bg-brand-500 hover:bg-brand-600 dark:bg-brand-400 sm:px-6">…</button>
156
183
  ```
157
184
 
158
185
  Supported variants:
@@ -165,6 +192,8 @@ Supported variants:
165
192
  - **Attribute selectors** — `data-[state=open]`, `aria-[pressed=true]`
166
193
  - **Arbitrary** — `[selector]`, `[@media(...)]`
167
194
 
195
+ This list is a subset — the full variant tables are in [docs/class-syntax.md](docs/class-syntax.md).
196
+
168
197
  ### Arbitrary values
169
198
 
170
199
  ```html
@@ -183,24 +212,21 @@ When multiple utilities share the same variant prefix, group them with `{…}` i
183
212
 
184
213
  ```html
185
214
  <!-- These two lines are equivalent -->
186
- <div class="hover:text-red-500 hover:bg-blue-100 hover:underline">…</div>
187
- <div class="hover:{text-red-500 bg-blue-100 underline}">…</div>
215
+ <div class="hover:text-brand-500 hover:bg-brand-100 hover:underline">…</div>
216
+ <div class="hover:{text-brand-500 bg-brand-100 underline}">…</div>
188
217
  ```
189
218
 
190
- Groups stack and nest:
219
+ Prefixes chain:
191
220
 
192
221
  ```html
193
222
  <!-- Chained variants -->
194
- <div class="sm:hover:{bg-gray-700 text-white}">…</div>
223
+ <div class="sm:hover:{bg-theme-700 text-white}">…</div>
195
224
 
196
225
  <!-- Multiple groups in one class string -->
197
- <div class="focus:{outline-2 outline-blue-500} disabled:{opacity-50 cursor-not-allowed}">…</div>
198
-
199
- <!-- The data-attribute case -->
200
- <div class="data-[active]:{relative px-2} data-[slot=sidebar]:{fixed leading-none}">…</div>
226
+ <div class="focus:{outline-2 outline-brand-500} disabled:{opacity-50 cursor-not-allowed}">…</div>
201
227
  ```
202
228
 
203
- Expansion happens at scan-time, so the runtime never sees the grouped form. Nesting is capped at depth 10; expanded output is capped at 1MB.
229
+ Expansion happens at scan-time, so the runtime never sees the grouped form. Only plain prefixes work bracketed variants like `data-[state=open]:` cannot prefix a group, and braces do not nest (chain the prefixes instead). Expansion input is capped at 500,000 characters, output at 100,000.
204
230
 
205
231
  ## Theming with CSS directives
206
232
 
@@ -211,8 +237,8 @@ Customization happens in your CSS input, not a JS config. The engine recognizes:
211
237
  | `@color` | Define color tokens. Supports generative (`chroma hue`), explicit (`oklch(...)`, `#rrggbb`), light/dark pairs, and aliases. |
212
238
  | `@spacing` | Set the spacing base unit. |
213
239
  | `@text` | Define text size tokens (`size, line-height`). |
214
- | `@font`, `@font-face` | Register font families inside a single `@font { … }` block. Local files are repeatable `face:` entries (e.g. upright + italic). Known families get an automatic zero-CLS metrics fallback (`metrics: none` opts out). |
215
- | `@rounded` | Border-radius tokens; modifier sets corner shape (`round`, `squircle`, `superellipse(N)`, etc). |
240
+ | `@font` | Register font families inside a single `@font { … }` block. Local files are repeatable `face:` entries (e.g. upright + italic). Known families get an automatic zero-CLS metrics fallback (`metrics: none` opts out). Plain `@font-face` rules are standard CSS and pass through untouched. |
241
+ | `@rounded` | Corner shape (`round`, `squircle`, `superellipse(N)`, etc). Radii are spacing multiples, so there are no radius tokens. |
216
242
  | `@fluid` | Configure fluid type/spacing range. |
217
243
  | `@animate` | Register named animations with inline `@keyframes`. |
218
244
  | `@utility` | Define a custom utility (static or functional `name-*`). |
@@ -221,7 +247,10 @@ Customization happens in your CSS input, not a JS config. The engine recognizes:
221
247
  | `@slot` | Slot marker inside `@custom` block form. |
222
248
  | `@source` | Declare additional source globs from CSS. Supports `not "..."` and `inline("...")`. |
223
249
  | `@preflight` | Toggle preflight base styles. |
224
- | `@layer`, `@media`, `@custom-media`, `@import` | Standard CSS plus a few extensions. |
250
+ | `@breakpoint`, `@shadow`, `@weight`, `@ease`, `@blur`, `@z`, `@leading`, `@tracking`, `@opacity`, `@duration` | Key-value token scales; `!key;` removes a token. |
251
+ | `@register` | Emit CSS `@property` registrations. |
252
+ | `@layer` | Place the generated output in cascade layers (intercepted, own grammar). |
253
+ | `@media`, `@import`, other standard at-rules | Standard CSS — passed through untouched. |
225
254
 
226
255
  Example:
227
256
 
@@ -250,11 +279,11 @@ Example:
250
279
  }
251
280
  }
252
281
 
253
- @source "../emails/**/*.html";
254
- @source not "../**/legacy/*";
282
+ @source "emails/**/*.html";
283
+ @source not "src/**/legacy/*";
255
284
  ```
256
285
 
257
- Default theme keys: `colors`, `spacing`, `text`, `breakpoints`, `rounded`, `shadows`, `weights`, `easing`, `fluid`, `animations`, `blur`, `z`.
286
+ Default theme scales: `colors`, `spacing`, `text`, `breakpoints`, `shadows`, `weights`, `easing`, `fluid`, `animations`, `blur`, `leading`, `tracking`. The `z`, `opacity`, and `duration` scales ship empty — numeric class forms are computed, and their directives add named tokens.
258
287
 
259
288
  ## `ri()` — runtime class merger
260
289
 
@@ -263,8 +292,8 @@ Default theme keys: `colors`, `spacing`, `text`, `breakpoints`, `rounded`, `shad
263
292
  ```ts
264
293
  import { ri } from "rainbowindex";
265
294
 
266
- ri("px-2 py-1", isActive && "bg-blue-500", "px-4");
267
- // → "py-1 bg-blue-500 px-4" (px-2 is dropped — px-4 wins)
295
+ ri("px-2 py-1", isActive && "bg-brand-500", "px-4");
296
+ // → "py-1 bg-brand-500 px-4" (px-2 is dropped — px-4 wins)
268
297
  ```
269
298
 
270
299
  Accepted inputs:
@@ -297,17 +326,17 @@ themes in the same Node process, that shared state will leak — use
297
326
  // Anywhere ri() is single-compile-safe (browser, Vite, PostCSS):
298
327
  import { ri } from "rainbowindex";
299
328
 
300
- const className = ri("px-2 py-1", isActive && "bg-blue-500", "px-4");
329
+ const className = ri("px-2 py-1", isActive && "bg-brand-500", "px-4");
301
330
  ```
302
331
 
303
332
  ```ts
304
- // SSR / multi-tenant — capture the snapshot at compile time, bind per-request:
305
- import { compileProject, createRi, finalizeCompilationContext } from "rainbowindex";
333
+ // SSR / multi-tenant — bind a merger to one compile, isolated per theme:
334
+ import { compileProject, createRi } from "rainbowindex";
335
+ import { createThemeSnapshot } from "rainbowindex/editor";
306
336
 
307
337
  // At server startup (once per theme):
308
- const ctx = await compileProject({ cwd, css });
309
- const snapshot = finalizeCompilationContext(ctx);
310
- const ri = createRi(snapshot);
338
+ const result = await compileProject({ css });
339
+ const ri = createRi(createThemeSnapshot(result.theme));
311
340
 
312
341
  // In your request handler:
313
342
  function render(req, res) {
@@ -316,9 +345,9 @@ function render(req, res) {
316
345
  }
317
346
  ```
318
347
 
319
- If you call `ri()` while a compilation is still in progress (e.g. inside a
320
- server-rendering pass that triggers a fresh compile), the runtime emits a
321
- throttled `[RI-2004]` warning. That's your signal to switch to `createRi()`.
348
+ In any Node/SSR process, the default `ri()` emits a throttled `[RI-2004]`
349
+ warning (at most once per 60 s) to flag its shared module state. Switching to
350
+ `createRi(snapshot)` silences it.
322
351
 
323
352
  ## Editor tooling API
324
353
 
@@ -370,16 +399,16 @@ Warnings carry `RI-NNNN` codes. Ranges:
370
399
 
371
400
  | Range | Subsystem |
372
401
  | --- | --- |
402
+ | 00xx | PostCSS plugin bootstrap (thrown) |
373
403
  | 10xx | Compilation & directives |
374
- | 11xx | Color directives |
404
+ | 11xx | Color directives & resolver catch-alls |
375
405
  | 12xx | Font system |
376
406
  | 13xx | Merge / compilation context |
377
407
  | 14xx | Source scanner |
378
408
  | 15xx | Typography utilities |
379
409
  | 16xx | Integration plugins (Vite, PostCSS, CLI wiring) |
380
- | 20xx | CSS function processing |
381
- | 21xx | `ri()` runtime |
410
+ | 20xx | CSS functions, `ri()` runtime & `compile()` validation |
382
411
 
383
- See the [diagnostics reference](https://rainbowindex.dev/docs/diagnostics) for the full code → cause → fix table.
412
+ See [docs/diagnostics.md](docs/diagnostics.md) for the full code → cause → fix table.
384
413
 
385
414
  Warnings are deduplicated and capped at 200 per compile, with 20 slots reserved for high-severity errors.
package/dist/browser.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- export { C as ColorDefinition, a as CompilationContext, b as CompilationSnapshot, F as FluidConfig, T as TextSize, c as Theme, d as createCompilationContext, e as defaultTheme, f as finalizeCompilationContext, r as registerColorNames, g as registerCustomFontFamilies, h as registerCustomTextSizes, i as registerCustomUtility } from './context-ruu2x_jR.js';
2
- export { D as DEFAULT_TEXT_SIZES, c as createRi, r as ri, s as safelist } from './safelist-D9-Plqta.js';
1
+ export { C as ColorDefinition, a as CompilationContext, b as CompilationSnapshot, D as DEFAULT_TEXT_SIZES, F as FluidConfig, T as TextSize, c as Theme, d as createCompilationContext, e as defaultTheme, f as finalizeCompilationContext, r as registerColorNames, g as registerCustomFontFamilies, h as registerCustomTextSizes, i as registerCustomUtility } from './context-B9yhJxd5.js';
2
+ export { c as createRi, r as ri, s as safelist } from './safelist-DAkKuxCk.js';
3
3
 
4
4
  declare function browserEntryUnavailable(): never;
5
5
 
package/dist/browser.mjs CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  registerCustomTextSizes,
14
14
  registerCustomUtility,
15
15
  ri
16
- } from "./chunk-KRZL4IDK.mjs";
16
+ } from "./chunk-4UKFK2GE.mjs";
17
17
 
18
18
  // src/entries/browser.ts
19
19
  function browserEntryUnavailable() {
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  compileScannedProject
3
- } from "./chunk-DT5HYIM3.mjs";
3
+ } from "./chunk-F4VCBISU.mjs";
4
4
  import {
5
5
  APPLY_ALIASES,
6
6
  DIRECTIVE_NAMES_SET,
@@ -11,16 +11,16 @@ import {
11
11
  computeSortKey,
12
12
  expandVariantGroups,
13
13
  forEachApplyClass,
14
- getCustomUtility,
15
14
  hasApplyLikeDirective,
16
15
  hasCSSFunctions,
17
16
  hasRIActivation,
17
+ matchCustomUtility,
18
18
  parseUtility,
19
19
  pushWarningsDeduped,
20
20
  resolveUtilityDeclarations,
21
21
  resolveVariant,
22
22
  splitSelectorList
23
- } from "./chunk-ZI5ZYNSU.mjs";
23
+ } from "./chunk-PDORZSQX.mjs";
24
24
 
25
25
  // src/integrations/postcss/index.ts
26
26
  import postcss2 from "postcss";
@@ -28,6 +28,7 @@ import { isAbsolute } from "path";
28
28
 
29
29
  // src/integrations/postcss/apply.ts
30
30
  import postcss from "postcss";
31
+ var applyStateByTheme = /* @__PURE__ */ new WeakMap();
31
32
  function makeDeclGroup(r) {
32
33
  const sortKey = computeSortKey(r.declarations[0]?.property ?? "");
33
34
  return {
@@ -100,9 +101,16 @@ function processApply(root, theme, warnings) {
100
101
  atRule.name = "apply";
101
102
  });
102
103
  }
103
- const customVariantMap = new Map(theme.customVariants.map((cv) => [cv.name, cv]));
104
- const checkedApplyRoots = /* @__PURE__ */ new Set();
105
- const resolveCache = /* @__PURE__ */ new Map();
104
+ let state = applyStateByTheme.get(theme);
105
+ if (!state) {
106
+ state = {
107
+ customVariantMap: new Map(theme.customVariants.map((cv) => [cv.name, cv])),
108
+ applyRootWarnings: /* @__PURE__ */ new Map(),
109
+ resolveCache: /* @__PURE__ */ new Map()
110
+ };
111
+ applyStateByTheme.set(theme, state);
112
+ }
113
+ const { customVariantMap, applyRootWarnings, resolveCache } = state;
106
114
  for (let depth = 0; depth < MAX_APPLY_DEPTH; depth++) {
107
115
  const applyNodes = [];
108
116
  const classListByNode = /* @__PURE__ */ new Map();
@@ -128,7 +136,7 @@ function processApply(root, theme, warnings) {
128
136
  warnings,
129
137
  customVariantMap,
130
138
  groupRoots,
131
- checkedApplyRoots,
139
+ applyRootWarnings,
132
140
  resolveCache
133
141
  );
134
142
  }
@@ -146,7 +154,7 @@ function processApply(root, theme, warnings) {
146
154
  }
147
155
  }
148
156
  }
149
- function expandApply(atRule, classNames, theme, warnings, customVariantMap, groupRoots, checkedApplyRoots, resolveCache) {
157
+ function expandApply(atRule, classNames, theme, warnings, customVariantMap, groupRoots, applyRootWarnings, resolveCache) {
150
158
  const parentRule = atRule.parent;
151
159
  if (parentRule?.type !== "rule") {
152
160
  warnings.push("[RI-1006] @apply must be used inside a CSS rule, not at the top level.");
@@ -177,7 +185,7 @@ function expandApply(atRule, classNames, theme, warnings, customVariantMap, grou
177
185
  theme,
178
186
  entryWarnings,
179
187
  customVariantMap,
180
- checkedApplyRoots
188
+ applyRootWarnings
181
189
  ),
182
190
  warnings: entryWarnings
183
191
  };
@@ -252,19 +260,24 @@ function expandApply(atRule, classNames, theme, warnings, customVariantMap, grou
252
260
  parentContainer.insertAfter(insertAfter, rule);
253
261
  insertAfter = rule;
254
262
  }
255
- const groupInfoByKey = /* @__PURE__ */ new Map();
256
- let hasGroupVariants = false;
257
- for (const [key, bucket] of variantBuckets) {
258
- const info = resolveGroupVariantInfo(bucket.wrappers, parentRule, groupRoots);
259
- groupInfoByKey.set(key, info);
260
- if (info) hasGroupVariants = true;
261
- }
262
263
  let fullSelectorMemo = null;
263
264
  const fullNestingSelector = () => {
264
265
  if (fullSelectorMemo === null)
265
266
  fullSelectorMemo = resolveFullNestingSelector(parentRule);
266
267
  return fullSelectorMemo;
267
268
  };
269
+ const groupInfoByKey = /* @__PURE__ */ new Map();
270
+ let hasGroupVariants = false;
271
+ for (const [key, bucket] of variantBuckets) {
272
+ const info = resolveGroupVariantInfo(
273
+ bucket.wrappers,
274
+ parentRule,
275
+ groupRoots,
276
+ fullNestingSelector
277
+ );
278
+ groupInfoByKey.set(key, info);
279
+ if (info) hasGroupVariants = true;
280
+ }
268
281
  let docRootMemo = null;
269
282
  const docRoot = () => {
270
283
  if (docRootMemo === null) docRootMemo = parentContainer.root();
@@ -276,7 +289,7 @@ function expandApply(atRule, classNames, theme, warnings, customVariantMap, grou
276
289
  if (groupVariantInfo) {
277
290
  const fullSelector = fullNestingSelector();
278
291
  const { groupRootSelector } = groupVariantInfo;
279
- const rootBranches = splitSelectorList(groupRootSelector);
292
+ const rootBranches = groupVariantInfo.isSelf ? [] : splitSelectorList(groupRootSelector);
280
293
  const fullBranches = splitSelectorList(fullSelector);
281
294
  const baseBranches = [];
282
295
  for (const fb of fullBranches) {
@@ -332,7 +345,7 @@ function expandApply(atRule, classNames, theme, warnings, customVariantMap, grou
332
345
  }
333
346
  atRule.remove();
334
347
  }
335
- function resolveGroupVariantInfo(wrappers, parentRule, groupRoots) {
348
+ function resolveGroupVariantInfo(wrappers, parentRule, groupRoots, fullNestingSelector) {
336
349
  if (groupRoots.size === 0) return null;
337
350
  const hasGroupVariant = wrappers.some(
338
351
  (w) => w.selectorSuffix && w.replaceAmpersand && GROUP_VARIANT_RE.test(w.selectorSuffix)
@@ -340,23 +353,23 @@ function resolveGroupVariantInfo(wrappers, parentRule, groupRoots) {
340
353
  if (!hasGroupVariant) return null;
341
354
  const groupInfo = findGroupAncestor(parentRule, groupRoots);
342
355
  if (!groupInfo) return null;
356
+ const isSelf = fullNestingSelector() === groupInfo.groupRootSelector;
343
357
  const rewrittenWrappers = wrappers.map((w) => {
344
358
  if (!w.selectorSuffix || !w.replaceAmpersand) return w;
345
359
  const match = w.selectorSuffix.match(GROUP_VARIANT_RE);
346
360
  if (!match) return w;
347
361
  const pseudo = match[1];
362
+ if (isSelf) return { ...w, selectorSuffix: `&${pseudo}` };
348
363
  const rootBranches = splitSelectorList(groupInfo.groupRootSelector);
349
364
  const newSuffix = rootBranches.map((b) => `${b}${pseudo} &`).join(", ");
350
365
  return { ...w, selectorSuffix: newSuffix };
351
366
  });
352
- return { rewrittenWrappers, groupRootSelector: groupInfo.groupRootSelector };
367
+ return { rewrittenWrappers, groupRootSelector: groupInfo.groupRootSelector, isSelf };
353
368
  }
354
369
  var MARKER_CLASSES = /* @__PURE__ */ new Set(["group"]);
355
370
  var MAX_CUSTOM_APPLY_DEPTH = 5;
356
- function findCustomUtility(utility, value, theme) {
357
- const target = value === null ? utility : `${utility}-${value}`;
358
- const cu = getCustomUtility(theme, target);
359
- return cu && !cu.functional ? cu : void 0;
371
+ function findCustomUtility(utility, value, negative, theme) {
372
+ return matchCustomUtility(utility, value, negative, theme)?.cu;
360
373
  }
361
374
  function checkCustomApplyWarnings(cu, theme, warnings, visiting = /* @__PURE__ */ new Set()) {
362
375
  if (!hasApplyLikeDirective(cu.body)) return;
@@ -378,12 +391,12 @@ function checkCustomApplyWarnings(cu, theme, warnings, visiting = /* @__PURE__ *
378
391
  visiting.add(cu.name);
379
392
  for (const innerClass of innerClasses) {
380
393
  const parsed = parseUtility(innerClass);
381
- const innerCu = findCustomUtility(parsed.utility, parsed.value, theme);
394
+ const innerCu = findCustomUtility(parsed.utility, parsed.value, parsed.negative, theme);
382
395
  if (innerCu) checkCustomApplyWarnings(innerCu, theme, warnings, visiting);
383
396
  }
384
397
  visiting.delete(cu.name);
385
398
  }
386
- function resolveClassName(className, theme, warnings, customVariantMap, checkedApplyRoots) {
399
+ function resolveClassName(className, theme, warnings, customVariantMap, applyRootWarnings) {
387
400
  const parsed = parseUtility(className);
388
401
  if (parsed.variants.length === 0 && MARKER_CLASSES.has(parsed.utility) && parsed.value === null) {
389
402
  return null;
@@ -394,10 +407,15 @@ function resolveClassName(className, theme, warnings, customVariantMap, checkedA
394
407
  return null;
395
408
  }
396
409
  const declarations = [...utilResult.declarations];
397
- const cu = findCustomUtility(parsed.utility, parsed.value, theme);
398
- if (cu && !checkedApplyRoots.has(cu.name)) {
399
- checkedApplyRoots.add(cu.name);
400
- checkCustomApplyWarnings(cu, theme, warnings);
410
+ const cu = findCustomUtility(parsed.utility, parsed.value, parsed.negative, theme);
411
+ if (cu) {
412
+ let cuWarnings = applyRootWarnings.get(cu.name);
413
+ if (!cuWarnings) {
414
+ cuWarnings = [];
415
+ checkCustomApplyWarnings(cu, theme, cuWarnings);
416
+ applyRootWarnings.set(cu.name, cuWarnings);
417
+ }
418
+ warnings.push(...cuWarnings);
401
419
  }
402
420
  const variantWrappers = [];
403
421
  for (const variant of parsed.variants) {