@navecss/core 0.2.0 → 0.4.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,6 +1,6 @@
1
1
  # @navecss/core
2
2
 
3
- > Layer architecture, reset, atomic utilities, and PostCSS plugin for Nave.
3
+ > Layer architecture, reset, atomic utilities, the Vite and PostCSS plugins, a Lightning CSS adapter and the `navecss-core` command for Nave.
4
4
 
5
5
  ## Installation
6
6
 
@@ -15,23 +15,40 @@ pnpm add @navecss/core
15
15
  most of them are also derived with relative colour syntax. An older browser
16
16
  drops whichever of them it does not support, without an error: surfaces and
17
17
  text lose their colours. Your CSS build has to target the same floor, and
18
- [PostCSS plugin setup](#postcss-plugin-setup) has the line for Vite.
18
+ [Vite plugin setup](#vite-plugin-setup) has the line for Vite.
19
19
  [Why this floor](https://github.com/navecss/navecss/blob/main/docs/04-adr/0005-browser-floor.md).
20
20
  - **A resolver that reads `exports` maps.** Every entry point, the stylesheet
21
21
  included, is reachable only through the package's `exports` map: there is no
22
22
  `main` field to fall back on.
23
- - **ES modules.** `@navecss/core/cx`, `@navecss/core/atoms` and
24
- `@navecss/core/postcss` load through `import` only. A `require()` of any of
23
+ - **ES modules.** `@navecss/core/cx`, `@navecss/core/atoms`,
24
+ `@navecss/core/vite`, `@navecss/core/lightningcss`, `@navecss/core/check` and `@navecss/core/collect` load through `import` only. A `require()` of any of
25
25
  them fails with `ERR_PACKAGE_PATH_NOT_EXPORTED`, which reads as though the
26
26
  entry point did not exist. In TypeScript, set `moduleResolution` to `bundler`,
27
27
  `node16` or `nodenext`; under `node10` their types are not found. A CommonJS
28
28
  file type-checks against them and then fails when it runs, so import them from
29
- an ES module.
29
+ an ES module. `@navecss/core/postcss` also loads through `require()` (Node
30
+ loads the ES module for you, which the Node version below includes), so a
31
+ CommonJS `postcss.config.js` can use it.
32
+ For TypeScript to check a `require()` of it, use `module` `nodenext`
33
+ (TypeScript 5.8 or later) or `node20` (5.9 or later) with `moduleResolution`
34
+ unset or `nodenext`, or `module` `preserve` with `moduleResolution` `bundler`,
35
+ or turn on `skipLibCheck`. Under `module` `node16`, or `nodenext` before 5.8,
36
+ TypeScript describes a Node that cannot `require()` an ES module and reports
37
+ an error in the package's types.
30
38
  - **Node 22.18 or later.**
31
- - **PostCSS 8, for `@nave` only.** `postcss` is an optional peer dependency, so
32
- nothing installs it for you: add it yourself if you use `@nave`. Without it,
33
- importing `@navecss/core/postcss` fails with `Cannot find package 'postcss'`.
34
- The stylesheets and `cx()` need no PostCSS.
39
+ - **A build step that resolves `@nave`: the Vite plugin, PostCSS 8, the Lightning
40
+ CSS adapter or the command.** On
41
+ Vite, `@navecss/core/vite` needs nothing installed beside Vite, and
42
+ [Vite plugin setup](#vite-plugin-setup) is its whole setup. On any other
43
+ pipeline that runs PostCSS plugins, use the PostCSS plugin: `postcss` is an
44
+ optional peer dependency, so nothing installs it for you. Add it yourself if
45
+ you use that route; without it, importing `@navecss/core/postcss` fails with
46
+ `Cannot find package 'postcss'`. [PostCSS plugin setup](#postcss-plugin-setup)
47
+ has it. A host that runs Lightning CSS itself uses
48
+ [the Lightning CSS adapter](#lightning-css-adapter-setup), which needs nothing
49
+ installed beside Lightning CSS, and a project with no bundler uses
50
+ [`navecss-core expand`](#navecss-core-expand). The stylesheets and `cx()` need
51
+ none of them.
35
52
 
36
53
  ## Setup
37
54
 
@@ -74,7 +91,7 @@ Every built-in atom, with the declarations it applies, is listed in
74
91
 
75
92
  ### Primary — @nave directives
76
93
 
77
- Requires the PostCSS plugin. Everything stays in CSS files.
94
+ Requires the Vite plugin or the PostCSS plugin. Everything stays in CSS files.
78
95
 
79
96
  ```css
80
97
  /* button.module.css */
@@ -97,7 +114,7 @@ floor under [Requirements](#requirements).
97
114
 
98
115
  ### Escape hatch — cx() utility
99
116
 
100
- No PostCSS required. Atom names move to JSX — autocompleted, and type-checked.
117
+ No build plugin required. Atom names move to JSX — autocompleted, and type-checked.
101
118
 
102
119
  ```tsx
103
120
  import { cx } from '@navecss/core/cx'
@@ -106,7 +123,10 @@ import styles from './button.module.css'
106
123
  ;<button className={`${cx('interactive', 'focusRing', 'transition')} ${styles.root}`} />
107
124
  ```
108
125
 
109
- Use this if you cannot or do not want to add a PostCSS plugin to your build.
126
+ Use this if you cannot or do not want to add a Vite or PostCSS plugin to your build.
127
+ With the Vite plugin, the build reads every `cx()` call to choose which atoms ship, and fails on one
128
+ whose atoms it cannot read, so a name chosen at run time goes through `cx.dynamic()`: see
129
+ [Which atoms the build ships](#which-atoms-the-build-ships).
110
130
 
111
131
  A `cx()` class is a default, not an override: it sits in the `atomic` layer,
112
132
  below your component CSS, so your own rule wins wherever the two set the same
@@ -136,8 +156,9 @@ What that buys you, and what it does not:
136
156
  _emits_ is `nave-sr-only`; neither spelling is the key), and any class that is
137
157
  not a Nave atom. **TypeScript only:
138
158
  a JavaScript consumer gets none of it.** For a build-level check that does not
139
- depend on types, use the `@nave` directive with `navePlugin()` — it fails the
140
- build on an unknown atom by default.
159
+ depend on types, use the Vite plugin, which by default fails the build on an
160
+ unknown atom in a `cx()` call, or the `@nave` directive, whose build steps all
161
+ fail on an unknown atom by default.
141
162
  - **`cx.raw()` cannot shadow one of your classes.** It never consults the atom
142
163
  map, so `cx.raw('container')` is the literal `container`. Inside `cx()` an
143
164
  atom name still resolves to that atom's global class — `cx('container')` is
@@ -155,7 +176,7 @@ What that buys you, and what it does not:
155
176
 
156
177
  ### Tokens only
157
178
 
158
- For library authors or teams building on top of Base UI or Radix UI.
179
+ For library authors or teams building on top of Radix UI or another headless library. On Base UI, [`@navecss/base-ui`](https://github.com/navecss/navecss/tree/main/packages/base-ui#readme) styles its components with these same tokens.
159
180
  CSS custom properties are always available — no atomic layer required.
160
181
 
161
182
  ```css
@@ -170,27 +191,382 @@ Every custom property Nave declares is in the stylesheet you already installed,
170
191
 
171
192
  ---
172
193
 
173
- ## PostCSS plugin setup
194
+ ## Setting up `@nave`
195
+
196
+ On Vite, use the Vite plugin: it is the one route this package teaches for Vite. On
197
+ Next.js, webpack, or any other pipeline that runs PostCSS plugins, use the PostCSS plugin. A host
198
+ that runs Lightning CSS directly uses [the Lightning CSS adapter](#lightning-css-adapter-setup),
199
+ and a project with no bundler uses [`navecss-core expand`](#navecss-core-expand).
200
+
201
+ ### Vite plugin setup
174
202
 
175
203
  ```ts
176
204
  // vite.config.ts
177
205
  import { defineConfig } from 'vite'
178
- import { navePlugin } from '@navecss/core/postcss'
206
+ import { navePlugin } from '@navecss/core/vite'
179
207
 
180
208
  export default defineConfig({
181
- // your existing options, plugins included, stay as they are
182
- css: { postcss: { plugins: [navePlugin()] } },
209
+ // your existing options stay as they are; add navePlugin() to your existing plugins
210
+ plugins: [navePlugin()],
183
211
  build: { cssTarget: ['chrome125', 'edge125', 'firefox128', 'safari18', 'ios18'] },
184
212
  })
185
213
  ```
186
214
 
215
+ `navePlugin()` takes the same `extend` and `onUnknown` options as the PostCSS plugin
216
+ ([Options](#options)) and returns two Vite plugins in an array, which `plugins` takes as one entry.
217
+ It has no dependency and no peer. It expands `@nave` after Vite's own CSS step has run, so a stylesheet reached only
218
+ through `@import`, a Sass file (including a `@mixin` that holds a directive), a CSS Module, an
219
+ `?inline` or `?url` import, a Vue or Svelte style block and a `.css` inside `node_modules` are all
220
+ read as the CSS they compile to, in `vite build` and in the dev server, under either
221
+ `css.transformer`. A `?raw` import returns the file's text as it is. Astro is not covered: it was
222
+ not measured at this release, and no fixture of it runs.
223
+
224
+ **It also chooses which atoms ship.** A `cx()` call in your code whose atoms the build cannot read,
225
+ such as `cx(variant)` with a prop, is an error in the dev server and fails `vite build`.
226
+ By default only the atoms Nave can read a use for are served, in the dev server as in the build. A `nave-*` class neither can see, such as one in a server template or in CMS text, or one a package listed in `keepFor` applies at run time, has no rule unless its atom is listed in `keep` or in a `keepFor` entry. Run your checks, keyboard, screen-reader and automated accessibility checks included, against a production build as well as in dev, because the two can still differ; [Which atoms the build ships](#which-atoms-the-build-ships) says what the build reads and where dev differs.
227
+
187
228
  `build.cssTarget` is the browser floor, in Vite's terms. Vite's default targets
188
229
  older browsers, and building for them gains you nothing, because the output
189
230
  still needs the floor. It does cost you something: Vite rewrites `light-dark()`
190
231
  in Nave's colours into an emulation that a `color-scheme` set from script, or on
191
- part of the page, does not switch. If your project already has a
192
- `postcss.config.js`, put `navePlugin()` there instead: Vite reads no PostCSS
193
- config file once `css.postcss` is set inline.
232
+ part of the page, does not switch. The plugin sets no floor of its own, because the floor is a
233
+ requirement of Nave's stylesheets, not of the directive: a project that never writes `@nave`
234
+ needs the same key.
235
+
236
+ **Migrating from the PostCSS plugin.** Move `navePlugin()` from `css.postcss` or
237
+ `postcss.config.js` to `plugins`, importing it from `@navecss/core/vite`. Leaving both is
238
+ harmless, because whichever pass runs second finds no directive left. Moving also changes which
239
+ atoms ship: the PostCSS plugin ships every atom, and this one chooses them from the uses it can
240
+ read, in the dev server too, so after the move a `cx()` call it cannot read fails the build, and a
241
+ Nave class it cannot see has no rule unless its atom is listed in `keep`.
242
+
243
+ **Under `css.transformer: 'lightningcss'`** the plugin expands exactly as it does under the default
244
+ transformer once both floor keys below are set. At Vite's default targets, which sit below the
245
+ floor, Lightning CSS lowers CSS nesting before any plugin runs, and two things change.
246
+ A directive written after a declaration that follows a nested rule can be moved ahead of that
247
+ declaration: in `.a { &:hover { color: red; } display: grid; @nave block; }`, `grid` wins with no
248
+ message, where the default transformer makes `block` win. A directive in a group rule nested in a
249
+ style rule (`.a { @media (...) { @nave flex; } }`) still fails the build, but the message no longer
250
+ names the `& { @nave ...; }` workaround, and its position is in the CSS as Lightning CSS rewrote
251
+ it, not in your file. The plugin also drops one message and only that one: Lightning CSS's
252
+ `Unknown at rule: @nave` warning, which Vite prints for every directive before any plugin runs.
253
+ It does so by wrapping the logger Vite resolved. Stated cost: that is a logger Vite owns, which is
254
+ your own object when you pass a `customLogger`. The filter matches case-insensitively, and an
255
+ escaped spelling (`@n\61ve`) may still warn. It fails open: if Vite changes its prefix or its
256
+ logging path the warnings come back and nothing is lost, and the end-of-build scan below catches
257
+ any directive that did survive. A warning about any other unknown at-rule is still printed. Set both
258
+ floor keys:
259
+
260
+ ```ts
261
+ // vite.config.ts, with css.transformer: 'lightningcss'
262
+ import { defineConfig } from 'vite'
263
+ import { navePlugin } from '@navecss/core/vite'
264
+
265
+ export default defineConfig({
266
+ plugins: [navePlugin()],
267
+ build: { cssTarget: ['chrome125', 'edge125', 'firefox128', 'safari18', 'ios18'] },
268
+ css: {
269
+ transformer: 'lightningcss',
270
+ lightningcss: {
271
+ targets: {
272
+ chrome: 125 << 16,
273
+ edge: 125 << 16,
274
+ firefox: 128 << 16,
275
+ safari: 18 << 16,
276
+ ios_saf: 18 << 16,
277
+ },
278
+ },
279
+ },
280
+ })
281
+ ```
282
+
283
+ `build.cssTarget` alone still rewrites `light-dark()` under that transformer, so the two keys go
284
+ together.
285
+
286
+ **The end of every build is checked.** The plugin reads the CSS files the build wrote and fails the
287
+ build if a `@nave` directive is left in one, with the same lines
288
+ [`navecss-core check`](#navecss-core-check) prints, under the plugin name `nave`. There is no
289
+ option to turn it off: a directive in shipped CSS is never wanted, and `onUnknown: 'ignore'`
290
+ does not change it. Four places are not covered. The dev server serves no bundle, so there is no
291
+ scan in dev. CSS that ends inside a JavaScript string (`?inline`) is not a CSS file the build wrote,
292
+ so it is not scanned. Files under Vite's `public/` directory are copied as they are and are not
293
+ scanned. A CSS file another plugin adds after the scan has run is not scanned either: the scan runs
294
+ in a post-ordered `generateBundle`, so it misses a file emitted from a post-ordered
295
+ `generateBundle` in a plugin listed after `navePlugin()`, and anything a plugin writes in
296
+ `writeBundle`. List `navePlugin()` after plugins that emit CSS files; a file written in
297
+ `writeBundle` stays outside the scan wherever the plugin is listed.
298
+
299
+ **A change to your atoms re-runs the stylesheets that use them.** Pass `extend` as a path to a
300
+ module (resolved from Vite's project root, whose default export is the atoms object), and the
301
+ plugin declares the file to Vite: edit it, and the dev server serves the new value with no restart,
302
+ and `vite build --watch` rebuilds. An object written inline in your config is not a file Vite can
303
+ watch.
304
+
305
+ The supported Vite range is measured, not declared: the fixtures run on Vite 8.2.1 and on the
306
+ newest 8.x at the time of each release.
307
+
308
+ #### Which atoms the build ships
309
+
310
+ The Vite plugin also chooses which of Nave's atoms reach your stylesheet. It reads the `cx()` calls
311
+ in the code the build compiles, dependencies included, and ships the rules for the atoms those calls
312
+ name, for the atom of each Nave class written as text (`nave-grid` in `index.html` or in a string in
313
+ your code, say), and for the atoms you list in `keep` and `keepFor`; `srOnlyFocusable` also ships
314
+ whenever `srOnly` does. The dev server serves the same atoms, from the code it has read. This is
315
+ what `navePlugin()` does with no options. On the CSS-first path, where `@nave` copies an atom's
316
+ declarations into your own rule, a project that never calls `cx()` and writes no Nave class ships no
317
+ rule from the atom layer.
318
+
319
+ In a build, the plugin also turns each `cx()` call it read into the class string the call returns, so
320
+ the map from atom names to classes does not ship: `cx('flex', 'grid')` becomes `"nave-flex nave-grid"`,
321
+ and `cx('flex', on && 'grid')` becomes `["nave-flex", on && "nave-grid"].filter(Boolean).join(" ")`,
322
+ with each argument evaluated once, in the order you wrote it. A direct `cx.raw()` call becomes the
323
+ same join, and a direct `cx.dynamic()` call a lookup in a map of the atoms in `keep` and `keepFor`.
324
+ The dev server keeps the run-time functions, which return the same strings. A bundle holds neither
325
+ the name map nor the atoms' declarations when every use of `cx` in it is one of those direct calls
326
+ and no module that imports `cx` calls `eval` directly. Two forms keep the map, since the build
327
+ cannot follow the alias: `const r = cx.raw` and `const { raw } = cx`. So does a package listed in
328
+ `keepFor` whose calls the build cannot read, because those calls stay calls to the run-time `cx()`.
329
+ A module that calls `eval` directly keeps its import of `cx`, and with it the map, whatever text it
330
+ runs (`eval('1')` too), because that text can read the module's names and the build cannot read the
331
+ text. An indirect call, `(0, eval)(text)` or `globalThis.eval(text)`, runs its text in the global
332
+ scope, where `cx` is not visible, and does not keep the import.
333
+
334
+ So that nothing is dropped on a guess, a `cx()` call whose atoms the build cannot read fails the
335
+ build. `vite build` reads every module first and fails once, at the end, with a report that lists
336
+ each such use with its file, and its line and column when the source maps lead back to them, then
337
+ says how to fix them:
338
+
339
+ ```text
340
+ 2 problems in 2 files: the build cannot tell which atoms these apply, and it ships only the atoms it can read.
341
+ src/Card.tsx:7:26: cx(variant): the argument is not a literal atom name.
342
+ src/List.tsx:12:26: cx is passed to map() as a value instead of being called.
343
+ Name the atoms at the call: a string literal, a const in the same file, or a condition choosing between them, as in cx(on ? 'flex' : 'grid').
344
+ To choose by a value, call cx() once per case and pick between the results: ({ row: cx('flex'), grid: cx('grid') })[variant].
345
+ If the name comes from data the build never sees, write cx.dynamic(name) and list every atom it can take in keep in navePlugin().
346
+ Call cx() where the classes are applied, with the atom names as its arguments: the build reads calls, not a cx that is assigned, passed or spread.
347
+ ```
348
+
349
+ The dev server reports the problems in your own code one module at a time. It reads your
350
+ dependencies' `cx()` calls as well, so their atoms are served in dev as in the build, but their
351
+ problems show in `vite build` only: in dev, Vite pre-bundles dependencies into files that can mix
352
+ several packages, so a problem there cannot be traced to one package. To read those calls, the
353
+ plugin keeps `@navecss/core` itself out of Vite's dependency pre-bundling (`optimizeDeps.exclude`),
354
+ so in dev the browser loads it as a few separate modules.
355
+
356
+ A stylesheet you import with `?inline` or `?raw` becomes text in your JavaScript, which the plugin
357
+ does not filter: if it holds Nave's atom layer, every atom in that layer ships, and `vite build`
358
+ warns, naming the file. Import it without the query to have the layer filtered.
359
+
360
+ **What the build can read.** An argument whose possible values the build can list: a string
361
+ literal; `null`, `undefined`, `false` or `''`; a condition choosing between those
362
+ (`on && 'flex'`, `on ? 'flex' : 'grid'`, `a || b`, `a ?? b`); or a variable in the same file
363
+ bound to one of them and never assigned again, such as a `const`. A prop, a parameter, a variable
364
+ that is assigned again, an import from another file, an array element, an object property and a
365
+ spread are not, and `cx` itself must be called, never passed on (`list.map(cx)` fails). Types do
366
+ not count: a value typed `AtomName` is still a value the build cannot list, and a cast is gone
367
+ before the build reads the call. A string holding two names (`cx('flex gap')`) fails too: pass
368
+ each name as its own argument.
369
+
370
+ To choose by a value, such as a `variant` prop, call `cx()` once per case and pick between the
371
+ results:
372
+
373
+ ```tsx
374
+ import { cx } from '@navecss/core/cx'
375
+
376
+ const layouts = { row: cx('flex'), grid: cx('grid') }
377
+
378
+ export function List({ variant }: { variant: keyof typeof layouts }) {
379
+ return <ul className={layouts[variant]} />
380
+ }
381
+ ```
382
+
383
+ **Names chosen at run time.** When a name is known only at run time, because it comes from data,
384
+ pass it to `cx.dynamic()` and list every atom it can take in `keep`:
385
+
386
+ ```tsx
387
+ // vite.config.ts
388
+ import { defineConfig } from 'vite'
389
+ import { navePlugin } from '@navecss/core/vite'
390
+
391
+ export default defineConfig({
392
+ plugins: [navePlugin({ keep: ['flex', 'grid'] })],
393
+ })
394
+
395
+ // Block.tsx, whose layout comes from your CMS
396
+ import { cx, type AtomName } from '@navecss/core/cx'
397
+
398
+ export const Block = ({ layout }: { layout: AtomName }) => <div className={cx.dynamic(layout)} />
399
+ ```
400
+
401
+ `cx.dynamic()` takes one name. A name listed in `keep` (or in a `keepFor` list) gets its atom's
402
+ class, in dev and in the build; any other value gets none (`''`), and in dev the console names any
403
+ name it gave no class and says why. Every atom in `keep` ships, used or not. A `cx.dynamic()` call
404
+ in your code with `keep` empty is an error, in the dev server as in `vite build`, since every such
405
+ call would apply nothing. The plugin hands `cx.dynamic()` the atoms of `keep` and `keepFor` as static data, and
406
+ adds `@navecss/core` to every server environment's `resolve.noExternal`, so `cx.dynamic()` in a
407
+ server build reads them too.
408
+
409
+ **Code you import.** A module that re-exports `cx`, such as a design system's `index.ts` holding
410
+ `export { cx } from '@navecss/core/cx'`, is a build error until you list it in `cxModules`. The
411
+ build then reads a file that imports `cx` from it as if that file imported `cx` from
412
+ `@navecss/core/cx`. It follows a listed module one step: the listed module must re-export Nave's
413
+ `cx` from `@navecss/core/cx` itself, under the name `cx`. Write an entry as a path from Vite's
414
+ `root`, an alias your config defines, or a package name, which is how you list a published package
415
+ that re-exports `cx`. An import of a listed module through a spelling the build does not recognise
416
+ fails `vite build`, which names the file and says what to write instead.
417
+
418
+ ```ts
419
+ // vite.config.ts
420
+ import { defineConfig } from 'vite'
421
+ import { navePlugin } from '@navecss/core/vite'
422
+
423
+ export default defineConfig({
424
+ plugins: [navePlugin({ cxModules: ['./src/ui/index.ts'] })],
425
+ })
426
+ ```
427
+
428
+ `@navecss/eslint-plugin` has a `cxModules` setting with the same name and the same entries, so one
429
+ list can serve both, with two differences. The lint reads a relative entry from the directory it
430
+ runs in, and the build from Vite's `root`: where those differ, as in a monorepo, write the entries
431
+ as aliases or package names. And the lint resolves an import as Node's `require()` does, where the
432
+ build uses Vite's resolver: the build recognises spellings the lint cannot resolve, such as an alias
433
+ set only in your Vite config, and where the lint cannot resolve a `#` import it matches a `#` entry
434
+ written the same way, which the build never does.
435
+
436
+ A dependency whose `cx()` calls the build cannot read fails the build with the package named,
437
+ because its code is not yours to change. List the package in `keepFor`, under its name, with the
438
+ atoms its calls can produce, which its documentation should give you:
439
+
440
+ ```ts
441
+ // vite.config.ts
442
+ import { defineConfig } from 'vite'
443
+ import { navePlugin } from '@navecss/core/vite'
444
+
445
+ export default defineConfig({
446
+ plugins: [navePlugin({ keepFor: { '@acme/ui': ['flex', 'inlineFlex', 'itemsCenter'] } })],
447
+ })
448
+ ```
449
+
450
+ Those atoms ship as if they were in `keep`, beside the atoms of the package's calls the build does
451
+ read. The calls it cannot read are not changed: one that produces any other atom still puts that
452
+ atom's class on the element, and the class has no rule, in dev as in the build, unless the atom
453
+ ships for another reason. An empty list, `keepFor: { '@acme/ui': [] }`, says that the calls the
454
+ build cannot read produce no atom. A `cx.dynamic()` call in the package needs its atoms in the
455
+ list: with an empty list, or none, it fails the build. Each client build records, in the
456
+ `nave-used-atoms.json` file described below, the atoms it read from each listed package's calls,
457
+ where the calls it could not read are, and any `keepFor` key that names no package the build read.
458
+
459
+ `keepFor` stands in for the package's own calls, never for a call your code makes, so three problems
460
+ in a listed package still fail the build. A module of the package that re-exports `cx` under the
461
+ name `cx` still fails when a file of yours imports `cx` from that module itself, in an `import` or
462
+ `export ... from` statement, or a dependency that does more than re-export it imports it from there,
463
+ and the report gives the `cxModules` entry that lets the build read those calls. Through a module
464
+ that only re-exports it, such as the package's own entry file or another package, a listed module
465
+ would be two steps away, so there the report names each file of yours that takes it: import `cx`
466
+ from `@navecss/core/cx` there instead. A module that gives `cx` out under another name, as a default
467
+ or inside a namespace, still fails when your code takes that export, and the report names each file
468
+ of yours that does: import `cx` from `@navecss/core/cx` there instead. And a module of the package
469
+ that you list in `cxModules` but that does not re-export Nave's `cx` under that name still fails:
470
+ remove it from `cxModules`.
471
+
472
+ A package inside your own workspace is your code, not a dependency: Vite resolves it by its real
473
+ path, outside `node_modules`, so its calls are reported as yours, in the dev server too, and
474
+ `keepFor` does not cover it.
475
+
476
+ **What the build cannot see.**
477
+
478
+ A class the build does not read (in a server template or CMS text, assembled where the build cannot see it, or applied at run time by a package listed in `keepFor`) has no rule in the production stylesheet unless its atom is listed in `keep` or in a `keepFor` entry (`nave-sr-only-focusable` also has its rule whenever `nave-sr-only` has one). An element carrying such a class renders as if the class were absent: a `nave-sr-only` or `nave-sr-only-focusable` element is shown, a `nave-hidden` or `nave-hide-*` element is displayed, and a `nave-focus-ring` element falls back to the browser's default focus indicator. A `nave-hidden` or `nave-hide-*` element that other styles also keep out of sight (moved off-screen, made transparent or clipped) stays out of sight but can take keyboard focus and is exposed to screen readers. The dev server serves the atoms the build would ship, chosen from the code it has read, so a class the build does not read has no rule in dev either. The dev server can still keep a rule the build drops, for code it read and the build does not (such as a module the page stopped importing while the dev server ran), and lack one the build keeps, for a class rendered only on a server that runs outside the dev server's process, so a page can still look and behave differently in dev than in the build. Before you ship, run your checks, keyboard, screen-reader and automated accessibility checks included, against the production build: `vite build` then `vite preview` for a client-rendered app, or your production server for a server-rendered one.
479
+
480
+ A class your code takes at run time from `@navecss/core/atoms` (`atomClassMap[variant]`,
481
+ `toClassName('grid')`) is one the build does not read: it neither reads nor refuses it, so list its
482
+ atom in `keep`.
483
+
484
+ If your pages are rendered outside the build, by a backend that serves its own templates and uses
485
+ Vite for its assets only, the build reads none of their classes. While `keep` is empty, the plugin
486
+ warns when it has read no HTML page, no server render and no use of Nave at all: no `cx()` call, no
487
+ Nave class and no `@nave` directive. A backend whose scripts call `cx()`, or whose CSS uses `@nave`,
488
+ gets no warning, and the classes in its templates have no rule all the same. List the atoms your
489
+ templates use in `keep`, or, if they are many, ship every atom (below).
490
+
491
+ The PostCSS plugin and the Lightning CSS adapter read no `cx()` call and leave every atom in the
492
+ layer, on Next.js and webpack alike; so does a page with no bundler, which links the standalone
493
+ stylesheet.
494
+
495
+ A server build leaves packages in `node_modules` external by default, so it does not read their
496
+ code. For such a package that depends on `@navecss/core`, the build warns, naming it, unless it is
497
+ listed in `keepFor`. A listed package is still checked where a server file of yours imports from it:
498
+ taking a `cx` export the client build found in that module fails the build (with two commands, the
499
+ next client build) with the report the client would give, and where the build cannot tell what your
500
+ file takes, because no environment read the file the server loads or your file reaches the package
501
+ in a way the build cannot read (a `require()`, say, or a `#` import), it warns, naming your file and
502
+ the module. On the server, its `cx.dynamic()` calls also map through every atom rather than through
503
+ `keep`, so the class such a call renders can be an atom outside the set the build ships, its emitted
504
+ set. Where the build ships no rule for that atom, the element renders as if the class were absent;
505
+ where another use put that atom in the emitted set, the server-rendered element carries the atom's
506
+ styling, and can keep it after the page hydrates: React, for one, does not guarantee to correct an
507
+ attribute that differs between the server's markup and the client's render. Add such a package to
508
+ `ssr.noExternal` in your Vite config: the build then reads it, and its `cx.dynamic()` calls on the
509
+ server read `keep` too. Listing it in `keepFor` instead stops that warning and ships its listed
510
+ atoms, but its `cx.dynamic()` calls on the server still map through every atom, and your server
511
+ files that import from it are still checked as above.
512
+
513
+ If you build the client and the server in two commands, build the server first: `vite build --ssr`,
514
+ then `vite build`. The two share a file in Vite's cache directory, so the client build also ships
515
+ the atoms the server build read. The client build also checks what each server build recorded there
516
+ of your server files' imports from packages listed in `keepFor`, so after you change server code,
517
+ build the server again before the client. A record whose server file no longer exists is dropped;
518
+ one from a server build you no longer run stays until you delete `nave-used-server-takes.json` from
519
+ Vite's cache directory and run each server build you do. Built the other way round, a server atom
520
+ the client's CSS lacks fails the server build, which records the atom for the next client build, and
521
+ the report says how to clear it. A server build that finds the client's list already used cannot
522
+ check the CSS, so it warns instead, naming each atom the last client build did not ship, and records
523
+ them the same way.
524
+
525
+ That file is `nave-used-atoms.json` in Vite's cache directory (`node_modules/.vite/` unless you set
526
+ `cacheDir`). Each `vite build` writes its atoms there, sorted, as the `emitted` list; after a client
527
+ build, that list is the set of atoms its CSS holds, so you can diff it between builds.
528
+
529
+ **Shipping every atom.** `navePlugin({ atomic: 'all' })` reads no `cx()` call and ships every atom,
530
+ as the PostCSS plugin does. It suits a project whose Nave classes are mostly written where no build
531
+ reads them, such as a backend's own templates or HTML from a CMS, where listing them in `keep` is
532
+ not practical. It gives up what this section describes: no `cx()` call is read, checked or turned
533
+ into its class string, so the name map ships, a name that is no atom passes through `cx()` as text,
534
+ as it does without the plugin, and `cx.dynamic()` maps through every atom. The dev server serves
535
+ every atom too, the plugin sets neither
536
+ `optimizeDeps.exclude` nor `resolve.noExternal`, no build writes `nave-used-atoms.json`, and
537
+ `keep`, `keepFor` and `cxModules` are still checked for well-formed values and otherwise do nothing.
538
+ It is also the way out that the plugin's own check names when a build's atom layer does not hold
539
+ exactly the atoms the build chose: removing the others relies on a part of Vite's CSS step that is
540
+ not a public Vite API, measured on Vite 8.2.1 and the newest 8.x at the time of each release, and on
541
+ a Vite where it does not take, every build fails that check until the plugin supports that version.
542
+
543
+ **If you publish components that call `cx()`.** Your users' builds read your published code. Write
544
+ every name your components choose at run time through `cx.dynamic()`, and every other call so the
545
+ build can read it. Then export the atoms those calls can take, so a user lists them in one line that
546
+ moves with your version:
547
+
548
+ ```ts
549
+ // in your package, published as @acme/ui/nave-atoms
550
+ import type { AtomName } from '@navecss/core/cx'
551
+
552
+ export const naveAtoms = ['flex', 'grid', 'itemsCenter'] as const satisfies readonly AtomName[]
553
+
554
+ // in your user's vite.config.ts
555
+ // import { naveAtoms } from '@acme/ui/nave-atoms'
556
+ // navePlugin({ keepFor: { '@acme/ui': naveAtoms } })
557
+ ```
558
+
559
+ A user whose app sits in the same repository as your package, as a workspace package, passes the
560
+ list to `keep` instead (`navePlugin({ keep: naveAtoms })`): the build reads a workspace package as
561
+ their own code, and `keepFor` does not reach it.
562
+
563
+ List every atom your components can apply at run time, `focusRing`, `srOnly`, `srOnlyFocusable`, `hidden` and the `hide*` atoms included where you use them: a consumer passes them to `keepFor` under your package's name, and one left out renders as if the class were absent in their production build.
564
+
565
+ ### PostCSS plugin setup
566
+
567
+ For Next.js, webpack under `postcss-loader`, and any other pipeline that runs PostCSS plugins.
568
+
569
+ Add it to your PostCSS config:
194
570
 
195
571
  ```js
196
572
  // postcss.config.js
@@ -201,31 +577,59 @@ export default {
201
577
  }
202
578
  ```
203
579
 
204
- ### Options
580
+ A CommonJS config works too. Name it `postcss.config.cjs`, or keep `.js` in a project
581
+ without `"type": "module"`:
205
582
 
206
- ```ts
207
- navePlugin({
208
- // 'error' (default) — throw, failing the build
209
- // 'warn' — log and skip
210
- // 'ignore' — silently skip
211
- onUnknown: 'error',
212
- })
583
+ ```js
584
+ // postcss.config.cjs
585
+ module.exports = {
586
+ plugins: [require('@navecss/core/postcss')()],
587
+ }
213
588
  ```
214
589
 
215
- To add atoms of your own to `@nave`, pass them as `extend`: see
216
- [CONSUMER-ATOMS.md](./CONSUMER-ATOMS.md).
217
-
218
590
  `postcss` is an optional peer dependency.
219
591
  If you are using only `cx()` or tokens, you do not need to install it.
220
592
 
593
+ #### Next.js
594
+
595
+ Next.js reads a PostCSS plugin by its package name, so give it the name as a key and do not
596
+ call `navePlugin()`:
597
+
598
+ ```js
599
+ // postcss.config.mjs
600
+ export default {
601
+ plugins: { '@navecss/core/postcss': {} },
602
+ }
603
+ ```
604
+
605
+ The `navePlugin()` form above fails on Next.js's webpack pipeline with
606
+ `An unknown PostCSS plugin was provided`: that is Next 15's default and `next build --webpack`
607
+ on Next 16. Next 16's default, Turbopack, accepts either form.
608
+
609
+ Next.js also needs your browser floor, in `package.json`:
610
+
611
+ ```json
612
+ {
613
+ "browserslist": ["chrome 125", "edge 125", "firefox 128", "safari 18", "ios_saf 18"]
614
+ }
615
+ ```
616
+
617
+ Without it, Turbopack compiles for older browsers and rewrites every `light-dark()` in your CSS,
618
+ Nave's colours included, into `--lightningcss-light` and `--lightningcss-dark` variables. Spell
619
+ the floor out as above: Next 15 rejects the shorter `baseline 2024` with `Unknown browser baseline`.
620
+
221
621
  **If your Vite config sets `css.transformer: 'lightningcss'`, this plugin never
222
622
  runs.** That option replaces Vite's CSS pipeline with Lightning CSS, which does
223
623
  not run PostCSS plugins at all: the build stays green, `@nave` reaches the
224
624
  browser as an unknown at-rule, and the browser drops it, so the rule renders
225
- with none of the declarations its atoms were going to give it. Leave the
226
- default transformer in place on a project using `@nave`.
625
+ with none of the declarations its atoms were going to give it. On Vite, use
626
+ [the Vite plugin](#vite-plugin-setup), which runs under that option; to stay on this plugin,
627
+ leave the default transformer in place. A host that runs Lightning CSS directly, not through
628
+ Vite, uses [the Lightning CSS adapter](#lightning-css-adapter-setup). Add
629
+ [`navecss-core check`](#navecss-core-check) to your build script as well, and a
630
+ build that skips the plugin this way fails instead of shipping.
227
631
 
228
- ### Plugin order
632
+ #### Plugin order
229
633
 
230
634
  Put `navePlugin()` before any autoprefixing or syntax-down-levelling plugin (`autoprefixer`,
231
635
  `postcss-preset-env`, and the like) in your `plugins` array. `navePlugin()` resolves `@nave`
@@ -235,6 +639,71 @@ ship the vendor-prefixed properties their declarations need (`interactive`'s
235
639
  `-webkit-user-select` alongside `user-select`, for one), so this is only a concern for your own
236
640
  CSS sharing the same pipeline.
237
641
 
642
+ ### Lightning CSS adapter setup
643
+
644
+ For a host that runs Lightning CSS directly: your own script, or a tool that calls its
645
+ `transform()` or `bundleAsync()`. It is not the route for Vite: on Vite, use
646
+ [the Vite plugin](#vite-plugin-setup), which also runs under `css.transformer: 'lightningcss'`.
647
+
648
+ ```js
649
+ import { readFile } from 'node:fs/promises'
650
+ import { bundleAsync, transform } from 'lightningcss'
651
+ import { navePlugin } from '@navecss/core/lightningcss'
652
+
653
+ const nave = navePlugin()
654
+
655
+ // One file, through transform(): expand it first, then pass the code and the map on.
656
+ const { code, map } = nave.expand(await readFile('src/app.css'), 'src/app.css')
657
+ const result = transform({ filename: 'src/app.css', code, inputSourceMap: map, sourceMap: true })
658
+
659
+ // An entry file and everything it @imports, through bundleAsync().
660
+ const bundled = await bundleAsync({ filename: 'src/app.css', resolver: nave.resolver })
661
+ ```
662
+
663
+ The adapter expands the text before Lightning CSS reads it, so Lightning CSS never sees a
664
+ directive: no `Unknown at rule` warning, no Lightning CSS parse error on a malformed directive, and
665
+ Nave's own messages, with the file's path and the line and column of each problem.
666
+ `expand(code, filename)` takes the text or its bytes and returns `{ code, map }`, the code as bytes
667
+ and the map as a string, ready for `transform()`'s `code` and `inputSourceMap`. `resolver.read` reads each file
668
+ `bundleAsync()` asks for and returns its expanded text, so a file reached through `@import` is
669
+ covered too. It takes `extend` (an object of your own atoms) and `onUnknown` as the other plugins
670
+ do ([Options](#options)); under `'warn'` it prints each problem with `console.warn`.
671
+
672
+ The adapter imports nothing from `lightningcss`, types included, and declares no peer: you bring
673
+ your own copy. The supported range is documented, not declared: `lightningcss` 1.24.1 and later.
674
+ Earlier releases can drop the message of an error a resolver throws during `bundleAsync()`, so a
675
+ malformed directive can fail a build with an empty error instead of Nave's message. The nested rules
676
+ the adapter writes also need CSS nesting, on by default only from 1.22 (1.20 and 1.21 cannot parse
677
+ them). The fixtures run on 1.24.1 and on the newest release at the time of each release of this
678
+ package. One cost, on the `bundleAsync()` path: `read` returns a string and no source
679
+ map, so Nave's insertions are not in the output map there. The inserted text adds no line breaks,
680
+ so line numbers hold. On the `transform()` path the map is chained through `inputSourceMap`.
681
+
682
+ ### Options
683
+
684
+ ```ts
685
+ navePlugin({
686
+ // 'error' (default) — throw, failing the build
687
+ // 'warn' — log and skip
688
+ // 'ignore' — silently skip
689
+ onUnknown: 'error',
690
+ })
691
+ ```
692
+
693
+ Under `'error'`, every problem this option covers in one stylesheet is reported
694
+ in one error, positioned at the first: its message, then
695
+ `N more in this stylesheet:` and one `line:column:` line for each of the
696
+ others. The report covers one stylesheet, so a bundler that stops at the first
697
+ failing file reports one file per build. Under `'warn'`, each problem is a
698
+ warning of its own.
699
+
700
+ To add atoms of your own to `@nave`, pass them as `extend`: see
701
+ [CONSUMER-ATOMS.md](./CONSUMER-ATOMS.md). An `extend` object written inline in
702
+ a cached host's config, or imported into it, does not invalidate that host's
703
+ cache; pass `extend` as a path to the module instead when that matters. Only
704
+ the named file is re-read and declared to the host as a dependency; a module
705
+ it imports is neither (same doc).
706
+
238
707
  ### Where `@nave` is valid
239
708
 
240
709
  `@nave` must be the direct child of a CSS rule selector block. Any rule will
@@ -254,6 +723,97 @@ failure, not a silently incomplete build):
254
723
 
255
724
  ---
256
725
 
726
+ ## `navecss-core check`
727
+
728
+ A survival check: it reads built CSS and reports every `@nave` directive that
729
+ reached it, so a missing PostCSS pipeline or a bypassed one (Vite's
730
+ `css.transformer: 'lightningcss'`, for one) fails the build instead of
731
+ shipping a page with none of the declarations its atoms were going to give
732
+ it.
733
+
734
+ ```json
735
+ {
736
+ "scripts": {
737
+ "build": "vite build && navecss-core check --source=dist"
738
+ }
739
+ }
740
+ ```
741
+
742
+ For Next.js, point it at the build's static output:
743
+
744
+ ```json
745
+ {
746
+ "scripts": {
747
+ "build": "next build && navecss-core check --source=.next/static"
748
+ }
749
+ }
750
+ ```
751
+
752
+ `--source=` is repeatable, and each value is a file or a directory read
753
+ recursively for `.css`. Exit codes:
754
+
755
+ - `0` — at least one stylesheet was read, and none held `@nave`.
756
+ - `1` — at least one stylesheet held `@nave`; the findings are printed, one
757
+ line each (file, line, column, the directive as written, and its enclosing
758
+ selector when known).
759
+ - `2` — a usage error, an unreadable `--source` path, or no stylesheet found
760
+ at all. An unreadable path gives `2` even when a stylesheet that was read
761
+ held `@nave`; those findings are still printed.
762
+
763
+ Run it from a `package.json` script, chained with `&&` after the build that
764
+ should have resolved every directive, so it reads that build's output. The
765
+ command comes with `@navecss/core`; it is not a package of its own.
766
+
767
+ ---
768
+
769
+ ## `navecss-core expand`
770
+
771
+ For a project with no bundler. It reads each stylesheet, expands every `@nave`, and writes the
772
+ result:
773
+
774
+ ```json
775
+ {
776
+ "scripts": {
777
+ "build": "navecss-core expand --source=src/app.css --out=app.css",
778
+ "watch": "navecss-core expand --source=src/app.css --out=app.css --watch"
779
+ }
780
+ }
781
+ ```
782
+
783
+ `--source=` and `--out=` are repeated as pairs, matched by order, one pair per file; unequal
784
+ counts are a usage error. `--extend=<module>` is the path to a module whose default export is
785
+ your own atoms, read the way the PostCSS plugin reads one. `--watch` rewrites each `--out` when
786
+ its source, or the `--extend` module, changes, and keeps running after a problem. Every problem
787
+ across every file is printed in one run, one report per stylesheet. Exit codes:
788
+
789
+ - `0` — every file was expanded and written.
790
+ - `1` — at least one stylesheet held a problem; the problems are printed and `--out` is not
791
+ written, for any of the pairs.
792
+ - `2` — the run could not be done as asked: a usage error (such as a missing or unmatched `--out`,
793
+ an unknown flag, or an `--out` that is also a `--source`), an `--extend` module that could not be
794
+ used, a `--source` that could not be read, an `--out` that could not be written, or, under
795
+ `--watch`, a directory that could not be watched. Nothing is written, except that files written
796
+ before an `--out` that failed stay written.
797
+
798
+ It does not resolve or inline `@import`; that would make it a CSS bundler. It
799
+ expands only the files you name with `--source`, so a stylesheet you import that
800
+ holds `@nave` needs a pair of its own. An `@import` of a relative or absolute URL
801
+ is kept as written and the file it names is not read, so its path has to work
802
+ from where the output is served, not from the source's directory. A bare import,
803
+ such as one of a package (`@import url('@navecss/core')`, which is how the
804
+ bundler route's `app.css` starts), is reported as a `bare-import` problem,
805
+ because a browser cannot load it. For Nave's stylesheets, link
806
+ `@navecss/core/standalone` instead, which has no `@import` in it, by path
807
+ (`node_modules/@navecss/core/dist/standalone.css`) or from a CDN
808
+ (`https://cdn.jsdelivr.net/npm/@navecss/core@<version>/dist/standalone.css`, with
809
+ `<version>` the version you installed), before your expanded stylesheet. For
810
+ `@navecss/core/no-tokens`, link that entry's own file, `dist/no-tokens.css`, the
811
+ same way: the standalone stylesheet holds Nave's token layer, which that entry
812
+ leaves out. [Without a bundler](https://github.com/navecss/navecss#without-a-bundler)
813
+ has the whole setup.
814
+
815
+ ---
816
+
257
817
  ## Editor, linter and coding agent
258
818
 
259
819
  `@nave` is new to these tools, so each one needs a step, and this package ships what each of
@@ -321,13 +881,18 @@ Consumers of `@navecss/tokens` alone get no guide: it ships only in this package
321
881
 
322
882
  ## Exports
323
883
 
324
- | Export | Description |
325
- | ------------------------- | ----------------------------------------------------------------------------------------------------- |
326
- | `@navecss/core` | CSS entry point — `@import url('@navecss/core')` |
327
- | `@navecss/core/layers` | The `@layer` order statement alone. Import it first, before any other stylesheet |
328
- | `@navecss/core/no-tokens` | Entry point for projects that generate their own Nave token layer; see the header comment in the file |
329
- | `@navecss/core/reset` | Reset stylesheet only. It reads Nave's custom properties, so it needs a token layer beside it |
330
- | `@navecss/core/atomic` | Generated atomic CSS (global `nave-` classes) |
331
- | `@navecss/core/cx` | `cx()` / `cx.raw()` utilities + `AtomName` type |
332
- | `@navecss/core/atoms` | Atom definitions + `atomClassMap` |
333
- | `@navecss/core/postcss` | PostCSS plugin — `navePlugin()` |
884
+ | Export | Description |
885
+ | ---------------------------- | ----------------------------------------------------------------------------------------------------- |
886
+ | `@navecss/core` | CSS entry point — `@import url('@navecss/core')` |
887
+ | `@navecss/core/layers` | The `@layer` order statement alone. Import it first, before any other stylesheet |
888
+ | `@navecss/core/no-tokens` | Entry point for projects that generate their own Nave token layer; see the header comment in the file |
889
+ | `@navecss/core/reset` | Reset stylesheet only. It reads Nave's custom properties, so it needs a token layer beside it |
890
+ | `@navecss/core/atomic` | Generated atomic CSS (global `nave-` classes) |
891
+ | `@navecss/core/cx` | `cx()` / `cx.raw()` utilities + `AtomName` type |
892
+ | `@navecss/core/atoms` | Atom definitions + `atomClassMap` |
893
+ | `@navecss/core/vite` | Vite plugin — `navePlugin()` |
894
+ | `@navecss/core/lightningcss` | Lightning CSS adapter — `navePlugin()`, returning `expand` and `resolver` |
895
+ | `@navecss/core/standalone` | The whole stylesheet in one file, with no `@import`, for projects with no bundler |
896
+ | `@navecss/core/postcss` | PostCSS plugin — `navePlugin()` |
897
+ | `@navecss/core/check` | The survival check as a function — `check({ source })` |
898
+ | `@navecss/core/collect` | Reads which atoms a module uses, for build tools; not meant for application code |