@assure-one/design-system 1.35.0 → 1.37.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.
Files changed (38) hide show
  1. package/README.md +52 -0
  2. package/codemods/README.md +155 -12
  3. package/codemods/lib/registry.mjs +5 -0
  4. package/codemods/transforms/cm-07-input-size.mjs +104 -0
  5. package/codemods/transforms/cm-08-search-select-to-combobox.mjs +236 -0
  6. package/codemods/transforms/cm-09-side-drawer-to-sheet.mjs +348 -0
  7. package/codemods/transforms/cm-10-date-picker-value-change.mjs +416 -0
  8. package/codemods/transforms/cm-13-tooltip-delay.mjs +99 -0
  9. package/codemods/transforms/cm-14-hidden-mirrors.mjs +2 -2
  10. package/dist/css/components.css +1 -1
  11. package/dist/css/legacy-aliases.css +25 -1
  12. package/dist/css/shadcn.css +2 -2
  13. package/dist/css/tailwind.css +9 -0
  14. package/dist/css/tokens.css +64 -38
  15. package/dist/design-system-provider-C8UlFe52.d.ts +549 -0
  16. package/dist/icons/index.d.ts +2 -2
  17. package/dist/icons/index.js +6 -2
  18. package/dist/icons/index.js.map +1 -1
  19. package/dist/{index-B3OiMlQv.d.ts → index-vztxMSfl.d.ts} +7 -4
  20. package/dist/index.d.ts +2259 -345
  21. package/dist/index.js +7229 -3057
  22. package/dist/index.js.map +1 -1
  23. package/dist/next/index.d.ts +20 -0
  24. package/dist/next/index.js +16 -0
  25. package/dist/next/index.js.map +1 -0
  26. package/dist/styles.css +1 -1
  27. package/dist/{system-lyhOUTpa.d.ts → system-CyAVbHhM.d.ts} +17 -17
  28. package/dist/testing/index.cjs +133 -11
  29. package/dist/testing/index.d.cts +98 -2
  30. package/dist/testing/index.d.ts +98 -2
  31. package/dist/testing/index.js +132 -12
  32. package/dist/tokens/index.d.ts +1 -1
  33. package/dist/tokens/index.js +8 -8
  34. package/dist/tokens/index.js.map +1 -1
  35. package/docs/components.md +606 -0
  36. package/docs/components.registry.json +1604 -0
  37. package/docs/for-ai-agents.md +153 -0
  38. package/package.json +15 -3
package/README.md CHANGED
@@ -8,12 +8,16 @@ The single source of truth for visual design across all Assure products.
8
8
 
9
9
  ## Documentation
10
10
 
11
+ - [**`docs/components.md`**](./docs/components.md) — the component catalogue: every public component by need and category, what it replaces, what not to use it for, deprecated aliases and their codemods. Generated from the API reports; ships in the package (`node_modules/@assure-one/design-system/docs/components.md`).
12
+ - [**`docs/for-ai-agents.md`**](./docs/for-ai-agents.md) — the short rulebook for AI agents and new contributors: never raw HTML, the `Field` recipe, `className` vs `classNames`, the `ds:` prefix, tokens, codemods, testing, what is experimental, how to propose a component.
11
13
  - [**`CHANGELOG.md`**](./CHANGELOG.md) — release-by-release log of what shipped.
12
14
  - [**`CONTRIBUTING.md`**](./CONTRIBUTING.md) — dev loop, release flow, versioning, pitfalls.
13
15
  - [**`CLAUDE.md`**](./CLAUDE.md) — invariants and conventions for AI assistants working in this repo.
14
16
  - [**`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
17
  - [**`docs/testing.md`**](./docs/testing.md) — rendering the real package in your app's Jest tests (`@assure-one/design-system/testing`, experimental).
18
+ - [**`docs/forms.md`**](./docs/forms.md) — building a form: `Field` + design-system controls + a Server Action + zod, reset and `required`, what each control submits, and testing it with `fillField` / `selectOption` / `pickDate`.
16
19
  - [**`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.
20
+ - [**`docs/adr/010-design-system-provider.md`**](./docs/adr/010-design-system-provider.md) — the optional `DesignSystemProvider` (messages, locale, link adapter) and the `/next` entry; the quick start is [below](#the-provider-and-the-next-entry-experimental).
17
21
  - [**`claude-skills/`**](./claude-skills) — drop-in Claude Code skill for consuming projects.
18
22
 
19
23
  ## Install
@@ -84,6 +88,54 @@ canvas, and guarantees WCAG AA for every foreground it emits. The seed shape is
84
88
  gated by decision D7 and may change while that decision is open — see
85
89
  [`docs/integration/css.md`](./docs/integration/css.md#5-runtime-brand-theming).
86
90
 
91
+ ### The provider and the `/next` entry (experimental)
92
+
93
+ Nothing requires a provider: every component renders English strings, formats
94
+ with `en-US` and (for `LinkButton` / `Logo`) routes through `next/link` on its
95
+ own. `DesignSystemProvider` is the one optional, thin, client-side provider
96
+ for what CSS cannot do — the generic strings, the locale, the text direction,
97
+ the portal container, the link and image adapters, shared tooltip defaults and
98
+ (opt-in) the toast region. It renders no DOM wrapper ([ADR-010](./docs/adr/010-design-system-provider.md)).
99
+
100
+ ```tsx
101
+ // app/layout.tsx (Next.js) — the Next adapters are filled in for you
102
+ import { NextDesignSystemProvider } from "@assure-one/design-system/next";
103
+
104
+ export default function RootLayout({ children }) {
105
+ return (
106
+ <html lang="de">
107
+ <body>
108
+ <NextDesignSystemProvider
109
+ locale="de-DE"
110
+ messages={{ select: { clear: "Auswahl löschen" }, combobox: { empty: "Keine Treffer" } }}
111
+ >
112
+ {children}
113
+ </NextDesignSystemProvider>
114
+ </body>
115
+ </html>
116
+ );
117
+ }
118
+ ```
119
+
120
+ - **`messages`** — any subset of the typed catalogue `DsMessages`, by component
121
+ namespace (`select`, `searchInput`, `combobox`, `field`, `fileUpload`; more
122
+ join as the overlays adopt it). Resolution is always *component prop >
123
+ provider > English default*, per key. The full English list is
124
+ `src/foundation/messages/en.json`; parameterised messages are functions
125
+ (`combobox.create: (query) => string`).
126
+ - **`locale`** — a BCP 47 tag, default `"en-US"` on the server *and* the client.
127
+ Pass the locale your app already knows server-side; the design system never
128
+ reads `navigator.language` during render, so server and client HTML agree.
129
+ - **`linkComponent` / `imageComponent`** — what `LinkButton` and `Logo` render
130
+ through. Without them the components keep their static `next/link` /
131
+ `next/image` imports until 2.0, so a Next.js app that renders no provider is
132
+ unchanged. A non-Next app passes its router's link; a Next.js app uses
133
+ `NextDesignSystemProvider` so it is ready for the 2.0 removal of the static
134
+ imports.
135
+ - **`portalContainer`**, **`dir`**, **`tooltip`**, **`toasts`** — documented on
136
+ the props. `toasts` mounts the toast region for `useToast()`; leave it off if
137
+ you already render `ToastProvider`.
138
+
87
139
  ## Develop
88
140
 
89
141
  ```bash
@@ -107,18 +107,23 @@ 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-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`, dynamicreport |
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 |
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-13 | A | `Tooltip delayMs` `delayDuration` (pure rename, literal or dynamic value; both names, spreads "could not be transformed") |
114
+ | CM-07 | A | `inputSize` → `size` on `Input`, `Textarea`, `SearchInput` (pure rename, literal or dynamic value; both names, spreads"could not be transformed") |
115
+ | CM-02 | A | Button explicit default size: `size="md"` on every `Button`, `SubmitButton` and `LinkButton` that passes no `size` (pixel-neutral) |
116
+ | CM-04 | R | Button family legacy `variant` → `variant` + `intent` pair (`primary``solid`, `destructive` `solid` + `danger`, …); `accent`, `dashed`, dynamic → report |
117
+ | CM-06 | R | `tone` `intent` on StatusDot, IconTile, Spinner, SegmentedProgress, SuiteProgress (`pro` `brand`, listed for review); product hues → report |
118
+ | 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 |
119
+ | CM-12 | R | Button `type="submit"` where the intent is evident (inside `<form>` in the same file, no `onClick`); everything else untyped → report |
120
+ | CM-10 | R | `DatePicker onChange={e => f(e.target.value)}` `onValueChange={value => f(value)}` for the recognisable arrow shapes; handlers by reference, other shapes report |
121
+ | CM-08 | R | `SearchSelect` `Combobox`: single-mode call sites rewritten (tag, import, `{id,label}` accessors, placeholder `aria-label`); multi API, extras and unnamed sites → report |
122
+ | CM-09 | R | `SideDrawer` → `Sheet`: drawers with a `SideDrawer.Header` rewritten (root → `Sheet` + `SheetContent side size`, `onClose` → `onOpenChange`, header → `SheetHeader`/`SheetTitle`/`SheetDescription`, Body/Footer → `SheetBody`/`SheetFooter`, import); no header, dynamic width, spreads → report |
123
+ | CM-14 | X | hidden-input mirror finder: hidden `<input name>` a consumer added because a design-system control posts nothing |
124
+ | CM-15 | X | DOM-selector finder: consumer code that depends on the internal DOM of design-system components, mapped to registry `C-DOM-*` ids |
125
+ | CM-16 | X | `globals.css` analyser: `@source` into the package, duplicate preflight, colliding `@theme` keys, unlayered globals, legacy `var()` |
126
+ | CM-20 | X | Select sentinel finder: option values standing in for "no value", and the line where each is converted back |
122
127
 
123
128
  The remaining ids of plan §29 land with the waves that ship their replacement
124
129
  APIs.
@@ -146,6 +151,51 @@ the same slot, and which one wins depends on attribute order). Not touched and
146
151
  not reported: elements already on the new names, local components with the
147
152
  same names, test files.
148
153
 
154
+ ### CM-13 — `Tooltip delayMs` → `delayDuration`
155
+
156
+ Class A. W5-12 made `delayMs` a deprecated alias of Radix's `delayDuration`
157
+ on the design-system `Tooltip` (contract C-TOOLTIP-DELAY, removed in 3.0).
158
+ Both name the same milliseconds and `delayDuration` wins when both are
159
+ given, so the rename is behaviour-neutral and a pure rename: only the
160
+ attribute's name is replaced, and its value — a literal or a dynamic
161
+ `delayMs={slow ? 700 : 300}` — keeps its text and formatting. Aliased and
162
+ namespace imports count.
163
+
164
+ | rule | Contract | What happens |
165
+ | --------------- | --------------- | ----------------------------------- |
166
+ | `delay-renamed` | C-TOOLTIP-DELAY | `delayMs={…}` → `delayDuration={…}` |
167
+
168
+ Not touched, and listed under "could not be transformed": an element that
169
+ passes **both** `delayDuration` and `delayMs` (`delayDuration` already wins;
170
+ a rename would make a duplicate attribute — delete `delayMs` by hand), and an
171
+ element with a spread (`<Tooltip delayMs={300} {...props}>` — the spread may
172
+ carry `delayDuration`). Not touched and not reported: elements already on
173
+ `delayDuration`, local components called Tooltip, test files.
174
+
175
+ ### CM-07 — `inputSize` → `size` on Input, Textarea, SearchInput
176
+
177
+ Class A. W4-05/06/07 put the three text controls on the control scale:
178
+ `size` is the prop, `inputSize` the deprecated spelling of the same value
179
+ (contract C-INPUT-SIZE, removed in 3.0). Every legacy word renders the
180
+ classes it always did and `size` wins when both are given, so the rename is
181
+ pixel-neutral and a pure rename: only the attribute's name is replaced, and
182
+ its value — a literal or a dynamic `inputSize={dense ? "sm" : "md"}` — keeps
183
+ its text and formatting. Aliased and namespace imports count.
184
+
185
+ | rule | Contract | What happens |
186
+ | -------------- | ------------ | ---------------------------- |
187
+ | `size-renamed` | C-INPUT-SIZE | `inputSize={…}` → `size={…}` |
188
+
189
+ Not touched, and listed under "could not be transformed": an element that
190
+ passes **both** `size` and `inputSize` (`size` already wins; a rename would
191
+ make a duplicate attribute — delete `inputSize` by hand), and an element with
192
+ a spread (`<Input inputSize="sm" {...props}>` — the spread may carry `size`,
193
+ and which one wins depends on attribute order). Not touched and not reported:
194
+ elements already on `size`, other controls with a `size` of their own, local
195
+ components with the same names, test files.
196
+ `tests/codemods/cm-07.test.mjs` checks the component set against the sources
197
+ that declare `inputSize?:`.
198
+
149
199
  ### CM-04 — Button family legacy `variant` → `variant` + `intent`
150
200
 
151
201
  Class R. W3-02 gave `Button`, `LinkButton` and `SubmitButton` the two axes of
@@ -292,6 +342,43 @@ capitalised `<Form>` does not count: the react-hook-form / shadcn `Form` is a
292
342
  context provider, not a form element. `SubmitButton`, `LinkButton`, a Button
293
343
  with any `type`, a Button with `asChild` and test files are not reported.
294
344
 
345
+ ### CM-10 — DatePicker fake-event `onChange` → `onValueChange`
346
+
347
+ Class R. `DatePicker` never fired a DOM event: its `onChange` receives
348
+ `{ target: { name, value } }` with the ISO string (`""` when cleared), and
349
+ every consumer handler unwraps `.target.value`. W4-18 added the value callback
350
+ of target architecture §25, `onValueChange(iso | null)`, and kept the fake
351
+ event as a deprecated alias that fires after it (contract C-DATE-FAKEEVENT,
352
+ removed in 3.0). CM-10 rewrites the handlers whose shape makes the unwrapping
353
+ visible — an inline arrow whose parameter is read **only** as the value:
354
+
355
+ | handler | becomes |
356
+ | ----------------------------------------- | --------------------------------- |
357
+ | `(e) => f(e.target.value)` (typed or not) | `(value) => f(value)` |
358
+ | `e => setX(e.target.value)` | `value => setX(value)` |
359
+ | `({ target }) => f(target.value)` | `(value) => f(value)` |
360
+ | `({ target: { value } }) => f(value)` | `(value) => f(value)` |
361
+ | `() => refetch()` | body unchanged, attribute renamed |
362
+
363
+ The parameter is named `value` — `v`, then `next`, when the enclosing
364
+ component binds that name already, so nothing is shadowed — and the body
365
+ keeps its formatting. **A cleared picker now passes `null` where `onChange`
366
+ passed `""`**: that is why every rewrite is in the review list (medium), and
367
+ why a handler that reads a member of the value (`e.target.value.slice(0, 4)`)
368
+ is not rewritten — it would throw on clear.
369
+
370
+ | rule | Contract | What happens |
371
+ | ---------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
372
+ | `fake-event-unwrapped` | C-DATE-FAKEEVENT | one of the shapes above was rewritten (applied, **medium**): confirm `null` on clear is what the state wants |
373
+ | `handler-reference` | C-DATE-FAKEEVENT | the handler is passed by reference (`onChange={handleChange}`, `{field.onChange}`, `{onField("due")}`): its body is elsewhere (review, high) |
374
+ | `handler-shape` | C-DATE-FAKEEVENT | an inline handler that reads more than `.target.value` — `e.target.name`, the whole event, a member of the value — or a `function` expression (review, high) |
375
+ | `has-value-change` | C-DATE-FAKEEVENT | `onValueChange` is already wired; `onChange` still fires after it — delete it by hand (review, low) |
376
+ | `spread-props` | C-DATE-FAKEEVENT | `{...props}` may carry either callback; which one wins depends on attribute order (review, medium) |
377
+
378
+ `DateRangePicker` is not touched: its `onChange` is a value callback already.
379
+ Not touched and not reported: elements that only pass `onValueChange`, local
380
+ components called `DatePicker`, test files.
381
+
295
382
  ### CM-14 — hidden-input mirror finder
296
383
 
297
384
  Report-only, **for ever**: deleting a mirror changes what the server receives,
@@ -350,6 +437,62 @@ Sentinels outside the Select family — grouping keys, tab ids, route segments
350
437
  are out of scope even when the spelling matches, and nothing is followed across
351
438
  files.
352
439
 
440
+ ### CM-08 — `SearchSelect` → `Combobox`
441
+
442
+ Class R. W4-12 made `SearchSelect` a deprecated adapter over `Combobox`
443
+ (contract C-SEARCHSELECT, removed in 3.0). A **single-mode** call site
444
+ (`value` / `onValueChange`, no spread) is rewritten: the tag and the named
445
+ import become `Combobox`, `getOptionValue={(o) => o.id}` and
446
+ `getOptionLabel={(o) => o.label}` are inserted after `options` so the
447
+ `{ id, label }` records need no remapping, and a literal `placeholder` — which
448
+ named the SearchSelect input — is copied into `aria-label`. Everything else is
449
+ reported for a human.
450
+
451
+ | rule | Contract | What happens |
452
+ | ----------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
453
+ | `renamed` | C-SEARCHSELECT | tag, import and accessors rewritten (applied, low) |
454
+ | `clear-null` | C-SEARCHSELECT | the rewritten Combobox reports `null` on clear where SearchSelect passed `""`; check the handler (applied, **medium**) |
455
+ | `option-fields` | C-SEARCHSELECT | `description` / `icon` / `color` on the records are not read through the accessors; move them to `renderOption` if used (applied, low) |
456
+ | `rendering` | C-SEARCHSELECT | per call site (plan §29): SearchSelect opened on focus, below the field at its width, never flipping; Combobox flips to stay in the viewport and opens on click or typing (applied on a rewritten element, review otherwise, low) |
457
+ | `multi-api` | C-SEARCHSELECT | `selected` / `onSelect` / `onRemove` → `multiple` + `value: string[]` + `onValueChange`: the state shape changes (review, high) |
458
+ | `unmapped-prop` | C-SEARCHSELECT | `groupFilter`, `pinnedGroups`, `showGroupCounts`, `onCreateNew`, `createNewLabel`, `descriptionVariant`, `closeOnSelect` have no Combobox prop (review, high) |
459
+ | `accessible-name` | C-SEARCHSELECT | no literal placeholder and no `aria-label`: left alone — a Combobox without a name fires the W4-02 development error (review, high) |
460
+ | `spread-props` | C-SEARCHSELECT | `{...props}` may carry any of the above (review, medium) |
461
+ | `option-type` | C-SEARCHSELECT | `SearchSelectOption` is still imported; the Combobox shape is `Option` (review, low) |
462
+
463
+ Not touched: elements already on `Combobox` (idempotency), local components
464
+ with the same name, test files.
465
+
466
+ ### CM-09 — `SideDrawer` → `Sheet`
467
+
468
+ Class R. W5-08 made `SideDrawer` a deprecated adapter over `Sheet` (contract
469
+ C-SIDEDRAWER, removed in 3.0). A drawer **with a `SideDrawer.Header`** is
470
+ rewritten: the root becomes `Sheet` with `onOpenChange={(open) => !open &&
471
+ onClose()}` (a handler by reference is called, an arrow with an expression
472
+ body is inlined, anything else is called through `(expr)()`); the children
473
+ move into a `SheetContent` that carries the `side` and the mapped `size`
474
+ (`sm` → `sm`, `md` → `lg`; `lg` and the omitted default, 50vw, become `xl`
475
+ and are reported); the header's `title` and `subtitle` become `SheetTitle` and
476
+ `SheetDescription` inside `SheetHeader`; `SideDrawer.Body` / `.Footer` become
477
+ `SheetBody` / `SheetFooter` with their `className`. A header that had its own
478
+ `onClose` button hands the job to the sheet's built-in close button; one that
479
+ had none gets `showCloseButton={false}`. The named import follows once no
480
+ `SideDrawer` element is left in the file.
481
+
482
+ | rule | Contract | What happens |
483
+ | ------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------ |
484
+ | `renamed` | C-SIDEDRAWER | root, content, parts and import rewritten (applied, low) |
485
+ | `width-approximate` | C-SIDEDRAWER | `width="lg"` or no `width` (50vw) → `size="xl"` (760px); set `--ds-sheet-width` for an exact width (applied, **medium**) |
486
+ | `header-close` | C-SIDEDRAWER | the header's close button is now the sheet's built-in one, last child of the content (applied, low) |
487
+ | `rendering` | C-SIDEDRAWER | the Sheet parts pad differently (`p-6` header and footer, `px-6` body) and draw no rules between them (applied, low) |
488
+ | `no-header` | C-SIDEDRAWER | a drawer without `SideDrawer.Header` has no title to become the `SheetTitle`: left alone (review, high) |
489
+ | `width-dynamic` | C-SIDEDRAWER | `width={expr}` has no `size` step: left alone (review, high) |
490
+ | `spread-props` | C-SIDEDRAWER | `{...props}` on the drawer or a part may carry any of the above: left alone (review, medium) |
491
+
492
+ Not touched: elements already on `Sheet` (idempotency), local components with
493
+ the same name, test files. The rewritten children are re-indented one level;
494
+ run the project's formatter afterwards.
495
+
353
496
  ### CM-15 — DOM-selector finder
354
497
 
355
498
  Report-only. It never modifies a file; the runner throws if it tries. What it
@@ -4,11 +4,16 @@
4
4
  */
5
5
  export const CODEMODS = [
6
6
  { id: "CM-05", module: "../transforms/cm-05-button-icon-slots.mjs" },
7
+ { id: "CM-13", module: "../transforms/cm-13-tooltip-delay.mjs" },
8
+ { id: "CM-07", module: "../transforms/cm-07-input-size.mjs" },
7
9
  { id: "CM-02", module: "../transforms/cm-02-button-explicit-size.mjs" },
8
10
  { id: "CM-04", module: "../transforms/cm-04-button-variant-intent.mjs" },
9
11
  { id: "CM-06", module: "../transforms/cm-06-tone-to-intent.mjs" },
10
12
  { id: "CM-19", module: "../transforms/cm-19-progress-explicit-intent.mjs" },
11
13
  { id: "CM-12", module: "../transforms/cm-12-button-type-submit.mjs" },
14
+ { id: "CM-10", module: "../transforms/cm-10-date-picker-value-change.mjs" },
15
+ { id: "CM-08", module: "../transforms/cm-08-search-select-to-combobox.mjs" },
16
+ { id: "CM-09", module: "../transforms/cm-09-side-drawer-to-sheet.mjs" },
12
17
  { id: "CM-14", module: "../transforms/cm-14-hidden-mirrors.mjs" },
13
18
  { id: "CM-15", module: "../transforms/cm-15-dom-selectors.mjs" },
14
19
  { id: "CM-16", module: "../transforms/cm-16-globals-css.mjs" },
@@ -0,0 +1,104 @@
1
+ /**
2
+ * CM-07 — `inputSize` → `size` on `Input`, `Textarea` and `SearchInput`
3
+ * (class A; plan §29 seq 3, registry `C-INPUT-SIZE`).
4
+ *
5
+ * W4-05/06/07 put the three text controls on the control scale: `size` is the
6
+ * prop, `inputSize` the deprecated spelling of the same value
7
+ * (`InputVariantsOptions`, `TextareaProps`, `SearchInputProps`). Every legacy
8
+ * word renders the classes it always did and `size` wins when both are
9
+ * given, so the rename is pixel-neutral and a pure rename: only the
10
+ * attribute's name is replaced — a literal or a dynamic `inputSize={x}` keeps
11
+ * its value and its formatting.
12
+ *
13
+ * | rule | registry | what happens |
14
+ * | -------------- | ------------ | ------------------------------------- |
15
+ * | `size-renamed` | C-INPUT-SIZE | `inputSize={…}` → `size={…}` |
16
+ *
17
+ * ## What it leaves alone, and lists under "could not be transformed"
18
+ *
19
+ * - An element that passes **both** `size` and `inputSize`: `size` already
20
+ * wins in the component, and a rename would make a duplicate attribute.
21
+ * A human deletes `inputSize`.
22
+ * - An element with a spread (`<Input inputSize="sm" {...props}>`): the
23
+ * spread may carry `size`, and which one wins depends on attribute order —
24
+ * renaming the literal could change it.
25
+ *
26
+ * Elements already on `size` are not touched (idempotency). Local components
27
+ * with the same names are not touched; test files are skipped, as by the
28
+ * scanner that measures C-INPUT-SIZE.
29
+ */
30
+ import { analyseForms } from "../lib/forms.mjs";
31
+ import { applyEdits, openingOf, renameAttribute } from "../lib/jsx-edit.mjs";
32
+
33
+ export const meta = {
34
+ id: "CM-07",
35
+ title: "Input, Textarea, SearchInput: inputSize → size",
36
+ class: "A",
37
+ oneShot: false,
38
+ requires: { codemods: [], dsVersion: null },
39
+ parses: ["code"],
40
+ includeTests: false,
41
+ usesTypeScript: true,
42
+ usesPostcss: false,
43
+ registryIds: ["C-INPUT-SIZE"],
44
+ };
45
+
46
+ /**
47
+ * The design-system components that accept the deprecated `inputSize`
48
+ * (W4-05/06/07). `tests/codemods/cm-07.test.mjs` checks the set against the
49
+ * sources that declare `inputSize?:`.
50
+ */
51
+ export const SIZED_CONTROLS = new Set(["Input", "Textarea", "SearchInput"]);
52
+
53
+ export const LEGACY_PROP = "inputSize";
54
+ export const PROP = "size";
55
+
56
+ export function transform(file, { ts }) {
57
+ const facts = analyseForms(ts, file.source, file.rel);
58
+ const findings = [];
59
+ const notTransformed = [];
60
+ const edits = [];
61
+
62
+ for (const el of facts.elements) {
63
+ if (!el.isDs || !SIZED_CONTROLS.has(el.base) || el.component !== el.base) continue;
64
+ if (!el.props.has(LEGACY_PROP)) continue;
65
+
66
+ if (el.props.has(PROP)) {
67
+ notTransformed.push({
68
+ line: el.line,
69
+ reason: "both-names",
70
+ detail: `<${el.tag}> passes \`${LEGACY_PROP}\` and \`${PROP}\` — \`${PROP}\` already wins; delete \`${LEGACY_PROP}\` by hand`,
71
+ });
72
+ continue;
73
+ }
74
+ if (el.spread) {
75
+ notTransformed.push({
76
+ line: el.line,
77
+ reason: "spread-props",
78
+ detail: `<${el.tag} {…}> — the spread may carry \`${PROP}\`; rename by hand once you know it does not`,
79
+ });
80
+ continue;
81
+ }
82
+
83
+ const edit = renameAttribute(ts, facts.sf, openingOf(ts, el.node), LEGACY_PROP, PROP);
84
+ if (!edit) continue;
85
+ edits.push(edit);
86
+ findings.push({
87
+ line: el.props.get(LEGACY_PROP).line,
88
+ registryId: "C-INPUT-SIZE",
89
+ rule: "size-renamed",
90
+ match: `<${el.tag} ${LEGACY_PROP}>`,
91
+ component: el.component,
92
+ action: "applied",
93
+ gate: null,
94
+ detail: { from: LEGACY_PROP, to: PROP, value: el.props.get(LEGACY_PROP).text },
95
+ });
96
+ }
97
+
98
+ return {
99
+ output: edits.length ? applyEdits(file.source, edits) : file.source,
100
+ findings,
101
+ notTransformed,
102
+ parseErrors: facts.parseErrors,
103
+ };
104
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * CM-08 — `SearchSelect` → `Combobox` (class R; plan §29 seq 10, registry
3
+ * `C-SEARCHSELECT`, lands with W4-12).
4
+ *
5
+ * W4-12 turned `SearchSelect` into a deprecated adapter over `Combobox`. The
6
+ * single-mode call sites (33 of 40 uses in the products) are a mechanical
7
+ * rewrite; the multi API and the SearchSelect-only props are not, and are
8
+ * reported for a human.
9
+ *
10
+ * ## What it rewrites (single mode: `value` / `onValueChange`, no spread)
11
+ *
12
+ * - the tag `SearchSelect` → `Combobox`, and the named import with it when
13
+ * the file has no other `SearchSelect` element left
14
+ * - `options` keeps its expression; `getOptionValue={(o) => o.id}` and
15
+ * `getOptionLabel={(o) => o.label}` are inserted after it, so the `{ id,
16
+ * label }` records need no remapping (target architecture §32 accessors)
17
+ * - `placeholder="…"` named the SearchSelect input; a Combobox is named by a
18
+ * Field label or `aria-label`, so a literal placeholder is copied into
19
+ * `aria-label` when the element has none
20
+ *
21
+ * ## What it reports
22
+ *
23
+ * | rule | action | severity | why |
24
+ * | -------------------- | ------- | -------- | ----------------------------------------------------------------------------------- |
25
+ * | `renamed` | applied | low | tag, import and accessors rewritten |
26
+ * | `clear-null` | applied | medium | the rewritten element's Combobox reports `null` on clear where SearchSelect passed `""`; check the handler |
27
+ * | `rendering` | applied / review | low | SearchSelect opened on focus, below the field, never flipping; Combobox flips to stay in the viewport and opens on click/typing (plan §29: flagged per call site — `applied` on a rewritten element, `review` on one left alone) |
28
+ * | `option-fields` | applied | low | `description` / `icon` / `color` on the records are not read through the accessors; move them to `renderOption` if used |
29
+ * | `multi-api` | review | high | `selected` / `onSelect` / `onRemove` → `multiple` + `value: string[]` + `onValueChange`; the state shape changes |
30
+ * | `unmapped-prop` | review | high | `groupFilter` / `pinnedGroups` / `showGroupCounts` / `onCreateNew` / `createNewLabel` / `descriptionVariant` / `closeOnSelect` have no Combobox prop |
31
+ * | `accessible-name` | review | high | no literal placeholder and no `aria-label`: the element is left alone — a Combobox without a name fires the W4-02 development error |
32
+ * | `spread-props` | review | medium | `{...props}` may carry any of the above |
33
+ * | `option-type` | review | low | `SearchSelectOption` is still imported; the Combobox shape is `Option` |
34
+ *
35
+ * Elements already on `Combobox` are not touched (idempotency). Local
36
+ * components with the same name are not touched; test files are skipped.
37
+ */
38
+ import { analyseForms } from "../lib/forms.mjs";
39
+ import { applyEdits, insertAttribute, openingOf } from "../lib/jsx-edit.mjs";
40
+
41
+ export const meta = {
42
+ id: "CM-08",
43
+ title: "SearchSelect → Combobox (single mode automatic; multi and extras reported)",
44
+ class: "R",
45
+ oneShot: false,
46
+ requires: { codemods: [], dsVersion: null },
47
+ parses: ["code"],
48
+ includeTests: false,
49
+ usesTypeScript: true,
50
+ usesPostcss: false,
51
+ registryIds: ["C-SEARCHSELECT"],
52
+ };
53
+
54
+ export const RULES = {
55
+ renamed: { action: "applied", severity: "low" },
56
+ "clear-null": { action: "applied", severity: "medium" },
57
+ rendering: { action: "applied", severity: "low" },
58
+ "option-fields": { action: "applied", severity: "low" },
59
+ "multi-api": { action: "review", severity: "high" },
60
+ "unmapped-prop": { action: "review", severity: "high" },
61
+ "accessible-name": { action: "review", severity: "high" },
62
+ "spread-props": { action: "review", severity: "medium" },
63
+ "option-type": { action: "review", severity: "low" },
64
+ };
65
+
66
+ /** Props the Combobox takes under the same name and meaning. */
67
+ export const MAPPED_PROPS = new Set([
68
+ "options",
69
+ "value",
70
+ "onValueChange",
71
+ "placeholder",
72
+ "className",
73
+ "disabled",
74
+ "onCreate",
75
+ "id",
76
+ "aria-label",
77
+ "aria-labelledby",
78
+ "aria-describedby",
79
+ ]);
80
+ export const MULTI_PROPS = new Set(["selected", "onSelect", "onRemove"]);
81
+ export const UNMAPPED_PROPS = new Set([
82
+ "groupFilter",
83
+ "pinnedGroups",
84
+ "showGroupCounts",
85
+ "onCreateNew",
86
+ "createNewLabel",
87
+ "descriptionVariant",
88
+ "closeOnSelect",
89
+ ]);
90
+
91
+ const ACCESSORS = "getOptionValue={(o) => o.id} getOptionLabel={(o) => o.label}";
92
+
93
+ export function transform(file, { ts }) {
94
+ const facts = analyseForms(ts, file.source, file.rel);
95
+ const findings = [];
96
+ const edits = [];
97
+ let remaining = 0;
98
+ let renamedAny = false;
99
+
100
+ for (const el of facts.elements) {
101
+ if (!el.isDs || el.base !== "SearchSelect" || el.component !== el.base) continue;
102
+ const base = {
103
+ line: el.line,
104
+ registryId: "C-SEARCHSELECT",
105
+ match: `<${el.tag}>`,
106
+ component: el.component,
107
+ gate: null,
108
+ detail: {},
109
+ };
110
+ const add = (rule, detail = {}, override = {}) =>
111
+ findings.push({
112
+ ...base,
113
+ rule,
114
+ ...RULES[rule],
115
+ ...override,
116
+ detail: { ...base.detail, ...detail },
117
+ });
118
+
119
+ const names = [...el.props.keys()];
120
+ const multi = names.filter((n) => MULTI_PROPS.has(n));
121
+ const unmapped = names.filter((n) => UNMAPPED_PROPS.has(n));
122
+ let blocked = false;
123
+ if (el.spread) {
124
+ add("spread-props");
125
+ blocked = true;
126
+ }
127
+ if (multi.length) {
128
+ add("multi-api", { props: multi });
129
+ blocked = true;
130
+ }
131
+ if (unmapped.length) {
132
+ add("unmapped-prop", { props: unmapped });
133
+ blocked = true;
134
+ }
135
+ if (blocked) {
136
+ remaining += 1;
137
+ add("rendering", {}, { action: "review" });
138
+ continue;
139
+ }
140
+
141
+ // A Combobox without an accessible name fires the W4-02 development
142
+ // error at once, so an element whose name cannot be derived (no literal
143
+ // placeholder, no aria-label) is left for a human rather than rewritten.
144
+ const placeholder = el.props.get("placeholder");
145
+ const hasName = el.props.has("aria-label") || el.props.has("aria-labelledby");
146
+ const literal =
147
+ placeholder && !placeholder.expression && placeholder.literals.length === 1
148
+ ? placeholder.literals[0]
149
+ : null;
150
+ if (!hasName && literal === null) {
151
+ remaining += 1;
152
+ add("accessible-name");
153
+ add("rendering", {}, { action: "review" });
154
+ continue;
155
+ }
156
+
157
+ const opening = openingOf(ts, el.node);
158
+ const sf = facts.sf;
159
+ edits.push({
160
+ pos: opening.tagName.getStart(sf),
161
+ end: opening.tagName.getEnd(),
162
+ text: "Combobox",
163
+ });
164
+ if (ts.isJsxElement(el.node)) {
165
+ const closing = el.node.closingElement.tagName;
166
+ edits.push({ pos: closing.getStart(sf), end: closing.getEnd(), text: "Combobox" });
167
+ }
168
+ if (el.props.has("options")) {
169
+ edits.push(insertAttribute(ts, sf, opening, ACCESSORS, { after: "options" }));
170
+ }
171
+ if (!hasName) {
172
+ edits.push(
173
+ insertAttribute(ts, sf, opening, `aria-label=${JSON.stringify(literal)}`, {
174
+ after: "placeholder",
175
+ }),
176
+ );
177
+ }
178
+ renamedAny = true;
179
+ add("renamed");
180
+ if (el.props.has("onValueChange")) add("clear-null");
181
+ add("option-fields");
182
+ add("rendering");
183
+ }
184
+
185
+ // The import: `SearchSelect` → `Combobox` when no element is left on the
186
+ // old name; a `SearchSelectOption` type import is reported, never rewritten.
187
+ if (renamedAny) {
188
+ const sf = facts.sf;
189
+ for (const stmt of sf.statements) {
190
+ if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier)) continue;
191
+ if (!stmt.moduleSpecifier.text.startsWith("@assure-one/design-system")) continue;
192
+ const bindings = stmt.importClause?.namedBindings;
193
+ if (!bindings || !ts.isNamedImports(bindings)) continue;
194
+ const hasCombobox = bindings.elements.some(
195
+ (e) => (e.propertyName ?? e.name).text === "Combobox",
196
+ );
197
+ for (const element of bindings.elements) {
198
+ const imported = (element.propertyName ?? element.name).text;
199
+ if (imported === "SearchSelectOption") {
200
+ findings.push({
201
+ line: facts.lineOf(element),
202
+ registryId: "C-SEARCHSELECT",
203
+ match: "SearchSelectOption",
204
+ component: "SearchSelect",
205
+ gate: null,
206
+ rule: "option-type",
207
+ ...RULES["option-type"],
208
+ detail: {},
209
+ });
210
+ }
211
+ if (imported !== "SearchSelect" || element.propertyName || remaining > 0) continue;
212
+ if (hasCombobox) {
213
+ // Drop the specifier: `Combobox` is already imported.
214
+ const list = bindings.elements;
215
+ const index = list.indexOf(element);
216
+ const from = index === 0 ? element.getStart(sf) : list[index - 1].getEnd();
217
+ const end = index === 0 && list.length > 1 ? list[1].getStart(sf) : element.getEnd();
218
+ edits.push({ pos: from, end, text: "" });
219
+ } else {
220
+ edits.push({
221
+ pos: element.name.getStart(sf),
222
+ end: element.name.getEnd(),
223
+ text: "Combobox",
224
+ });
225
+ }
226
+ }
227
+ }
228
+ }
229
+
230
+ return {
231
+ output: edits.length ? applyEdits(file.source, edits) : file.source,
232
+ findings,
233
+ notTransformed: [],
234
+ parseErrors: facts.parseErrors,
235
+ };
236
+ }