@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.
@@ -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,192 @@
1
+ /**
2
+ * CM-19 — ProgressBar / ProgressRing: explicit `intent`, the value → colour
3
+ * rule written down (class R; plan §29 seq 7, registry `C-PROGRESS`).
4
+ *
5
+ * The two meters colour themselves **by value** when no colour is passed
6
+ * (`< 50%` brand, `≥ 50%` warning, `≥ 80%` success) — a business mapping the
7
+ * design system should not own (Tax lists it as a gotcha), which 2.0 switches
8
+ * off (W9-10). W3-21 added `intent` to the family; passing it disables the
9
+ * rule. CM-19 makes every call site say what it renders today, so the flip
10
+ * changes nothing a consumer did not write down:
11
+ *
12
+ * - `variant="<legacy word>"` becomes `intent="<the same colour>"`
13
+ * (`default` → `brand`, `destructive` → `danger`, the rest keep their name);
14
+ * - a meter with no colour and a **literal** `value` (and literal or absent
15
+ * `max`) gets the `intent` the rule picks for that value — the auto-colour
16
+ * result, preserved and now visible;
17
+ * - `value={null}` (indeterminate `ProgressBar`) gets `intent="brand"`.
18
+ *
19
+ * The rename to the unified `Progress variant="bar" | "ring"` of target
20
+ * architecture §26 is the second half of this codemod and lands with that
21
+ * component; today's edit is what makes it a pure rename later.
22
+ *
23
+ * ## What it reports
24
+ *
25
+ * | rule | action | severity | why |
26
+ * | --------------------------- | ------- | -------- | ---------------------------------------------------------------------- |
27
+ * | `variant-renamed` | applied | low | the colour word moved to the `intent` axis |
28
+ * | `auto-colour-made-explicit` | applied | medium | the intent the rule picked for a literal value is now written down — a human confirms that colour was the point, not the number |
29
+ * | `indeterminate-brand` | applied | low | an indeterminate track was always brand |
30
+ * | `dynamic-value` | review | high | no colour and `value={expr}`: the colour depends on runtime data, which is business logic (plan §29 never automates it) |
31
+ * | `dynamic-variant` | review | high | `variant={expr}`: map the expression's values by hand |
32
+ * | `spread-props` | review | medium | `{...props}` may carry `variant`, `intent` or `value` |
33
+ * | `unknown-variant` | review | high | a word the component never accepted |
34
+ *
35
+ * `SegmentedProgress` and `SuiteProgress` have no value → colour rule; their
36
+ * `tone` moves to `intent` through CM-06. Elements that already pass `intent`
37
+ * are not touched (idempotency); local components with the same names and
38
+ * test files are skipped.
39
+ */
40
+ import { analyseForms } from "../lib/forms.mjs";
41
+ import {
42
+ applyEdits,
43
+ attributeNamed,
44
+ insertAttribute,
45
+ openingOf,
46
+ replaceAttribute,
47
+ } from "../lib/jsx-edit.mjs";
48
+
49
+ export const meta = {
50
+ id: "CM-19",
51
+ title: "ProgressBar/ProgressRing: explicit intent, auto-colour written down",
52
+ class: "R",
53
+ oneShot: false,
54
+ requires: { codemods: [], dsVersion: null },
55
+ parses: ["code"],
56
+ includeTests: false,
57
+ usesTypeScript: true,
58
+ usesPostcss: false,
59
+ registryIds: ["C-PROGRESS"],
60
+ };
61
+
62
+ /** The meters that colour by value (src/primitives/progress-{bar,ring}.tsx). */
63
+ export const AUTO_COLOURED = new Set(["ProgressBar", "ProgressRing"]);
64
+
65
+ /** `variant` → `intent` (src/foundation/progress-intent.ts `PROGRESS_LEGACY_TO_INTENT`). */
66
+ export const VARIANT_MAP = {
67
+ default: "brand",
68
+ success: "success",
69
+ warning: "warning",
70
+ destructive: "danger",
71
+ info: "info",
72
+ };
73
+
74
+ /** The default `max` of both meters. */
75
+ export const DEFAULT_MAX = 100;
76
+
77
+ /** The intent the value rule picks (src/foundation/progress-intent.ts `autoProgressIntent`). */
78
+ export function autoIntent(value, max = DEFAULT_MAX) {
79
+ const percentage = Math.min(100, Math.max(0, (value / max) * 100));
80
+ return percentage >= 80 ? "success" : percentage >= 50 ? "warning" : "brand";
81
+ }
82
+
83
+ /**
84
+ * The number a JSX attribute passes, when it is a literal: `value={42}`,
85
+ * `value={-1}`, `value={1.5}`. `null` for `value={null}`, `undefined` for
86
+ * anything else (an expression, a missing attribute, a string).
87
+ */
88
+ export function literalNumber(ts, attr) {
89
+ if (!attr) return undefined;
90
+ const init = attr.initializer;
91
+ if (!init || !ts.isJsxExpression(init) || !init.expression) return undefined;
92
+ let expr = init.expression;
93
+ while (ts.isParenthesizedExpression(expr)) expr = expr.expression;
94
+ if (expr.kind === ts.SyntaxKind.NullKeyword) return null;
95
+ if (ts.isNumericLiteral(expr)) return Number(expr.text);
96
+ if (
97
+ ts.isPrefixUnaryExpression(expr) &&
98
+ expr.operator === ts.SyntaxKind.MinusToken &&
99
+ ts.isNumericLiteral(expr.operand)
100
+ ) {
101
+ return -Number(expr.operand.text);
102
+ }
103
+ return undefined;
104
+ }
105
+
106
+ export function transform(file, { ts }) {
107
+ const facts = analyseForms(ts, file.source, file.rel);
108
+ const findings = [];
109
+ const edits = [];
110
+
111
+ for (const el of facts.elements) {
112
+ if (!el.isDs || !AUTO_COLOURED.has(el.base) || el.component !== el.base) continue;
113
+ if (el.props.has("intent")) continue;
114
+
115
+ const opening = openingOf(ts, el.node);
116
+ const variant = el.props.get("variant");
117
+ const base = {
118
+ line: el.line,
119
+ registryId: "C-PROGRESS",
120
+ match: `<${el.tag}>`,
121
+ component: el.component,
122
+ gate: null,
123
+ detail: {
124
+ variant: variant ? (variant.expression ? null : (variant.literals[0] ?? null)) : undefined,
125
+ value: undefined,
126
+ max: undefined,
127
+ intent: undefined,
128
+ },
129
+ };
130
+ const review = (rule, severity, detail = {}) =>
131
+ findings.push({
132
+ ...base,
133
+ rule,
134
+ severity,
135
+ action: "review",
136
+ detail: { ...base.detail, ...detail },
137
+ });
138
+
139
+ if (el.spread) {
140
+ review("spread-props", "medium");
141
+ continue;
142
+ }
143
+
144
+ if (variant) {
145
+ if (variant.expression || variant.literals.length !== 1) {
146
+ review("dynamic-variant", "high");
147
+ continue;
148
+ }
149
+ const intent = VARIANT_MAP[variant.literals[0]];
150
+ if (!intent) {
151
+ review("unknown-variant", "high");
152
+ continue;
153
+ }
154
+ edits.push(replaceAttribute(ts, facts.sf, opening, "variant", `intent="${intent}"`));
155
+ findings.push({
156
+ ...base,
157
+ rule: "variant-renamed",
158
+ severity: "low",
159
+ action: "applied",
160
+ detail: { ...base.detail, intent },
161
+ });
162
+ continue;
163
+ }
164
+
165
+ const value = literalNumber(ts, attributeNamed(ts, opening, "value"));
166
+ const maxAttr = attributeNamed(ts, opening, "max");
167
+ const max = maxAttr ? literalNumber(ts, maxAttr) : DEFAULT_MAX;
168
+ if (value === undefined || max === undefined || max === null) {
169
+ review("dynamic-value", "high", {
170
+ value: value === undefined ? null : value,
171
+ max: max ?? null,
172
+ });
173
+ continue;
174
+ }
175
+ const intent = value === null ? "brand" : autoIntent(value, max);
176
+ edits.push(insertAttribute(ts, facts.sf, opening, `intent="${intent}"`, { after: "value" }));
177
+ findings.push({
178
+ ...base,
179
+ rule: value === null ? "indeterminate-brand" : "auto-colour-made-explicit",
180
+ severity: value === null ? "low" : "medium",
181
+ action: "applied",
182
+ detail: { ...base.detail, value, max, intent },
183
+ });
184
+ }
185
+
186
+ return {
187
+ output: edits.length ? applyEdits(file.source, edits) : file.source,
188
+ findings,
189
+ notTransformed: [],
190
+ parseErrors: facts.parseErrors,
191
+ };
192
+ }