haus-tokens 0.2.0 → 1.0.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
@@ -44,11 +44,21 @@ Two kinds of primitive read are documented rather than hidden. Sizes: 31
44
44
  declarations across the haus components take a size off the space ladder, on
45
45
  `height`, `width`, `min-*`/`max-*` and `transform` offsets, because a size is a
46
46
  value rather than a role. They are the avatar sizes, the checkbox and radio
47
- boxes, the toggle track and thumb, and a few min/max bounds. And 56 read a primitive that has no semantic alias
48
- because the primitive's own name already is the role: `--font-sans`,
49
- `--weight-*`, `--border-width-*`, `--opacity-disabled`, `--icon-sm`,
50
- `--z-modal`. No component reads a colour, radius, shadow or motion primitive,
51
- and a test in `haus-components` holds that line.
47
+ boxes, the toggle track and thumb, and a few min/max bounds. And 41 read a primitive that has no semantic
48
+ alias because the primitive's own name already is the role: 32 read
49
+ `--haus-font-sans`, five a control height, three `--haus-icon-sm` and one
50
+ `--haus-tracking-normal`. A font family is not a role and there is no honest
51
+ alias to invent for it. No component reads a colour, radius, shadow, stacking or
52
+ motion primitive, and a test in `haus-components` holds that line.
53
+
54
+ That second number went 61 to 77 when the six overlay components landed, then
55
+ **77 to 41 at the 1.0 cut**, and the fall is the part worth reading. `Popover`
56
+ and `Tooltip` had to reach for `--haus-z-*` directly because eight z-index
57
+ primitives had no role between them, and a product wanting the stacking order
58
+ had to read `primitives.css` for it, which is why drift and vault each settled
59
+ one for themselves. haus#27 put the ladders in `primitives.css` and the names in
60
+ `semantics.css`. **No component declaration changed**: the same reads resolve
61
+ one layer higher.
52
62
 
53
63
  ## Typed constants
54
64
 
@@ -80,17 +90,80 @@ numbers, a composite is an alias:
80
90
  "motion": { "fade-in": { "$type": "string", "$value": "{duration.normal} {easing.enter}" } }
81
91
  ```
82
92
 
93
+ ## The guard
94
+
95
+ `var(--x)` for an undefined `--x` is invalid at computed-value time: the
96
+ declaration is dropped and the property inherits. No console warning, no build
97
+ error, nothing in review: a focus ring is simply absent, and a missing duration
98
+ looks like a design choice.
99
+
100
+ That makes *have you loaded what my components read* a question worth failing a
101
+ build over, and it is a question this package can answer because this package
102
+ defines the contract. Import it from your own suite:
103
+
104
+ ```ts
105
+ import { readFileSync } from 'node:fs'
106
+ import { findUndefinedTokens } from 'haus-tokens/guard'
107
+
108
+ const read = (p: string) => readFileSync(p, 'utf8')
109
+
110
+ it('reads no role this app does not load', () => {
111
+ const { missing, read: reads } = findUndefinedTokens({
112
+ reads: [read('node_modules/haus-components/dist/styles.css')],
113
+ defines: [
114
+ read('node_modules/haus-tokens/dist/primitives.css'),
115
+ read('node_modules/haus-tokens/dist/motion.css'),
116
+ read('node_modules/haus-tokens/dist/semantics.css'),
117
+ read('src/tokens/overrides.css'),
118
+ ],
119
+ })
120
+
121
+ // Assert the floor as well as the failure: a wrong path makes the check pass
122
+ // by finding nothing.
123
+ expect(reads.length).toBeGreaterThan(50)
124
+ expect(missing).toEqual([])
125
+ })
126
+ ```
127
+
128
+ It is **pure and does no file reading**, so it runs anywhere and this package
129
+ gains no dependency on `node:fs`. You know which files you load; it only knows
130
+ what the contract is.
131
+
132
+ `alsoDefined` takes properties set outside CSS, a component doing
133
+ `style={{ '--haus-avatar-bg': v }}` defines one that no stylesheet will show.
134
+
135
+ A read carrying a fallback, `var(--x, 0.2s)`, is a real value either way and is
136
+ not a failure. `findFallbackTokens` lists those separately, because a fallback
137
+ that never loses is a hardcoded value wearing a token's clothes and that is
138
+ worth reading occasionally rather than failing a build over.
139
+
140
+ **Why it exists.** drift wrote this check for itself and it caught five roles
141
+ before they reached a screen: `--color-ink-on-primary`, `--elevation-floating`,
142
+ `--motion-duration-emphasis`, `--radius-marker` and `--haus-focus-ring-error`.
143
+ Every consumer after it would have written the same test or shipped the same
144
+ silent hole.
145
+
146
+ It is also run against this package. `src/guard.test.ts` checks `semantics.css`
147
+ and `brands/vault.css` resolve against the layers below them: which is how the
148
+ guard's own regex bug was found, a missing `m` flag that reported 41 undefined
149
+ roles in a file that has none.
150
+
83
151
  ## Three copies, one truth
84
152
 
85
- Stating the same tokens three times invites exactly the drift this design
86
- system exists to prevent. So two of the three are not stated at all:
87
- `tokens.json` is the source, and `primitives.css` and `index.ts` are generated
88
- from it by `scripts/build-tokens.ts`. `pnpm run tokens:check` regenerates them
89
- in memory and fails if what is committed differs, which is the first step CI
90
- runs. Change a value in `tokens.json` and `pnpm run tokens` writes the other
91
- two; change one of the other two by hand and CI says so.
153
+ Stating the same tokens more than once invites exactly the drift this design
154
+ system exists to prevent, so most of the restatements are not stated at all.
155
+ `scripts/build-tokens.ts` writes five files: `primitives.css` and `motion.css`
156
+ from `tokens.json`, `index.ts` as the typed export, `brand.ts` as the `BrandMap`
157
+ type from `brand.css`, and `tokens.json`'s own `semantic.color` block from
158
+ `brand.css` as well.
159
+
160
+ `pnpm run tokens:check` regenerates all five in memory and fails if what is
161
+ committed differs, which is the first step CI runs. Change a source and
162
+ `pnpm run tokens` writes the derived files; change a derived file by hand and CI
163
+ says so, which has already caught someone editing a comment inside one.
92
164
 
93
- `semantics.css` is the one token file still written by hand, because a role is a
165
+ Two token files are written by hand. `brand.css` says which primitive each role
166
+ takes, and `semantics.css` says what each role means, because a role is a
94
167
  decision rather than a derivation. `semantics.test.ts` holds it to the rules
95
168
  this package states: every token it reads is declared in the layers below, no
96
169
  declaration carries a raw value, every subtle and default surface has its
package/dist/brand.css ADDED
@@ -0,0 +1,97 @@
1
+ /* ─── haus / brand ───────────────────────────────────────────────────────────
2
+ Which primitive each role takes. The one file a consumer replaces.
3
+
4
+ It contains nothing else: no new names, no raw values, no structure. A brand
5
+ author sees a flat list of ramp choices; a component author sees an unchanged
6
+ role name in semantics.css. That separation is the whole of ruling A3 — before
7
+ it, the brand and the role system were the same file, so taking haus's roles
8
+ meant taking haus's palette, and two products declared their own tokens rather
9
+ than do that.
10
+
11
+ Every entry here is required. `BrandMap` in the package's TypeScript export is
12
+ generated from this file, so a brand that omits a role or misnames one fails a
13
+ consumer's build instead of rendering an unresolved var().
14
+
15
+ The default brand applies at :root, so the common case needs no attribute. A
16
+ named brand applies at [data-haus-theme="<name>"] and nests, because custom
17
+ properties inherit. See brands/vault.css for a complete second brand.
18
+ ─────────────────────────────────────────────────────────────────────────── */
19
+
20
+ @layer haus.brand {
21
+
22
+ :root {
23
+
24
+ /* ── Surfaces ────────────────────────────────────────────────────────── */
25
+ --haus-brand-surface-default: var(--haus-damson-0);
26
+ --haus-brand-surface-subtle: var(--haus-damson-100);
27
+ --haus-brand-surface-raised: var(--haus-damson-0); /* elevation via shadow, not colour */
28
+ --haus-brand-surface-overlay: var(--haus-damson-50); /* modal / popover, barely off-white */
29
+ --haus-brand-surface-sunken: var(--haus-damson-200);
30
+ --haus-brand-surface-inverse: var(--haus-damson-900); /* dark chip, tooltip bg in light mode */
31
+ --haus-brand-surface-disabled: var(--haus-damson-100);
32
+ --haus-brand-surface-inverse-hover: oklch(from var(--haus-damson-0) l c h / 0.10);
33
+
34
+ /* ── Ink ─────────────────────────────────────────────────────────────── */
35
+ --haus-brand-ink-primary: var(--haus-damson-900);
36
+ --haus-brand-ink-secondary: var(--haus-damson-700);
37
+ --haus-brand-ink-tertiary: var(--haus-damson-500);
38
+ --haus-brand-ink-disabled: var(--haus-damson-400);
39
+ --haus-brand-ink-inverse: var(--haus-damson-0);
40
+ --haus-brand-ink-link: var(--haus-aronia-500);
41
+ --haus-brand-ink-on-primary: var(--haus-damson-0);
42
+
43
+ /* ── Borders ─────────────────────────────────────────────────────────── */
44
+ --haus-brand-border-subtle: var(--haus-damson-100);
45
+ --haus-brand-border-default: var(--haus-damson-200);
46
+ --haus-brand-border-strong: var(--haus-damson-300);
47
+ --haus-brand-border-disabled: var(--haus-damson-200);
48
+ --haus-brand-border-inverse: oklch(from var(--haus-damson-0) l c h / 0.30);
49
+ --haus-brand-border-inverse-hover: oklch(from var(--haus-damson-0) l c h / 0.50);
50
+
51
+ /* ── Primary ─────────────────────────────────────────────────────────── */
52
+ --haus-brand-primary-default: var(--haus-aronia-500);
53
+ --haus-brand-primary-hover: var(--haus-aronia-600);
54
+ --haus-brand-primary-pressed: var(--haus-aronia-700);
55
+ --haus-brand-primary-subtle: var(--haus-aronia-100);
56
+ --haus-brand-primary-on-subtle: var(--haus-aronia-700);
57
+ --haus-brand-primary-disabled: var(--haus-damson-200);
58
+
59
+ /* ── Info ────────────────────────────────────────────────────────────── */
60
+ --haus-brand-info-subtle: var(--haus-elderberry-100);
61
+ --haus-brand-info-border: var(--haus-elderberry-200);
62
+ --haus-brand-info-default: var(--haus-elderberry-500);
63
+ --haus-brand-info-on-subtle: var(--haus-elderberry-700);
64
+ --haus-brand-info-on-default: var(--haus-damson-0);
65
+ --haus-brand-info-emphasis: var(--haus-elderberry-900);
66
+
67
+ /* ── Success ─────────────────────────────────────────────────────────── */
68
+ --haus-brand-success-subtle: var(--haus-greengage-100);
69
+ --haus-brand-success-border: var(--haus-greengage-200);
70
+ --haus-brand-success-default: var(--haus-greengage-500);
71
+ --haus-brand-success-on-subtle: var(--haus-greengage-700);
72
+ --haus-brand-success-on-default: var(--haus-damson-900); /* dark text, because greengage-500 fails with white */
73
+ --haus-brand-success-solid: var(--haus-greengage-700); /* 700-level: white text passes at 6.82:1 */
74
+ --haus-brand-success-emphasis: var(--haus-greengage-900);
75
+
76
+ /* ── Warning ─────────────────────────────────────────────────────────── */
77
+ --haus-brand-warning-subtle: var(--haus-mango-100);
78
+ --haus-brand-warning-border: var(--haus-mango-200);
79
+ --haus-brand-warning-default: var(--haus-mango-500);
80
+ --haus-brand-warning-on-subtle: var(--haus-mango-700);
81
+ --haus-brand-warning-on-default: var(--haus-damson-900); /* dark text, because mango-500 fails with white */
82
+ --haus-brand-warning-solid: var(--haus-mango-700); /* 700-level: white text passes at 7.05:1 */
83
+ --haus-brand-warning-emphasis: var(--haus-mango-900);
84
+
85
+ /* ── Error ───────────────────────────────────────────────────────────── */
86
+ --haus-brand-error-subtle: var(--haus-cherry-100);
87
+ --haus-brand-error-border: var(--haus-cherry-200);
88
+ --haus-brand-error-default: var(--haus-cherry-500);
89
+ --haus-brand-error-on-subtle: var(--haus-cherry-700);
90
+ --haus-brand-error-on-default: var(--haus-damson-0);
91
+ --haus-brand-error-emphasis: var(--haus-cherry-900);
92
+
93
+ /* ── Everything else ─────────────────────────────────────────────────── */
94
+ --haus-brand-backdrop: oklch(from var(--haus-damson-950) l c h / var(--haus-opacity-60));
95
+
96
+ }
97
+ }
@@ -0,0 +1,125 @@
1
+ /* ─── haus / brands / vault ──────────────────────────────────────────────────
2
+ A second, complete brand, and the reason it exists is that **a contract with
3
+ one implementation is not a contract**. The split in brand.css only proves
4
+ anything if a different brand can be written against it without touching
5
+ semantics.css or a single component, and this is that proof.
6
+
7
+ It is vault's ruby, not an invention. vault is a shipped product with its own
8
+ palette that declared 159 custom properties rather than take haus's, and its
9
+ migration is a later step, so writing the brand here is rehearsal rather than
10
+ decoration.
11
+
12
+ **The brand is `vault` and the ramp stays `ruby`, renamed at the 1.0 cut.**
13
+ Brand answers *whose* and ramp answers *which colour*. vault ships six
14
+ gemstone ramps, ruby among them, so naming the ramp `--haus-vault-500` would
15
+ claim vault has one colour. `[data-haus-theme='vault']` holding
16
+ `--haus-ruby-500` reads correctly in both directions.
17
+
18
+ The ten ramp steps gained the `--haus-` prefix in the same cut. They were the
19
+ only unprefixed custom properties in the system, and the prefix is not
20
+ tidiness: an unprefixed `--ruby-500` collides with a consumer running
21
+ Tailwind.
22
+
23
+ Applied by attribute, and it nests:
24
+
25
+ <div data-haus-theme="vault"> … </div>
26
+
27
+ Every entry brand.css declares appears here. That is not tidiness: `BrandMap`
28
+ is generated from brand.css, so an omission is a type error rather than an
29
+ unresolved var() that silently drops a declaration.
30
+ ─────────────────────────────────────────────────────────────────────────── */
31
+
32
+ @layer haus.brand {
33
+
34
+ [data-haus-theme='vault'] {
35
+
36
+ /* ── The ruby palette ──────────────────────────────────────────────────
37
+ Declared here rather than in primitives.css: a brand brings its own ramp.
38
+ Values are vault's, unchanged — this is a real brand rather than a
39
+ plausible one, which is the point of shipping it. */
40
+ --haus-ruby-100: oklch(96.5% 0.016 3);
41
+ --haus-ruby-200: oklch(91% 0.044 3);
42
+ --haus-ruby-300: oklch(82% 0.082 3);
43
+ --haus-ruby-400: oklch(64% 0.15 3);
44
+ --haus-ruby-500: oklch(48.2% 0.186 2.5);
45
+ --haus-ruby-600: oklch(44% 0.176 1.7);
46
+ --haus-ruby-700: oklch(40.5% 0.164 0.9);
47
+ --haus-ruby-800: oklch(33% 0.116 0);
48
+ --haus-ruby-900: oklch(23% 0.09 358);
49
+ --haus-ruby-950: oklch(15% 0.056 356);
50
+
51
+
52
+ /* ── Surface ───────────────────────────────────────────────────────── */
53
+ --haus-brand-surface-default: var(--haus-damson-0);
54
+ --haus-brand-surface-subtle: var(--haus-damson-100);
55
+ --haus-brand-surface-raised: var(--haus-damson-0);
56
+ --haus-brand-surface-overlay: var(--haus-damson-50);
57
+ --haus-brand-surface-sunken: var(--haus-damson-200);
58
+ --haus-brand-surface-inverse: var(--haus-damson-900);
59
+ --haus-brand-surface-disabled: var(--haus-damson-100);
60
+ --haus-brand-surface-inverse-hover: oklch(from var(--haus-damson-0) l c h / 0.10);
61
+
62
+ /* ── Ink ───────────────────────────────────────────────────────────── */
63
+ --haus-brand-ink-primary: var(--haus-damson-900);
64
+ --haus-brand-ink-secondary: var(--haus-damson-700);
65
+ --haus-brand-ink-tertiary: var(--haus-damson-500);
66
+ --haus-brand-ink-disabled: var(--haus-damson-400);
67
+ --haus-brand-ink-inverse: var(--haus-damson-0);
68
+ --haus-brand-ink-link: var(--haus-ruby-500);
69
+ --haus-brand-ink-on-primary: var(--haus-damson-0);
70
+
71
+ /* ── Border ────────────────────────────────────────────────────────── */
72
+ --haus-brand-border-subtle: var(--haus-damson-100);
73
+ --haus-brand-border-default: var(--haus-damson-200);
74
+ --haus-brand-border-strong: var(--haus-damson-300);
75
+ --haus-brand-border-disabled: var(--haus-damson-200);
76
+ --haus-brand-border-inverse: oklch(from var(--haus-damson-0) l c h / 0.30);
77
+ --haus-brand-border-inverse-hover: oklch(from var(--haus-damson-0) l c h / 0.50);
78
+
79
+ /* ── Primary ───────────────────────────────────────────────────────── */
80
+ --haus-brand-primary-default: var(--haus-ruby-500);
81
+ --haus-brand-primary-hover: var(--haus-ruby-600);
82
+ --haus-brand-primary-pressed: var(--haus-ruby-700);
83
+ --haus-brand-primary-subtle: var(--haus-ruby-100);
84
+ --haus-brand-primary-on-subtle: var(--haus-ruby-700);
85
+ --haus-brand-primary-disabled: var(--haus-damson-200);
86
+
87
+ /* ── Info ──────────────────────────────────────────────────────────── */
88
+ --haus-brand-info-subtle: var(--haus-elderberry-100);
89
+ --haus-brand-info-border: var(--haus-elderberry-200);
90
+ --haus-brand-info-default: var(--haus-elderberry-500);
91
+ --haus-brand-info-on-subtle: var(--haus-elderberry-700);
92
+ --haus-brand-info-on-default: var(--haus-damson-0);
93
+ --haus-brand-info-emphasis: var(--haus-elderberry-900);
94
+
95
+ /* ── Success ───────────────────────────────────────────────────────── */
96
+ --haus-brand-success-subtle: var(--haus-greengage-100);
97
+ --haus-brand-success-border: var(--haus-greengage-200);
98
+ --haus-brand-success-default: var(--haus-greengage-500);
99
+ --haus-brand-success-on-subtle: var(--haus-greengage-700);
100
+ --haus-brand-success-on-default: var(--haus-damson-900);
101
+ --haus-brand-success-solid: var(--haus-greengage-700);
102
+ --haus-brand-success-emphasis: var(--haus-greengage-900);
103
+
104
+ /* ── Warning ───────────────────────────────────────────────────────── */
105
+ --haus-brand-warning-subtle: var(--haus-mango-100);
106
+ --haus-brand-warning-border: var(--haus-mango-200);
107
+ --haus-brand-warning-default: var(--haus-mango-500);
108
+ --haus-brand-warning-on-subtle: var(--haus-mango-700);
109
+ --haus-brand-warning-on-default: var(--haus-damson-900);
110
+ --haus-brand-warning-solid: var(--haus-mango-700);
111
+ --haus-brand-warning-emphasis: var(--haus-mango-900);
112
+
113
+ /* ── Error ─────────────────────────────────────────────────────────── */
114
+ --haus-brand-error-subtle: var(--haus-cherry-100);
115
+ --haus-brand-error-border: var(--haus-cherry-200);
116
+ --haus-brand-error-default: var(--haus-cherry-500);
117
+ --haus-brand-error-on-subtle: var(--haus-cherry-700);
118
+ --haus-brand-error-on-default: var(--haus-damson-0);
119
+ --haus-brand-error-emphasis: var(--haus-cherry-900);
120
+
121
+ /* ── Backdrop ──────────────────────────────────────────────────────── */
122
+ --haus-brand-backdrop: oklch(from var(--haus-damson-950) l c h / var(--haus-opacity-60));
123
+
124
+ }
125
+ }
package/dist/guard.cjs ADDED
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+
3
+ // src/guard.ts
4
+ var READ_NO_FALLBACK = /var\(\s*(--[a-zA-Z0-9-]+)\s*\)/g;
5
+ var READ_WITH_FALLBACK = /var\(\s*(--[a-zA-Z0-9-]+)\s*,/g;
6
+ var DECLARATION = /(?:^|[;{])\s*(--[a-zA-Z0-9-]+)\s*:/gm;
7
+ function collect(sources, pattern) {
8
+ const found = /* @__PURE__ */ new Set();
9
+ for (const css of sources) {
10
+ pattern.lastIndex = 0;
11
+ for (const m of css.matchAll(pattern)) found.add(m[1]);
12
+ }
13
+ return found;
14
+ }
15
+ function findUndefinedTokens(input) {
16
+ const read = collect(input.reads, READ_NO_FALLBACK);
17
+ const defined = collect(input.defines, DECLARATION);
18
+ for (const name of input.alsoDefined ?? []) defined.add(name);
19
+ return {
20
+ missing: [...read].filter((name) => !defined.has(name)).sort(),
21
+ read: [...read].sort(),
22
+ defined: [...defined].sort()
23
+ };
24
+ }
25
+ function findFallbackTokens(input) {
26
+ const withFallback = collect(input.reads, READ_WITH_FALLBACK);
27
+ const defined = collect(input.defines, DECLARATION);
28
+ return [...withFallback].filter((name) => !defined.has(name)).sort();
29
+ }
30
+
31
+ exports.findFallbackTokens = findFallbackTokens;
32
+ exports.findUndefinedTokens = findUndefinedTokens;
33
+ //# sourceMappingURL=guard.cjs.map
34
+ //# sourceMappingURL=guard.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/guard.ts"],"names":[],"mappings":";;;AAuDA,IAAM,gBAAA,GAAmB,iCAAA;AACzB,IAAM,kBAAA,GAAqB,gCAAA;AAK3B,IAAM,WAAA,GAAc,sCAAA;AAuBpB,SAAS,OAAA,CAAQ,SAAmB,OAAA,EAA8B;AAChE,EAAA,MAAM,KAAA,uBAAY,GAAA,EAAY;AAC9B,EAAA,KAAA,MAAW,OAAO,OAAA,EAAS;AAGzB,IAAA,OAAA,CAAQ,SAAA,GAAY,CAAA;AACpB,IAAA,KAAA,MAAW,CAAA,IAAK,IAAI,QAAA,CAAS,OAAO,GAAG,KAAA,CAAM,GAAA,CAAI,CAAA,CAAE,CAAC,CAAE,CAAA;AAAA,EACxD;AACA,EAAA,OAAO,KAAA;AACT;AASO,SAAS,oBAAoB,KAAA,EAA0C;AAC5E,EAAA,MAAM,IAAA,GAAO,OAAA,CAAQ,KAAA,CAAM,KAAA,EAAO,gBAAgB,CAAA;AAClD,EAAA,MAAM,OAAA,GAAU,OAAA,CAAQ,KAAA,CAAM,OAAA,EAAS,WAAW,CAAA;AAClD,EAAA,KAAA,MAAW,QAAQ,KAAA,CAAM,WAAA,IAAe,EAAC,EAAG,OAAA,CAAQ,IAAI,IAAI,CAAA;AAE5D,EAAA,OAAO;AAAA,IACL,OAAA,EAAS,CAAC,GAAG,IAAI,EAAE,MAAA,CAAO,CAAC,IAAA,KAAS,CAAC,OAAA,CAAQ,GAAA,CAAI,IAAI,CAAC,EAAE,IAAA,EAAK;AAAA,IAC7D,IAAA,EAAM,CAAC,GAAG,IAAI,EAAE,IAAA,EAAK;AAAA,IACrB,OAAA,EAAS,CAAC,GAAG,OAAO,EAAE,IAAA;AAAK,GAC7B;AACF;AAUO,SAAS,mBAAmB,KAAA,EAA6D;AAC9F,EAAA,MAAM,YAAA,GAAe,OAAA,CAAQ,KAAA,CAAM,KAAA,EAAO,kBAAkB,CAAA;AAC5D,EAAA,MAAM,OAAA,GAAU,OAAA,CAAQ,KAAA,CAAM,OAAA,EAAS,WAAW,CAAA;AAClD,EAAA,OAAO,CAAC,GAAG,YAAY,CAAA,CAAE,MAAA,CAAO,CAAC,IAAA,KAAS,CAAC,OAAA,CAAQ,GAAA,CAAI,IAAI,CAAC,EAAE,IAAA,EAAK;AACrE","file":"guard.cjs","sourcesContent":["/**\n * The token guard: every custom property a stylesheet reads must be defined\n * somewhere the consumer actually loads.\n *\n * CSS fails silently here, which is what makes this worth shipping. `var(--x)`\n * for an undefined `--x` is invalid at computed-value time: the declaration is\n * dropped and the property inherits. No console warning, no build error,\n * nothing in review: a focus ring is simply absent, and a missing duration\n * looks like a design choice.\n *\n * drift wrote this check for itself and it caught five roles before they\n * reached a screen: `--color-ink-on-primary`, `--elevation-floating`,\n * `--motion-duration-emphasis`, `--radius-marker` and `--haus-focus-ring-error`.\n * The contract is defined in this package, so the check belongs here rather\n * than being rewritten by every consumer (haus#19). Shipping it is also the\n * point: a design system that can say *you have not loaded what my components\n * read* is a different thing from one that hopes you did.\n *\n * **Pure, and it does no file reading.** It takes CSS as strings, so it runs in\n * a Vitest suite, a Node script, a build step or a browser without this package\n * growing a dependency on `node:fs`. The consumer knows which files it loads;\n * this only knows what the contract is.\n *\n * @example\n * ```ts\n * import { readFileSync } from 'node:fs'\n * import { findUndefinedTokens } from 'haus-tokens/guard'\n *\n * const read = (p: string) => readFileSync(p, 'utf8')\n *\n * it('reads no role this app does not load', () => {\n * const { missing } = findUndefinedTokens({\n * reads: [read('node_modules/haus-components/dist/styles.css')],\n * defines: [\n * read('node_modules/haus-tokens/dist/primitives.css'),\n * read('node_modules/haus-tokens/dist/semantics.css'),\n * read('node_modules/haus-tokens/dist/motion.css'),\n * read('src/tokens/overrides.css'),\n * ],\n * })\n * expect(missing).toEqual([])\n * })\n * ```\n */\n\n/**\n * `var(--x)` with no fallback.\n *\n * A reference with a fallback, `var(--x, 0.2s)`, is a real value whether or\n * not the property is set, so it cannot fail at computed-value time and is\n * excluded. It is still usually a sign the name is wrong: a fallback that never\n * loses is a hardcoded value wearing a token's clothes. `findFallbackTokens`\n * below is for looking at those deliberately, rather than failing a build on\n * something that works.\n */\nconst READ_NO_FALLBACK = /var\\(\\s*(--[a-zA-Z0-9-]+)\\s*\\)/g\nconst READ_WITH_FALLBACK = /var\\(\\s*(--[a-zA-Z0-9-]+)\\s*,/g\n// `m` is load-bearing: without it `^` only matches the start of the whole\n// string, so every declaration on its own indented line after a newline is\n// missed and only the first in each block is seen. Caught by this package's\n// own guard test: semantics.css came back with 41 undefined roles.\nconst DECLARATION = /(?:^|[;{])\\s*(--[a-zA-Z0-9-]+)\\s*:/gm\n\nexport interface TokenGuardInput {\n /** The CSS doing the reading: the component stylesheet, your own modules. */\n reads: string[]\n /** The CSS doing the defining: the token layers you load, plus your own. */\n defines: string[]\n /**\n * Names defined outside CSS. A component that sets `style={{ '--x': v }}`\n * defines the property on the element, and no stylesheet will show it.\n */\n alsoDefined?: string[]\n}\n\nexport interface TokenGuardResult {\n /** Read with no fallback and defined nowhere. Sorted, so a diff is stable. */\n missing: string[]\n /** Every property read without a fallback. */\n read: string[]\n /** Every property declared across `defines` and `alsoDefined`. */\n defined: string[]\n}\n\nfunction collect(sources: string[], pattern: RegExp): Set<string> {\n const found = new Set<string>()\n for (const css of sources) {\n // A fresh lastIndex per source: a /g regex is stateful, and reusing one\n // across inputs silently skips the start of every source after the first.\n pattern.lastIndex = 0\n for (const m of css.matchAll(pattern)) found.add(m[1]!)\n }\n return found\n}\n\n/**\n * Which properties are read but never defined.\n *\n * An empty `missing` is the assertion. The other two fields are for a consumer\n * that wants to report rather than fail: `read.length` is also worth asserting\n * as a floor, because a wrong path makes every check pass by finding nothing.\n */\nexport function findUndefinedTokens(input: TokenGuardInput): TokenGuardResult {\n const read = collect(input.reads, READ_NO_FALLBACK)\n const defined = collect(input.defines, DECLARATION)\n for (const name of input.alsoDefined ?? []) defined.add(name)\n\n return {\n missing: [...read].filter((name) => !defined.has(name)).sort(),\n read: [...read].sort(),\n defined: [...defined].sort(),\n }\n}\n\n/**\n * Which properties are only ever read with a fallback.\n *\n * Not a failure, and deliberately a separate function so it cannot be mistaken\n * for one. A fallback that never loses is a hardcoded value wearing a token's\n * clothes, and the list is worth reading occasionally rather than failing a\n * build over.\n */\nexport function findFallbackTokens(input: Pick<TokenGuardInput, 'reads' | 'defines'>): string[] {\n const withFallback = collect(input.reads, READ_WITH_FALLBACK)\n const defined = collect(input.defines, DECLARATION)\n return [...withFallback].filter((name) => !defined.has(name)).sort()\n}\n"]}
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The token guard: every custom property a stylesheet reads must be defined
3
+ * somewhere the consumer actually loads.
4
+ *
5
+ * CSS fails silently here, which is what makes this worth shipping. `var(--x)`
6
+ * for an undefined `--x` is invalid at computed-value time: the declaration is
7
+ * dropped and the property inherits. No console warning, no build error,
8
+ * nothing in review: a focus ring is simply absent, and a missing duration
9
+ * looks like a design choice.
10
+ *
11
+ * drift wrote this check for itself and it caught five roles before they
12
+ * reached a screen: `--color-ink-on-primary`, `--elevation-floating`,
13
+ * `--motion-duration-emphasis`, `--radius-marker` and `--haus-focus-ring-error`.
14
+ * The contract is defined in this package, so the check belongs here rather
15
+ * than being rewritten by every consumer (haus#19). Shipping it is also the
16
+ * point: a design system that can say *you have not loaded what my components
17
+ * read* is a different thing from one that hopes you did.
18
+ *
19
+ * **Pure, and it does no file reading.** It takes CSS as strings, so it runs in
20
+ * a Vitest suite, a Node script, a build step or a browser without this package
21
+ * growing a dependency on `node:fs`. The consumer knows which files it loads;
22
+ * this only knows what the contract is.
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * import { readFileSync } from 'node:fs'
27
+ * import { findUndefinedTokens } from 'haus-tokens/guard'
28
+ *
29
+ * const read = (p: string) => readFileSync(p, 'utf8')
30
+ *
31
+ * it('reads no role this app does not load', () => {
32
+ * const { missing } = findUndefinedTokens({
33
+ * reads: [read('node_modules/haus-components/dist/styles.css')],
34
+ * defines: [
35
+ * read('node_modules/haus-tokens/dist/primitives.css'),
36
+ * read('node_modules/haus-tokens/dist/semantics.css'),
37
+ * read('node_modules/haus-tokens/dist/motion.css'),
38
+ * read('src/tokens/overrides.css'),
39
+ * ],
40
+ * })
41
+ * expect(missing).toEqual([])
42
+ * })
43
+ * ```
44
+ */
45
+ interface TokenGuardInput {
46
+ /** The CSS doing the reading: the component stylesheet, your own modules. */
47
+ reads: string[];
48
+ /** The CSS doing the defining: the token layers you load, plus your own. */
49
+ defines: string[];
50
+ /**
51
+ * Names defined outside CSS. A component that sets `style={{ '--x': v }}`
52
+ * defines the property on the element, and no stylesheet will show it.
53
+ */
54
+ alsoDefined?: string[];
55
+ }
56
+ interface TokenGuardResult {
57
+ /** Read with no fallback and defined nowhere. Sorted, so a diff is stable. */
58
+ missing: string[];
59
+ /** Every property read without a fallback. */
60
+ read: string[];
61
+ /** Every property declared across `defines` and `alsoDefined`. */
62
+ defined: string[];
63
+ }
64
+ /**
65
+ * Which properties are read but never defined.
66
+ *
67
+ * An empty `missing` is the assertion. The other two fields are for a consumer
68
+ * that wants to report rather than fail: `read.length` is also worth asserting
69
+ * as a floor, because a wrong path makes every check pass by finding nothing.
70
+ */
71
+ declare function findUndefinedTokens(input: TokenGuardInput): TokenGuardResult;
72
+ /**
73
+ * Which properties are only ever read with a fallback.
74
+ *
75
+ * Not a failure, and deliberately a separate function so it cannot be mistaken
76
+ * for one. A fallback that never loses is a hardcoded value wearing a token's
77
+ * clothes, and the list is worth reading occasionally rather than failing a
78
+ * build over.
79
+ */
80
+ declare function findFallbackTokens(input: Pick<TokenGuardInput, 'reads' | 'defines'>): string[];
81
+
82
+ export { type TokenGuardInput, type TokenGuardResult, findFallbackTokens, findUndefinedTokens };
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The token guard: every custom property a stylesheet reads must be defined
3
+ * somewhere the consumer actually loads.
4
+ *
5
+ * CSS fails silently here, which is what makes this worth shipping. `var(--x)`
6
+ * for an undefined `--x` is invalid at computed-value time: the declaration is
7
+ * dropped and the property inherits. No console warning, no build error,
8
+ * nothing in review: a focus ring is simply absent, and a missing duration
9
+ * looks like a design choice.
10
+ *
11
+ * drift wrote this check for itself and it caught five roles before they
12
+ * reached a screen: `--color-ink-on-primary`, `--elevation-floating`,
13
+ * `--motion-duration-emphasis`, `--radius-marker` and `--haus-focus-ring-error`.
14
+ * The contract is defined in this package, so the check belongs here rather
15
+ * than being rewritten by every consumer (haus#19). Shipping it is also the
16
+ * point: a design system that can say *you have not loaded what my components
17
+ * read* is a different thing from one that hopes you did.
18
+ *
19
+ * **Pure, and it does no file reading.** It takes CSS as strings, so it runs in
20
+ * a Vitest suite, a Node script, a build step or a browser without this package
21
+ * growing a dependency on `node:fs`. The consumer knows which files it loads;
22
+ * this only knows what the contract is.
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * import { readFileSync } from 'node:fs'
27
+ * import { findUndefinedTokens } from 'haus-tokens/guard'
28
+ *
29
+ * const read = (p: string) => readFileSync(p, 'utf8')
30
+ *
31
+ * it('reads no role this app does not load', () => {
32
+ * const { missing } = findUndefinedTokens({
33
+ * reads: [read('node_modules/haus-components/dist/styles.css')],
34
+ * defines: [
35
+ * read('node_modules/haus-tokens/dist/primitives.css'),
36
+ * read('node_modules/haus-tokens/dist/semantics.css'),
37
+ * read('node_modules/haus-tokens/dist/motion.css'),
38
+ * read('src/tokens/overrides.css'),
39
+ * ],
40
+ * })
41
+ * expect(missing).toEqual([])
42
+ * })
43
+ * ```
44
+ */
45
+ interface TokenGuardInput {
46
+ /** The CSS doing the reading: the component stylesheet, your own modules. */
47
+ reads: string[];
48
+ /** The CSS doing the defining: the token layers you load, plus your own. */
49
+ defines: string[];
50
+ /**
51
+ * Names defined outside CSS. A component that sets `style={{ '--x': v }}`
52
+ * defines the property on the element, and no stylesheet will show it.
53
+ */
54
+ alsoDefined?: string[];
55
+ }
56
+ interface TokenGuardResult {
57
+ /** Read with no fallback and defined nowhere. Sorted, so a diff is stable. */
58
+ missing: string[];
59
+ /** Every property read without a fallback. */
60
+ read: string[];
61
+ /** Every property declared across `defines` and `alsoDefined`. */
62
+ defined: string[];
63
+ }
64
+ /**
65
+ * Which properties are read but never defined.
66
+ *
67
+ * An empty `missing` is the assertion. The other two fields are for a consumer
68
+ * that wants to report rather than fail: `read.length` is also worth asserting
69
+ * as a floor, because a wrong path makes every check pass by finding nothing.
70
+ */
71
+ declare function findUndefinedTokens(input: TokenGuardInput): TokenGuardResult;
72
+ /**
73
+ * Which properties are only ever read with a fallback.
74
+ *
75
+ * Not a failure, and deliberately a separate function so it cannot be mistaken
76
+ * for one. A fallback that never loses is a hardcoded value wearing a token's
77
+ * clothes, and the list is worth reading occasionally rather than failing a
78
+ * build over.
79
+ */
80
+ declare function findFallbackTokens(input: Pick<TokenGuardInput, 'reads' | 'defines'>): string[];
81
+
82
+ export { type TokenGuardInput, type TokenGuardResult, findFallbackTokens, findUndefinedTokens };
package/dist/guard.js ADDED
@@ -0,0 +1,31 @@
1
+ // src/guard.ts
2
+ var READ_NO_FALLBACK = /var\(\s*(--[a-zA-Z0-9-]+)\s*\)/g;
3
+ var READ_WITH_FALLBACK = /var\(\s*(--[a-zA-Z0-9-]+)\s*,/g;
4
+ var DECLARATION = /(?:^|[;{])\s*(--[a-zA-Z0-9-]+)\s*:/gm;
5
+ function collect(sources, pattern) {
6
+ const found = /* @__PURE__ */ new Set();
7
+ for (const css of sources) {
8
+ pattern.lastIndex = 0;
9
+ for (const m of css.matchAll(pattern)) found.add(m[1]);
10
+ }
11
+ return found;
12
+ }
13
+ function findUndefinedTokens(input) {
14
+ const read = collect(input.reads, READ_NO_FALLBACK);
15
+ const defined = collect(input.defines, DECLARATION);
16
+ for (const name of input.alsoDefined ?? []) defined.add(name);
17
+ return {
18
+ missing: [...read].filter((name) => !defined.has(name)).sort(),
19
+ read: [...read].sort(),
20
+ defined: [...defined].sort()
21
+ };
22
+ }
23
+ function findFallbackTokens(input) {
24
+ const withFallback = collect(input.reads, READ_WITH_FALLBACK);
25
+ const defined = collect(input.defines, DECLARATION);
26
+ return [...withFallback].filter((name) => !defined.has(name)).sort();
27
+ }
28
+
29
+ export { findFallbackTokens, findUndefinedTokens };
30
+ //# sourceMappingURL=guard.js.map
31
+ //# sourceMappingURL=guard.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/guard.ts"],"names":[],"mappings":";AAuDA,IAAM,gBAAA,GAAmB,iCAAA;AACzB,IAAM,kBAAA,GAAqB,gCAAA;AAK3B,IAAM,WAAA,GAAc,sCAAA;AAuBpB,SAAS,OAAA,CAAQ,SAAmB,OAAA,EAA8B;AAChE,EAAA,MAAM,KAAA,uBAAY,GAAA,EAAY;AAC9B,EAAA,KAAA,MAAW,OAAO,OAAA,EAAS;AAGzB,IAAA,OAAA,CAAQ,SAAA,GAAY,CAAA;AACpB,IAAA,KAAA,MAAW,CAAA,IAAK,IAAI,QAAA,CAAS,OAAO,GAAG,KAAA,CAAM,GAAA,CAAI,CAAA,CAAE,CAAC,CAAE,CAAA;AAAA,EACxD;AACA,EAAA,OAAO,KAAA;AACT;AASO,SAAS,oBAAoB,KAAA,EAA0C;AAC5E,EAAA,MAAM,IAAA,GAAO,OAAA,CAAQ,KAAA,CAAM,KAAA,EAAO,gBAAgB,CAAA;AAClD,EAAA,MAAM,OAAA,GAAU,OAAA,CAAQ,KAAA,CAAM,OAAA,EAAS,WAAW,CAAA;AAClD,EAAA,KAAA,MAAW,QAAQ,KAAA,CAAM,WAAA,IAAe,EAAC,EAAG,OAAA,CAAQ,IAAI,IAAI,CAAA;AAE5D,EAAA,OAAO;AAAA,IACL,OAAA,EAAS,CAAC,GAAG,IAAI,EAAE,MAAA,CAAO,CAAC,IAAA,KAAS,CAAC,OAAA,CAAQ,GAAA,CAAI,IAAI,CAAC,EAAE,IAAA,EAAK;AAAA,IAC7D,IAAA,EAAM,CAAC,GAAG,IAAI,EAAE,IAAA,EAAK;AAAA,IACrB,OAAA,EAAS,CAAC,GAAG,OAAO,EAAE,IAAA;AAAK,GAC7B;AACF;AAUO,SAAS,mBAAmB,KAAA,EAA6D;AAC9F,EAAA,MAAM,YAAA,GAAe,OAAA,CAAQ,KAAA,CAAM,KAAA,EAAO,kBAAkB,CAAA;AAC5D,EAAA,MAAM,OAAA,GAAU,OAAA,CAAQ,KAAA,CAAM,OAAA,EAAS,WAAW,CAAA;AAClD,EAAA,OAAO,CAAC,GAAG,YAAY,CAAA,CAAE,MAAA,CAAO,CAAC,IAAA,KAAS,CAAC,OAAA,CAAQ,GAAA,CAAI,IAAI,CAAC,EAAE,IAAA,EAAK;AACrE","file":"guard.js","sourcesContent":["/**\n * The token guard: every custom property a stylesheet reads must be defined\n * somewhere the consumer actually loads.\n *\n * CSS fails silently here, which is what makes this worth shipping. `var(--x)`\n * for an undefined `--x` is invalid at computed-value time: the declaration is\n * dropped and the property inherits. No console warning, no build error,\n * nothing in review: a focus ring is simply absent, and a missing duration\n * looks like a design choice.\n *\n * drift wrote this check for itself and it caught five roles before they\n * reached a screen: `--color-ink-on-primary`, `--elevation-floating`,\n * `--motion-duration-emphasis`, `--radius-marker` and `--haus-focus-ring-error`.\n * The contract is defined in this package, so the check belongs here rather\n * than being rewritten by every consumer (haus#19). Shipping it is also the\n * point: a design system that can say *you have not loaded what my components\n * read* is a different thing from one that hopes you did.\n *\n * **Pure, and it does no file reading.** It takes CSS as strings, so it runs in\n * a Vitest suite, a Node script, a build step or a browser without this package\n * growing a dependency on `node:fs`. The consumer knows which files it loads;\n * this only knows what the contract is.\n *\n * @example\n * ```ts\n * import { readFileSync } from 'node:fs'\n * import { findUndefinedTokens } from 'haus-tokens/guard'\n *\n * const read = (p: string) => readFileSync(p, 'utf8')\n *\n * it('reads no role this app does not load', () => {\n * const { missing } = findUndefinedTokens({\n * reads: [read('node_modules/haus-components/dist/styles.css')],\n * defines: [\n * read('node_modules/haus-tokens/dist/primitives.css'),\n * read('node_modules/haus-tokens/dist/semantics.css'),\n * read('node_modules/haus-tokens/dist/motion.css'),\n * read('src/tokens/overrides.css'),\n * ],\n * })\n * expect(missing).toEqual([])\n * })\n * ```\n */\n\n/**\n * `var(--x)` with no fallback.\n *\n * A reference with a fallback, `var(--x, 0.2s)`, is a real value whether or\n * not the property is set, so it cannot fail at computed-value time and is\n * excluded. It is still usually a sign the name is wrong: a fallback that never\n * loses is a hardcoded value wearing a token's clothes. `findFallbackTokens`\n * below is for looking at those deliberately, rather than failing a build on\n * something that works.\n */\nconst READ_NO_FALLBACK = /var\\(\\s*(--[a-zA-Z0-9-]+)\\s*\\)/g\nconst READ_WITH_FALLBACK = /var\\(\\s*(--[a-zA-Z0-9-]+)\\s*,/g\n// `m` is load-bearing: without it `^` only matches the start of the whole\n// string, so every declaration on its own indented line after a newline is\n// missed and only the first in each block is seen. Caught by this package's\n// own guard test: semantics.css came back with 41 undefined roles.\nconst DECLARATION = /(?:^|[;{])\\s*(--[a-zA-Z0-9-]+)\\s*:/gm\n\nexport interface TokenGuardInput {\n /** The CSS doing the reading: the component stylesheet, your own modules. */\n reads: string[]\n /** The CSS doing the defining: the token layers you load, plus your own. */\n defines: string[]\n /**\n * Names defined outside CSS. A component that sets `style={{ '--x': v }}`\n * defines the property on the element, and no stylesheet will show it.\n */\n alsoDefined?: string[]\n}\n\nexport interface TokenGuardResult {\n /** Read with no fallback and defined nowhere. Sorted, so a diff is stable. */\n missing: string[]\n /** Every property read without a fallback. */\n read: string[]\n /** Every property declared across `defines` and `alsoDefined`. */\n defined: string[]\n}\n\nfunction collect(sources: string[], pattern: RegExp): Set<string> {\n const found = new Set<string>()\n for (const css of sources) {\n // A fresh lastIndex per source: a /g regex is stateful, and reusing one\n // across inputs silently skips the start of every source after the first.\n pattern.lastIndex = 0\n for (const m of css.matchAll(pattern)) found.add(m[1]!)\n }\n return found\n}\n\n/**\n * Which properties are read but never defined.\n *\n * An empty `missing` is the assertion. The other two fields are for a consumer\n * that wants to report rather than fail: `read.length` is also worth asserting\n * as a floor, because a wrong path makes every check pass by finding nothing.\n */\nexport function findUndefinedTokens(input: TokenGuardInput): TokenGuardResult {\n const read = collect(input.reads, READ_NO_FALLBACK)\n const defined = collect(input.defines, DECLARATION)\n for (const name of input.alsoDefined ?? []) defined.add(name)\n\n return {\n missing: [...read].filter((name) => !defined.has(name)).sort(),\n read: [...read].sort(),\n defined: [...defined].sort(),\n }\n}\n\n/**\n * Which properties are only ever read with a fallback.\n *\n * Not a failure, and deliberately a separate function so it cannot be mistaken\n * for one. A fallback that never loses is a hardcoded value wearing a token's\n * clothes, and the list is worth reading occasionally rather than failing a\n * build over.\n */\nexport function findFallbackTokens(input: Pick<TokenGuardInput, 'reads' | 'defines'>): string[] {\n const withFallback = collect(input.reads, READ_WITH_FALLBACK)\n const defined = collect(input.defines, DECLARATION)\n return [...withFallback].filter((name) => !defined.has(name)).sort()\n}\n"]}