@assure-one/design-system 1.31.0 → 1.33.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.
Files changed (42) hide show
  1. package/README.md +61 -6
  2. package/codemods/0.2.0-radix-migration.mjs +315 -0
  3. package/codemods/README.md +365 -0
  4. package/codemods/lib/css-selectors.mjs +33 -0
  5. package/codemods/lib/css-values.mjs +223 -0
  6. package/codemods/lib/ds-stylesheet.mjs +168 -0
  7. package/codemods/lib/environment.mjs +72 -0
  8. package/codemods/lib/files.mjs +100 -0
  9. package/codemods/lib/forms.mjs +253 -0
  10. package/codemods/lib/jsx.mjs +0 -0
  11. package/codemods/lib/ledger.mjs +84 -0
  12. package/codemods/lib/registry.mjs +32 -0
  13. package/codemods/lib/report.mjs +119 -0
  14. package/codemods/lib/runner.mjs +164 -0
  15. package/codemods/run.mjs +161 -0
  16. package/codemods/transforms/cm-14-hidden-mirrors.mjs +275 -0
  17. package/codemods/transforms/cm-15-dom-selectors.mjs +573 -0
  18. package/codemods/transforms/cm-16-globals-css.mjs +487 -0
  19. package/codemods/transforms/cm-20-select-sentinels.mjs +442 -0
  20. package/dist/css/base.css +60 -0
  21. package/dist/css/components.css +7 -0
  22. package/dist/css/legacy-aliases.css +665 -0
  23. package/dist/css/shadcn.css +155 -0
  24. package/dist/css/tailwind.css +296 -0
  25. package/dist/css/tokens.css +630 -0
  26. package/dist/index.d.ts +807 -27
  27. package/dist/index.js +2038 -683
  28. package/dist/index.js.map +1 -1
  29. package/dist/styles.css +1 -1
  30. package/dist/system-BDU18fVg.d.ts +559 -0
  31. package/dist/testing/index.cjs +458 -0
  32. package/dist/testing/index.d.cts +253 -0
  33. package/dist/testing/index.d.ts +253 -0
  34. package/dist/testing/index.js +452 -0
  35. package/dist/testing/setup.cjs +123 -0
  36. package/dist/testing/setup.js +121 -0
  37. package/dist/testing/style-stub.cjs +7 -0
  38. package/dist/testing/style-stub.js +5 -0
  39. package/dist/tokens/index.d.ts +50 -439
  40. package/dist/tokens/index.js +557 -50
  41. package/dist/tokens/index.js.map +1 -1
  42. package/package.json +74 -5
@@ -0,0 +1,365 @@
1
+ # @assure-one/design-system codemods
2
+
3
+ Migration tooling shipped inside the package: every codemod of the program in
4
+ implementation plan §29 runs from the installed copy in a consumer project, so
5
+ a consumer never installs a second tool and always runs the codemods that match
6
+ its design-system version.
7
+
8
+ ## Running a codemod
9
+
10
+ ```bash
11
+ # What is available, and what this project has not applied yet
12
+ node node_modules/@assure-one/design-system/codemods/run.mjs list
13
+
14
+ # Preview: report only, no file is written
15
+ node node_modules/@assure-one/design-system/codemods/run.mjs CM-15 src --dry --print
16
+
17
+ # Apply, and write a JSON + Markdown report next to the run
18
+ node node_modules/@assure-one/design-system/codemods/run.mjs CM-05 src --report reports/cm-05
19
+
20
+ # Everything this project is missing, in program order
21
+ node node_modules/@assure-one/design-system/codemods/run.mjs upgrade --dry
22
+ ```
23
+
24
+ There is deliberately **no** `bin` entry and **no** `exports` entry for the
25
+ codemods: `exports` is locked (L-ENTRIES) and a `bin` would put a command on
26
+ consumers' `PATH` that has nothing to do with using the design system. The file
27
+ path above is the documented interface. Inside this repository the same runner
28
+ is `pnpm codemods <id> <paths>`.
29
+
30
+ | Option | Effect |
31
+ | ------------------------------- | ------------------------------------------------------------------------------ |
32
+ | `--dry` | report what would happen; write neither files nor the ledger |
33
+ | `--root <dir>` | project root (default: the nearest `package.json` above the working directory) |
34
+ | `--report <prefix>` | write `<prefix>.json` and `<prefix>.md` |
35
+ | `--json <file>` / `--md <file>` | write one of the two |
36
+ | `--print` | print the full Markdown report |
37
+ | `--quiet` | print nothing but errors |
38
+ | `--fail-on-findings` | exit 1 when a finding maps to a compatibility contract (for CI gates) |
39
+
40
+ Exit codes: `0` done · `1` findings with `--fail-on-findings` · `2` usage error
41
+ · `3` refused by a guard.
42
+
43
+ Paths are files or directories; with none, the whole project root is scanned.
44
+ `node_modules`, `dist`, `.next`, `build`, `coverage`, `public/` and the other
45
+ generated directories are always skipped, and test files are read only by
46
+ codemods that ask for them.
47
+
48
+ ## Classes
49
+
50
+ | Class | Meaning |
51
+ | ----- | ------------------------------------------------------ |
52
+ | **A** | safely automatic |
53
+ | **R** | automatic, plus a report a human reviews |
54
+ | **X** | report-only; the runner refuses to let it write a file |
55
+
56
+ Nothing that changes submitted form data, business mappings, copy, colour
57
+ _meaning_ or e-sign flow logic is ever automated.
58
+
59
+ ## The ledger — `.ds-migrations.json`
60
+
61
+ Applying a transforming codemod appends an entry to `.ds-migrations.json` in
62
+ the consumer project root:
63
+
64
+ ```json
65
+ {
66
+ "schema": 1,
67
+ "applied": [
68
+ {
69
+ "id": "CM-05",
70
+ "appliedAt": "2026-09-17T09:12:44.001Z",
71
+ "dsVersion": "1.31.0",
72
+ "filesChanged": 12
73
+ }
74
+ ]
75
+ }
76
+ ```
77
+
78
+ Commit it with the codemod's changes. It is what makes
79
+
80
+ - **one-shot codemods safe** — a codemod declaring `oneShot` refuses to run a
81
+ second time (a `--dry` preview is still allowed, with a warning);
82
+ - **prerequisites enforceable** — `requires.codemods` refuses to run before the
83
+ codemods it builds on, and `requires.dsVersion` before the release that ships
84
+ the replacement API;
85
+ - **`upgrade` exact** — a consumer several versions behind gets precisely the
86
+ codemods it is missing, in program order.
87
+
88
+ Report-only codemods and dry runs never touch it. The consumer scanner reads
89
+ the file and the migration dashboard shows the applied ids
90
+ (`migration-status/<app>.json` → `codemodLedger`).
91
+
92
+ ## Reports
93
+
94
+ Every run produces the same report: a JSON document (`schema`, the codemod, the
95
+ design-system version, a summary, the findings, the files changed, what could
96
+ **not** be transformed and parse errors) and a Markdown rendering of it. "Could
97
+ not be transformed" is the part that matters for class A and R codemods: it is
98
+ the list a human has to pick up — spread props, dynamic expressions, conflicting
99
+ `classNames` objects.
100
+
101
+ ## Codemods
102
+
103
+ | Id | Class | What it does |
104
+ | ----- | ----- | ----------------------------------------------------------------------------------------------------------------------------------- |
105
+ | CM-14 | X | hidden-input mirror finder: hidden `<input name>` a consumer added because a design-system control posts nothing |
106
+ | CM-15 | X | DOM-selector finder: consumer code that depends on the internal DOM of design-system components, mapped to registry `C-DOM-*` ids |
107
+ | CM-16 | X | `globals.css` analyser: `@source` into the package, duplicate preflight, colliding `@theme` keys, unlayered globals, legacy `var()` |
108
+ | CM-20 | X | Select sentinel finder: option values standing in for "no value", and the line where each is converted back |
109
+
110
+ The remaining ids of plan §29 land with the waves that ship their replacement
111
+ APIs.
112
+
113
+ ### CM-14 — hidden-input mirror finder
114
+
115
+ Report-only, **for ever**: deleting a mirror changes what the server receives,
116
+ and plan §29 never automates that. It is the input to W4-04 (native form
117
+ participation) and to the registry contract `C-HIDDEN-MIRRORS`.
118
+
119
+ The applications do not put a mirror next to its control — they collect the
120
+ mirrors at the top of the `<form>` and bind the control far below ([CU §18]).
121
+ "Next to" is therefore read as _bound to the same state_:
122
+
123
+ | Rule | Contract | Found |
124
+ | --------------------------- | ---------------- | ------------------------------------------------------------------------------ |
125
+ | `mirror-shared-binding` | C-HIDDEN-MIRRORS | the hidden input's `value` reads a binding a design-system control is bound to |
126
+ | `mirror-in-form` | C-HIDDEN-MIRRORS | same `<form>` as a control that posts nothing today; nothing ties the two |
127
+ | `unregistered-hidden-input` | (unregistered) | no control to mirror — a value the server supplied (a token, an id) |
128
+
129
+ Each finding names the control, the field name and whether that control takes
130
+ `name` **today** (`yes`) or only after Wave 4 (`planned`); severity is `high`
131
+ when the control takes `name` and the call site already passes it, because the
132
+ form then posts the field twice.
133
+
134
+ The third rule is why the report reconciles with the audit's census of every
135
+ hidden `<input>` ([CU §24]): CM-14 reports the same population, classified,
136
+ rather than a smaller number with no explanation. It does not look across
137
+ files — a mirror whose control lives elsewhere lands in `mirror-in-form` or
138
+ `unregistered-hidden-input` and says so. An `<input type={expr}>` whose type is
139
+ computed appears in "could not be transformed".
140
+
141
+ The `name` capability table lives in the transform, and
142
+ `tests/codemods/cm-14.test.mjs` checks it against the design system's own
143
+ sources so it cannot describe components this version does not ship.
144
+
145
+ ### CM-20 — Select sentinel finder
146
+
147
+ Report-only, **for ever**: replacing a sentinel with `null` changes what the
148
+ server receives. It is the input to W4-08 (`Select value: string | null`) and
149
+ to the registry contract `C-SELECT-EMPTY`.
150
+
151
+ The spellings are **found, not assumed**. The registry names `"none"` and
152
+ `"__unassigned__"`; the applications use far more, and `"none"` is also a real
153
+ domain value. CM-20 reports a spelling only where the file itself proves it
154
+ stands in for nothing:
155
+
156
+ | Rule | Contract | Found |
157
+ | --------------------- | -------------- | ----------------------------------------------------------------- |
158
+ | `empty-string-value` | C-SELECT-EMPTY | `value=""` on a Select-family control or an option |
159
+ | `sentinel-dunder` | C-SELECT-EMPTY | a `__…__` spelling used as a Select value |
160
+ | `sentinel-constant` | C-SELECT-EMPTY | a no-value-named constant used as a Select value |
161
+ | `sentinel-word` | C-SELECT-EMPTY | `none`/`all`/… **with** a no-value label or a round trip |
162
+ | `sentinel-discovered` | C-SELECT-EMPTY | any other spelling the file converts to nothing |
163
+ | `sentinel-conversion` | C-SELECT-EMPTY | the line that maps a reported sentinel to `null`/`undefined`/`""` |
164
+
165
+ A known word with no evidence at all is **not** reported: CM-20 would rather
166
+ miss a sentinel than invite someone to change a value the backend depends on.
167
+ Sentinels outside the Select family — grouping keys, tab ids, route segments —
168
+ are out of scope even when the spelling matches, and nothing is followed across
169
+ files.
170
+
171
+ ### CM-15 — DOM-selector finder
172
+
173
+ Report-only. It never modifies a file; the runner throws if it tries. What it
174
+ looks for:
175
+
176
+ - **class tokens that reach into a component's children** on a design-system
177
+ tag — arbitrary variants with a descendant, child or sibling combinator, the
178
+ `*` / `**` child variants, `has-[…]` — and the same tokens on a wrapper
179
+ element whose subtree holds a design-system component;
180
+ - **group references the design system provides** (`group-data-[labels…]`,
181
+ `…/sidebar`, `…/row`);
182
+ - **DOM queries** — `querySelector(All)`, `closest`, `matches`, Playwright
183
+ `locator` — whose selector names design-system internals, including
184
+ selectors held in a file-level constant;
185
+ - **text matching on copy the design system renders** ("Next", "Notifications",
186
+ "Next page", "Clear search", "Back") through `getByText`-style queries,
187
+ `getByRole({ name })` or `textContent` comparisons;
188
+ - **tests that parse the built bundle** (`…/design-system/dist/index.js`);
189
+ - **global CSS selectors** that name design-system internals.
190
+
191
+ Self-only variants (`[&[data-state=open]]:…`, `[&:hover]:…`), Radix
192
+ `data-state`/`data-side`/`data-align` attributes, ARIA roles and cmdk
193
+ attributes are **not** reported: they are permanent contracts (P-DATA-STATE,
194
+ P-ROLES, C-CMDK-ATTR).
195
+
196
+ Findings carry a `registryId` when they match a contract and
197
+ `rule: "unregistered-…"` when they are DOM coupling the registry does not track
198
+ yet — those are the candidates for new registry entries. `confidence` is `high`
199
+ for a token on the component itself, `medium` for a wrapper or a heuristic,
200
+ `low` for an unmapped wrapper.
201
+
202
+ CM-15 feeds 2.0 gate G7 (`consumerSelectors = 0` for the freezes with removal
203
+ target 2.0). Use `--fail-on-findings` to gate on it. The committed findings for
204
+ the four consumer apps are in `docs/codemods/`.
205
+
206
+ ### CM-16 — `globals.css` analyser
207
+
208
+ Report-only, and the input to the consumer compatibility presets (plan §12).
209
+ It finds, with a line number and a severity, the mechanisms by which an
210
+ application restyles design-system components today:
211
+
212
+ | Rule | Contract | Gate | Found |
213
+ | ------------------------- | --------------- | ---- | ------------------------------------------------------------------------ |
214
+ | `source-into-ds` | C-CSS-SOURCE | G2 | `@source` pointing into the design-system package |
215
+ | `ds-styles-import` | C-CSS-STYLES | G1 | `@assure-one/design-system/styles.css` imported (from CSS or from code) |
216
+ | `duplicate-preflight` | C-CSS-STYLES | G1 | the app compiles a Tailwind preflight while the DS sheet ships one too |
217
+ | `theme-collision-differs` | C-TOKENS-LEGACY | — | an `@theme` key that shadows a DS token **with a different value** |
218
+ | `theme-collision-equal` | C-TOKENS-LEGACY | — | the same name with the same value today |
219
+ | `legacy-var-write` | C-TOKENS-WRITE | — | a DS token name declared outside `@theme` — a deliberate override |
220
+ | `legacy-var-read` | C-TOKENS-LEGACY | — | `var(--<ds token>)` read by app CSS |
221
+ | `brand-scope-spelling` | C-CSS-DARKCLASS | — | DS tokens re-declared under `.dark` or `[data-brand="…"]` |
222
+ | `dark-variant-mismatch` | C-CSS-DARKCLASS | — | an app `dark` variant that cannot match the `.dark` element itself |
223
+ | `unlayered-ds-dom` | (unregistered) | — | an unlayered app rule that styles DS markup (`button`, `*`, `.shadow-…`) |
224
+ | `layered-ds-dom` | (unregistered) | — | the same, inside the app's own `base`/`components` layer |
225
+
226
+ Severity is `high` for anything that blocks G1/G2 or changes what a DS
227
+ component renders, `medium` for coupling that a rename has to carry, `low` for
228
+ what is only fragile today.
229
+
230
+ Boundary with CM-15: selectors that name design-system **internals**
231
+ (`[data-radix-…]`, `[data-esign-…]`, `[data-labels]`, `[cmdk-…]`, the toast
232
+ region) are the `C-DOM-*` contracts and belong to CM-15, which already reports
233
+ them from CSS. CM-16 skips them, so the two finders never count the same rule
234
+ twice.
235
+
236
+ CM-16 reads the **authored** stylesheets; `scripts/derive-consumer-preset`
237
+ (W1-18) reads the **compiled** ones with a full cascade. The two therefore
238
+ count different things on purpose — `docs/codemods/cm-16-globals-css.md` puts
239
+ the numbers side by side and explains every difference. Both call two values
240
+ "different" through the same normalisation (`codemods/lib/css-values.mjs`),
241
+ which is why the value-level counts can be compared at all.
242
+
243
+ It parses CSS with the project's own `postcss` (borrowed exactly as
244
+ `typescript` is, see "Packaging"), and compares against the design system's
245
+ own `dist/styles.css` from the installed package.
246
+
247
+ ## Writing a codemod
248
+
249
+ A codemod is one module in `codemods/transforms/`, registered in
250
+ `codemods/lib/registry.mjs` (the registry order is the program order that
251
+ `upgrade` follows):
252
+
253
+ ```js
254
+ export const meta = {
255
+ id: "CM-07",
256
+ title: "inputSize → size on Input, Textarea and SearchInput",
257
+ class: "A",
258
+ oneShot: false,
259
+ requires: { codemods: [], dsVersion: "1.32.0" },
260
+ parses: ["code"], // "code" and/or "css"
261
+ includeTests: false,
262
+ usesTypeScript: true,
263
+ usesPostcss: false,
264
+ registryIds: ["C-INPUT-SIZE"],
265
+ };
266
+
267
+ export function transform(file, { ts, postcss }) {
268
+ // file: { rel, path, source, kind, test }
269
+ return { output, findings, notTransformed, parseErrors };
270
+ }
271
+ ```
272
+
273
+ `usesTypeScript` and `usesPostcss` ask the runner to resolve that parser from
274
+ the project and inject it; a codemod never imports one itself.
275
+
276
+ The runner owns everything else: the guards, which files are read, writing
277
+ files and the ledger, the report, and the rule that a class X codemod may not
278
+ change anything.
279
+
280
+ Fixtures live in `codemods/__test__/<id>/*.snap` — one file per case, holding a
281
+ small project and the expected findings (and, for transforming codemods, the
282
+ expected output):
283
+
284
+ ```
285
+ === file: src/thing.tsx
286
+ <source>
287
+ === expect
288
+ { "findings": [ ["src/thing.tsx:12", "C-DOM-03", "class-on-component", "<class token>"] ] }
289
+ ```
290
+
291
+ `codemods/__test__/harness.mjs` materialises a fixture in a temporary
292
+ directory, runs the codemod **twice** and asserts that the second run finds the
293
+ same things and changes nothing — the idempotency requirement of plan §29.
294
+ `tests/codemods/*.test.mjs` runs all of it as part of `pnpm test:contracts`.
295
+
296
+ Fixtures are `.snap` on purpose: Tailwind scans every other repository file for
297
+ class names, and a fixture full of consumer classes would change
298
+ `dist/styles.css`. `tests/codemods/tailwind-inert.test.mjs` proves the codemod
299
+ sources, fixtures and findings stay invisible to that scan — which is also why
300
+ class-like text in a `.mjs` or `.md` file here is assembled from parts.
301
+
302
+ ## Packaging
303
+
304
+ `codemods` is part of the published tarball (`package.json` → `files`);
305
+ `codemods/__test__` is not. The codemods need `typescript` at run time and
306
+ resolve it from the consumer project, so the published package gains no
307
+ dependency. See `docs/codemods/README.md` for that decision.
308
+
309
+ ## Legacy: 0.2.0-radix-migration.mjs
310
+
311
+ The pre-program codemod, written before the runner existed. It is a
312
+ jscodeshift transform and is run with `npx jscodeshift`, not with `run.mjs`.
313
+
314
+ ### Run it
315
+
316
+ From the consumer repo (firm or portal), with `@assure-one/design-system@0.2.0` installed:
317
+
318
+ ```bash
319
+ # Dry-run first to preview
320
+ npx jscodeshift -t node_modules/@assure-one/design-system/codemods/0.2.0-radix-migration.mjs \
321
+ --extensions=tsx,ts,jsx,js --parser=tsx --dry --print 'src/**/*.{ts,tsx}'
322
+
323
+ # Apply
324
+ VERBOSE=1 npx jscodeshift -t node_modules/@assure-one/design-system/codemods/0.2.0-radix-migration.mjs \
325
+ --extensions=tsx,ts,jsx,js --parser=tsx 'src/**/*.{ts,tsx}'
326
+ ```
327
+
328
+ ### What it changes
329
+
330
+ 1. `Slider` value props become `number[]` (Radix range convention)
331
+ - `<Slider value={50} />` becomes `<Slider value={[50]} />`
332
+ - `<Slider defaultValue={75} />` becomes `<Slider defaultValue={[75]} />`
333
+ - Adds a `// TODO 0.2.0: onValueChange now receives number[]` comment above any `<Slider>` with `onValueChange` so you review the callback body.
334
+ - Identifier expressions (`value={vol}`) get a TODO instead of being auto-wrapped — we can't tell from AST if the variable is already `number[]`. Tsc will catch the rest.
335
+
336
+ 2. `Avatar` no longer accepts a `next/image` child
337
+ - `<Avatar><Image src=".." alt=".." /></Avatar>` becomes `<Avatar src=".." alt=".." />`
338
+ - Inner non-`next/image` children are left alone.
339
+ - If the `Avatar` already has `src`, the inner `<Image>` is left and a TODO is emitted.
340
+
341
+ ### Explicit no-ops (documented for completeness)
342
+
343
+ - `useToast` — fully back-compat. `useToast()` returned `{ toast }` in 0.1.x; 0.2.0 returns `{ toast, dismiss }`. Existing destructures keep working; `dismiss` is just newly available.
344
+ - `TeamMemberSelect` — the `value: ""` (unassigned) consumer contract is preserved. The `__unassigned__` sentinel is internal.
345
+
346
+ ### Scoping
347
+
348
+ The transform only rewrites `Slider`/`Avatar` JSX whose name is imported from `@assure-one/design-system` in the same file. Local components named `Slider` or `Avatar` from other paths are left alone.
349
+
350
+ ### Idempotency
351
+
352
+ Running twice produces zero further changes. Already-array Slider props and already-flattened Avatars are detected and skipped.
353
+
354
+ ### Validate the output
355
+
356
+ Test fixtures live in `codemods/__test__/*.input.tsx`. To smoke-test against them:
357
+
358
+ ```bash
359
+ cp codemods/__test__/slider.input.tsx /tmp/slider.tsx
360
+ VERBOSE=1 npx jscodeshift -t codemods/0.2.0-radix-migration.mjs \
361
+ --extensions=tsx --parser=tsx /tmp/slider.tsx
362
+ diff codemods/__test__/slider.input.tsx /tmp/slider.tsx
363
+ ```
364
+
365
+ After running on your codebase, run `tsc --noEmit` to catch any leftover number-vs-`number[]` mismatches the TODO comments flagged.
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Rule selectors of a CSS file with their line numbers, without a CSS
3
+ * parser dependency. Comments and strings are blanked (keeping offsets), then
4
+ * every `prelude {` whose prelude is not an at-rule is a selector. Good
5
+ * enough for finding selectors; not a validator.
6
+ */
7
+ export function cssSelectors(text) {
8
+ const blank = (s) => s.replace(/[^\n]/g, " ");
9
+ const clean = text
10
+ .replace(/\/\*[\s\S]*?\*\//g, blank)
11
+ .replace(
12
+ /"(?:\\.|[^"\\\n])*"|'(?:\\.|[^'\\\n])*'/g,
13
+ (m) => m[0] + blank(m.slice(1, -1)) + m[0],
14
+ );
15
+ const out = [];
16
+ let start = 0;
17
+ for (let i = 0; i < clean.length; i += 1) {
18
+ const ch = clean[i];
19
+ if (ch === "{" || ch === "}" || ch === ";") {
20
+ if (ch === "{") {
21
+ const prelude = clean.slice(start, i);
22
+ const offset = start + (prelude.length - prelude.trimStart().length);
23
+ const selector = text.slice(offset, i).trim();
24
+ if (selector && !selector.startsWith("@") && !/^[\d.%,\s]+$|^(?:from|to)$/.test(selector)) {
25
+ const line = text.slice(0, offset).split("\n").length;
26
+ out.push({ selector: selector.replace(/\s+/g, " "), line });
27
+ }
28
+ }
29
+ start = i + 1;
30
+ }
31
+ }
32
+ return out;
33
+ }
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Custom-property values: normalisation for comparison, and `var()`
3
+ * resolution. Dependency-free and pure — no file system, no CSS parser — so
4
+ * it can ship inside the package and run from a consumer project.
5
+ *
6
+ * It is the single implementation shared by
7
+ * `scripts/derive-consumer-preset` (W1-18) and CM-16: the two tools must
8
+ * agree on when two declarations of the same token "differ", or their counts
9
+ * cannot be compared. `scripts/derive-consumer-preset/lib/normalize.mjs` and
10
+ * `…/lib/cascade.mjs` re-export from here.
11
+ *
12
+ * Two values are "the same" when their normalised text is equal. The
13
+ * normalisation is conservative: it only removes differences that cannot
14
+ * change rendering (case of hex colours and keywords, short hex, whitespace,
15
+ * quotes around font names, `calc()` over px/rem only, trailing zeros). It
16
+ * does not convert between colour spaces: `#6c42f8` and an `oklab(...)`
17
+ * spelling of the same colour stay different, and the report shows both.
18
+ */
19
+
20
+ const ROOT_FONT_SIZE_PX = 16;
21
+
22
+ export function normalizeValue(value) {
23
+ if (value == null) return value;
24
+ let v = String(value).trim().replace(/\s+/g, " ");
25
+ v = v
26
+ .replace(/\s*,\s*/g, ",")
27
+ .replace(/\(\s+/g, "(")
28
+ .replace(/\s+\)/g, ")");
29
+ // Minifiers drop optional whitespace: `…) infinite` -> `…)infinite`, `16 / 9` -> `16/9`.
30
+ v = v.replace(/\)\s*(?=[\w#.-])/g, ") ").replace(/\s*\/\s*/g, "/");
31
+ v = v.replace(/#([0-9a-f]{3,8})\b/gi, (_, hex) => `#${expandHex(hex.toLowerCase())}`);
32
+ v = v.replace(/["']([^"',]+)["']/g, "$1");
33
+ v = evaluateCalcs(v);
34
+ v = v.replace(/(\d*\.\d*?)0+(?=[a-z%]|\b)/gi, (m, num) =>
35
+ num.endsWith(".") ? num.slice(0, -1) : num,
36
+ );
37
+ v = v.replace(/(^|[\s(,])0\.(\d)/g, "$1.$2");
38
+ if (/^[A-Za-z-]+$/.test(v)) v = v.toLowerCase();
39
+ if (/^-?\d*\.?\d+rem$/.test(v)) v = `${lengthToPx(v)}px`;
40
+ return v;
41
+ }
42
+
43
+ function expandHex(hex) {
44
+ if (hex.length === 3 || hex.length === 4) return [...hex].map((c) => c + c).join("");
45
+ return hex;
46
+ }
47
+
48
+ /** Evaluate innermost calc() expressions made only of px/rem/unitless terms. */
49
+ export function evaluateCalcs(value) {
50
+ let prev;
51
+ let v = value;
52
+ do {
53
+ prev = v;
54
+ v = v.replace(/calc\(([^()]*)\)/g, (whole, expr) => {
55
+ const result = evaluateLength(expr);
56
+ return result === null ? whole : result;
57
+ });
58
+ } while (v !== prev);
59
+ return v;
60
+ }
61
+
62
+ /**
63
+ * Evaluate `a op b op c` where terms are px, rem or unitless numbers.
64
+ * Returns a px string ("8px") or a unitless number string, or null.
65
+ */
66
+ export function evaluateLength(expr) {
67
+ const tokens = expr.match(/-?\d*\.?\d+(?:px|rem)?|[+\-*/]/g);
68
+ if (!tokens || tokens.join("").replace(/\s/g, "") !== expr.replace(/\s/g, "")) return null;
69
+ // Terms: { n: number, unit: "px" | "" }
70
+ const terms = [];
71
+ const ops = [];
72
+ let expectTerm = true;
73
+ for (const t of tokens) {
74
+ if (expectTerm) {
75
+ const m = /^(-?\d*\.?\d+)(px|rem)?$/.exec(t);
76
+ if (!m) return null;
77
+ const n = Number(m[1]) * (m[2] === "rem" ? ROOT_FONT_SIZE_PX : 1);
78
+ terms.push({ n, unit: m[2] ? "px" : "" });
79
+ expectTerm = false;
80
+ } else {
81
+ if (!/^[+\-*/]$/.test(t)) return null;
82
+ ops.push(t);
83
+ expectTerm = true;
84
+ }
85
+ }
86
+ if (expectTerm) return null;
87
+ // * and / first
88
+ for (let i = 0; i < ops.length; ) {
89
+ if (ops[i] === "*" || ops[i] === "/") {
90
+ const a = terms[i];
91
+ const b = terms[i + 1];
92
+ let r;
93
+ if (ops[i] === "*") {
94
+ if (a.unit && b.unit) return null;
95
+ r = { n: a.n * b.n, unit: a.unit || b.unit };
96
+ } else {
97
+ if (b.unit || b.n === 0) return null;
98
+ r = { n: a.n / b.n, unit: a.unit };
99
+ }
100
+ terms.splice(i, 2, r);
101
+ ops.splice(i, 1);
102
+ } else i++;
103
+ }
104
+ let acc = terms[0];
105
+ for (let i = 0; i < ops.length; i++) {
106
+ const b = terms[i + 1];
107
+ if (acc.unit !== b.unit) return null;
108
+ acc = { n: ops[i] === "+" ? acc.n + b.n : acc.n - b.n, unit: acc.unit };
109
+ }
110
+ const n = Math.round(acc.n * 10000) / 10000;
111
+ return `${n}${acc.unit}`;
112
+ }
113
+
114
+ /** Normalise a length-ish value to px when it is a plain rem/px value. */
115
+ export function lengthToPx(value) {
116
+ const m = /^(-?\d*\.?\d+)(px|rem)$/.exec(String(value).trim());
117
+ if (!m) return null;
118
+ return Number(m[1]) * (m[2] === "rem" ? ROOT_FONT_SIZE_PX : 1);
119
+ }
120
+
121
+ export function sameValue(a, b) {
122
+ if (a === undefined || b === undefined) return a === b;
123
+ const na = normalizeValue(a);
124
+ const nb = normalizeValue(b);
125
+ if (na === nb) return true;
126
+ const pa = lengthToPx(na);
127
+ const pb = lengthToPx(nb);
128
+ return pa !== null && pa === pb;
129
+ }
130
+
131
+ /** Names referenced through `var(--name…)` in a value, in order. */
132
+ export function referencedVars(value) {
133
+ return [...String(value).matchAll(/var\(\s*(--[\w-]+)/g)].map((m) => m[1]);
134
+ }
135
+
136
+ const INVALID = Symbol("invalid");
137
+
138
+ /**
139
+ * Compute custom-property values for one element.
140
+ * @param {Map<string, {value: string}>} winners own declarations
141
+ * @param {Map<string, string>} inherited parent's (or, for CM-16, the design
142
+ * system sheet's) computed values
143
+ * @returns {Map<string, string>} computed values (invalid ones omitted)
144
+ */
145
+ export function computeCustomProperties(winners, inherited = new Map()) {
146
+ const computed = new Map();
147
+ const state = new Map(); // prop -> "visiting" | "done"
148
+ const visit = (prop) => {
149
+ if (!winners.has(prop)) return inherited.has(prop) ? inherited.get(prop) : undefined;
150
+ if (state.get(prop) === "done") return computed.has(prop) ? computed.get(prop) : INVALID;
151
+ if (state.get(prop) === "visiting") return INVALID;
152
+ state.set(prop, "visiting");
153
+ const value = substitute(winners.get(prop).value, visit);
154
+ state.set(prop, "done");
155
+ if (value === INVALID) computed.delete(prop);
156
+ else computed.set(prop, value);
157
+ return value;
158
+ };
159
+ for (const prop of winners.keys()) visit(prop);
160
+ const out = new Map(inherited);
161
+ for (const prop of winners.keys()) {
162
+ if (computed.has(prop)) out.set(prop, computed.get(prop));
163
+ else out.delete(prop); // guaranteed-invalid: behaves as unset for custom props
164
+ }
165
+ return out;
166
+ }
167
+
168
+ /** Replace every var() in `value`; returns INVALID when a cycle is hit. */
169
+ function substitute(value, lookup) {
170
+ let out = "";
171
+ let i = 0;
172
+ while (i < value.length) {
173
+ const start = value.indexOf("var(", i);
174
+ if (start === -1 || (start > 0 && /[\w-]/.test(value[start - 1]))) {
175
+ if (start === -1) {
176
+ out += value.slice(i);
177
+ break;
178
+ }
179
+ out += value.slice(i, start + 4);
180
+ i = start + 4;
181
+ continue;
182
+ }
183
+ out += value.slice(i, start);
184
+ const end = matchParen(value, start + 3);
185
+ if (end === -1) {
186
+ out += value.slice(start);
187
+ break;
188
+ }
189
+ const inner = value.slice(start + 4, end);
190
+ const comma = topLevelComma(inner);
191
+ const name = (comma === -1 ? inner : inner.slice(0, comma)).trim();
192
+ const fallback = comma === -1 ? null : inner.slice(comma + 1).trim();
193
+ const resolved = lookup(name);
194
+ if (resolved === INVALID) return INVALID;
195
+ if (resolved !== undefined) out += resolved;
196
+ else if (fallback !== null) {
197
+ const fb = substitute(fallback, lookup);
198
+ if (fb === INVALID) return INVALID;
199
+ out += fb;
200
+ } else out += `var(${name})`; // external: keep it visible
201
+ i = end + 1;
202
+ }
203
+ return out;
204
+ }
205
+
206
+ function matchParen(s, open) {
207
+ let depth = 0;
208
+ for (let i = open; i < s.length; i++) {
209
+ if (s[i] === "(") depth++;
210
+ else if (s[i] === ")" && --depth === 0) return i;
211
+ }
212
+ return -1;
213
+ }
214
+
215
+ function topLevelComma(s) {
216
+ let depth = 0;
217
+ for (let i = 0; i < s.length; i++) {
218
+ if (s[i] === "(") depth++;
219
+ else if (s[i] === ")") depth--;
220
+ else if (s[i] === "," && depth === 0) return i;
221
+ }
222
+ return -1;
223
+ }