@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 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 tokens once in your root layout:
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.