@navecss/core 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ATOMS.md CHANGED
@@ -51,24 +51,24 @@ variants, if any.
51
51
 
52
52
  ## Typography
53
53
 
54
- | Atom | Class | Declarations | Variants |
55
- | ----------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- |
56
- | `truncate` | `nave-truncate` | `overflow: hidden;`<br>`text-overflow: ellipsis;`<br>`white-space: nowrap;` | — |
57
- | `srOnly` | `nave-sr-only` | `position: absolute;`<br>`width: 1px;`<br>`height: 1px;`<br>`padding: 0;`<br>`margin: -1px;`<br>`overflow: hidden;`<br>`clip-path: inset(50%);`<br>`white-space: nowrap;`<br>`border-width: 0;` | — |
58
- | `srOnlyFocusable` | `nave-sr-only-focusable` | `position: absolute;`<br>`width: 1px;`<br>`height: 1px;`<br>`padding: 0;`<br>`margin: -1px;`<br>`overflow: hidden;`<br>`clip-path: inset(50%);`<br>`white-space: nowrap;`<br>`border-width: 0;` | `:focus-visible`<br>`:focus-within` |
59
- | `noWrap` | `nave-no-wrap` | `white-space: nowrap;` | — |
60
- | `breakWord` | `nave-break-word` | `overflow-wrap: break-word;` | — |
61
- | `textLeft` | `nave-text-left` | `text-align: left;` | — |
62
- | `textCenter` | `nave-text-center` | `text-align: center;` | — |
63
- | `textRight` | `nave-text-right` | `text-align: right;` | — |
54
+ | Atom | Class | Declarations | Variants |
55
+ | ----------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
56
+ | `truncate` | `nave-truncate` | `overflow: hidden;`<br>`text-overflow: ellipsis;`<br>`white-space: nowrap;` | — |
57
+ | `srOnly` | `nave-sr-only` | `position: absolute;`<br>`width: 1px;`<br>`height: 1px;`<br>`padding: 0;`<br>`margin: -1px;`<br>`overflow: hidden;`<br>`clip-path: inset(50%);`<br>`white-space: nowrap;`<br>`border-width: 0;` | — |
58
+ | `srOnlyFocusable` | `nave-sr-only-focusable` | `position: absolute;`<br>`width: 1px;`<br>`height: 1px;`<br>`padding: 0;`<br>`margin: -1px;`<br>`overflow: hidden;`<br>`clip-path: inset(50%);`<br>`white-space: nowrap;`<br>`border-width: 0;` | `:focus-visible` — `position: static;`<br>`width: auto;`<br>`height: auto;`<br>`margin: 0;`<br>`overflow: visible;`<br>`clip-path: none;`<br>`white-space: normal;`<br>`:focus-within` — `position: static;`<br>`width: auto;`<br>`height: auto;`<br>`margin: 0;`<br>`overflow: visible;`<br>`clip-path: none;`<br>`white-space: normal;` |
59
+ | `noWrap` | `nave-no-wrap` | `white-space: nowrap;` | — |
60
+ | `breakWord` | `nave-break-word` | `overflow-wrap: break-word;` | — |
61
+ | `textLeft` | `nave-text-left` | `text-align: left;` | — |
62
+ | `textCenter` | `nave-text-center` | `text-align: center;` | — |
63
+ | `textRight` | `nave-text-right` | `text-align: right;` | — |
64
64
 
65
65
  ## Interaction
66
66
 
67
- | Atom | Class | Declarations | Variants |
68
- | --------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- |
69
- | `interactive` | `nave-interactive` | `cursor: pointer;`<br>`-webkit-user-select: none;`<br>`user-select: none;`<br>`-webkit-tap-highlight-color: transparent;` | — |
70
- | `focusRing` | `nave-focus-ring` | `outline: none;` | `:focus-visible` |
71
- | `disabledState` | `nave-disabled-state` | — | `:disabled, [aria-disabled="true"]` |
67
+ | Atom | Class | Declarations | Variants |
68
+ | --------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
69
+ | `interactive` | `nave-interactive` | `cursor: pointer;`<br>`-webkit-user-select: none;`<br>`user-select: none;`<br>`-webkit-tap-highlight-color: transparent;` | — |
70
+ | `focusRing` | `nave-focus-ring` | `outline: none;` | `:focus-visible` — `outline: var(--nave-border-width-focus) solid var(--nave-color-border-focus);`<br>`outline-offset: 2px;` |
71
+ | `disabledState` | `nave-disabled-state` | — | `:disabled, [aria-disabled="true"]` — `color: var(--nave-color-content-disabled);`<br>`border-color: var(--nave-color-border-disabled);`<br>`pointer-events: none;`<br>On the aria-disabled branch the element stays focusable by design: this atom only blocks pointer activation (pointer-events: none), so the component's own activation handler must also check the attribute and no-op on Enter and Space, since CSS cannot prevent keyboard activation. |
72
72
 
73
73
  ## Visual
74
74
 
@@ -95,14 +95,14 @@ variants, if any.
95
95
 
96
96
  ## Responsive
97
97
 
98
- | Atom | Class | Declarations | Variants |
99
- | ----------------------- | ------------------------------- | ------------ | --------------------------- |
100
- | `hidePhoneOnly` | `nave-hide-phone-only` | — | `@media (width < 37.5em)` |
101
- | `hideTabletPortraitUp` | `nave-hide-tablet-portrait-up` | — | `@media (width >= 37.5em)` |
102
- | `hideTabletLandscapeUp` | `nave-hide-tablet-landscape-up` | — | `@media (width >= 56.25em)` |
103
- | `hideDesktopUp` | `nave-hide-desktop-up` | — | `@media (width >= 75em)` |
104
- | `stackPhoneOnly` | `nave-stack-phone-only` | — | `@media (width < 37.5em)` |
105
- | `wFullPhoneOnly` | `nave-w-full-phone-only` | — | `@media (width < 37.5em)` |
98
+ | Atom | Class | Declarations | Variants |
99
+ | ----------------------- | ------------------------------- | ------------ | ----------------------------------------------------- |
100
+ | `hidePhoneOnly` | `nave-hide-phone-only` | — | `@media (width < 37.5em)` — `display: none;` |
101
+ | `hideTabletPortraitUp` | `nave-hide-tablet-portrait-up` | — | `@media (width >= 37.5em)` — `display: none;` |
102
+ | `hideTabletLandscapeUp` | `nave-hide-tablet-landscape-up` | — | `@media (width >= 56.25em)` — `display: none;` |
103
+ | `hideDesktopUp` | `nave-hide-desktop-up` | — | `@media (width >= 75em)` — `display: none;` |
104
+ | `stackPhoneOnly` | `nave-stack-phone-only` | — | `@media (width < 37.5em)` — `flex-direction: column;` |
105
+ | `wFullPhoneOnly` | `nave-w-full-phone-only` | — | `@media (width < 37.5em)` — `width: 100%;` |
106
106
 
107
107
  ## Pairing notes
108
108
 
package/README.md CHANGED
@@ -57,7 +57,10 @@ repeats the same statement, which changes nothing
57
57
 
58
58
  Your component CSS goes in `@layer components.consumer`, and a deliberate
59
59
  exception goes in `@layer overrides`, which beats every other layer. Left outside
60
- any layer, your CSS would beat all of them, `overrides` included. Do not write
60
+ any layer, your CSS would beat all of them, `overrides` included.
61
+ That is the order for normal declarations. `!important` reverses it: an `!important` in
62
+ `overrides` loses to one in any earlier layer, Nave's reset included, and one outside any
63
+ layer loses to every layered one. Do not write
61
64
  into a bare `@layer components` or `@layer tokens`: a rule written directly into a
62
65
  layer outranks everything in that layer's sublayers.
63
66
 
@@ -142,12 +145,13 @@ What that buys you, and what it does not:
142
145
  `hidden`, `grid`, `flex`, `block`, `border`, `rounded`, `transition`,
143
146
  `relative`, `absolute`, `gap`, `truncate`, `interactive`) belong on the
144
147
  `cx.raw()` side when you mean your own.
145
- - **Nothing checks the rest of the `className` attribute.** A bare
146
- `'legacy-card'` sitting beside these calls is seen by nothing and still works.
147
- So `cx.raw()` is a **declared** escape channel, not an enforced one: its value
148
- today is that every deliberate step outside the system leaves a token you can
149
- find (`grep -r 'cx.raw'`). A lint that closes the attribute itself is not part
150
- of this release.
148
+ - **The rest of the `className` attribute is checked by
149
+ [`@navecss/eslint-plugin`](https://github.com/navecss/navecss/tree/main/packages/eslint-plugin#readme).**
150
+ A bare `'legacy-card'` sitting beside these calls is not seen by `tsc` or by
151
+ `@nave`, so `cx.raw()` is a **declared** escape channel — install that plugin
152
+ for an **enforced** one: it reports an undeclared literal class written in
153
+ `className`, and requires a reason on a `cx.raw()` call that carries one, so
154
+ a deliberate step outside the system stays a reasoned, countable call.
151
155
 
152
156
  ### Tokens only
153
157
 
@@ -250,6 +254,71 @@ failure, not a silently incomplete build):
250
254
 
251
255
  ---
252
256
 
257
+ ## Editor, linter and coding agent
258
+
259
+ `@nave` is new to these tools, so each one needs a step, and this package ships what each of
260
+ them reads.
261
+
262
+ **VS Code.** This package ships a data file that declares `@nave` to the CSS language service:
263
+
264
+ ```json
265
+ { "css.customData": ["./node_modules/@navecss/core/nave.css-data.json"] }
266
+ ```
267
+
268
+ In a monorepo, the path is relative to the folder you open, so it goes through the
269
+ `node_modules` of the package that depends on `@navecss/core`. VS Code reads the file at
270
+ startup, so reload the window once it is installed.
271
+
272
+ **Stylelint.** To have `@nave` accepted without disabling `at-rule-no-unknown` for anything
273
+ else, declare it under `languageOptions.syntax.atRules`:
274
+
275
+ ```json
276
+ { "languageOptions": { "syntax": { "atRules": { "nave": { "prelude": "<custom-ident>+" } } } } }
277
+ ```
278
+
279
+ This needs stylelint 16.17.0 or later. `@navecss/stylelint-config`'s own peer range is
280
+ `^17.0.0`; the two are stated separately because the config does more than this one line, and
281
+ this line works on its own, with no config installed, from 16.17.0 on. For a check that values
282
+ on a list of properties (colour, spacing and others) use a `var()` or an admitted keyword, extend
283
+ [`@navecss/stylelint-config`](https://github.com/navecss/navecss/tree/main/packages/stylelint-config#readme)
284
+ beside this line; it never replaces it.
285
+
286
+ **ESLint®.** For the JSX side of the same idea — a literal class in `className`, or a literal
287
+ value on a tokenized `style` property — install
288
+ [`@navecss/eslint-plugin`](https://github.com/navecss/navecss/tree/main/packages/eslint-plugin#readme):
289
+
290
+ ```js
291
+ // eslint.config.js
292
+ import nave from '@navecss/eslint-plugin'
293
+
294
+ export default [
295
+ // ...your existing config
296
+ nave.configs.recommended,
297
+ ]
298
+ ```
299
+
300
+ **Coding agents.** This package ships a guide for coding agents at `skills/navecss/SKILL.md`:
301
+ the built-in atoms, the custom properties this build emits, the layer order, and where consumer
302
+ CSS goes, for the version you have installed. No agent looks inside `node_modules` on its own,
303
+ so add a pointer to your own `AGENTS.md` (and to `CLAUDE.md` too if you have one, since Claude
304
+ Code reads `AGENTS.md` only where there is no `CLAUDE.md`):
305
+
306
+ ```md
307
+ ## Styling: NaveCSS
308
+
309
+ Before writing or changing CSS or a `className`, read
310
+ `node_modules/@navecss/core/skills/navecss/SKILL.md`, resolved from the
311
+ package that depends on `@navecss/core`. The Nave custom properties and
312
+ built-in atoms it lists are the only ones there are. Atoms this project
313
+ registers through `navePlugin({ extend })` are valid too, in `@nave` only,
314
+ never in `cx()`. If a name you need is in neither place, say so rather
315
+ than invent one.
316
+ ```
317
+
318
+ Consumers of `@navecss/tokens` alone get no guide: it ships only in this package.
319
+
320
+ ---
321
+
253
322
  ## Exports
254
323
 
255
324
  | Export | Description |
package/dist/cx.d.ts CHANGED
@@ -51,10 +51,11 @@ import { AtomName } from './atoms.js';
51
51
  * relative, absolute, gap, truncate, interactive.
52
52
  *
53
53
  * What is NOT checked:
54
- * Nothing here looks at the rest of the className attribute. A bare string
55
- * sitting beside these calls is not seen by anything, so cx.raw() is a
56
- * DECLARED escape channel (greppable: `grep -r 'cx.raw'`), not an enforced
57
- * one.
54
+ * Nothing here looks at the rest of the className attribute. @navecss/eslint-plugin does:
55
+ * install it for an ENFORCED escape channel, reporting an undeclared literal class written
56
+ * in className and requiring a reason on a cx.raw() call that carries one (a class the
57
+ * project declares as its own passes by design). Without it, cx.raw() is only a DECLARED
58
+ * escape channel (greppable: `grep -r 'cx.raw'`).
58
59
  *
59
60
  * Note on consumer atoms:
60
61
  * Atoms defined via navePlugin({ extend }) are @nave-directive only.
package/dist/postcss.d.ts CHANGED
@@ -16,6 +16,13 @@ import { AtomDefinition } from './atoms.js';
16
16
  * navePlugin({ extend: myAtoms }) // Nave + consumer atoms
17
17
  * navePlugin({ onUnknown: 'warn' }) // Log and skip instead of failing the build
18
18
  *
19
+ * `extend` is trusted, consumer-authored code, run at build time in the same file that could
20
+ * already run arbitrary JavaScript — it is not sanitised input. `validateExtendAtoms` still
21
+ * parses every declaration, pseudo selector key, and media/container condition an atom carries,
22
+ * and rejects any that does not parse as exactly that one construct, since a typo there is
23
+ * otherwise silent CSS injection into the generated output rather than a build error at the
24
+ * point of the mistake.
25
+ *
19
26
  * Consumer atoms:
20
27
  * import type { AtomDefinition } from '@navecss/core/postcss'
21
28
  * import { media } from '@navecss/tokens/breakpoints'
package/dist/postcss.js CHANGED
@@ -2,69 +2,62 @@ import { atoms } from './chunk-BVON7XKC.js';
2
2
  import postcss from 'postcss';
3
3
 
4
4
  // src/selector-utils.ts
5
- function nextQuote(quote, char) {
6
- if (quote) return char === quote ? void 0 : quote;
7
- return char === '"' || char === "'" ? char : quote;
8
- }
9
- function depthDelta(quote, char) {
10
- if (quote) return 0;
11
- if (char === "(" || char === "[") return 1;
12
- if (char === ")" || char === "]") return -1;
13
- return 0;
14
- }
15
- function consumeSplitChar(state, char) {
16
- const { branches, depth, quote, isEscaped, current } = state;
17
- if (isEscaped) {
18
- return { branches, depth, quote, isEscaped: false, current: current + char };
19
- }
20
- if (char === "\\") {
21
- return { branches, depth, quote, isEscaped: true, current: current + char };
22
- }
23
- const nextDepth = depth + depthDelta(quote, char);
24
- if (!quote && char === "," && nextDepth === 0) {
25
- return {
26
- branches: [...branches, current],
27
- depth: nextDepth,
28
- quote,
29
- isEscaped: false,
30
- current: ""
31
- };
5
+ function isCommentClose(text, index, commentStart) {
6
+ return text[index] === "/" && text[index - 1] === "*" && index - 1 >= commentStart + 2;
7
+ }
8
+ function isConsumedAsLiteral(state, text, index) {
9
+ if (state.commentStart !== void 0) {
10
+ if (isCommentClose(text, index, state.commentStart)) state.commentStart = void 0;
11
+ return true;
32
12
  }
33
- return {
34
- branches,
35
- depth: nextDepth,
36
- quote: nextQuote(quote, char),
37
- isEscaped: false,
38
- current: current + char
39
- };
13
+ if (state.isEscaped) {
14
+ state.isEscaped = false;
15
+ return true;
16
+ }
17
+ if (text[index] === "\\") {
18
+ state.isEscaped = true;
19
+ return true;
20
+ }
21
+ if (!state.quote) return false;
22
+ if (text[index] === state.quote) state.quote = void 0;
23
+ return true;
24
+ }
25
+ function isCodeChar(state, text, index) {
26
+ if (isConsumedAsLiteral(state, text, index)) return false;
27
+ const char = text[index];
28
+ if (char === '"' || char === "'") {
29
+ state.quote = char;
30
+ return false;
31
+ }
32
+ if (char === "/" && text[index + 1] === "*") {
33
+ state.commentStart = index;
34
+ return false;
35
+ }
36
+ return true;
37
+ }
38
+ function codeMask(text) {
39
+ const state = { quote: void 0, isEscaped: false, commentStart: void 0 };
40
+ return Array.from({ length: text.length }, (_, index) => isCodeChar(state, text, index));
40
41
  }
41
42
  function splitTopLevel(selectorList) {
42
- let state = {
43
- branches: [],
44
- depth: 0,
45
- quote: void 0,
46
- isEscaped: false,
47
- current: ""
48
- };
49
- for (const char of selectorList) state = consumeSplitChar(state, char);
50
- return [...state.branches, state.current];
51
- }
52
- function hasUnquotedAmpersand(branch) {
53
- let quote;
54
- let isEscaped = false;
55
- for (const char of branch) {
56
- if (isEscaped) {
57
- isEscaped = false;
58
- continue;
59
- }
60
- if (char === "\\") {
61
- isEscaped = true;
62
- continue;
43
+ const mask = codeMask(selectorList);
44
+ const branches = [];
45
+ let depth = 0;
46
+ let start = 0;
47
+ for (const [index, isCode] of mask.entries()) {
48
+ if (!isCode) continue;
49
+ const char = selectorList[index];
50
+ if (char === "(" || char === "[") depth += 1;
51
+ else if (char === ")" || char === "]") depth -= 1;
52
+ else if (char === "," && depth === 0) {
53
+ branches.push(selectorList.slice(start, index));
54
+ start = index + 1;
63
55
  }
64
- if (!quote && char === "&") return true;
65
- quote = nextQuote(quote, char);
66
56
  }
67
- return false;
57
+ return [...branches, selectorList.slice(start)];
58
+ }
59
+ function hasNestingSelector(branch) {
60
+ return codeMask(branch).some((isCode, index) => isCode && branch[index] === "&");
68
61
  }
69
62
  function anchorSelectorList(selectorList) {
70
63
  return splitTopLevel(selectorList).map((branch) => {
@@ -74,7 +67,7 @@ function anchorSelectorList(selectorList) {
74
67
  `anchorSelectorList: empty selector branch in pseudo key "${selectorList}". Remove the stray or trailing comma: an empty branch would otherwise compile to a bare "&", matching the atom unconditionally.`
75
68
  );
76
69
  }
77
- return hasUnquotedAmpersand(trimmed) ? trimmed : `&${trimmed}`;
70
+ return hasNestingSelector(trimmed) ? trimmed : `&${trimmed}`;
78
71
  }).join(", ");
79
72
  }
80
73
 
@@ -140,6 +133,98 @@ function stampSource(node, source) {
140
133
  child.source = source;
141
134
  });
142
135
  }
136
+ function isPlainObject(value) {
137
+ return typeof value === "object" && value !== null && !Array.isArray(value);
138
+ }
139
+ function isDeclarationValid(prop, value) {
140
+ let root;
141
+ try {
142
+ root = postcss.parse(`a{${prop}:${value}}`);
143
+ } catch {
144
+ return false;
145
+ }
146
+ if (root.nodes.length !== 1) return false;
147
+ const rule = root.nodes[0];
148
+ if (rule.type !== "rule" || rule.nodes.length !== 1) return false;
149
+ const decl = rule.nodes[0];
150
+ if (decl.type !== "decl") return false;
151
+ return decl.prop === prop && decl.toString() === `${prop}:${value}`.trimEnd();
152
+ }
153
+ function isPropValid(prop) {
154
+ return isDeclarationValid(prop, "0");
155
+ }
156
+ function isSelectorValid(key) {
157
+ let selector;
158
+ try {
159
+ selector = anchorSelectorList(key);
160
+ } catch {
161
+ return false;
162
+ }
163
+ let root;
164
+ try {
165
+ root = postcss.parse(`${selector}{}`);
166
+ } catch {
167
+ return false;
168
+ }
169
+ if (root.nodes.length !== 1) return false;
170
+ const rule = root.nodes[0];
171
+ return rule.type === "rule" && rule.selector === selector && rule.nodes.length === 0;
172
+ }
173
+ function isConditionValid(atName, condition) {
174
+ let root;
175
+ try {
176
+ root = postcss.parse(`@${atName} ${condition}{}`);
177
+ } catch {
178
+ return false;
179
+ }
180
+ if (root.nodes.length !== 1) return false;
181
+ const node = root.nodes[0];
182
+ return node.type === "atrule" && node.name === atName && node.params === condition.trim() && (node.nodes?.length ?? 0) === 0;
183
+ }
184
+ function fail(describedAs, value) {
185
+ throw new Error(
186
+ `navePlugin({ extend }): ${describedAs} does not parse as a single CSS declaration, selector or condition, and would break out of the rule it is spliced into: ${JSON.stringify(value)}. extend is trusted, consumer-authored code, not sanitised input \u2014 fix the atom definition.`
187
+ );
188
+ }
189
+ function assertDeclarationsSafe(declarations, where) {
190
+ if (!isPlainObject(declarations)) return;
191
+ for (const [prop, value] of Object.entries(declarations)) {
192
+ if (typeof prop === "string" && !isPropValid(prop)) {
193
+ fail(`${where}'s declaration property "${prop}"`, prop);
194
+ }
195
+ if (typeof value === "string" && !isDeclarationValid(prop, value)) {
196
+ fail(`${where}'s declaration value for "${prop}"`, value);
197
+ }
198
+ }
199
+ }
200
+ function assertPseudosSafe(pseudos, where) {
201
+ if (!isPlainObject(pseudos)) return;
202
+ for (const [pseudo, declarations] of Object.entries(pseudos)) {
203
+ if (!isSelectorValid(pseudo)) fail(`${where}'s pseudo "${pseudo}"`, pseudo);
204
+ assertDeclarationsSafe(declarations, `${where}'s pseudo "${pseudo}"`);
205
+ }
206
+ }
207
+ function assertAtBlocksSafe(blocks, atName, where) {
208
+ if (!isPlainObject(blocks)) return;
209
+ for (const [condition, block] of Object.entries(blocks)) {
210
+ if (!isConditionValid(atName, condition))
211
+ fail(`${where}'s ${atName} condition "${condition}"`, condition);
212
+ if (!isPlainObject(block)) continue;
213
+ const blockWhere = `${where}'s ${atName} "${condition}"`;
214
+ assertDeclarationsSafe(block.declarations, blockWhere);
215
+ assertPseudosSafe(block.pseudos, blockWhere);
216
+ }
217
+ }
218
+ function validateExtendAtoms(extend) {
219
+ for (const [name, atom] of Object.entries(extend)) {
220
+ if (!atom) continue;
221
+ const where = `atom "${name}"`;
222
+ assertDeclarationsSafe(atom.declarations, where);
223
+ assertPseudosSafe(atom.pseudos, where);
224
+ assertAtBlocksSafe(atom.media, "media", where);
225
+ assertAtBlocksSafe(atom.container, "container", where);
226
+ }
227
+ }
143
228
 
144
229
  // src/postcss.ts
145
230
  function reportUnknown(msg, ctx) {
@@ -177,6 +262,7 @@ function insertDeclarations(atRule, declNodes, isFollowingNested) {
177
262
  }
178
263
  var navePlugin = (options = {}) => {
179
264
  const { onUnknown = "error", extend = {} } = options;
265
+ validateExtendAtoms(extend);
180
266
  const allAtoms = { ...atoms, ...extend };
181
267
  const validAtomNames = new Set(
182
268
  Object.entries(allAtoms).filter(([, definition]) => definition).map(([name]) => name)
@@ -0,0 +1,12 @@
1
+ {
2
+ "version": 1.1,
3
+ "atDirectives": [
4
+ {
5
+ "name": "@nave",
6
+ "description": {
7
+ "kind": "markdown",
8
+ "value": "The `@nave` at-rule applies one or more of Nave’s built-in atomic utilities to the CSS rule it appears in. Loading this file stops an editor from reporting `@nave` as an unknown at-rule and shows this description on hover; it does not complete atom names.\n\n- **Display:** `flex`, `inlineFlex`, `grid`, `block`, `inlineBlock`, `hidden`\n- **Flex:** `flexCol`, `flexWrap`, `itemsCenter`, `itemsStart`, `itemsEnd`, `justifyCenter`, `justifyBetween`, `justifyEnd`, `flexGrow`, `flexShrink0`, `gap`\n- **Position:** `relative`, `absolute`, `insetFull`\n- **Sizing:** `wFull`, `hFull`, `minW0`\n- **Typography:** `truncate`, `srOnly`, `srOnlyFocusable`, `noWrap`, `breakWord`, `textLeft`, `textCenter`, `textRight`\n- **Interaction:** `interactive`, `focusRing`, `disabledState`\n- **Visual:** `rounded`, `roundedCard`, `roundedFull`, `border`, `overflowHidden`, `overflowAuto`\n- **Transition:** `transition`\n- **Containment:** `container`\n- **Responsive:** `hidePhoneOnly`, `hideTabletPortraitUp`, `hideTabletLandscapeUp`, `hideDesktopUp`, `stackPhoneOnly`, `wFullPhoneOnly`\n\nAtoms registered through `extend` are valid and are not listed here."
9
+ }
10
+ }
11
+ ]
12
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@navecss/core",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "Nave design system — layer architecture, reset, atomic utilities, PostCSS plugin",
@@ -39,7 +39,9 @@
39
39
  "files": [
40
40
  "dist",
41
41
  "CONSUMER-ATOMS.md",
42
- "ATOMS.md"
42
+ "ATOMS.md",
43
+ "nave.css-data.json",
44
+ "skills"
43
45
  ],
44
46
  "peerDependencies": {
45
47
  "postcss": "^8"
@@ -50,7 +52,7 @@
50
52
  }
51
53
  },
52
54
  "dependencies": {
53
- "@navecss/tokens": "^0.1.0"
55
+ "@navecss/tokens": "^0.2.0"
54
56
  },
55
57
  "devDependencies": {
56
58
  "@types/node": "^26.6.2",
@@ -59,10 +61,11 @@
59
61
  "@vitest/coverage-v8": "5.0.1",
60
62
  "playwright": "1.63.0",
61
63
  "postcss": "^8.5.28",
62
- "prettier": "3.9.8",
64
+ "prettier": "3.9.9",
63
65
  "tsup": "^8.5.1",
64
66
  "typescript": "6.0.3",
65
- "vitest": "5.0.1"
67
+ "vitest": "5.0.1",
68
+ "vscode-css-languageservice": "6.3.10"
66
69
  },
67
70
  "engines": {
68
71
  "node": ">=22.18"
@@ -0,0 +1,314 @@
1
+ ---
2
+ name: navecss
3
+ description: 'Reference for Nave — the @nave at-rule, cx()/cx.raw(), --nave-* custom
4
+ properties and the @layer order — so an agent applies existing atoms and tokens instead
5
+ of inventing new CSS.'
6
+ ---
7
+
8
+ # navecss
9
+
10
+ An index of Nave’s vocabulary for a coding agent: every built-in atom, every `--nave-*`
11
+ custom property, the layer order, and the idiom that applies them. See
12
+ [ATOMS.md](../../ATOMS.md) for the same atom table alone, and
13
+ [Theming](https://github.com/navecss/navecss/blob/main/README.md#theming) for how a project
14
+ changes the seed colour and everything derived from it.
15
+
16
+ ## Using `@nave` and `var(--nave-*)`
17
+
18
+ Apply built-in atoms with the `@nave` at-rule inside a CSS rule; read values through
19
+ `var(--nave-*)`. Both vocabularies are closed sets: the built-in atom names and the
20
+ `--nave-*` names are exactly the ones on this page.
21
+ An atom name that is neither on this page nor in the project’s own `navePlugin({ extend })`
22
+ configuration is not used, and a `--nave-*` name not on this page is not used either:
23
+ when the name you need does not exist, say so rather than invent one.
24
+ An undeclared `--nave-*` name renders nothing; `@navecss/stylelint-config` reports it.
25
+
26
+ Atoms a project registers through `navePlugin({ extend })` are not listed on this page.
27
+ They are valid in `@nave` only, never in `cx()`, and they live in that project’s own
28
+ `navePlugin({ extend })` configuration, so look for them there
29
+ (their shape is in [CONSUMER-ATOMS.md](../../CONSUMER-ATOMS.md)).
30
+ Where one shares a built-in atom’s name,
31
+ `@nave` applies the project’s atom and `cx()` still returns the built-in,
32
+ so the declarations this page shows for that name are not what `@nave` applies there.
33
+
34
+ ```css
35
+ /* button.module.css */
36
+ @layer components.consumer {
37
+ .root {
38
+ @nave interactive focusRing transition;
39
+ background: var(--nave-color-action-primary);
40
+ color: var(--nave-color-on-action-primary);
41
+ padding: var(--nave-spacing-control-md) var(--nave-spacing-control-lg);
42
+ border-radius: var(--nave-radius-control);
43
+ }
44
+ }
45
+ ```
46
+
47
+ Consumer rules go in `@layer components.consumer`, and a deliberate exception in
48
+ `@layer overrides`; never write an unlayered rule. Atom names are the camelCase keys listed
49
+ under Atoms below (`focusRing`, `justifyBetween`), never the `nave-` class they emit.
50
+
51
+ See where `@nave` is valid (nesting depth, `@media`/`@container`, `@keyframes`) in
52
+ [the package README](../../README.md#where-nave-is-valid).
53
+
54
+ ## `cx()`
55
+
56
+ The JavaScript escape hatch: `cx('interactive', 'focusRing')` returns the class of each
57
+ built-in atom it names, and `cx()` takes built-in atoms and nothing else.
58
+ Its type check is TypeScript only: a JavaScript consumer gets none of it. A type error from
59
+ `cx()` means the name is wrong: it is never a reason to reach for `cx.raw()` or a cast.
60
+
61
+ ## `cx.raw()`
62
+
63
+ `cx.raw()` is for a class from outside any system Nave sees, the project’s own classes
64
+ included. Where a project class shares an atom’s name, `cx('container')` is Nave’s atom and
65
+ `cx.raw('container')` is the project’s class.
66
+
67
+ `cx.raw()` is also for a CSS Module class behind a condition, shown by the code span
68
+ `cx.raw(isActive && styles.active)`, because a template-literal slot interpolates a falsy
69
+ condition’s own value — naming `false`, `undefined` and `0` — as a class.
70
+
71
+ If the project runs `@navecss/eslint-plugin`, its reports name their remedies, most preferred first: take the first one that fits. Never answer a report with a disable comment, and never write a `cx.raw()` reason to make a report go away.
72
+
73
+ ## Atoms
74
+
75
+ Every built-in atom: the name as written in `@nave` and `cx()`, the declarations it
76
+ applies, and its pseudo-class, `@media` or `@container` variants.
77
+ A name not on this list is not a built-in atom.
78
+ A project’s own `navePlugin({ extend })` atoms are not listed here (see Using `@nave` above);
79
+ a name that is in neither is not used, so do not invent one.
80
+
81
+ ### Display
82
+
83
+ | Atom | Declarations | Variants |
84
+ | ------------- | ------------------------ | -------- |
85
+ | `flex` | `display: flex;` | — |
86
+ | `inlineFlex` | `display: inline-flex;` | — |
87
+ | `grid` | `display: grid;` | — |
88
+ | `block` | `display: block;` | — |
89
+ | `inlineBlock` | `display: inline-block;` | — |
90
+ | `hidden` | `display: none;` | — |
91
+
92
+ ### Flex
93
+
94
+ | Atom | Declarations | Variants |
95
+ | ---------------- | -------------------------------------- | -------- |
96
+ | `flexCol` | `flex-direction: column;` | — |
97
+ | `flexWrap` | `flex-wrap: wrap;` | — |
98
+ | `itemsCenter` | `align-items: center;` | — |
99
+ | `itemsStart` | `align-items: flex-start;` | — |
100
+ | `itemsEnd` | `align-items: flex-end;` | — |
101
+ | `justifyCenter` | `justify-content: center;` | — |
102
+ | `justifyBetween` | `justify-content: space-between;` | — |
103
+ | `justifyEnd` | `justify-content: flex-end;` | — |
104
+ | `flexGrow` | `flex-grow: 1;` | — |
105
+ | `flexShrink0` | `flex-shrink: 0;` | — |
106
+ | `gap` | `gap: var(--nave-spacing-content-md);` | — |
107
+
108
+ ### Position
109
+
110
+ | Atom | Declarations | Variants |
111
+ | ----------- | --------------------- | -------- |
112
+ | `relative` | `position: relative;` | — |
113
+ | `absolute` | `position: absolute;` | — |
114
+ | `insetFull` | `inset: 0;` | — |
115
+
116
+ ### Sizing
117
+
118
+ | Atom | Declarations | Variants |
119
+ | ------- | --------------- | -------- |
120
+ | `wFull` | `width: 100%;` | — |
121
+ | `hFull` | `height: 100%;` | — |
122
+ | `minW0` | `min-width: 0;` | — |
123
+
124
+ ### Typography
125
+
126
+ | Atom | Declarations | Variants |
127
+ | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
128
+ | `truncate` | `overflow: hidden;`<br>`text-overflow: ellipsis;`<br>`white-space: nowrap;` | — |
129
+ | `srOnly` | `position: absolute;`<br>`width: 1px;`<br>`height: 1px;`<br>`padding: 0;`<br>`margin: -1px;`<br>`overflow: hidden;`<br>`clip-path: inset(50%);`<br>`white-space: nowrap;`<br>`border-width: 0;` | — |
130
+ | `srOnlyFocusable` | `position: absolute;`<br>`width: 1px;`<br>`height: 1px;`<br>`padding: 0;`<br>`margin: -1px;`<br>`overflow: hidden;`<br>`clip-path: inset(50%);`<br>`white-space: nowrap;`<br>`border-width: 0;` | `:focus-visible` — `position: static;`<br>`width: auto;`<br>`height: auto;`<br>`margin: 0;`<br>`overflow: visible;`<br>`clip-path: none;`<br>`white-space: normal;`<br>`:focus-within` — `position: static;`<br>`width: auto;`<br>`height: auto;`<br>`margin: 0;`<br>`overflow: visible;`<br>`clip-path: none;`<br>`white-space: normal;` |
131
+ | `noWrap` | `white-space: nowrap;` | — |
132
+ | `breakWord` | `overflow-wrap: break-word;` | — |
133
+ | `textLeft` | `text-align: left;` | — |
134
+ | `textCenter` | `text-align: center;` | — |
135
+ | `textRight` | `text-align: right;` | — |
136
+
137
+ ### Interaction
138
+
139
+ | Atom | Declarations | Variants |
140
+ | --------------- | ------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
141
+ | `interactive` | `cursor: pointer;`<br>`-webkit-user-select: none;`<br>`user-select: none;`<br>`-webkit-tap-highlight-color: transparent;` | — |
142
+ | `focusRing` | `outline: none;` | `:focus-visible` — `outline: var(--nave-border-width-focus) solid var(--nave-color-border-focus);`<br>`outline-offset: 2px;` |
143
+ | `disabledState` | — | `:disabled, [aria-disabled="true"]` — `color: var(--nave-color-content-disabled);`<br>`border-color: var(--nave-color-border-disabled);`<br>`pointer-events: none;`<br>On the aria-disabled branch the element stays focusable by design: this atom only blocks pointer activation (pointer-events: none), so the component's own activation handler must also check the attribute and no-op on Enter and Space, since CSS cannot prevent keyboard activation. |
144
+
145
+ ### Visual
146
+
147
+ | Atom | Declarations | Variants |
148
+ | ---------------- | ----------------------------------------------------------------------------- | -------- |
149
+ | `rounded` | `border-radius: var(--nave-radius-control);` | — |
150
+ | `roundedCard` | `border-radius: var(--nave-radius-card);` | — |
151
+ | `roundedFull` | `border-radius: var(--nave-radius-full);` | — |
152
+ | `border` | `border: var(--nave-border-width-sm) solid var(--nave-color-border-default);` | — |
153
+ | `overflowHidden` | `overflow: hidden;` | — |
154
+ | `overflowAuto` | `overflow: auto;` | — |
155
+
156
+ ### Transition
157
+
158
+ | Atom | Declarations | Variants |
159
+ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
160
+ | `transition` | `transition-property: color, background-color, border-color, opacity, box-shadow;`<br>`transition-duration: var(--nave-motion-duration-base);`<br>`transition-timing-function: var(--nave-motion-easing-standard);` | — |
161
+
162
+ ### Containment
163
+
164
+ | Atom | Declarations | Variants |
165
+ | ----------- | ------------------------------ | -------- |
166
+ | `container` | `container-type: inline-size;` | — |
167
+
168
+ ### Responsive
169
+
170
+ | Atom | Declarations | Variants |
171
+ | ----------------------- | ------------ | ----------------------------------------------------- |
172
+ | `hidePhoneOnly` | — | `@media (width < 37.5em)` — `display: none;` |
173
+ | `hideTabletPortraitUp` | — | `@media (width >= 37.5em)` — `display: none;` |
174
+ | `hideTabletLandscapeUp` | — | `@media (width >= 56.25em)` — `display: none;` |
175
+ | `hideDesktopUp` | — | `@media (width >= 75em)` — `display: none;` |
176
+ | `stackPhoneOnly` | — | `@media (width < 37.5em)` — `flex-direction: column;` |
177
+ | `wFullPhoneOnly` | — | `@media (width < 37.5em)` — `width: 100%;` |
178
+
179
+ ## Custom properties
180
+
181
+ Every `--nave-*` custom property Nave declares, with its description where one exists. A
182
+ name not on this list is not declared — an undeclared `--nave-*` name passes the build and
183
+ renders nothing.
184
+
185
+ - `--nave-border-width-focus`: Focus ring indicator outline width
186
+ - `--nave-border-width-lg`
187
+ - `--nave-border-width-none`
188
+ - `--nave-border-width-sm`: Default border / divider
189
+ - `--nave-color-action-primary`
190
+ - `--nave-color-action-primary-active`
191
+ - `--nave-color-action-primary-hover`
192
+ - `--nave-color-action-secondary`
193
+ - `--nave-color-border-control`: Identifying boundary for a control (inputs, form boundaries, and the secondary action control).
194
+ - `--nave-color-border-default`: Decorative dividers and card edges.
195
+ - `--nave-color-border-disabled`
196
+ - `--nave-color-border-focus`
197
+ - `--nave-color-border-strong`
198
+ - `--nave-color-content-disabled`: Text inside a disabled interactive component.
199
+ - `--nave-color-content-inverse`: Declared foreground for surface.inverse.
200
+ - `--nave-color-content-link`: Do not distinguish a link by colour alone. Nave's own examples keep a non-colour distinction wherever this token is shown.
201
+ - `--nave-color-content-primary`
202
+ - `--nave-color-content-secondary`
203
+ - `--nave-color-content-tertiary`: Placeholder and hint text only.
204
+ - `--nave-color-feedback-danger`: Solid-background role for a destructive control or error state.
205
+ - `--nave-color-feedback-danger-foreground`: Foreground role for danger text and icons.
206
+ - `--nave-color-feedback-info`: Alias of feedback.warning (shared tinted-neutral family).
207
+ - `--nave-color-feedback-info-foreground`
208
+ - `--nave-color-feedback-success`: Alias of feedback.warning (shared tinted-neutral family).
209
+ - `--nave-color-feedback-success-foreground`
210
+ - `--nave-color-feedback-warning`: Solid-background role for a warning state (shared tinted-neutral family).
211
+ - `--nave-color-feedback-warning-foreground`: Foreground role for warning text and icons.
212
+ - `--nave-color-on-action-primary`
213
+ - `--nave-color-on-feedback-danger`: Declared foreground for the solid feedback.danger background.
214
+ - `--nave-color-on-feedback-info`
215
+ - `--nave-color-on-feedback-success`
216
+ - `--nave-color-on-feedback-warning`
217
+ - `--nave-color-surface-base`
218
+ - `--nave-color-surface-inverse`
219
+ - `--nave-color-surface-overlay`
220
+ - `--nave-color-surface-raised`
221
+ - `--nave-color-surface-sunken`
222
+ - `--nave-color-tint`
223
+ - `--nave-font-family-base`: Body / UI typeface
224
+ - `--nave-font-family-display`: Heading typeface — swap for a display font when ready
225
+ - `--nave-font-family-mono`: Code / monospace typeface
226
+ - `--nave-font-size-2xl`
227
+ - `--nave-font-size-3xl`
228
+ - `--nave-font-size-4xl`
229
+ - `--nave-font-size-lg`
230
+ - `--nave-font-size-md`: Base body size (16px default)
231
+ - `--nave-font-size-sm`
232
+ - `--nave-font-size-xl`
233
+ - `--nave-font-size-xs`
234
+ - `--nave-font-weight-bold`
235
+ - `--nave-font-weight-medium`
236
+ - `--nave-font-weight-regular`
237
+ - `--nave-font-weight-semibold`
238
+ - `--nave-layer-base`: Default document flow
239
+ - `--nave-layer-dropdown`: Dropdowns, context menus, select panels
240
+ - `--nave-layer-modal`: Modal dialogs and drawers
241
+ - `--nave-layer-overlay`: Backdrop overlays behind modals
242
+ - `--nave-layer-sticky`: Sticky headers, floating toolbars
243
+ - `--nave-layer-toast`: Toast notifications and snackbars
244
+ - `--nave-layer-tooltip`: Tooltips — always on top of everything
245
+ - `--nave-letter-spacing-normal`: Default — no adjustment
246
+ - `--nave-letter-spacing-tight`: Large headings and display text
247
+ - `--nave-letter-spacing-wide`: Uppercase labels, small caps
248
+ - `--nave-line-height-base`: Default body line height
249
+ - `--nave-line-height-loose`
250
+ - `--nave-line-height-none`: Single-line / icon buttons — no extra leading
251
+ - `--nave-line-height-relaxed`
252
+ - `--nave-line-height-snug`
253
+ - `--nave-line-height-tight`: Headings
254
+ - `--nave-motion-duration-base`: Default state transitions: hover, focus
255
+ - `--nave-motion-duration-fast`: Micro-interactions: tooltips, badges
256
+ - `--nave-motion-duration-instant`: No animation — kept at 0ms even under prefers-reduced-motion
257
+ - `--nave-motion-duration-slow`: Layout shifts: accordion, drawer
258
+ - `--nave-motion-easing-accelerate`: Elements leaving the screen
259
+ - `--nave-motion-easing-decelerate`: Elements entering the screen
260
+ - `--nave-motion-easing-standard`: General-purpose — enters and exits
261
+ - `--nave-opacity-disabled`: Opacity multiplier for muting content that is inert and contains nothing focusable (e.g. a disabled icon or illustration). Nave's own interactive-disabled state is expressed through content.disabled and border.disabled instead, not this token: opacity composites everything an element paints, including a focus indicator inside it.
262
+ - `--nave-radius-card`: Cards, panels, dialogs
263
+ - `--nave-radius-control`: Inputs, buttons, tags
264
+ - `--nave-radius-full`: Pill / badge shape
265
+ - `--nave-radius-none`
266
+ - `--nave-radius-overlay`: Modals, drawers, sheets
267
+ - `--nave-radius-sm`
268
+ - `--nave-shadow-modal`: Dialogs and modals — highest elevation
269
+ - `--nave-shadow-none`: No elevation
270
+ - `--nave-shadow-overlay`: Popovers, dropdowns, floating elements
271
+ - `--nave-shadow-raised`: Cards, slight elevation above base
272
+ - `--nave-size-avatar-lg`
273
+ - `--nave-size-avatar-md`
274
+ - `--nave-size-avatar-sm`
275
+ - `--nave-size-control-lg`: Large controls
276
+ - `--nave-size-control-md`: Default control height
277
+ - `--nave-size-control-sm`: Small controls
278
+ - `--nave-size-control-xs`: Compact/dense controls
279
+ - `--nave-size-icon-lg`
280
+ - `--nave-size-icon-md`: Default inline icon
281
+ - `--nave-size-icon-sm`
282
+ - `--nave-size-icon-xl`
283
+ - `--nave-size-icon-xs`
284
+ - `--nave-spacing-content-lg`
285
+ - `--nave-spacing-content-md`: Default component gap and padding
286
+ - `--nave-spacing-content-sm`
287
+ - `--nave-spacing-content-xl`
288
+ - `--nave-spacing-content-xs`
289
+ - `--nave-spacing-control-lg`
290
+ - `--nave-spacing-control-md`
291
+ - `--nave-spacing-control-sm`: No matching _primitive.space rung; authored directly
292
+ - `--nave-spacing-control-xs`
293
+ - `--nave-spacing-layout-lg`
294
+ - `--nave-spacing-layout-md`
295
+ - `--nave-spacing-layout-sm`
296
+ - `--nave-spacing-layout-xs`
297
+
298
+ ## Layers
299
+
300
+ Nave’s layer order: `@layer tokens.defaults, tokens.presets, reset, atomic, components.nave, components.consumer, overrides;`
301
+
302
+ The order holds only if it is the first `@layer` declaration the page sees:
303
+ a stylesheet that declares a layer and loads earlier fixes that layer’s position first,
304
+ and the order inverts with no error.
305
+ So keep `@navecss/core/layers`, the order statement alone, as the first import of the
306
+ entry stylesheet, and load that stylesheet before anything that brings its own stylesheet,
307
+ components included.
308
+
309
+ Your own component CSS goes in `@layer components.consumer`; a deliberate exception goes in
310
+ `@layer overrides`, which beats every other layer. A rule left outside any layer beats them
311
+ all, `overrides` included, so never write unlayered CSS.
312
+ That is the order for normal declarations. `!important` reverses it: an `!important` in
313
+ `overrides` loses to one in any earlier layer, Nave’s reset included, and one outside any
314
+ layer loses to every layered one.