@assure-one/design-system 1.33.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 +1 -0
- package/codemods/README.md +212 -15
- package/codemods/lib/jsx-edit.mjs +118 -0
- package/codemods/lib/ledger.mjs +18 -6
- package/codemods/lib/registry.mjs +6 -0
- package/codemods/lib/report.mjs +1 -0
- package/codemods/lib/runner.mjs +23 -4
- package/codemods/transforms/cm-02-button-explicit-size.mjs +96 -0
- package/codemods/transforms/cm-04-button-variant-intent.mjs +223 -0
- package/codemods/transforms/cm-05-button-icon-slots.mjs +117 -0
- package/codemods/transforms/cm-06-tone-to-intent.mjs +190 -0
- package/codemods/transforms/cm-12-button-type-submit.mjs +115 -0
- package/codemods/transforms/cm-19-progress-explicit-intent.mjs +192 -0
- package/dist/css/components.css +3 -3
- package/dist/css/legacy-aliases.css +5 -0
- package/dist/css/tokens.css +5 -0
- package/dist/icons/index.d.ts +3 -0
- package/dist/icons/index.js +1833 -0
- package/dist/icons/index.js.map +1 -0
- package/dist/index-B3OiMlQv.d.ts +509 -0
- package/dist/index.d.ts +1140 -432
- package/dist/index.js +5197 -4469
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/{system-BDU18fVg.d.ts → system-lyhOUTpa.d.ts} +1 -1
- package/dist/testing/index.cjs +12 -5
- package/dist/testing/index.js +12 -5
- package/dist/tokens/index.d.ts +1 -1
- package/package.json +10 -2
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CM-06 — `tone=` → `intent=` on StatusDot, IconTile, Spinner, SegmentedProgress
|
|
3
|
+
* and SuiteProgress (class R; plan §29 seq 6, registry `C-TONE`).
|
|
4
|
+
*
|
|
5
|
+
* W3-21 gave the five components the shared `intent` axis of ADR-007 and kept
|
|
6
|
+
* `tone` as a deprecated alias. Every legacy word resolves to one intent and
|
|
7
|
+
* renders the same classes, so the rename is pixel-neutral; the value map is
|
|
8
|
+
* the components' own alias tables (`STATUS_DOT_TONE_TO_INTENT`, …):
|
|
9
|
+
*
|
|
10
|
+
* | component | `tone` → `intent` |
|
|
11
|
+
* | ----------------- | -------------------------------------------------------------------------- |
|
|
12
|
+
* | StatusDot | `pro` → `brand`; `danger` `warning` `info` `success` `neutral` unchanged |
|
|
13
|
+
* | IconTile | `pro` → `brand`; status words unchanged; `audit` `books` `tax` → review |
|
|
14
|
+
* | Spinner | `muted` → `neutral`, `accent` → `info`, `destructive` → `danger`, `current` → removed (the default); `success` `warning` unchanged |
|
|
15
|
+
* | SegmentedProgress | `default` → `brand`, `destructive` → `danger`; `success` `warning` `info` unchanged; `tax` `audit` `accounting` → review |
|
|
16
|
+
* | SuiteProgress | `pro` → `brand`; `audit` `books` `tax` → review |
|
|
17
|
+
*
|
|
18
|
+
* ## What it reports
|
|
19
|
+
*
|
|
20
|
+
* | rule | action | severity | why |
|
|
21
|
+
* | --------------- | ------- | -------- | ---------------------------------------------------------------------------- |
|
|
22
|
+
* | `tone-renamed` | applied | low | same word on the new axis (or `default` → `brand`, `destructive` → `danger`) |
|
|
23
|
+
* | `pro-as-brand` | applied | medium | `pro` is the brand colour — unless the call site meant "generic purple" (Portal's firm-branded context); a human confirms |
|
|
24
|
+
* | `tone-removed` | applied | low | `tone="current"` on Spinner is the default; the attribute goes |
|
|
25
|
+
* | `product-tone` | review | medium | `audit` `books` `tax` `accounting` are product hues, never intents (ADR-007 §2): they stay on `tone` until the products own a service → colour map |
|
|
26
|
+
* | `dynamic-tone` | review | high | `tone={expr}`: the value is not visible here; map it by hand |
|
|
27
|
+
* | `spread-props` | review | medium | `{...props}` may carry `tone` too; renaming the literal could change which one wins |
|
|
28
|
+
* | `has-intent` | review | low | both axes present; `intent` already wins, delete `tone` by hand |
|
|
29
|
+
* | `unknown-tone` | review | high | a word the component never accepted |
|
|
30
|
+
*
|
|
31
|
+
* Elements that already speak `intent` and have no `tone` are not touched
|
|
32
|
+
* (idempotency). Local components with the same names are not touched; test
|
|
33
|
+
* files are skipped, as by the scanner that measures C-TONE.
|
|
34
|
+
*/
|
|
35
|
+
import { analyseForms } from "../lib/forms.mjs";
|
|
36
|
+
import { applyEdits, openingOf, removeAttribute, replaceAttribute } from "../lib/jsx-edit.mjs";
|
|
37
|
+
|
|
38
|
+
export const meta = {
|
|
39
|
+
id: "CM-06",
|
|
40
|
+
title: "tone → intent on StatusDot, IconTile, Spinner, SegmentedProgress and SuiteProgress",
|
|
41
|
+
class: "R",
|
|
42
|
+
oneShot: false,
|
|
43
|
+
requires: { codemods: [], dsVersion: null },
|
|
44
|
+
parses: ["code"],
|
|
45
|
+
includeTests: false,
|
|
46
|
+
usesTypeScript: true,
|
|
47
|
+
usesPostcss: false,
|
|
48
|
+
registryIds: ["C-TONE"],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Marker: the legacy word has no intent and stays on `tone` (a product hue). */
|
|
52
|
+
export const PRODUCT = Symbol("product-tone");
|
|
53
|
+
/** Marker: the legacy word is the default and the attribute is simply removed. */
|
|
54
|
+
export const REMOVE = Symbol("remove");
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* `tone` → `intent` per component (mirrors the alias tables in
|
|
58
|
+
* src/primitives/{status-dot,icon-tile,spinner,segmented-progress,suite-progress}.tsx).
|
|
59
|
+
*/
|
|
60
|
+
export const TONE_MAP = {
|
|
61
|
+
StatusDot: {
|
|
62
|
+
danger: "danger",
|
|
63
|
+
warning: "warning",
|
|
64
|
+
info: "info",
|
|
65
|
+
success: "success",
|
|
66
|
+
pro: "brand",
|
|
67
|
+
neutral: "neutral",
|
|
68
|
+
},
|
|
69
|
+
IconTile: {
|
|
70
|
+
pro: "brand",
|
|
71
|
+
neutral: "neutral",
|
|
72
|
+
info: "info",
|
|
73
|
+
success: "success",
|
|
74
|
+
warning: "warning",
|
|
75
|
+
danger: "danger",
|
|
76
|
+
audit: PRODUCT,
|
|
77
|
+
books: PRODUCT,
|
|
78
|
+
tax: PRODUCT,
|
|
79
|
+
},
|
|
80
|
+
Spinner: {
|
|
81
|
+
current: REMOVE,
|
|
82
|
+
muted: "neutral",
|
|
83
|
+
accent: "info",
|
|
84
|
+
success: "success",
|
|
85
|
+
warning: "warning",
|
|
86
|
+
destructive: "danger",
|
|
87
|
+
},
|
|
88
|
+
SegmentedProgress: {
|
|
89
|
+
default: "brand",
|
|
90
|
+
success: "success",
|
|
91
|
+
warning: "warning",
|
|
92
|
+
destructive: "danger",
|
|
93
|
+
info: "info",
|
|
94
|
+
tax: PRODUCT,
|
|
95
|
+
audit: PRODUCT,
|
|
96
|
+
accounting: PRODUCT,
|
|
97
|
+
},
|
|
98
|
+
SuiteProgress: {
|
|
99
|
+
pro: "brand",
|
|
100
|
+
audit: PRODUCT,
|
|
101
|
+
books: PRODUCT,
|
|
102
|
+
tax: PRODUCT,
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export const TONED_COMPONENTS = new Set(Object.keys(TONE_MAP));
|
|
107
|
+
|
|
108
|
+
/** The rule a literal tone falls under, or null when the word is unknown. */
|
|
109
|
+
export function classifyTone(component, tone) {
|
|
110
|
+
const target = TONE_MAP[component]?.[tone];
|
|
111
|
+
if (target === undefined) return null;
|
|
112
|
+
if (target === PRODUCT) return { rule: "product-tone", action: "review", severity: "medium" };
|
|
113
|
+
if (target === REMOVE)
|
|
114
|
+
return { rule: "tone-removed", action: "applied", severity: "low", intent: null };
|
|
115
|
+
if (tone === "pro")
|
|
116
|
+
return { rule: "pro-as-brand", action: "applied", severity: "medium", intent: target };
|
|
117
|
+
return { rule: "tone-renamed", action: "applied", severity: "low", intent: target };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function transform(file, { ts }) {
|
|
121
|
+
const facts = analyseForms(ts, file.source, file.rel);
|
|
122
|
+
const findings = [];
|
|
123
|
+
const edits = [];
|
|
124
|
+
|
|
125
|
+
for (const el of facts.elements) {
|
|
126
|
+
if (!el.isDs || !TONED_COMPONENTS.has(el.base) || el.component !== el.base) continue;
|
|
127
|
+
const tone = el.props.get("tone");
|
|
128
|
+
if (!tone) continue;
|
|
129
|
+
|
|
130
|
+
const base = {
|
|
131
|
+
line: el.line,
|
|
132
|
+
registryId: "C-TONE",
|
|
133
|
+
match: `<${el.tag} tone=${tone.text ?? ""}>`,
|
|
134
|
+
component: el.component,
|
|
135
|
+
gate: null,
|
|
136
|
+
detail: {
|
|
137
|
+
tone: tone.expression ? null : (tone.literals[0] ?? null),
|
|
138
|
+
dynamic: tone.expression,
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
const review = (rule, severity) => findings.push({ ...base, rule, severity, action: "review" });
|
|
142
|
+
|
|
143
|
+
if (tone.expression || tone.literals.length !== 1) {
|
|
144
|
+
review("dynamic-tone", "high");
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (el.spread) {
|
|
148
|
+
review("spread-props", "medium");
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (el.props.has("intent")) {
|
|
152
|
+
review("has-intent", "low");
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const word = tone.literals[0];
|
|
156
|
+
const outcome = classifyTone(el.base, word);
|
|
157
|
+
if (!outcome) {
|
|
158
|
+
review("unknown-tone", "high");
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
if (outcome.action === "review") {
|
|
162
|
+
review(outcome.rule, outcome.severity);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const opening = openingOf(ts, el.node);
|
|
166
|
+
const edit =
|
|
167
|
+
outcome.intent === null
|
|
168
|
+
? removeAttribute(ts, facts.sf, opening, "tone")
|
|
169
|
+
: replaceAttribute(ts, facts.sf, opening, "tone", `intent="${outcome.intent}"`);
|
|
170
|
+
if (!edit) {
|
|
171
|
+
review("dynamic-tone", "high");
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
edits.push(edit);
|
|
175
|
+
findings.push({
|
|
176
|
+
...base,
|
|
177
|
+
rule: outcome.rule,
|
|
178
|
+
severity: outcome.severity,
|
|
179
|
+
action: "applied",
|
|
180
|
+
detail: { ...base.detail, intent: outcome.intent },
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
output: edits.length ? applyEdits(file.source, edits) : file.source,
|
|
186
|
+
findings,
|
|
187
|
+
notTransformed: [],
|
|
188
|
+
parseErrors: facts.parseErrors,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CM-12 — Button: `type="submit"` where the intent is evident, a report for
|
|
3
|
+
* the rest (class R; plan §29 seq 8, registry `C-BTN-TYPE`, feeds 2.0 gate
|
|
4
|
+
* G4).
|
|
5
|
+
*
|
|
6
|
+
* A `Button` that passes no `type` renders `<button>` without one, which the
|
|
7
|
+
* browser treats as `type="submit"`: inside a `<form>` it submits. 65% of the
|
|
8
|
+
* applications' Buttons pass no `type`, and the design system flips the
|
|
9
|
+
* default to `"button"` in 2.0 (W9-05) — a change that silently stops a form
|
|
10
|
+
* from submitting wherever the untyped Button was the submitter. CM-12
|
|
11
|
+
* writes down, before the flip, which Buttons submit **on purpose**.
|
|
12
|
+
*
|
|
13
|
+
* ## What it changes
|
|
14
|
+
*
|
|
15
|
+
* Only what is evident from the file itself: an untyped design-system
|
|
16
|
+
* `Button` that is lexically inside a lower-case `<form>` element **in the
|
|
17
|
+
* same file**, has no `onClick` and no spread gets `type="submit"`. That
|
|
18
|
+
* states what the browser does today, so the edit is behaviour-neutral; the
|
|
19
|
+
* report lists it (rule `submit-in-form`) because a human still has to agree
|
|
20
|
+
* that submitting is what the button is for — a "Cancel" that matches this
|
|
21
|
+
* shape is a bug today, and the codemod has just made it visible.
|
|
22
|
+
*
|
|
23
|
+
* ## What it reports and leaves to a human
|
|
24
|
+
*
|
|
25
|
+
* | rule | severity | why a human decides |
|
|
26
|
+
* | ------------------ | -------- | ------------------------------------------------------------------------------ |
|
|
27
|
+
* | `in-form-onclick` | high | inside a `<form>` with an `onClick`: submit with side effects, or a plain button? |
|
|
28
|
+
* | `in-form-spread` | high | inside a `<form>` with `{...props}`: the spread may or may not carry `type` |
|
|
29
|
+
* | `outside-form` | low | no `<form>` in this file; if the component is rendered in one elsewhere, it submits today |
|
|
30
|
+
*
|
|
31
|
+
* Choosing `submit` or `button` is behaviour, which plan §29 never automates
|
|
32
|
+
* beyond the evident case. Nothing is followed across files: a component
|
|
33
|
+
* defined in this file and rendered inside a `<form>` by another is
|
|
34
|
+
* `outside-form`. A capitalised `<Form>` is not a form element (the
|
|
35
|
+
* react-hook-form / shadcn `Form` is a context provider), so it does not
|
|
36
|
+
* count.
|
|
37
|
+
*
|
|
38
|
+
* Not reported: `SubmitButton` (always `type="submit"`), `LinkButton` (an
|
|
39
|
+
* anchor), a Button with any `type` — literal or dynamic — and a Button with
|
|
40
|
+
* `asChild` (it renders its child, `type` does not apply to it). Test files
|
|
41
|
+
* are skipped, as by the scanner that measures G4.
|
|
42
|
+
*/
|
|
43
|
+
import { analyseForms } from "../lib/forms.mjs";
|
|
44
|
+
import { applyInsertions, insertAttribute, openingOf } from "../lib/jsx-edit.mjs";
|
|
45
|
+
|
|
46
|
+
export const meta = {
|
|
47
|
+
id: "CM-12",
|
|
48
|
+
title: 'Button: type="submit" on evident form submitters, a review list for the rest',
|
|
49
|
+
class: "R",
|
|
50
|
+
oneShot: false,
|
|
51
|
+
requires: { codemods: [], dsVersion: null },
|
|
52
|
+
parses: ["code"],
|
|
53
|
+
includeTests: false,
|
|
54
|
+
usesTypeScript: true,
|
|
55
|
+
usesPostcss: false,
|
|
56
|
+
registryIds: ["C-BTN-TYPE"],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** The one design-system component whose untyped rendering submits. */
|
|
60
|
+
export const TYPED_BUTTON = "Button";
|
|
61
|
+
|
|
62
|
+
/** The element that gives an untyped button a form to submit. Lower-case only. */
|
|
63
|
+
export const FORM_TAG = "form";
|
|
64
|
+
|
|
65
|
+
/** Whether the element has a lower-case `<form>` ancestor in this file. */
|
|
66
|
+
export function insideForm(elements, el) {
|
|
67
|
+
for (let i = el.parent; i !== null; i = elements[i].parent) {
|
|
68
|
+
if (elements[i].tag === FORM_TAG) return true;
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function transform(file, { ts }) {
|
|
74
|
+
const facts = analyseForms(ts, file.source, file.rel);
|
|
75
|
+
const findings = [];
|
|
76
|
+
const edits = [];
|
|
77
|
+
|
|
78
|
+
for (const el of facts.elements) {
|
|
79
|
+
if (!el.isDs || el.base !== TYPED_BUTTON || el.component !== el.base) continue;
|
|
80
|
+
if (el.props.has("type") || el.props.has("asChild")) continue;
|
|
81
|
+
|
|
82
|
+
const inForm = insideForm(facts.elements, el);
|
|
83
|
+
const onClick = el.props.has("onClick");
|
|
84
|
+
const base = {
|
|
85
|
+
line: el.line,
|
|
86
|
+
registryId: "C-BTN-TYPE",
|
|
87
|
+
match: `<${el.tag}>`,
|
|
88
|
+
component: el.component,
|
|
89
|
+
gate: "G4",
|
|
90
|
+
detail: { inForm, onClick, spread: el.spread },
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
if (!inForm) {
|
|
94
|
+
findings.push({ ...base, rule: "outside-form", severity: "low", action: "review" });
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (el.spread) {
|
|
98
|
+
findings.push({ ...base, rule: "in-form-spread", severity: "high", action: "review" });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (onClick) {
|
|
102
|
+
findings.push({ ...base, rule: "in-form-onclick", severity: "high", action: "review" });
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
edits.push(insertAttribute(ts, facts.sf, openingOf(ts, el.node), 'type="submit"'));
|
|
106
|
+
findings.push({ ...base, rule: "submit-in-form", severity: "medium", action: "applied" });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
output: edits.length ? applyInsertions(file.source, edits) : file.source,
|
|
111
|
+
findings,
|
|
112
|
+
notTransformed: [],
|
|
113
|
+
parseErrors: facts.parseErrors,
|
|
114
|
+
};
|
|
115
|
+
}
|