@assure-one/design-system 1.30.0 → 1.32.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 +28 -1
- package/codemods/0.2.0-radix-migration.mjs +315 -0
- package/codemods/README.md +305 -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/jsx.mjs +0 -0
- package/codemods/lib/ledger.mjs +84 -0
- package/codemods/lib/registry.mjs +30 -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-15-dom-selectors.mjs +573 -0
- package/codemods/transforms/cm-16-globals-css.mjs +487 -0
- package/dist/css/base.css +60 -0
- package/dist/css/legacy-aliases.css +489 -0
- package/dist/css/shadcn.css +155 -0
- package/dist/css/tailwind.css +233 -0
- package/dist/css/tokens.css +439 -0
- package/dist/index.d.ts +327 -33
- package/dist/index.js +1843 -706
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- 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 +114 -96
- package/dist/tokens/index.js +67 -48
- package/dist/tokens/index.js.map +1 -1
- package/package.json +84 -14
package/README.md
CHANGED
|
@@ -11,6 +11,7 @@ The single source of truth for visual design across all Assure products.
|
|
|
11
11
|
- [**`CHANGELOG.md`**](./CHANGELOG.md) — release-by-release log of what shipped.
|
|
12
12
|
- [**`CONTRIBUTING.md`**](./CONTRIBUTING.md) — dev loop, release flow, versioning, pitfalls.
|
|
13
13
|
- [**`CLAUDE.md`**](./CLAUDE.md) — invariants and conventions for AI assistants working in this repo.
|
|
14
|
+
- [**`docs/testing.md`**](./docs/testing.md) — rendering the real package in your app's Jest tests (`@assure-one/design-system/testing`, experimental).
|
|
14
15
|
- [**`claude-skills/`**](./claude-skills) — drop-in Claude Code skill for consuming projects.
|
|
15
16
|
|
|
16
17
|
## Install
|
|
@@ -19,7 +20,10 @@ The single source of truth for visual design across all Assure products.
|
|
|
19
20
|
pnpm add @assure-one/design-system
|
|
20
21
|
```
|
|
21
22
|
|
|
22
|
-
Import
|
|
23
|
+
Import the consumer stylesheet once in your root layout. Its bundled component
|
|
24
|
+
utilities intentionally live below your app's Tailwind `utilities` layer, so
|
|
25
|
+
local classes and responsive variants remain authoritative even when a bundler
|
|
26
|
+
reorders CSS chunks:
|
|
23
27
|
|
|
24
28
|
```ts
|
|
25
29
|
import "@assure-one/design-system/styles.css";
|
|
@@ -35,6 +39,29 @@ export default function Page() {
|
|
|
35
39
|
}
|
|
36
40
|
```
|
|
37
41
|
|
|
42
|
+
### The split CSS entries (experimental)
|
|
43
|
+
|
|
44
|
+
`styles.css` stays the supported way to consume the design system, unchanged.
|
|
45
|
+
Alongside it, the package now also publishes the individual stylesheets the new
|
|
46
|
+
CSS mode is built from. They are **experimental**: nothing requires them yet,
|
|
47
|
+
no component reads them, and importing them changes nothing on its own.
|
|
48
|
+
|
|
49
|
+
| Subpath | What it is |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| `./css/tokens.css` | The `--ds-*` design tokens: `:root`, the colour scheme scope and the brand scopes. Usable without any component. |
|
|
52
|
+
| `./css/legacy-aliases.css` | Read-aliases from today's token names to the namespaced ones (`--color-surface: var(--ds-color-surface)`), for as long as your own CSS reads design-system token names. |
|
|
53
|
+
| `./css/tailwind.css` | A Tailwind `@theme` bridge, so `bg-surface/40`, `hover:text-fg-3` and `rounded-control` compile in *your* build instead of silently producing nothing. |
|
|
54
|
+
| `./css/shadcn.css` | The app-vocabulary bridge: `background`, `foreground`, `primary`, `muted`, `destructive`, the radius scale and the rest, each reading one `--app-*` input you can override. |
|
|
55
|
+
| `./css/base.css` | Optional document defaults: body background, ink, font and `color-scheme`. Ships no preflight, no element rules and no font import. |
|
|
56
|
+
|
|
57
|
+
Import order, once the mode is supported end to end, is
|
|
58
|
+
`tokens.css` → `tailwind.css` → `shadcn.css` (optional) → `base.css`
|
|
59
|
+
(optional), after your own `@import "tailwindcss"`. The integration guide with
|
|
60
|
+
the full sequence, the layer statement and the per-app preset lands with the
|
|
61
|
+
rest of Wave 1; until then the reasoning lives in
|
|
62
|
+
[ADR-004](./docs/adr/004-css-delivery-cascade.md) and
|
|
63
|
+
[ADR-005](./docs/adr/005-app-vocabulary-bridge.md).
|
|
64
|
+
|
|
38
65
|
## Develop
|
|
39
66
|
|
|
40
67
|
```bash
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @assure-one/design-system 0.1.x → 0.2.0 migration codemod
|
|
3
|
+
*
|
|
4
|
+
* Run from the consumer repo (firm or portal):
|
|
5
|
+
*
|
|
6
|
+
* npx jscodeshift -t node_modules/@assure-one/design-system/codemods/0.2.0-radix-migration.mjs \
|
|
7
|
+
* --extensions=tsx,ts,jsx,js \
|
|
8
|
+
* --parser=tsx \
|
|
9
|
+
* 'src/**\/*.{ts,tsx,js,jsx}'
|
|
10
|
+
*
|
|
11
|
+
* Or, if you've copied the file locally:
|
|
12
|
+
*
|
|
13
|
+
* npx jscodeshift -t ./codemods/0.2.0-radix-migration.mjs \
|
|
14
|
+
* --extensions=tsx,ts,jsx,js --parser=tsx 'src/**\/*.{ts,tsx}'
|
|
15
|
+
*
|
|
16
|
+
* VERBOSE per-file logs:
|
|
17
|
+
* VERBOSE=1 npx jscodeshift -t ... 'src/**\/*.tsx'
|
|
18
|
+
*
|
|
19
|
+
* What it does
|
|
20
|
+
* ------------
|
|
21
|
+
* 1. <Slider value={n} /> → <Slider value={[n]} />
|
|
22
|
+
* <Slider defaultValue={n} /> → <Slider defaultValue={[n]} />
|
|
23
|
+
* Adds a `// TODO 0.2.0: onValueChange now receives number[]` line above
|
|
24
|
+
* any <Slider> with `onValueChange` so the consumer can review the body.
|
|
25
|
+
*
|
|
26
|
+
* 2. <Avatar><Image src=... alt=... /></Avatar> (next/image inner)
|
|
27
|
+
* → <Avatar src=... alt=... />
|
|
28
|
+
* Drops the inner <Image>. If <Avatar> already has `src`, leaves it and
|
|
29
|
+
* emits a TODO. If the <Image> isn't `next/image`, leaves it untouched.
|
|
30
|
+
*
|
|
31
|
+
* 3. useToast — no-op. `useToast()` already returned `{ toast }`; 0.2.0
|
|
32
|
+
* adds `dismiss` (additive). Existing call sites keep working.
|
|
33
|
+
*
|
|
34
|
+
* 4. TeamMemberSelect — no-op. The `value: ""` (unassigned) contract is
|
|
35
|
+
* preserved on the consumer-facing API; the `__unassigned__` sentinel
|
|
36
|
+
* is internal only.
|
|
37
|
+
*
|
|
38
|
+
* Idempotent: running twice changes nothing the second time. Already-array
|
|
39
|
+
* Slider values and already-flattened Avatars are skipped.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
const SLIDER_NUMERIC_PROPS = ["value", "defaultValue"];
|
|
43
|
+
const TODO_SLIDER = " TODO 0.2.0: onValueChange now receives number[] (was number) ";
|
|
44
|
+
const TODO_SLIDER_IDENT =
|
|
45
|
+
" TODO 0.2.0: Slider value/defaultValue is now number[] — wrap in [] if this expression is still number ";
|
|
46
|
+
const TODO_AVATAR_CONFLICT =
|
|
47
|
+
" TODO 0.2.0: <Avatar> already has src — could not auto-merge with inner next/image child ";
|
|
48
|
+
const TODO_AVATAR_MERGED =
|
|
49
|
+
" TODO 0.2.0: review hoisted Avatar src/alt (was inner next/image child) ";
|
|
50
|
+
|
|
51
|
+
export default function transformer(file, api) {
|
|
52
|
+
const j = api.jscodeshift;
|
|
53
|
+
const root = j(file.source);
|
|
54
|
+
let changed = false;
|
|
55
|
+
const log = [];
|
|
56
|
+
|
|
57
|
+
/* -------------------------------------------------------------------- */
|
|
58
|
+
/* Helpers */
|
|
59
|
+
/* -------------------------------------------------------------------- */
|
|
60
|
+
|
|
61
|
+
function jsxOpeningName(opening) {
|
|
62
|
+
const name = opening.name;
|
|
63
|
+
if (!name) return null;
|
|
64
|
+
if (name.type === "JSXIdentifier") return name.name;
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function findAttr(opening, attrName) {
|
|
69
|
+
return opening.attributes?.find(
|
|
70
|
+
(a) =>
|
|
71
|
+
a.type === "JSXAttribute" &&
|
|
72
|
+
a.name?.type === "JSXIdentifier" &&
|
|
73
|
+
a.name.name === attrName,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isAlreadyArrayExpression(attr) {
|
|
78
|
+
if (!attr || attr.type !== "JSXAttribute") return false;
|
|
79
|
+
const v = attr.value;
|
|
80
|
+
if (!v || v.type !== "JSXExpressionContainer") return false;
|
|
81
|
+
const expr = v.expression;
|
|
82
|
+
if (!expr) return false;
|
|
83
|
+
// already array literal
|
|
84
|
+
if (expr.type === "ArrayExpression") return true;
|
|
85
|
+
// identifier / member / call — assume the consumer is already passing an
|
|
86
|
+
// array; do not double-wrap. (Idempotency over a brittle type check.)
|
|
87
|
+
return (
|
|
88
|
+
expr.type === "Identifier" ||
|
|
89
|
+
expr.type === "MemberExpression" ||
|
|
90
|
+
expr.type === "CallExpression" ||
|
|
91
|
+
expr.type === "ConditionalExpression" ||
|
|
92
|
+
expr.type === "LogicalExpression"
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isScalarNumericLiteralExpression(attr) {
|
|
97
|
+
if (!attr || attr.type !== "JSXAttribute") return false;
|
|
98
|
+
const v = attr.value;
|
|
99
|
+
// Bare `value=42` is not legal JSX numeric, so we only care about expr
|
|
100
|
+
// containers wrapping a NumericLiteral or unary minus numeric.
|
|
101
|
+
if (!v || v.type !== "JSXExpressionContainer") return false;
|
|
102
|
+
const e = v.expression;
|
|
103
|
+
if (!e) return false;
|
|
104
|
+
if (e.type === "NumericLiteral" || e.type === "Literal") {
|
|
105
|
+
return typeof e.value === "number";
|
|
106
|
+
}
|
|
107
|
+
if (
|
|
108
|
+
e.type === "UnaryExpression" &&
|
|
109
|
+
e.operator === "-" &&
|
|
110
|
+
e.argument &&
|
|
111
|
+
(e.argument.type === "NumericLiteral" ||
|
|
112
|
+
(e.argument.type === "Literal" && typeof e.argument.value === "number"))
|
|
113
|
+
) {
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function wrapAttrInArray(attr) {
|
|
120
|
+
const expr = attr.value.expression;
|
|
121
|
+
attr.value = j.jsxExpressionContainer(j.arrayExpression([expr]));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function hasLeadingComment(node, text) {
|
|
125
|
+
const list = node.leadingComments || [];
|
|
126
|
+
return list.some((c) => c.value === text);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function addLeadingComment(path, text) {
|
|
130
|
+
const node = path.node;
|
|
131
|
+
if (hasLeadingComment(node, text)) return false;
|
|
132
|
+
node.comments = node.comments || [];
|
|
133
|
+
const comment = { type: "CommentLine", value: text, leading: true, trailing: false };
|
|
134
|
+
node.comments.unshift(comment);
|
|
135
|
+
// jscodeshift / recast also reads `leadingComments`
|
|
136
|
+
node.leadingComments = node.leadingComments || [];
|
|
137
|
+
node.leadingComments.unshift(comment);
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/* -------------------------------------------------------------------- */
|
|
142
|
+
/* Detect imported local names */
|
|
143
|
+
/* */
|
|
144
|
+
/* We only want to touch <Slider> and <Avatar> when they come from */
|
|
145
|
+
/* @assure-one/design-system (or are unambiguous globally). To stay */
|
|
146
|
+
/* safe across the firm + portal codebases, we scope by import source. */
|
|
147
|
+
/* -------------------------------------------------------------------- */
|
|
148
|
+
|
|
149
|
+
const dsLocalNames = new Set();
|
|
150
|
+
const nextImageLocalNames = new Set();
|
|
151
|
+
|
|
152
|
+
root.find(j.ImportDeclaration).forEach((path) => {
|
|
153
|
+
const src = path.node.source?.value;
|
|
154
|
+
if (!src) return;
|
|
155
|
+
if (src === "@assure-one/design-system") {
|
|
156
|
+
for (const spec of path.node.specifiers || []) {
|
|
157
|
+
if (spec.type === "ImportSpecifier" && spec.local?.name) {
|
|
158
|
+
dsLocalNames.add(spec.local.name);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (src === "next/image") {
|
|
163
|
+
for (const spec of path.node.specifiers || []) {
|
|
164
|
+
if (
|
|
165
|
+
(spec.type === "ImportDefaultSpecifier" ||
|
|
166
|
+
spec.type === "ImportSpecifier") &&
|
|
167
|
+
spec.local?.name
|
|
168
|
+
) {
|
|
169
|
+
nextImageLocalNames.add(spec.local.name);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// The DS exports `Slider` and `Avatar` under those exact names. If the
|
|
176
|
+
// consumer didn't import them from DS we leave their JSX alone — that
|
|
177
|
+
// avoids touching a local component that happens to share a name.
|
|
178
|
+
const sliderLocal = dsLocalNames.has("Slider") ? "Slider" : null;
|
|
179
|
+
const avatarLocal = dsLocalNames.has("Avatar") ? "Avatar" : null;
|
|
180
|
+
|
|
181
|
+
/* -------------------------------------------------------------------- */
|
|
182
|
+
/* Change 1 — Slider value/defaultValue scalar → array */
|
|
183
|
+
/* -------------------------------------------------------------------- */
|
|
184
|
+
|
|
185
|
+
if (sliderLocal) {
|
|
186
|
+
root
|
|
187
|
+
.find(j.JSXElement, {
|
|
188
|
+
openingElement: { name: { type: "JSXIdentifier", name: sliderLocal } },
|
|
189
|
+
})
|
|
190
|
+
.forEach((path) => {
|
|
191
|
+
const opening = path.node.openingElement;
|
|
192
|
+
|
|
193
|
+
let wrappedAny = false;
|
|
194
|
+
let needsIdentTodo = false;
|
|
195
|
+
for (const propName of SLIDER_NUMERIC_PROPS) {
|
|
196
|
+
const attr = findAttr(opening, propName);
|
|
197
|
+
if (!attr) continue;
|
|
198
|
+
if (isScalarNumericLiteralExpression(attr)) {
|
|
199
|
+
wrapAttrInArray(attr);
|
|
200
|
+
changed = true;
|
|
201
|
+
wrappedAny = true;
|
|
202
|
+
log.push(`Slider: ${propName}={n} → ${propName}={[n]}`);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (isAlreadyArrayExpression(attr)) {
|
|
206
|
+
// Could be Identifier / MemberExpression / CallExpression / etc.
|
|
207
|
+
// We can't tell from AST whether it's number[] (already migrated)
|
|
208
|
+
// or number (needs migration). Leave it and emit a TODO so the
|
|
209
|
+
// consumer reviews. Idempotent on the array-literal case.
|
|
210
|
+
const v = attr.value?.expression;
|
|
211
|
+
if (v && v.type !== "ArrayExpression") {
|
|
212
|
+
needsIdentTodo = true;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (needsIdentTodo) {
|
|
217
|
+
if (addLeadingComment(path, TODO_SLIDER_IDENT)) {
|
|
218
|
+
changed = true;
|
|
219
|
+
log.push("Slider: added value/defaultValue identifier TODO comment");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// TODO comment for onValueChange
|
|
224
|
+
const onValueChange = findAttr(opening, "onValueChange");
|
|
225
|
+
if (onValueChange) {
|
|
226
|
+
if (addLeadingComment(path, TODO_SLIDER)) {
|
|
227
|
+
changed = true;
|
|
228
|
+
log.push("Slider: added onValueChange TODO comment");
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/* -------------------------------------------------------------------- */
|
|
235
|
+
/* Change 2 — Avatar with next/image child → flat src/alt props */
|
|
236
|
+
/* -------------------------------------------------------------------- */
|
|
237
|
+
|
|
238
|
+
if (avatarLocal && nextImageLocalNames.size > 0) {
|
|
239
|
+
root
|
|
240
|
+
.find(j.JSXElement, {
|
|
241
|
+
openingElement: { name: { type: "JSXIdentifier", name: avatarLocal } },
|
|
242
|
+
})
|
|
243
|
+
.forEach((path) => {
|
|
244
|
+
const el = path.node;
|
|
245
|
+
const opening = el.openingElement;
|
|
246
|
+
const children = el.children || [];
|
|
247
|
+
|
|
248
|
+
// Find a single next/image child (ignoring whitespace text nodes)
|
|
249
|
+
const meaningful = children.filter(
|
|
250
|
+
(c) =>
|
|
251
|
+
!(
|
|
252
|
+
c.type === "JSXText" &&
|
|
253
|
+
(c.value === "" || /^\s*$/.test(c.value))
|
|
254
|
+
),
|
|
255
|
+
);
|
|
256
|
+
if (meaningful.length !== 1) return;
|
|
257
|
+
const child = meaningful[0];
|
|
258
|
+
if (child.type !== "JSXElement") return;
|
|
259
|
+
const childName = jsxOpeningName(child.openingElement);
|
|
260
|
+
if (!childName || !nextImageLocalNames.has(childName)) return;
|
|
261
|
+
|
|
262
|
+
// Don't merge if Avatar already has src — emit TODO and skip
|
|
263
|
+
const existingSrc = findAttr(opening, "src");
|
|
264
|
+
if (existingSrc) {
|
|
265
|
+
if (addLeadingComment(path, TODO_AVATAR_CONFLICT)) {
|
|
266
|
+
changed = true;
|
|
267
|
+
log.push("Avatar: src conflict — TODO comment added");
|
|
268
|
+
}
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Hoist src + alt from <Image>
|
|
273
|
+
const childOpening = child.openingElement;
|
|
274
|
+
const srcAttr = findAttr(childOpening, "src");
|
|
275
|
+
const altAttr = findAttr(childOpening, "alt");
|
|
276
|
+
if (!srcAttr) return; // nothing to hoist
|
|
277
|
+
|
|
278
|
+
const newAttrs = [...opening.attributes];
|
|
279
|
+
newAttrs.push(j.jsxAttribute(j.jsxIdentifier("src"), srcAttr.value));
|
|
280
|
+
if (altAttr && !findAttr(opening, "alt")) {
|
|
281
|
+
newAttrs.push(j.jsxAttribute(j.jsxIdentifier("alt"), altAttr.value));
|
|
282
|
+
}
|
|
283
|
+
opening.attributes = newAttrs;
|
|
284
|
+
|
|
285
|
+
// Self-close the Avatar (no remaining children)
|
|
286
|
+
opening.selfClosing = true;
|
|
287
|
+
el.closingElement = null;
|
|
288
|
+
el.children = [];
|
|
289
|
+
|
|
290
|
+
addLeadingComment(path, TODO_AVATAR_MERGED);
|
|
291
|
+
changed = true;
|
|
292
|
+
log.push("Avatar: hoisted next/image src/alt and removed inner element");
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/* -------------------------------------------------------------------- */
|
|
297
|
+
/* Done */
|
|
298
|
+
/* -------------------------------------------------------------------- */
|
|
299
|
+
|
|
300
|
+
if (!changed) return null;
|
|
301
|
+
|
|
302
|
+
if (process.env.VERBOSE) {
|
|
303
|
+
// eslint-disable-next-line no-console
|
|
304
|
+
console.log(`[0.2.0-radix-migration] ${file.path}`);
|
|
305
|
+
for (const line of log) {
|
|
306
|
+
// eslint-disable-next-line no-console
|
|
307
|
+
console.log(` - ${line}`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return root.toSource({ quote: "double" });
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// jscodeshift parser hint
|
|
315
|
+
export const parser = "tsx";
|
|
@@ -0,0 +1,305 @@
|
|
|
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-15 | X | DOM-selector finder: consumer code that depends on the internal DOM of design-system components, mapped to registry `C-DOM-*` ids |
|
|
106
|
+
| CM-16 | X | `globals.css` analyser: `@source` into the package, duplicate preflight, colliding `@theme` keys, unlayered globals, legacy `var()` |
|
|
107
|
+
|
|
108
|
+
The remaining ids of plan §29 land with the waves that ship their replacement
|
|
109
|
+
APIs.
|
|
110
|
+
|
|
111
|
+
### CM-15 — DOM-selector finder
|
|
112
|
+
|
|
113
|
+
Report-only. It never modifies a file; the runner throws if it tries. What it
|
|
114
|
+
looks for:
|
|
115
|
+
|
|
116
|
+
- **class tokens that reach into a component's children** on a design-system
|
|
117
|
+
tag — arbitrary variants with a descendant, child or sibling combinator, the
|
|
118
|
+
`*` / `**` child variants, `has-[…]` — and the same tokens on a wrapper
|
|
119
|
+
element whose subtree holds a design-system component;
|
|
120
|
+
- **group references the design system provides** (`group-data-[labels…]`,
|
|
121
|
+
`…/sidebar`, `…/row`);
|
|
122
|
+
- **DOM queries** — `querySelector(All)`, `closest`, `matches`, Playwright
|
|
123
|
+
`locator` — whose selector names design-system internals, including
|
|
124
|
+
selectors held in a file-level constant;
|
|
125
|
+
- **text matching on copy the design system renders** ("Next", "Notifications",
|
|
126
|
+
"Next page", "Clear search", "Back") through `getByText`-style queries,
|
|
127
|
+
`getByRole({ name })` or `textContent` comparisons;
|
|
128
|
+
- **tests that parse the built bundle** (`…/design-system/dist/index.js`);
|
|
129
|
+
- **global CSS selectors** that name design-system internals.
|
|
130
|
+
|
|
131
|
+
Self-only variants (`[&[data-state=open]]:…`, `[&:hover]:…`), Radix
|
|
132
|
+
`data-state`/`data-side`/`data-align` attributes, ARIA roles and cmdk
|
|
133
|
+
attributes are **not** reported: they are permanent contracts (P-DATA-STATE,
|
|
134
|
+
P-ROLES, C-CMDK-ATTR).
|
|
135
|
+
|
|
136
|
+
Findings carry a `registryId` when they match a contract and
|
|
137
|
+
`rule: "unregistered-…"` when they are DOM coupling the registry does not track
|
|
138
|
+
yet — those are the candidates for new registry entries. `confidence` is `high`
|
|
139
|
+
for a token on the component itself, `medium` for a wrapper or a heuristic,
|
|
140
|
+
`low` for an unmapped wrapper.
|
|
141
|
+
|
|
142
|
+
CM-15 feeds 2.0 gate G7 (`consumerSelectors = 0` for the freezes with removal
|
|
143
|
+
target 2.0). Use `--fail-on-findings` to gate on it. The committed findings for
|
|
144
|
+
the four consumer apps are in `docs/codemods/`.
|
|
145
|
+
|
|
146
|
+
### CM-16 — `globals.css` analyser
|
|
147
|
+
|
|
148
|
+
Report-only, and the input to the consumer compatibility presets (plan §12).
|
|
149
|
+
It finds, with a line number and a severity, the mechanisms by which an
|
|
150
|
+
application restyles design-system components today:
|
|
151
|
+
|
|
152
|
+
| Rule | Contract | Gate | Found |
|
|
153
|
+
| ------------------------- | --------------- | ---- | ------------------------------------------------------------------------ |
|
|
154
|
+
| `source-into-ds` | C-CSS-SOURCE | G2 | `@source` pointing into the design-system package |
|
|
155
|
+
| `ds-styles-import` | C-CSS-STYLES | G1 | `@assure-one/design-system/styles.css` imported (from CSS or from code) |
|
|
156
|
+
| `duplicate-preflight` | C-CSS-STYLES | G1 | the app compiles a Tailwind preflight while the DS sheet ships one too |
|
|
157
|
+
| `theme-collision-differs` | C-TOKENS-LEGACY | — | an `@theme` key that shadows a DS token **with a different value** |
|
|
158
|
+
| `theme-collision-equal` | C-TOKENS-LEGACY | — | the same name with the same value today |
|
|
159
|
+
| `legacy-var-write` | C-TOKENS-WRITE | — | a DS token name declared outside `@theme` — a deliberate override |
|
|
160
|
+
| `legacy-var-read` | C-TOKENS-LEGACY | — | `var(--<ds token>)` read by app CSS |
|
|
161
|
+
| `brand-scope-spelling` | C-CSS-DARKCLASS | — | DS tokens re-declared under `.dark` or `[data-brand="…"]` |
|
|
162
|
+
| `dark-variant-mismatch` | C-CSS-DARKCLASS | — | an app `dark` variant that cannot match the `.dark` element itself |
|
|
163
|
+
| `unlayered-ds-dom` | (unregistered) | — | an unlayered app rule that styles DS markup (`button`, `*`, `.shadow-…`) |
|
|
164
|
+
| `layered-ds-dom` | (unregistered) | — | the same, inside the app's own `base`/`components` layer |
|
|
165
|
+
|
|
166
|
+
Severity is `high` for anything that blocks G1/G2 or changes what a DS
|
|
167
|
+
component renders, `medium` for coupling that a rename has to carry, `low` for
|
|
168
|
+
what is only fragile today.
|
|
169
|
+
|
|
170
|
+
Boundary with CM-15: selectors that name design-system **internals**
|
|
171
|
+
(`[data-radix-…]`, `[data-esign-…]`, `[data-labels]`, `[cmdk-…]`, the toast
|
|
172
|
+
region) are the `C-DOM-*` contracts and belong to CM-15, which already reports
|
|
173
|
+
them from CSS. CM-16 skips them, so the two finders never count the same rule
|
|
174
|
+
twice.
|
|
175
|
+
|
|
176
|
+
CM-16 reads the **authored** stylesheets; `scripts/derive-consumer-preset`
|
|
177
|
+
(W1-18) reads the **compiled** ones with a full cascade. The two therefore
|
|
178
|
+
count different things on purpose — `docs/codemods/cm-16-globals-css.md` puts
|
|
179
|
+
the numbers side by side and explains every difference. Both call two values
|
|
180
|
+
"different" through the same normalisation (`codemods/lib/css-values.mjs`),
|
|
181
|
+
which is why the value-level counts can be compared at all.
|
|
182
|
+
|
|
183
|
+
It parses CSS with the project's own `postcss` (borrowed exactly as
|
|
184
|
+
`typescript` is, see "Packaging"), and compares against the design system's
|
|
185
|
+
own `dist/styles.css` from the installed package.
|
|
186
|
+
|
|
187
|
+
## Writing a codemod
|
|
188
|
+
|
|
189
|
+
A codemod is one module in `codemods/transforms/`, registered in
|
|
190
|
+
`codemods/lib/registry.mjs` (the registry order is the program order that
|
|
191
|
+
`upgrade` follows):
|
|
192
|
+
|
|
193
|
+
```js
|
|
194
|
+
export const meta = {
|
|
195
|
+
id: "CM-07",
|
|
196
|
+
title: "inputSize → size on Input, Textarea and SearchInput",
|
|
197
|
+
class: "A",
|
|
198
|
+
oneShot: false,
|
|
199
|
+
requires: { codemods: [], dsVersion: "1.32.0" },
|
|
200
|
+
parses: ["code"], // "code" and/or "css"
|
|
201
|
+
includeTests: false,
|
|
202
|
+
usesTypeScript: true,
|
|
203
|
+
usesPostcss: false,
|
|
204
|
+
registryIds: ["C-INPUT-SIZE"],
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
export function transform(file, { ts, postcss }) {
|
|
208
|
+
// file: { rel, path, source, kind, test }
|
|
209
|
+
return { output, findings, notTransformed, parseErrors };
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
`usesTypeScript` and `usesPostcss` ask the runner to resolve that parser from
|
|
214
|
+
the project and inject it; a codemod never imports one itself.
|
|
215
|
+
|
|
216
|
+
The runner owns everything else: the guards, which files are read, writing
|
|
217
|
+
files and the ledger, the report, and the rule that a class X codemod may not
|
|
218
|
+
change anything.
|
|
219
|
+
|
|
220
|
+
Fixtures live in `codemods/__test__/<id>/*.snap` — one file per case, holding a
|
|
221
|
+
small project and the expected findings (and, for transforming codemods, the
|
|
222
|
+
expected output):
|
|
223
|
+
|
|
224
|
+
```
|
|
225
|
+
=== file: src/thing.tsx
|
|
226
|
+
<source>
|
|
227
|
+
=== expect
|
|
228
|
+
{ "findings": [ ["src/thing.tsx:12", "C-DOM-03", "class-on-component", "<class token>"] ] }
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
`codemods/__test__/harness.mjs` materialises a fixture in a temporary
|
|
232
|
+
directory, runs the codemod **twice** and asserts that the second run finds the
|
|
233
|
+
same things and changes nothing — the idempotency requirement of plan §29.
|
|
234
|
+
`tests/codemods/*.test.mjs` runs all of it as part of `pnpm test:contracts`.
|
|
235
|
+
|
|
236
|
+
Fixtures are `.snap` on purpose: Tailwind scans every other repository file for
|
|
237
|
+
class names, and a fixture full of consumer classes would change
|
|
238
|
+
`dist/styles.css`. `tests/codemods/tailwind-inert.test.mjs` proves the codemod
|
|
239
|
+
sources, fixtures and findings stay invisible to that scan — which is also why
|
|
240
|
+
class-like text in a `.mjs` or `.md` file here is assembled from parts.
|
|
241
|
+
|
|
242
|
+
## Packaging
|
|
243
|
+
|
|
244
|
+
`codemods` is part of the published tarball (`package.json` → `files`);
|
|
245
|
+
`codemods/__test__` is not. The codemods need `typescript` at run time and
|
|
246
|
+
resolve it from the consumer project, so the published package gains no
|
|
247
|
+
dependency. See `docs/codemods/README.md` for that decision.
|
|
248
|
+
|
|
249
|
+
## Legacy: 0.2.0-radix-migration.mjs
|
|
250
|
+
|
|
251
|
+
The pre-program codemod, written before the runner existed. It is a
|
|
252
|
+
jscodeshift transform and is run with `npx jscodeshift`, not with `run.mjs`.
|
|
253
|
+
|
|
254
|
+
### Run it
|
|
255
|
+
|
|
256
|
+
From the consumer repo (firm or portal), with `@assure-one/design-system@0.2.0` installed:
|
|
257
|
+
|
|
258
|
+
```bash
|
|
259
|
+
# Dry-run first to preview
|
|
260
|
+
npx jscodeshift -t node_modules/@assure-one/design-system/codemods/0.2.0-radix-migration.mjs \
|
|
261
|
+
--extensions=tsx,ts,jsx,js --parser=tsx --dry --print 'src/**/*.{ts,tsx}'
|
|
262
|
+
|
|
263
|
+
# Apply
|
|
264
|
+
VERBOSE=1 npx jscodeshift -t node_modules/@assure-one/design-system/codemods/0.2.0-radix-migration.mjs \
|
|
265
|
+
--extensions=tsx,ts,jsx,js --parser=tsx 'src/**/*.{ts,tsx}'
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
### What it changes
|
|
269
|
+
|
|
270
|
+
1. `Slider` value props become `number[]` (Radix range convention)
|
|
271
|
+
- `<Slider value={50} />` becomes `<Slider value={[50]} />`
|
|
272
|
+
- `<Slider defaultValue={75} />` becomes `<Slider defaultValue={[75]} />`
|
|
273
|
+
- Adds a `// TODO 0.2.0: onValueChange now receives number[]` comment above any `<Slider>` with `onValueChange` so you review the callback body.
|
|
274
|
+
- 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.
|
|
275
|
+
|
|
276
|
+
2. `Avatar` no longer accepts a `next/image` child
|
|
277
|
+
- `<Avatar><Image src=".." alt=".." /></Avatar>` becomes `<Avatar src=".." alt=".." />`
|
|
278
|
+
- Inner non-`next/image` children are left alone.
|
|
279
|
+
- If the `Avatar` already has `src`, the inner `<Image>` is left and a TODO is emitted.
|
|
280
|
+
|
|
281
|
+
### Explicit no-ops (documented for completeness)
|
|
282
|
+
|
|
283
|
+
- `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.
|
|
284
|
+
- `TeamMemberSelect` — the `value: ""` (unassigned) consumer contract is preserved. The `__unassigned__` sentinel is internal.
|
|
285
|
+
|
|
286
|
+
### Scoping
|
|
287
|
+
|
|
288
|
+
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.
|
|
289
|
+
|
|
290
|
+
### Idempotency
|
|
291
|
+
|
|
292
|
+
Running twice produces zero further changes. Already-array Slider props and already-flattened Avatars are detected and skipped.
|
|
293
|
+
|
|
294
|
+
### Validate the output
|
|
295
|
+
|
|
296
|
+
Test fixtures live in `codemods/__test__/*.input.tsx`. To smoke-test against them:
|
|
297
|
+
|
|
298
|
+
```bash
|
|
299
|
+
cp codemods/__test__/slider.input.tsx /tmp/slider.tsx
|
|
300
|
+
VERBOSE=1 npx jscodeshift -t codemods/0.2.0-radix-migration.mjs \
|
|
301
|
+
--extensions=tsx --parser=tsx /tmp/slider.tsx
|
|
302
|
+
diff codemods/__test__/slider.input.tsx /tmp/slider.tsx
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
After running on your codebase, run `tsc --noEmit` to catch any leftover number-vs-`number[]` mismatches the TODO comments flagged.
|