@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.
- package/README.md +61 -6
- package/codemods/0.2.0-radix-migration.mjs +315 -0
- package/codemods/README.md +365 -0
- package/codemods/lib/css-selectors.mjs +33 -0
- package/codemods/lib/css-values.mjs +223 -0
- package/codemods/lib/ds-stylesheet.mjs +168 -0
- package/codemods/lib/environment.mjs +72 -0
- package/codemods/lib/files.mjs +100 -0
- package/codemods/lib/forms.mjs +253 -0
- package/codemods/lib/jsx.mjs +0 -0
- package/codemods/lib/ledger.mjs +84 -0
- package/codemods/lib/registry.mjs +32 -0
- package/codemods/lib/report.mjs +119 -0
- package/codemods/lib/runner.mjs +164 -0
- package/codemods/run.mjs +161 -0
- package/codemods/transforms/cm-14-hidden-mirrors.mjs +275 -0
- package/codemods/transforms/cm-15-dom-selectors.mjs +573 -0
- package/codemods/transforms/cm-16-globals-css.mjs +487 -0
- package/codemods/transforms/cm-20-select-sentinels.mjs +442 -0
- package/dist/css/base.css +60 -0
- package/dist/css/components.css +7 -0
- package/dist/css/legacy-aliases.css +665 -0
- package/dist/css/shadcn.css +155 -0
- package/dist/css/tailwind.css +296 -0
- package/dist/css/tokens.css +630 -0
- package/dist/index.d.ts +807 -27
- package/dist/index.js +2038 -683
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/system-BDU18fVg.d.ts +559 -0
- package/dist/testing/index.cjs +458 -0
- package/dist/testing/index.d.cts +253 -0
- package/dist/testing/index.d.ts +253 -0
- package/dist/testing/index.js +452 -0
- package/dist/testing/setup.cjs +123 -0
- package/dist/testing/setup.js +121 -0
- package/dist/testing/style-stub.cjs +7 -0
- package/dist/testing/style-stub.js +5 -0
- package/dist/tokens/index.d.ts +50 -439
- package/dist/tokens/index.js +557 -50
- package/dist/tokens/index.js.map +1 -1
- 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
|
+
}
|