@staffbase/design 18.8.1 → 19.0.1

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,176 @@
1
+ # v17 — Migrate Tailwind tokens
2
+
3
+ This codemod automatically migrates renamed Tailwind tokens introduced as breaking changes in `@staffbase/design` v17.
4
+
5
+ ## What it transforms
6
+
7
+ ### Border radius
8
+
9
+ All Tailwind positional prefixes are handled (`t`, `b`, `l`, `r`, `tl`, `tr`, `bl`, `br`, `s`, `e`, `ss`, `se`, `es`, `ee`):
10
+
11
+ | Before | After |
12
+ | ------------------- | -------------------- |
13
+ | `rounded-4` | `rounded-sm` |
14
+ | `rounded-6` | `rounded-md` |
15
+ | `rounded-8` | `rounded-lg` |
16
+ | `rounded-16` | `rounded-xl` |
17
+ | `rounded-t-4` | `rounded-t-sm` |
18
+ | `rounded-e-8` | `rounded-e-lg` |
19
+ | `rounded-tl-16` | `rounded-tl-xl` |
20
+ | `sm:rounded-4` | `sm:rounded-sm` |
21
+ | `hover:rounded-t-6` | `hover:rounded-t-md` |
22
+
23
+ ### Font size
24
+
25
+ | Before | After |
26
+ | --------- | ---------- |
27
+ | `text-12` | `text-xs` |
28
+ | `text-14` | `text-sm` |
29
+ | `text-16` | `text-md` |
30
+ | `text-18` | `text-lg` |
31
+ | `text-20` | `text-xl` |
32
+ | `text-24` | `text-2xl` |
33
+ | `text-28` | `text-3xl` |
34
+ | `text-32` | `text-4xl` |
35
+ | `text-36` | `text-5xl` |
36
+
37
+ ### Font weight
38
+
39
+ | Before | After |
40
+ | -------------- | ------------- |
41
+ | `font-regular` | `font-normal` |
42
+
43
+ ### Letter spacing
44
+
45
+ | Before | After |
46
+ | ------------------ | ----------------- |
47
+ | `tracking-default` | `tracking-normal` |
48
+
49
+ ### Pill
50
+
51
+ The `variant` prop (previously used for colour) is replaced by `color`. Only static string values are transformed; dynamic expressions will surface as TypeScript errors after migration.
52
+
53
+ | Before | After |
54
+ | ------------------------- | ------------------------- |
55
+ | `<Pill variant="blue">` | `<Pill color="primary">` |
56
+ | `<Pill variant="green">` | `<Pill color="success">` |
57
+ | `<Pill variant="grey">` | `<Pill color="neutral">` |
58
+ | `<Pill variant="red">` | `<Pill color="critical">` |
59
+ | `<Pill variant="yellow">` | `<Pill color="warning">` |
60
+
61
+ `variant="outline"` and `variant="solid"` are left untouched — they already use the new API.
62
+
63
+ ### PillGroup
64
+
65
+ `PillGroup` is removed. Its usage is replaced with a plain `div`:
66
+
67
+ ```tsx
68
+ // Before
69
+ import {PillGroup} from '@staffbase/design';
70
+ <PillGroup><Pill>One</Pill><Pill>Two</Pill></PillGroup>
71
+
72
+ // After
73
+ <div className='flex flex-wrap gap-1'><Pill>One</Pill><Pill>Two</Pill></div>
74
+ ```
75
+
76
+ A static `className` prop is merged into the base classes:
77
+
78
+ ```tsx
79
+ // Before
80
+ <PillGroup className='mt-4'>…</PillGroup>
81
+
82
+ // After
83
+ <div className='flex flex-wrap gap-1 mt-4'>…</div>
84
+ ```
85
+
86
+ The `PillGroup` import is removed automatically. Other props (`id`, `data-*`, `aria-*`, etc.) are preserved on the resulting `div`.
87
+
88
+ ### TextArea
89
+
90
+ `TextArea` is renamed to `TextAreaDeprecated` and `TextAreaProps` to `TextAreaDeprecatedProps`. Import specifiers, JSX usages, and TypeScript type references are all updated.
91
+
92
+ ```tsx
93
+ // Before
94
+ import {TextArea, TextAreaProps} from '@staffbase/design';
95
+ const props: TextAreaProps = {};
96
+ <TextArea {...props} />;
97
+
98
+ // After
99
+ import {TextAreaDeprecated, TextAreaDeprecatedProps} from '@staffbase/design';
100
+ const props: TextAreaDeprecatedProps = {};
101
+ <TextAreaDeprecated {...props} />;
102
+ ```
103
+
104
+ Aliased imports are handled correctly — only the exported name changes, the local alias is preserved:
105
+
106
+ ```tsx
107
+ // Before
108
+ import {TextArea as TA} from '@staffbase/design';
109
+
110
+ // After
111
+ import {TextAreaDeprecated as TA} from '@staffbase/design';
112
+ ```
113
+
114
+ **Tokens are replaced in all string contexts** — `className` props, `clsx()`/`cn()` calls, variant maps, and constants:
115
+
116
+ ```tsx
117
+ // Before
118
+ <div className="rounded-4 text-14 font-regular" />
119
+ clsx('rounded-8', isActive && 'rounded-t-16')
120
+ const styles = {base: 'tracking-default text-12 rounded-s-6'}
121
+
122
+ // After
123
+ <div className="rounded-sm text-sm font-normal" />
124
+ clsx('rounded-lg', isActive && 'rounded-t-xl')
125
+ const styles = {base: 'tracking-normal text-xs rounded-s-md'}
126
+ ```
127
+
128
+ ## Usage
129
+
130
+ ### Step 1 — Install jscodeshift
131
+
132
+ ```bash
133
+ npm install --save-dev jscodeshift
134
+ # or
135
+ pnpm add -D jscodeshift
136
+ ```
137
+
138
+ ### Step 2 — Run the codemod
139
+
140
+ Point the `--transform` flag at this file inside your `node_modules`:
141
+
142
+ ```bash
143
+ npx jscodeshift@latest \
144
+ --transform node_modules/@staffbase/design/codemods/v17-migrate-tokens/transform.js \
145
+ --extensions tsx,ts,jsx,js \
146
+ --parser tsx \
147
+ src/
148
+ ```
149
+
150
+ Run a **dry run** first to preview changes without writing files:
151
+
152
+ ```bash
153
+ npx jscodeshift@latest \
154
+ --transform node_modules/@staffbase/design/codemods/v17-migrate-tokens/transform.js \
155
+ --extensions tsx,ts,jsx,js \
156
+ --parser tsx \
157
+ --dry \
158
+ --print \
159
+ src/
160
+ ```
161
+
162
+ ### Step 3 — Review and commit
163
+
164
+ 1. Run your type-checker:
165
+ ```bash
166
+ npx tsc --noEmit
167
+ ```
168
+ 2. Run your tests.
169
+ 3. Commit the result.
170
+
171
+ ## What the codemod does NOT handle
172
+
173
+ - **CSS files** — `@apply rounded-4` in `.css` files is not transformed. Update these manually.
174
+ - **Dynamic class composition** — e.g. `` `rounded-${size}` `` where the suffix is a variable. Only static string content is replaced.
175
+ - **Dynamic `className` on `PillGroup`** — e.g. `<PillGroup className={cn('mt-4')}>`. The dynamic value is replaced with the base classes only; merge manually afterward.
176
+ - **Spacing tokens** — the v17 spacing scale change (divide by 4 for margins, padding, gap, etc.) is a numeric transformation that cannot be automated safely. Refer to the v17 migration guide.
@@ -0,0 +1,428 @@
1
+ /**
2
+ * Jscodeshift codemod: Migrate v17 Tailwind token renames.
3
+ *
4
+ * Replaces renamed tokens in all string literals (including clsx/cn calls and
5
+ * template literals) across JS/TS/JSX/TSX files.
6
+ *
7
+ * Border radius — all Tailwind positional prefixes handled
8
+ * (t, b, l, r, tl, tr, bl, br, s, e, ss, se, es, ee):
9
+ * rounded-{prefix?}-4 → rounded-{prefix?}-sm
10
+ * rounded-{prefix?}-6 → rounded-{prefix?}-md
11
+ * rounded-{prefix?}-8 → rounded-{prefix?}-lg
12
+ * rounded-{prefix?}-16 → rounded-{prefix?}-xl
13
+ *
14
+ * Font size:
15
+ * text-12 → text-xs text-24 → text-2xl
16
+ * text-14 → text-sm text-28 → text-3xl
17
+ * text-16 → text-md text-32 → text-4xl
18
+ * text-18 → text-lg text-36 → text-5xl
19
+ * text-20 → text-xl
20
+ *
21
+ * Font weight:
22
+ * font-regular → font-normal
23
+ *
24
+ * Letter spacing:
25
+ * tracking-default → tracking-normal
26
+ *
27
+ * Breakpoints (only as responsive prefixes — e.g. 3xl:flex, not text-3xl):
28
+ * 3xl → 2xl
29
+ * 2xs → xs
30
+ *
31
+ * Background overlay:
32
+ * bg-overlay-default → bg-neutral-strong/50
33
+ * bg-overlay-strong → bg-neutral-strong/80
34
+ *
35
+ * Pill — JSX prop migration (scoped to <Pill> imported from @staffbase/design):
36
+ * variant="blue" → color="primary"
37
+ * variant="green" → color="success"
38
+ * variant="grey" → color="neutral"
39
+ * variant="red" → color="critical"
40
+ * variant="yellow" → color="warning"
41
+ *
42
+ * PillGroup — replaced with a plain div:
43
+ * <PillGroup> → <div className="flex flex-wrap gap-1">
44
+ *
45
+ * TextArea — renamed to the deprecated export:
46
+ * TextArea → TextAreaDeprecated
47
+ * TextAreaProps → TextAreaDeprecatedProps
48
+ *
49
+ * Tooltip — swapped with the new implementation:
50
+ * Tooltip → TooltipDeprecated
51
+ * TooltipNew → Tooltip
52
+ *
53
+ * PillProps — replaced with an inlined type query:
54
+ * PillProps → React.ComponentProps<typeof Pill>
55
+ *
56
+ * Usage:
57
+ * npx jscodeshift@latest \
58
+ * --transform node_modules/@staffbase/design/codemods/v17-migrate-tokens/transform.js \
59
+ * --extensions tsx,ts,jsx,js \
60
+ * --parser tsx \
61
+ * src/
62
+ */
63
+
64
+ // ── Token mappings ────────────────────────────────────────────────────────────
65
+
66
+ const ROUNDED_SIZES = {4: 'sm', 6: 'md', 8: 'lg', 16: 'xl'};
67
+
68
+ // All Tailwind border-radius positional prefixes. Empty string = no prefix (e.g. rounded-4).
69
+ const ROUNDED_PREFIXES = [
70
+ '',
71
+ 't-',
72
+ 'b-',
73
+ 'l-',
74
+ 'r-',
75
+ 'tl-',
76
+ 'tr-',
77
+ 'bl-',
78
+ 'br-',
79
+ 's-',
80
+ 'e-',
81
+ 'ss-',
82
+ 'se-',
83
+ 'es-',
84
+ 'ee-',
85
+ ];
86
+
87
+ const TEXT_SIZES = {
88
+ 12: 'xs',
89
+ 14: 'sm',
90
+ 16: 'md',
91
+ 18: 'lg',
92
+ 20: 'xl',
93
+ 24: '2xl',
94
+ 28: '3xl',
95
+ 32: '4xl',
96
+ 36: '5xl',
97
+ };
98
+
99
+ const DIRECT_RENAMES = [
100
+ ['font-regular', 'font-normal'],
101
+ ['tracking-default', 'tracking-normal'],
102
+ ['bg-overlay-default', 'bg-neutral-strong/50'],
103
+ ['bg-overlay-strong', 'bg-neutral-strong/80'],
104
+ ];
105
+
106
+ // Breakpoint prefixes only — matched with a lookahead for `:` to avoid touching
107
+ // size suffixes like `text-3xl` or `rounded-2xl`.
108
+ const BREAKPOINT_RENAMES = [
109
+ ['3xl', '2xl'],
110
+ ['2xs', 'xs'],
111
+ ];
112
+
113
+ const DESIGN_PKG = '@staffbase/design';
114
+
115
+ const PILL_GROUP_CLASSES = 'flex flex-wrap gap-1';
116
+
117
+ const TEXT_AREA_RENAMES = new Map([
118
+ ['TextArea', 'TextAreaDeprecated'],
119
+ ['TextAreaProps', 'TextAreaDeprecatedProps'],
120
+ ]);
121
+
122
+ const TOOLTIP_RENAMES = new Map([
123
+ ['Tooltip', 'TooltipDeprecated'],
124
+ ['TooltipNew', 'Tooltip'],
125
+ ]);
126
+
127
+ // Pill: old variant color values → new color prop values.
128
+ // variant="outline" | "solid" are left untouched (they already use the new API).
129
+ const PILL_COLOR_MAP = {
130
+ blue: 'primary',
131
+ green: 'success',
132
+ grey: 'neutral',
133
+ red: 'critical',
134
+ yellow: 'warning',
135
+ };
136
+
137
+ // ── Pre-build replacement list ────────────────────────────────────────────────
138
+
139
+ /** @type {Array<[RegExp, string]>} */
140
+ const REPLACEMENTS = [];
141
+
142
+ for (const [size, name] of Object.entries(ROUNDED_SIZES)) {
143
+ for (const prefix of ROUNDED_PREFIXES) {
144
+ REPLACEMENTS.push([new RegExp(`\\brounded-${prefix}${size}\\b`, 'g'), `rounded-${prefix}${name}`]);
145
+ }
146
+ }
147
+
148
+ for (const [size, name] of Object.entries(TEXT_SIZES)) {
149
+ REPLACEMENTS.push([new RegExp(`\\btext-${size}\\b`, 'g'), `text-${name}`]);
150
+ }
151
+
152
+ for (const [from, to] of DIRECT_RENAMES) {
153
+ REPLACEMENTS.push([new RegExp(`\\b${from}\\b`, 'g'), to]);
154
+ }
155
+
156
+ // Breakpoints: match only when used as a responsive prefix (followed by `:`),
157
+ // not when part of a size utility (e.g. text-3xl, rounded-2xl).
158
+ for (const [from, to] of BREAKPOINT_RENAMES) {
159
+ REPLACEMENTS.push([new RegExp(`\\b${from}(?=:)`, 'g'), to]);
160
+ }
161
+
162
+ // ── Helpers ───────────────────────────────────────────────────────────────────
163
+
164
+ function replaceTokens(str) {
165
+ let result = str;
166
+ for (const [pattern, replacement] of REPLACEMENTS) {
167
+ result = result.replace(pattern, replacement);
168
+ }
169
+ return result;
170
+ }
171
+
172
+ // ── Pill migration ────────────────────────────────────────────────────────────
173
+
174
+ function migratePills(j, root) {
175
+ const designImports = root.find(j.ImportDeclaration, {source: {value: DESIGN_PKG}});
176
+ if (!designImports.length) return false;
177
+
178
+ let pillLocalName = null;
179
+ designImports.find(j.ImportSpecifier).forEach((spec) => {
180
+ if (spec.node.imported.name === 'Pill') {
181
+ pillLocalName = spec.node.local ? spec.node.local.name : 'Pill';
182
+ }
183
+ });
184
+ if (!pillLocalName) return false;
185
+
186
+ let dirty = false;
187
+ root.find(j.JSXElement, {openingElement: {name: {name: pillLocalName}}}).forEach((path) => {
188
+ const attrs = path.node.openingElement.attributes;
189
+ for (let i = 0; i < attrs.length; i++) {
190
+ const attr = attrs[i];
191
+ if (attr.type !== 'JSXAttribute' || attr.name.name !== 'variant') continue;
192
+ if (!attr.value || attr.value.type !== 'StringLiteral') continue;
193
+
194
+ const newColor = PILL_COLOR_MAP[attr.value.value];
195
+ if (!newColor) continue; // variant="outline" | "solid" — already new API, skip
196
+
197
+ attrs[i] = j.jsxAttribute(j.jsxIdentifier('color'), j.stringLiteral(newColor));
198
+ dirty = true;
199
+ }
200
+ });
201
+
202
+ return dirty;
203
+ }
204
+
205
+ // ── PillGroup migration ───────────────────────────────────────────────────────────
206
+
207
+ function migratePillGroups(j, root) {
208
+ const designImports = root.find(j.ImportDeclaration, {source: {value: DESIGN_PKG}});
209
+ if (!designImports.length) return false;
210
+
211
+ let localName = null;
212
+ designImports.find(j.ImportSpecifier).forEach((spec) => {
213
+ if (spec.node.imported.name === 'PillGroup') {
214
+ localName = spec.node.local ? spec.node.local.name : 'PillGroup';
215
+ }
216
+ });
217
+ if (!localName) return false;
218
+
219
+ let dirty = false;
220
+ root.find(j.JSXElement, {openingElement: {name: {name: localName}}}).forEach((path) => {
221
+ const {openingElement, closingElement} = path.node;
222
+
223
+ // Merge base classes with any existing static className.
224
+ const existingClassAttr = openingElement.attributes.find(
225
+ (attr) => attr.type === 'JSXAttribute' && attr.name.name === 'className',
226
+ );
227
+ let mergedClass = PILL_GROUP_CLASSES;
228
+ if (existingClassAttr?.value?.type === 'StringLiteral') {
229
+ mergedClass = `${PILL_GROUP_CLASSES} ${existingClassAttr.value.value}`;
230
+ }
231
+
232
+ // Build new attrs: className first, then all other existing attrs (minus old className).
233
+ const otherAttrs = openingElement.attributes.filter(
234
+ (attr) => !(attr.type === 'JSXAttribute' && attr.name.name === 'className'),
235
+ );
236
+ openingElement.name = j.jsxIdentifier('div');
237
+ openingElement.attributes = [
238
+ j.jsxAttribute(j.jsxIdentifier('className'), j.stringLiteral(mergedClass)),
239
+ ...otherAttrs,
240
+ ];
241
+ if (closingElement) closingElement.name = j.jsxIdentifier('div');
242
+
243
+ dirty = true;
244
+ });
245
+
246
+ if (!dirty) return false;
247
+
248
+ // Remove PillGroup import specifier.
249
+ designImports.find(j.ImportSpecifier).forEach((spec) => {
250
+ if (spec.node.imported.name === 'PillGroup') j(spec).remove();
251
+ });
252
+
253
+ return true;
254
+ }
255
+
256
+ // ── TextArea migration ───────────────────────────────────────────────────────────
257
+
258
+ function migrateTextArea(j, root) {
259
+ const designImports = root.find(j.ImportDeclaration, {source: {value: DESIGN_PKG}});
260
+ if (!designImports.length) return false;
261
+
262
+ // Collect local → new name mappings for non-aliased imports.
263
+ const localRenames = new Map();
264
+
265
+ designImports.find(j.ImportSpecifier).forEach((spec) => {
266
+ const importedName = spec.node.imported.name;
267
+ const newImportedName = TEXT_AREA_RENAMES.get(importedName);
268
+ if (!newImportedName) return;
269
+
270
+ const localName = spec.node.local.name;
271
+ spec.node.imported = j.identifier(newImportedName);
272
+
273
+ // When no alias, local name equals imported name — rename local usages too.
274
+ if (localName === importedName) {
275
+ spec.node.local = j.identifier(newImportedName);
276
+ localRenames.set(localName, newImportedName);
277
+ }
278
+ });
279
+
280
+ if (!localRenames.size) return false;
281
+
282
+ localRenames.forEach((newName, oldName) => {
283
+ root.find(j.Identifier, {name: oldName}).forEach((path) => {
284
+ const parent = path.parent.node;
285
+ if (parent.type === 'ImportSpecifier') return; // already handled above
286
+ // Skip property accesses (foo.TextArea) and non-computed object keys.
287
+ if (parent.type === 'MemberExpression' && parent.property === path.node && !parent.computed) return;
288
+ if (parent.type === 'ObjectProperty' && parent.key === path.node && !parent.computed) return;
289
+ path.node.name = newName;
290
+ });
291
+ });
292
+
293
+ return true;
294
+ }
295
+
296
+ // ── Tooltip migration ────────────────────────────────────────────────────────────
297
+
298
+ function migrateTooltip(j, root) {
299
+ const designImports = root.find(j.ImportDeclaration, {source: {value: DESIGN_PKG}});
300
+ if (!designImports.length) return false;
301
+
302
+ // Collect all renames first, then apply — avoids conflicts when both
303
+ // Tooltip and TooltipNew are imported in the same file.
304
+ const localRenames = new Map();
305
+
306
+ designImports.find(j.ImportSpecifier).forEach((spec) => {
307
+ const importedName = spec.node.imported.name;
308
+ const newImportedName = TOOLTIP_RENAMES.get(importedName);
309
+ if (!newImportedName) return;
310
+
311
+ const localName = spec.node.local.name;
312
+ spec.node.imported = j.identifier(newImportedName);
313
+
314
+ if (localName === importedName) {
315
+ spec.node.local = j.identifier(newImportedName);
316
+ localRenames.set(localName, newImportedName);
317
+ }
318
+ });
319
+
320
+ if (!localRenames.size) return false;
321
+
322
+ localRenames.forEach((newName, oldName) => {
323
+ root.find(j.Identifier, {name: oldName}).forEach((path) => {
324
+ const parent = path.parent.node;
325
+ if (parent.type === 'ImportSpecifier') return;
326
+ if (parent.type === 'MemberExpression' && parent.property === path.node && !parent.computed) return;
327
+ if (parent.type === 'ObjectProperty' && parent.key === path.node && !parent.computed) return;
328
+ path.node.name = newName;
329
+ });
330
+ });
331
+
332
+ return true;
333
+ }
334
+
335
+ // ── PillProps migration ───────────────────────────────────────────────────────────
336
+
337
+ function migratePillProps(j, root) {
338
+ const designImports = root.find(j.ImportDeclaration, {source: {value: DESIGN_PKG}});
339
+ if (!designImports.length) return false;
340
+
341
+ let pillPropsLocalName = null;
342
+ let pillLocalName = null;
343
+
344
+ designImports.find(j.ImportSpecifier).forEach((spec) => {
345
+ if (spec.node.imported.name === 'PillProps') pillPropsLocalName = spec.node.local.name;
346
+ if (spec.node.imported.name === 'Pill') pillLocalName = spec.node.local.name;
347
+ });
348
+
349
+ if (!pillPropsLocalName) return false;
350
+
351
+ // `typeof Pill` requires Pill to be imported as a value. Add it if missing.
352
+ if (!pillLocalName) {
353
+ pillLocalName = 'Pill';
354
+ designImports.get().node.specifiers.push(j.importSpecifier(j.identifier('Pill')));
355
+ }
356
+
357
+ const buildReplacementType = (localPillName) =>
358
+ j.tsTypeReference(
359
+ j.tsQualifiedName(j.identifier('React'), j.identifier('ComponentProps')),
360
+ j.tsTypeParameterInstantiation([j.tsTypeQuery(j.identifier(localPillName))]),
361
+ );
362
+
363
+ // Replace all TSTypeReference nodes that use the old local name.
364
+ let dirty = false;
365
+ root.find(j.TSTypeReference).forEach((path) => {
366
+ const {typeName} = path.node;
367
+ if (typeName.type === 'Identifier' && typeName.name === pillPropsLocalName) {
368
+ j(path).replaceWith(buildReplacementType(pillLocalName));
369
+ dirty = true;
370
+ }
371
+ });
372
+
373
+ if (!dirty) return false;
374
+
375
+ // Remove the PillProps import specifier.
376
+ designImports.find(j.ImportSpecifier).forEach((spec) => {
377
+ if (spec.node.imported.name === 'PillProps') j(spec).remove();
378
+ });
379
+
380
+ return true;
381
+ }
382
+
383
+ // ── Entry point ───────────────────────────────────────────────────────────────
384
+
385
+ export default function transform(file, api) {
386
+ const j = api.jscodeshift;
387
+ const root = j(file.source);
388
+ let dirty = false;
389
+
390
+ // Replace within all string literals (className props, clsx/cn args, constants, etc.).
391
+ root.find(j.StringLiteral).forEach((path) => {
392
+ const next = replaceTokens(path.node.value);
393
+ if (next !== path.node.value) {
394
+ path.node.value = next;
395
+ dirty = true;
396
+ }
397
+ });
398
+
399
+ // Replace within the static parts of template literals.
400
+ root.find(j.TemplateElement).forEach((path) => {
401
+ const {raw, cooked} = path.node.value;
402
+ const nextRaw = replaceTokens(raw);
403
+ if (nextRaw !== raw) {
404
+ path.node.value = {
405
+ raw: nextRaw,
406
+ cooked: cooked != null ? replaceTokens(cooked) : cooked,
407
+ };
408
+ dirty = true;
409
+ }
410
+ });
411
+
412
+ // Migrate <Pill variant="color"> → <Pill color="newColor">.
413
+ if (migratePills(j, root)) dirty = true;
414
+
415
+ // Migrate <PillGroup> → <div className="flex flex-wrap gap-1">.
416
+ if (migratePillGroups(j, root)) dirty = true;
417
+
418
+ // Rename TextArea → TextAreaDeprecated and TextAreaProps → TextAreaDeprecatedProps.
419
+ if (migrateTextArea(j, root)) dirty = true;
420
+
421
+ // Rename Tooltip → TooltipDeprecated and TooltipNew → Tooltip.
422
+ if (migrateTooltip(j, root)) dirty = true;
423
+
424
+ // Replace PillProps with React.ComponentProps<typeof Pill>.
425
+ if (migratePillProps(j, root)) dirty = true;
426
+
427
+ return dirty ? root.toSource({quote: 'single'}) : undefined;
428
+ }
@@ -0,0 +1,94 @@
1
+ # v19 — Migrate to the new Button
2
+
3
+ This codemod automatically migrates your code from the four deprecated button components in `@staffbase/design` v18 to the new unified `Button` in v19.
4
+
5
+ ## What it transforms
6
+
7
+ | Before (v18) | After (v19) |
8
+ | ----------------------------------- | ------------------------------------------------- |
9
+ | `<Button variant="primary">` | `<Button>` |
10
+ | `<Button variant="secondary">` | `<Button color="neutral">` |
11
+ | `<Button variant="critical">` | `<Button color="critical">` |
12
+ | `<GhostButton variant="primary">` | `<Button variant="ghost">` |
13
+ | `<GhostButton variant="secondary">` | `<Button variant="ghost" color="neutral">` |
14
+ | `<IconButton icon={<X />} />` | `<Button iconOnly><X /></Button>` |
15
+ | `<IconGhostButton icon={<X />} />` | `<Button variant="ghost" iconOnly><X /></Button>` |
16
+ | `<ButtonDeprecated …>` | `<Button …>` |
17
+
18
+ **Icon props are moved into children:**
19
+
20
+ ```tsx
21
+ // Before
22
+ <Button icon={<SaveIcon />} iconPosition="leading">Save</Button>
23
+
24
+ // After
25
+ <Button><SaveIcon />Save</Button>
26
+ ```
27
+
28
+ ```tsx
29
+ // Before
30
+ <GhostButton icon={<ShareIcon />} iconPosition="trailing">Share</GhostButton>
31
+
32
+ // After
33
+ <Button variant="ghost">Share<ShareIcon /></Button>
34
+ ```
35
+
36
+ **Imports are updated automatically:**
37
+
38
+ ```tsx
39
+ // Before
40
+ import {Button, GhostButton, IconButton, IconGhostButton} from '@staffbase/design';
41
+
42
+ // After
43
+ import {Button} from '@staffbase/design';
44
+ ```
45
+
46
+ ## Usage
47
+
48
+ ### Step 1 — Install jscodeshift
49
+
50
+ ```bash
51
+ npm install --save-dev jscodeshift
52
+ # or
53
+ pnpm add -D jscodeshift
54
+ ```
55
+
56
+ ### Step 2 — Run the codemod
57
+
58
+ Point the `--transform` flag at this file inside your `node_modules`:
59
+
60
+ ```bash
61
+ npx jscodeshift@latest \
62
+ --transform node_modules/@staffbase/design/codemods/v19-migrate-button/transform.js \
63
+ --extensions tsx,ts \
64
+ --parser tsx \
65
+ src/
66
+ ```
67
+
68
+ Run a **dry run** first to preview changes without writing files:
69
+
70
+ ```bash
71
+ npx jscodeshift@latest \
72
+ --transform node_modules/@staffbase/design/codemods/v19-migrate-button/transform.js \
73
+ --extensions tsx,ts \
74
+ --parser tsx \
75
+ --dry \
76
+ --print \
77
+ src/
78
+ ```
79
+
80
+ ### Step 3 — Review and commit
81
+
82
+ 1. Run your type-checker to catch anything the codemod couldn't handle statically:
83
+ ```bash
84
+ npx tsc --noEmit
85
+ ```
86
+ 2. Run your tests.
87
+ 3. Commit the result.
88
+
89
+ ## What the codemod does NOT handle
90
+
91
+ - **Dynamic `variant` expressions** — e.g. `variant={someVariable}`. The original `variant` prop is preserved so TypeScript will surface a type error at that location. Update these manually.
92
+ - **Spread props** — e.g. `<Button {...buttonProps} />`. The spread is preserved; if `buttonProps` contains old-style variant/icon fields those object shapes need updating manually.
93
+ - **Type annotations** — `ButtonDeprecatedProps`, `GhostButtonProps`, etc. are removed from imports. If you use these types in your own interfaces or function signatures, TypeScript errors will point you to the right places.
94
+ - **Non-JSX prop passing** — e.g. `React.createElement(Button, {variant: 'secondary'})`. Only JSX syntax is transformed.