@assure-one/design-system 1.34.0 → 1.35.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
@@ -13,6 +13,7 @@ The single source of truth for visual design across all Assure products.
13
13
  - [**`CLAUDE.md`**](./CLAUDE.md) — invariants and conventions for AI assistants working in this repo.
14
14
  - [**`docs/integration/css.md`**](./docs/integration/css.md) — how an application loads our CSS: layer order, import sequence, the compatibility preset, removing `@source`, runtime brand theming.
15
15
  - [**`docs/testing.md`**](./docs/testing.md) — rendering the real package in your app's Jest tests (`@assure-one/design-system/testing`, experimental).
16
+ - [**`docs/design-system/icons.md`**](./docs/design-system/icons.md) — the icon catalogue and its server-safe entry (`@assure-one/design-system/icons`, experimental): import an icon in a Server Component without a client boundary.
16
17
  - [**`claude-skills/`**](./claude-skills) — drop-in Claude Code skill for consuming projects.
17
18
 
18
19
  ## Install
@@ -107,18 +107,143 @@ the list a human has to pick up — spread props, dynamic expressions, conflicti
107
107
 
108
108
  ## Codemods
109
109
 
110
- | Id | Class | What it does |
111
- | ----- | ----- | ------------------------------------------------------------------------------------------------------------------------------------- |
112
- | CM-02 | A | Button explicit default size: `size="md"` on every `Button`, `SubmitButton` and `LinkButton` that passes no `size` (pixel-neutral) |
113
- | CM-12 | R | Button `type="submit"` where the intent is evident (inside `<form>` in the same file, no `onClick`); everything else untyped → report |
114
- | CM-14 | X | hidden-input mirror finder: hidden `<input name>` a consumer added because a design-system control posts nothing |
115
- | CM-15 | X | DOM-selector finder: consumer code that depends on the internal DOM of design-system components, mapped to registry `C-DOM-*` ids |
116
- | CM-16 | X | `globals.css` analyser: `@source` into the package, duplicate preflight, colliding `@theme` keys, unlayered globals, legacy `var()` |
117
- | CM-20 | X | Select sentinel finder: option values standing in for "no value", and the line where each is converted back |
110
+ | Id | Class | What it does |
111
+ | ----- | ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
112
+ | CM-05 | A | Button family `iconLeft` / `iconRight` `iconStart` / `iconEnd` (pure rename; both names of one slot, spreads → "could not be transformed") |
113
+ | CM-02 | A | Button explicit default size: `size="md"` on every `Button`, `SubmitButton` and `LinkButton` that passes no `size` (pixel-neutral) |
114
+ | CM-04 | R | Button family legacy `variant` `variant` + `intent` pair (`primary` `solid`, `destructive` `solid` + `danger`, …); `accent`, `dashed`, dynamic → report |
115
+ | CM-06 | R | `tone` `intent` on StatusDot, IconTile, Spinner, SegmentedProgress, SuiteProgress (`pro` `brand`, listed for review); product hues → report |
116
+ | CM-19 | R | ProgressBar/ProgressRing: `variant` `intent`, and the value colour rule written down as an explicit `intent` where the value is a literal; dynamic values → report |
117
+ | CM-12 | R | Button `type="submit"` where the intent is evident (inside `<form>` in the same file, no `onClick`); everything else untyped report |
118
+ | CM-14 | X | hidden-input mirror finder: hidden `<input name>` a consumer added because a design-system control posts nothing |
119
+ | CM-15 | X | DOM-selector finder: consumer code that depends on the internal DOM of design-system components, mapped to registry `C-DOM-*` ids |
120
+ | CM-16 | X | `globals.css` analyser: `@source` into the package, duplicate preflight, colliding `@theme` keys, unlayered globals, legacy `var()` |
121
+ | CM-20 | X | Select sentinel finder: option values standing in for "no value", and the line where each is converted back |
118
122
 
119
123
  The remaining ids of plan §29 land with the waves that ship their replacement
120
- APIs (CM-04 and CM-05 need the Button `intent` and `iconStart`/`iconEnd` API of
121
- W3-02 and W3-07).
124
+ APIs.
125
+
126
+ ### CM-05 — Button family `iconLeft` / `iconRight` → `iconStart` / `iconEnd`
127
+
128
+ Class A. W3-07 named the two icon slots of `Button`, `LinkButton` and
129
+ `SubmitButton` logically — `iconStart` is the left in LTR and the right in
130
+ RTL — and kept `iconLeft` / `iconRight` as deprecated aliases of the same
131
+ slots (contract C-BTN-ICONPROPS, removed in 3.0). Each alias is the same slot
132
+ under another name, so this is a pure rename: only the attribute's name is
133
+ replaced, and its value — a one-line icon or a multi-line element — keeps its
134
+ text and formatting. Aliased and namespace imports count.
135
+
136
+ | rule | Contract | What happens |
137
+ | -------------- | --------------- | ------------------------------------------------- |
138
+ | `icon-renamed` | C-BTN-ICONPROPS | `iconLeft` → `iconStart`, `iconRight` → `iconEnd` |
139
+
140
+ Not touched, and listed under "could not be transformed": an element that
141
+ passes **both names of one slot** (`iconStart` and `iconLeft` — the component
142
+ throws on that in development; a rename would make a duplicate attribute;
143
+ delete the deprecated one by hand), and an element with a spread
144
+ (`<Button iconLeft={…} {...props}>` — the spread may carry the other name for
145
+ the same slot, and which one wins depends on attribute order). Not touched and
146
+ not reported: elements already on the new names, local components with the
147
+ same names, test files.
148
+
149
+ ### CM-04 — Button family legacy `variant` → `variant` + `intent`
150
+
151
+ Class R. W3-02 gave `Button`, `LinkButton` and `SubmitButton` the two axes of
152
+ ADR-007 — `variant` is the emphasis, `intent` the meaning — and kept the
153
+ single-axis words as deprecated aliases (contract C-BTN-VAR, removed in 3.0),
154
+ each a fixed pair (`LEGACY_BUTTON_VARIANTS`, ADR-007 §4). Every alias renders
155
+ the exact classes it always did, so the rewrite is pixel-neutral. `intent` is
156
+ written only where it differs from what the new emphasis resolves on its own
157
+ (`DEFAULT_BUTTON_INTENT`, ADR-007 §3), so the output is the smallest call that
158
+ renders today's pixels; when written it goes right after `variant`, on its own
159
+ line in a one-attribute-per-line element. `tests/codemods/cm-04.test.mjs`
160
+ checks both tables against `src/primitives/behavior/button-base.ts`.
161
+
162
+ | legacy word | becomes | `intent` written? |
163
+ | ------------- | ---------------------------------- | ----------------------------------------- |
164
+ | `primary` | `variant="solid"` | no — `brand` is what `solid` defaults to |
165
+ | `secondary` | `variant="soft"` | no — `neutral` is what `soft` defaults to |
166
+ | `destructive` | `variant="solid" intent="danger"` | yes |
167
+ | `success` | `variant="solid" intent="success"` | yes |
168
+ | `accent` | left alone, reported | its suite-action colour has no pair (D13) |
169
+ | `dashed` | left alone, reported | its dashed border has no pair (2.x alias) |
170
+
171
+ | rule | Contract | What happens |
172
+ | ----------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
173
+ | `variant-split` | C-BTN-VAR | the alias became its pair, or its emphasis alone when the intent is the default (applied, low) |
174
+ | `destructive-as-danger` | C-BTN-VAR | `destructive` → `solid` + `danger` (applied, **medium**): the word moves to D4's default (`danger` canonical, ADR-007 §5); confirm |
175
+ | `legacy-look` | C-BTN-VAR, C-BTN-ACCENT | `dashed` (C-BTN-VAR) and `accent` (C-BTN-ACCENT, D13) have a look no pair renders; they stay (review, medium) |
176
+ | `dynamic-intent` | C-BTN-VAR | a legacy word next to `intent={expr}` whose alias intent is not the new emphasis's default: `undefined` would change the colour (review, medium) |
177
+ | `dynamic-variant` | C-BTN-VAR | `variant={expr}`: map the expression's values by hand (review, high) |
178
+ | `spread-props` | C-BTN-VAR | `{...props}` may carry `variant` or `intent`; rewriting the literal could change which wins (review, medium) |
179
+ | `unknown-variant` | C-BTN-VAR | a word the Button never accepted (review, high) |
180
+
181
+ A legacy word next to a **literal** `intent` (`variant="destructive"
182
+ intent="success"`) is rewritten to its emphasis only: the intent already wins
183
+ on both sides of the rewrite. `intent="destructive"` is not touched — that is
184
+ CM-17, gated by D4. Not touched and not reported: the emphases (`ghost`,
185
+ `outline`, `link`, `solid`, `soft`), elements with no `variant`, local
186
+ components with the same names, test files.
187
+
188
+ ### CM-06 — `tone` → `intent`
189
+
190
+ Class R. W3-21 gave `StatusDot`, `IconTile`, `Spinner`, `SegmentedProgress` and
191
+ `SuiteProgress` the shared `intent` axis of ADR-007 and kept `tone` as a
192
+ deprecated alias (contract C-TONE, removed in 3.0). Every legacy word resolves
193
+ to one intent and renders the same classes, so the rename is pixel-neutral. The
194
+ attribute is rewritten in place — `tone="pro"` → `intent="brand"` — so a
195
+ Prettier-formatted file stays formatted; `tone="current"` on `Spinner` is the
196
+ default and is removed.
197
+
198
+ | component | `tone` → `intent` |
199
+ | ----------------- | ------------------------------------------------------------------------------------------------------------------------ |
200
+ | StatusDot | `pro` → `brand`; `danger` `warning` `info` `success` `neutral` unchanged |
201
+ | IconTile | `pro` → `brand`; status words unchanged; `audit` `books` `tax` → review |
202
+ | Spinner | `muted` → `neutral`, `accent` → `info`, `destructive` → `danger`, `current` → removed; `success` `warning` unchanged |
203
+ | SegmentedProgress | `default` → `brand`, `destructive` → `danger`; `success` `warning` `info` unchanged; `tax` `audit` `accounting` → review |
204
+ | SuiteProgress | `pro` → `brand`; `audit` `books` `tax` → review |
205
+
206
+ | rule | Contract | What happens |
207
+ | -------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------- |
208
+ | `tone-renamed` | C-TONE | the attribute becomes `intent="…"` (applied, low) |
209
+ | `pro-as-brand` | C-TONE | `pro` becomes `brand` (applied, **medium**): confirm the call site meant the brand colour, not "generic purple" |
210
+ | `tone-removed` | C-TONE | `tone="current"` is deleted (applied, low) |
211
+ | `product-tone` | C-TONE | `audit` `books` `tax` `accounting` have no intent (ADR-007 §2) and stay on `tone` until the products own a service → colour map |
212
+ | `dynamic-tone` | C-TONE | `tone={expr}`: map the expression's values by hand (review, high) |
213
+ | `spread-props` | C-TONE | `{...props}` may carry `tone` too; renaming the literal could change which one wins (review) |
214
+ | `has-intent` | C-TONE | both axes present; `intent` already wins, delete `tone` by hand (review, low) |
215
+ | `unknown-tone` | C-TONE | a word the component never accepted (review, high) |
216
+
217
+ Not touched: elements that already speak `intent` (idempotency), local
218
+ components with the same names, test files.
219
+
220
+ ### CM-19 — Progress: explicit `intent`
221
+
222
+ Class R. `ProgressBar` and `ProgressRing` colour themselves **by value** when
223
+ no colour is passed (`< 50%` brand, `≥ 50%` warning, `≥ 80%` success) — a
224
+ business mapping the design system should not own, which 2.0 switches off
225
+ (W9-10, contract C-PROGRESS). W3-21 added `intent`; passing it disables the
226
+ rule. CM-19 makes every call site say what it renders today, so the flip
227
+ changes nothing a consumer did not write down: `variant="<legacy word>"`
228
+ becomes `intent="<colour>"` (`default` → `brand`, `destructive` → `danger`),
229
+ and a meter with no colour and a literal `value` (literal or absent `max`)
230
+ gets the `intent` the rule picks for that value, after `value`. The rename to
231
+ the unified `Progress variant="bar" | "ring"` (target architecture §26) is the
232
+ second half of this codemod and lands with that component.
233
+
234
+ | rule | Contract | What happens |
235
+ | --------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------- |
236
+ | `variant-renamed` | C-PROGRESS | `variant` becomes `intent` (applied, low) |
237
+ | `auto-colour-made-explicit` | C-PROGRESS | the value rule's intent is written down (applied, **medium**): confirm the colour, not the number, was the point |
238
+ | `indeterminate-brand` | C-PROGRESS | `value={null}` gets `intent="brand"` (applied, low) |
239
+ | `dynamic-value` | C-PROGRESS | no colour and `value={expr}` (or `max={expr}`): the colour depends on runtime data → a human (review, high) |
240
+ | `dynamic-variant` | C-PROGRESS | `variant={expr}`: map the values by hand (review, high) |
241
+ | `spread-props` | C-PROGRESS | `{...props}` may carry `variant`, `intent` or `value` (review) |
242
+ | `unknown-variant` | C-PROGRESS | a word the component never accepted (review, high) |
243
+
244
+ `SegmentedProgress` and `SuiteProgress` have no value rule; CM-06 moves their
245
+ `tone`. Not touched: elements that already pass `intent`, local components
246
+ with the same names, test files.
122
247
 
123
248
  ### CM-02 — Button explicit default size
124
249
 
@@ -46,14 +46,73 @@ export function insertAttribute(ts, sf, opening, text, { after = null } = {}) {
46
46
  return { pos: attributesStart, text: `${separator}${text}` };
47
47
  }
48
48
 
49
- /** `source` with every `{ pos, text }` insertion applied. */
50
- export function applyInsertions(source, edits) {
49
+ /** The JSX attribute named `name` on `opening`, or null. */
50
+ export function attributeNamed(ts, opening, name) {
51
+ return (
52
+ opening.attributes.properties.find(
53
+ (attr) => ts.isJsxAttribute(attr) && attr.name.getText() === name,
54
+ ) ?? null
55
+ );
56
+ }
57
+
58
+ /**
59
+ * The edit that replaces the attribute named `name` (its whole text, name and
60
+ * initializer) with `text`, e.g. `tone="pro"` → `intent="brand"`. The
61
+ * attribute keeps its place, so the file's formatting is untouched.
62
+ *
63
+ * @returns {{ pos: number, end: number, text: string } | null}
64
+ */
65
+ export function replaceAttribute(ts, sf, opening, name, text) {
66
+ const attr = attributeNamed(ts, opening, name);
67
+ if (!attr) return null;
68
+ return { pos: attr.getStart(sf), end: attr.getEnd(), text };
69
+ }
70
+
71
+ /**
72
+ * The edit that renames the attribute `name` to `to` and keeps its value,
73
+ * e.g. `iconLeft={<X />}` → `iconStart={<X />}`. Only the name's characters
74
+ * are replaced, so the initializer — however many lines it spans — and the
75
+ * file's formatting are untouched.
76
+ *
77
+ * @returns {{ pos: number, end: number, text: string } | null}
78
+ */
79
+ export function renameAttribute(ts, sf, opening, name, to) {
80
+ const attr = attributeNamed(ts, opening, name);
81
+ if (!attr) return null;
82
+ return { pos: attr.name.getStart(sf), end: attr.name.getEnd(), text: to };
83
+ }
84
+
85
+ /**
86
+ * The edit that removes the attribute named `name` together with the
87
+ * whitespace before it, so `<X a tone="current" b>` becomes `<X a b>` and a
88
+ * one-attribute-per-line element loses the whole line.
89
+ *
90
+ * @returns {{ pos: number, end: number, text: string } | null}
91
+ */
92
+ export function removeAttribute(ts, sf, opening, name) {
93
+ const attrs = opening.attributes.properties;
94
+ const index = attrs.findIndex((attr) => ts.isJsxAttribute(attr) && attr.name.getText() === name);
95
+ if (index < 0) return null;
96
+ const from = index === 0 ? opening.attributes.pos : attrs[index - 1].getEnd();
97
+ return { pos: from, end: attrs[index].getEnd(), text: "" };
98
+ }
99
+
100
+ /**
101
+ * `source` with every edit applied, from the end backwards so positions stay
102
+ * valid. An edit is `{ pos, text }` (an insertion) or `{ pos, end, text }` (a
103
+ * replacement of the range). Edits must not overlap.
104
+ */
105
+ export function applyEdits(source, edits) {
51
106
  let out = source;
52
- for (const edit of [...edits].sort((a, b) => b.pos - a.pos)) {
53
- out = `${out.slice(0, edit.pos)}${edit.text}${out.slice(edit.pos)}`;
107
+ const ordered = [...edits].sort((a, b) => b.pos - a.pos || (b.end ?? b.pos) - (a.end ?? a.pos));
108
+ for (const edit of ordered) {
109
+ out = `${out.slice(0, edit.pos)}${edit.text}${out.slice(edit.end ?? edit.pos)}`;
54
110
  }
55
111
  return out;
56
112
  }
57
113
 
114
+ /** `source` with every `{ pos, text }` insertion applied. */
115
+ export const applyInsertions = applyEdits;
116
+
58
117
  /** The opening element of a JSX element node (a self-closing one is its own). */
59
118
  export const openingOf = (ts, node) => (ts.isJsxElement(node) ? node.openingElement : node);
@@ -3,7 +3,11 @@
3
3
  * codemods listed here can be run by id; the sequence is the plan's.
4
4
  */
5
5
  export const CODEMODS = [
6
+ { id: "CM-05", module: "../transforms/cm-05-button-icon-slots.mjs" },
6
7
  { id: "CM-02", module: "../transforms/cm-02-button-explicit-size.mjs" },
8
+ { id: "CM-04", module: "../transforms/cm-04-button-variant-intent.mjs" },
9
+ { id: "CM-06", module: "../transforms/cm-06-tone-to-intent.mjs" },
10
+ { id: "CM-19", module: "../transforms/cm-19-progress-explicit-intent.mjs" },
7
11
  { id: "CM-12", module: "../transforms/cm-12-button-type-submit.mjs" },
8
12
  { id: "CM-14", module: "../transforms/cm-14-hidden-mirrors.mjs" },
9
13
  { id: "CM-15", module: "../transforms/cm-15-dom-selectors.mjs" },
@@ -0,0 +1,223 @@
1
+ /**
2
+ * CM-04 — Button family: legacy single-axis `variant` → the `variant` +
3
+ * `intent` pair (class R; plan §29 seq 5, registry `C-BTN-VAR`, `C-BTN-ACCENT`).
4
+ *
5
+ * W3-02 gave `Button`, `LinkButton` and `SubmitButton` the two axes of
6
+ * ADR-007 — `variant` is the emphasis, `intent` the meaning — and kept the
7
+ * single-axis words as deprecated aliases, each a fixed pair
8
+ * (`LEGACY_BUTTON_VARIANTS` in `src/primitives/behavior/button-base.ts`,
9
+ * ADR-007 §4). Every alias renders the exact classes it always did, so the
10
+ * rewrite is pixel-neutral:
11
+ *
12
+ * | legacy word | becomes | `intent` written? |
13
+ * | ------------- | ------------------------------------ | ---------------------------------------- |
14
+ * | `primary` | `variant="solid"` | no — `brand` is what `solid` defaults to |
15
+ * | `secondary` | `variant="soft"` | no — `neutral` is `soft`'s default |
16
+ * | `destructive` | `variant="solid" intent="danger"` | yes |
17
+ * | `success` | `variant="solid" intent="success"` | yes |
18
+ * | `accent` | left alone, reported | its suite-action colour has no pair (D13) |
19
+ * | `dashed` | left alone, reported | its dashed border has no pair (2.x alias) |
20
+ *
21
+ * `intent` is omitted where it equals what the new emphasis resolves on its
22
+ * own (`DEFAULT_BUTTON_INTENT`), so the output is the smallest call that
23
+ * renders today's pixels. `ghost`, `outline` and `link` are emphases already
24
+ * and are not touched.
25
+ *
26
+ * ## What it reports
27
+ *
28
+ * | rule | action | severity | why |
29
+ * | ----------------------- | ------- | -------- | ---------------------------------------------------------------------------- |
30
+ * | `variant-split` | applied | low | the alias became its pair (or its emphasis alone, when the intent is the default) |
31
+ * | `destructive-as-danger` | applied | medium | `destructive` → `solid` + `danger`: the word moves to D4's default (`danger` canonical, ADR-007 §5); a human confirms |
32
+ * | `legacy-look` | review | medium | `accent` (D13) and `dashed` have a look no pair renders; they stay until decided / 2.x |
33
+ * | `dynamic-intent` | review | medium | a legacy word next to `intent={expr}` whose alias intent is not the new emphasis's default: when the expression is `undefined` the alias renders its own intent, the new word its default |
34
+ * | `dynamic-variant` | review | high | `variant={expr}`: the value is not visible here; map it by hand |
35
+ * | `spread-props` | review | medium | `{...props}` may carry `variant` or `intent`; rewriting the literal could change which wins |
36
+ * | `unknown-variant` | review | high | a word the Button never accepted |
37
+ *
38
+ * A legacy word next to a **literal** `intent` (`variant="destructive"
39
+ * intent="success"`) is rewritten to its emphasis only: the intent already
40
+ * wins on both sides of the rewrite (ADR-007 §4, `resolveButtonStyle`).
41
+ * `intent="destructive"` is not touched — that is CM-17, gated by D4.
42
+ *
43
+ * Elements that already speak the new words are not touched (idempotency).
44
+ * Local components with the same names are not touched; test files are
45
+ * skipped, as by the scanner that measures C-BTN-VAR.
46
+ */
47
+ import { analyseForms } from "../lib/forms.mjs";
48
+ import { applyEdits, insertAttribute, openingOf, replaceAttribute } from "../lib/jsx-edit.mjs";
49
+
50
+ export const meta = {
51
+ id: "CM-04",
52
+ title: "Button family: legacy variant → variant + intent",
53
+ class: "R",
54
+ oneShot: false,
55
+ requires: { codemods: [], dsVersion: null },
56
+ parses: ["code"],
57
+ includeTests: false,
58
+ usesTypeScript: true,
59
+ usesPostcss: false,
60
+ registryIds: ["C-BTN-VAR", "C-BTN-ACCENT"],
61
+ };
62
+
63
+ /** The design-system components that read `buttonVariants` (W3-02). */
64
+ export const BUTTONS = new Set(["Button", "LinkButton", "SubmitButton"]);
65
+
66
+ /**
67
+ * The legacy alias map — `LEGACY_BUTTON_VARIANTS` of
68
+ * `src/primitives/behavior/button-base.ts`, row for row (ADR-007 §4). A row
69
+ * with a `look` is a legacy-only look: no pair renders it, so the word stays.
70
+ * `tests/codemods/cm-04.test.mjs` checks this table against the source.
71
+ */
72
+ export const LEGACY_VARIANTS = {
73
+ primary: { variant: "solid", intent: "brand" },
74
+ secondary: { variant: "soft", intent: "neutral" },
75
+ destructive: { variant: "solid", intent: "danger" },
76
+ success: { variant: "solid", intent: "success" },
77
+ accent: { variant: "solid", intent: "brand", look: "accent" },
78
+ dashed: { variant: "outline", intent: "neutral", look: "dashed" },
79
+ };
80
+
81
+ /**
82
+ * The intent each emphasis renders when only `variant` is passed —
83
+ * `DEFAULT_BUTTON_INTENT` of button-base.ts (ADR-007 §3). Where the alias
84
+ * intent equals this, `intent` is omitted from the output.
85
+ */
86
+ export const DEFAULT_INTENT = {
87
+ solid: "brand",
88
+ soft: "neutral",
89
+ outline: "neutral",
90
+ ghost: "neutral",
91
+ link: "brand",
92
+ };
93
+
94
+ /** The emphases: already vocabulary, never rewritten. */
95
+ export const EMPHASES = new Set(Object.keys(DEFAULT_INTENT));
96
+
97
+ /** The registry contract a legacy word belongs to. */
98
+ export const contractOf = (word) => (word === "accent" ? "C-BTN-ACCENT" : "C-BTN-VAR");
99
+
100
+ /**
101
+ * What a literal legacy `variant` becomes, or null when the word is not a
102
+ * legacy alias: `{ rule, action, severity, variant, intent }`, `intent` being
103
+ * `null` when it is the new emphasis's default and is left out.
104
+ */
105
+ export function classifyVariant(word) {
106
+ const pair = LEGACY_VARIANTS[word];
107
+ if (!pair) return null;
108
+ if (pair.look) return { rule: "legacy-look", action: "review", severity: "medium" };
109
+ const intent = pair.intent === DEFAULT_INTENT[pair.variant] ? null : pair.intent;
110
+ if (word === "destructive") {
111
+ return {
112
+ rule: "destructive-as-danger",
113
+ action: "applied",
114
+ severity: "medium",
115
+ variant: pair.variant,
116
+ intent,
117
+ };
118
+ }
119
+ return {
120
+ rule: "variant-split",
121
+ action: "applied",
122
+ severity: "low",
123
+ variant: pair.variant,
124
+ intent,
125
+ };
126
+ }
127
+
128
+ export function transform(file, { ts }) {
129
+ const facts = analyseForms(ts, file.source, file.rel);
130
+ const findings = [];
131
+ const edits = [];
132
+
133
+ for (const el of facts.elements) {
134
+ if (!el.isDs || !BUTTONS.has(el.base) || el.component !== el.base) continue;
135
+ const variant = el.props.get("variant");
136
+ if (!variant) continue;
137
+ const word = variant.expression || variant.literals.length !== 1 ? null : variant.literals[0];
138
+ if (word !== null && EMPHASES.has(word)) continue;
139
+
140
+ const intentAttr = el.props.get("intent") ?? null;
141
+ const base = {
142
+ line: el.line,
143
+ registryId: contractOf(word),
144
+ match: `<${el.tag} variant=${variant.text ?? ""}>`,
145
+ component: el.component,
146
+ gate: null,
147
+ detail: {
148
+ variant: word,
149
+ dynamic: variant.expression,
150
+ intent: intentAttr
151
+ ? intentAttr.expression
152
+ ? null
153
+ : (intentAttr.literals[0] ?? null)
154
+ : undefined,
155
+ },
156
+ };
157
+ const review = (rule, severity) => findings.push({ ...base, rule, severity, action: "review" });
158
+
159
+ if (word === null) {
160
+ review("dynamic-variant", "high");
161
+ continue;
162
+ }
163
+ if (el.spread) {
164
+ review("spread-props", "medium");
165
+ continue;
166
+ }
167
+ const outcome = classifyVariant(word);
168
+ if (!outcome) {
169
+ review("unknown-variant", "high");
170
+ continue;
171
+ }
172
+ if (outcome.action === "review") {
173
+ review(outcome.rule, outcome.severity);
174
+ continue;
175
+ }
176
+ // A dynamic `intent` may be `undefined` at runtime, in which case the
177
+ // alias renders its own intent and the new emphasis its default: only
178
+ // safe when the two agree.
179
+ if (intentAttr && intentAttr.expression && outcome.intent !== null) {
180
+ review("dynamic-intent", "medium");
181
+ continue;
182
+ }
183
+
184
+ const opening = openingOf(ts, el.node);
185
+ const replaced = replaceAttribute(
186
+ ts,
187
+ facts.sf,
188
+ opening,
189
+ "variant",
190
+ `variant="${outcome.variant}"`,
191
+ );
192
+ if (!replaced) {
193
+ review("dynamic-variant", "high");
194
+ continue;
195
+ }
196
+ edits.push(replaced);
197
+ const writesIntent = outcome.intent !== null && !intentAttr;
198
+ if (writesIntent) {
199
+ edits.push(
200
+ insertAttribute(ts, facts.sf, opening, `intent="${outcome.intent}"`, { after: "variant" }),
201
+ );
202
+ }
203
+ findings.push({
204
+ ...base,
205
+ rule: outcome.rule,
206
+ severity: outcome.severity,
207
+ action: "applied",
208
+ detail: {
209
+ ...base.detail,
210
+ variant: word,
211
+ emphasis: outcome.variant,
212
+ intent: writesIntent ? outcome.intent : base.detail.intent,
213
+ },
214
+ });
215
+ }
216
+
217
+ return {
218
+ output: edits.length ? applyEdits(file.source, edits) : file.source,
219
+ findings,
220
+ notTransformed: [],
221
+ parseErrors: facts.parseErrors,
222
+ };
223
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * CM-05 — Button family: `iconLeft` / `iconRight` → `iconStart` / `iconEnd`
3
+ * (class A; plan §29 seq 1, registry `C-BTN-ICONPROPS`).
4
+ *
5
+ * W3-07 named the two icon slots of `Button`, `LinkButton` and `SubmitButton`
6
+ * logically — `iconStart` is the left in LTR and the right in RTL — and kept
7
+ * `iconLeft` / `iconRight` as deprecated aliases of the same slots
8
+ * (`ButtonIconProps`, `resolveButtonIcons` in
9
+ * `src/primitives/behavior/button-base.ts`). Each alias is the same slot under
10
+ * another name, so the rename is a pure rename: only the attribute's name is
11
+ * replaced and its value — a one-line icon or a multi-line element — keeps its
12
+ * text and its formatting.
13
+ *
14
+ * | rule | registry | what happens |
15
+ * | -------------- | -------------- | ----------------------------------------- |
16
+ * | `icon-renamed` | C-BTN-ICONPROPS | `iconLeft` → `iconStart`, `iconRight` → `iconEnd` |
17
+ *
18
+ * ## What it leaves alone, and lists under "could not be transformed"
19
+ *
20
+ * - An element that passes **both names of one side** (`iconStart` and
21
+ * `iconLeft`): the component throws on that in development — two names,
22
+ * one slot — and a rename would turn the error into a duplicate attribute.
23
+ * Neither side of that element is touched; a human deletes one.
24
+ * - An element with a spread (`<Button iconLeft={…} {...props}>`): the spread
25
+ * may carry either name for the same slot, and which one wins depends on
26
+ * the order of the attributes — renaming the literal could change it.
27
+ *
28
+ * Elements that already use the new names are not touched (idempotency).
29
+ * Local components with the same names are not touched; test files are
30
+ * skipped, as by the scanner that measures C-BTN-ICONPROPS.
31
+ */
32
+ import { analyseForms } from "../lib/forms.mjs";
33
+ import { applyEdits, openingOf, renameAttribute } from "../lib/jsx-edit.mjs";
34
+
35
+ export const meta = {
36
+ id: "CM-05",
37
+ title: "Button family: iconLeft/iconRight → iconStart/iconEnd",
38
+ class: "A",
39
+ oneShot: false,
40
+ requires: { codemods: [], dsVersion: null },
41
+ parses: ["code"],
42
+ includeTests: false,
43
+ usesTypeScript: true,
44
+ usesPostcss: false,
45
+ registryIds: ["C-BTN-ICONPROPS"],
46
+ };
47
+
48
+ /** The design-system components with the two icon slots (W3-07). */
49
+ export const ICON_BUTTONS = new Set(["Button", "LinkButton", "SubmitButton"]);
50
+
51
+ /**
52
+ * Deprecated alias → the slot it names (`ButtonIconProps` of button-base.ts).
53
+ * `tests/codemods/cm-05.test.mjs` checks it against the source.
54
+ */
55
+ export const ICON_SLOTS = {
56
+ iconLeft: "iconStart",
57
+ iconRight: "iconEnd",
58
+ };
59
+
60
+ export function transform(file, { ts }) {
61
+ const facts = analyseForms(ts, file.source, file.rel);
62
+ const findings = [];
63
+ const notTransformed = [];
64
+ const edits = [];
65
+
66
+ for (const el of facts.elements) {
67
+ if (!el.isDs || !ICON_BUTTONS.has(el.base) || el.component !== el.base) continue;
68
+ const legacy = Object.keys(ICON_SLOTS).filter((name) => el.props.has(name));
69
+ if (!legacy.length) continue;
70
+
71
+ const clashes = legacy.filter((name) => el.props.has(ICON_SLOTS[name]));
72
+ if (clashes.length) {
73
+ notTransformed.push({
74
+ line: el.line,
75
+ reason: "both-names",
76
+ detail: `<${el.tag}> passes ${clashes
77
+ .map((name) => `\`${name}\` and \`${ICON_SLOTS[name]}\``)
78
+ .join(" and ")} — one slot, two names; delete the deprecated one by hand`,
79
+ });
80
+ continue;
81
+ }
82
+ if (el.spread) {
83
+ notTransformed.push({
84
+ line: el.line,
85
+ reason: "spread-props",
86
+ detail: `<${el.tag} {…}> — the spread may carry \`${legacy
87
+ .map((name) => ICON_SLOTS[name])
88
+ .join("\` or \`")}\` for the same slot; rename by hand once you know it does not`,
89
+ });
90
+ continue;
91
+ }
92
+
93
+ const opening = openingOf(ts, el.node);
94
+ for (const name of legacy) {
95
+ const edit = renameAttribute(ts, facts.sf, opening, name, ICON_SLOTS[name]);
96
+ if (!edit) continue;
97
+ edits.push(edit);
98
+ findings.push({
99
+ line: el.props.get(name).line,
100
+ registryId: "C-BTN-ICONPROPS",
101
+ rule: "icon-renamed",
102
+ match: `<${el.tag} ${name}>`,
103
+ component: el.component,
104
+ action: "applied",
105
+ gate: null,
106
+ detail: { from: name, to: ICON_SLOTS[name] },
107
+ });
108
+ }
109
+ }
110
+
111
+ return {
112
+ output: edits.length ? applyEdits(file.source, edits) : file.source,
113
+ findings,
114
+ notTransformed,
115
+ parseErrors: facts.parseErrors,
116
+ };
117
+ }