diff --git a/.changeset/button-icon-describe-shared-resolver.md b/.changeset/button-icon-describe-shared-resolver.md new file mode 100644 index 0000000000..cc45867d21 --- /dev/null +++ b/.changeset/button-icon-describe-shared-resolver.md @@ -0,0 +1,9 @@ +--- +"@objectstack/spec": patch +--- + +`element:button`'s `icon` description now names the shared icon resolver the renderer actually uses + +The `.describe()` on `ElementButtonPropsSchema.icon` — the sentence the docs site and the generated skill artifacts put in front of authors — said the renderer resolved the name "using its own PascalCase normaliser and rename map". That stopped being true when objectui moved the button off its file-local copy of that algorithm and onto the `resolveIcon` helper every `action:*` site already shared. The duplicate had been a defect in its own right: a rename added to the shared resolver to absorb a Lucide icon retirement reached every action site and silently missed `ui:button`. + +Nothing an author may write changed, and nothing about the platform's behaviour regressed. `icon` is still read, still rendered on either side of the label per `iconPosition`, still suppressed while `loading`, and an unknown name still resolves to nothing rather than degrading to a fallback glyph — which is still exactly what separates this slot from the `LazyIcon` path the container icons use. Only the sentence describing where the resolution lives had gone stale, and it is corrected here together with the cross-repo read-point anchors recorded beside it, re-measured at objectui `9602dc820450`. diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index d4fe79a5f8..1832baa7e3 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -54,7 +54,7 @@ const result = AIChatWindowProps.parse(data); | **label** | `string \| Record` | ✅ | Button display label | | **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional (default: `"primary"`) | Button visual variant | | **size** | `Enum<'small' \| 'medium' \| 'large'>` | optional (default: `"medium"`) | Button size | -| **icon** | `string` | optional | Lucide icon name rendered inside the button, left or right of the label per `iconPosition`. Read on this component — the renderer resolves it through `lucide-react`'s `icons` map using its own PascalCase normaliser and rename map, NOT the `LazyIcon` slot the container icons use; the two paths accept different spellings, and an unknown name here renders nothing rather than a fallback glyph. | +| **icon** | `string` | optional | Lucide icon name rendered inside the button, left or right of the label per `iconPosition`. Read on this component — the renderer resolves it through `lucide-react`'s `icons` map via the shared `resolveIcon` helper every `action:*` site uses (a PascalCase normaliser plus a one-entry rename map), NOT the `LazyIcon` slot the container icons use; the two paths accept different spellings, and an unknown name here renders nothing rather than a fallback glyph. | | **iconPosition** | `Enum<'left' \| 'right'>` | optional (default: `"left"`) | Icon position relative to label | | **disabled** | `boolean` | optional (default: `false`) | Disable the button | | **action** | `{ type?: Enum<'script' \| 'url' \| 'modal' \| 'flow' \| 'api' \| 'form'>; name?: string; label?: string \| Record; target?: string; … }` | optional | Inline action executed on click | diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 6ac1b916ba..d29b9549db 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -2433,22 +2433,29 @@ describe('#7751 — object-* block props schemas', () => { // that sent #9397 on a full dispatch cycle re-deriving the accordion read point. // #9881 and #9972 recorded the accordion and tab items; these two close the set. // -// Both re-measured at the pin this repo builds against — -// `.objectui-sha` = `190fbd01d`, re-derived there 2026-08-22 when the pin moved -// off `9a3daf8d3`. All four records (these two plus #9881/#9972 above) were -// re-counted at the new pin in the same pass and agree on it, as they did at -// the previous two moves: #10137 moved the pin while #9881/#9972 still cited -// `82a94170c`, and #10274 re-measured those four onto `9a3daf8d3`. The button -// anchors have been unchanged across every one of those moves — `button.tsx` -// is byte-identical at `9a3daf8d3` and `190fbd01d`. +// The button record re-measured at the pin this repo builds against — +// `.objectui-sha` = `9602dc820`, re-derived there 2026-08-28. This is the first +// move that changed the button READ POINT and not merely its line numbers: +// objectui#5993 deleted `button.tsx`'s file-local `toPascalCase` + +// `iconNameMap` + `icons` index and routed the button through the SHARED +// `resolveIcon` that every `action:*` site already used, so the resolution +// anchor now hops into `renderers/action/resolve-icon.ts`. What an author sees +// did not move with it: an unknown name still resolves to `null` and draws +// nothing, which is still the `LazyIcon` contrast the third test below pins. +// The earlier moves were line-number drift only — #10137 moved the pin while +// #9881/#9972 still cited `82a94170c`, #10274 re-measured those four onto +// `9a3daf8d3`, and `button.tsx` was byte-identical at `9a3daf8d3` and +// `190fbd01d`. describe('ElementButtonPropsSchema icon liveness (#10053)', () => { const button = ComponentPropsMap['element:button']; it('accepts an icon on a button — the value objectui resolves through the lucide `icons` map', () => { - // objectui `packages/components/src/renderers/form/button.tsx:44-47` - // PascalCases the name, applies its own one-entry rename map, and looks it - // up in `icons` from `lucide-react`; `:69` / `:71` draw it either side of - // the label per `iconPosition`. + // objectui `packages/components/src/renderers/form/button.tsx:36` hands the + // name to the shared `resolveIcon` + // (`packages/components/src/renderers/action/resolve-icon.ts:30-35`), which + // PascalCases it and applies the one-entry rename map at `:14-24` before + // looking it up in `icons` from `lucide-react`; `button.tsx:57` / `:59` + // draw it either side of the label per `iconPosition`. const result = button.safeParse({ label: 'Save', icon: 'arrow-right' }); expect(result.success).toBe(true); const parsed = (result.success ? result.data : undefined) as { icon?: string } | undefined; @@ -2468,12 +2475,20 @@ describe('ElementButtonPropsSchema icon liveness (#10053)', () => { it('keeps a `.describe()` that names the consumer AND the non-LazyIcon path', () => { // The second half is load-bearing, not decoration: this slot is the one // authorable icon on the surface that does NOT go through `LazyIcon`, so an - // author who assumes the shared resolver gets silence instead of a glyph. + // author who assumes `LazyIcon`'s tolerant fallback gets silence instead of + // a glyph. const shape = (ElementButtonPropsSchema as unknown as { def: { shape: Record }; }).def.shape; expect(shape.icon?.description).toContain('lucide-react'); expect(shape.icon?.description).toContain('LazyIcon'); + // And that the path is the SHARED one. Naming `resolveIcon` is what stops + // the describe drifting back to "its own normaliser": that sentence was + // true when the button carried a private copy of the algorithm, survived + // the copy's deletion unchanged, and shipped false to authors until this + // record was re-measured. The prose has to name the function, not just the + // library, for a reader to be able to check it. + expect(shape.icon?.description).toContain('resolveIcon'); }); }); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index f208800892..e86ff735c5 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -1822,33 +1822,46 @@ export const ElementButtonPropsSchema = lazySchema(() => strictObject({ * the button. * * The key is LIVE at the objectui pin this repo builds against - * (`.objectui-sha` = `190fbd01d`; re-derived at that pin 2026-08-22 — - * `button.tsx` is byte-identical to the one at `9a3daf8d3`, so every anchor - * below is unmoved): `components/src/renderers/form/ - * button.tsx:44-47` resolves `schema.icon`, and `:69` / `:71` render it on - * either side of the label per `iconPosition` (`mr-2 h-4 w-4` left, - * `ml-2 h-4 w-4` right), both suppressed while `loading`. + * (`.objectui-sha` = `9602dc820`; re-derived at that pin 2026-08-28 — the + * read point MOVED rather than died, and the anchors below now hop into a + * second file): `components/src/renderers/form/ + * button.tsx:36` resolves `schema.icon` through the shared `resolveIcon`, + * and `:57` / `:59` render it on either side of the label per + * `iconPosition` (`mr-2 h-4 w-4` left, `ml-2 h-4 w-4` right), both + * suppressed while `loading`. * * ⚠️ The resolution path is NOT `LazyIcon`, the slot the container icons on - * this surface use — it is a second, older path with its own normaliser and - * its own rename map, and the two accept different spellings: - * - here: `toPascalCase` (splits on `-` only) → a one-entry rename map - * (`Home` → `House`) → `icons[name]` from `lucide-react` - * (`button.tsx:14-27`). An unknown name resolves to `undefined` and the - * button renders with NO icon and no diagnostic anywhere. + * this surface use — it is the `action:*` resolver, and the two accept + * different spellings: + * - here: `resolveIcon` + * (`components/src/renderers/action/resolve-icon.ts:30-35`) → + * `toPascalCase`, which splits on `-` only, then a one-entry rename map + * (`Home` → `House`), both at `:14-24` → `icons[name]` from + * `lucide-react`. An unknown name resolves to `null` and the button + * renders with NO icon and no diagnostic anywhere. * - `LazyIcon` / `getLazyIcon` (`components/src/lib/lazy-icon.tsx:66-92`): * normalises to kebab-case, checks the name against Lucide's own name * list, and degrades an unknown name to the `Database` glyph. * So a spelling that draws an icon in a tab trigger can draw nothing here. * + * That the resolver is SHARED is what this record most recently had to be + * corrected for: until objectui#5993 `button.tsx` carried its own + * `toPascalCase` + `iconNameMap` + `icons` index — the same algorithm, but + * not the same function, so a rename added to `resolve-icon.ts` to absorb a + * lucide retirement (objectui#5586, objectui#5622) reached every `action:*` + * site and silently missed this one. Removing the duplicate changed where + * the algorithm lives, not what an author may write: the accept/reject + * behaviour promised above is the same on both sides of that move. + * * Also measured at the same pin: the renderer's registration publishes no - * `icon` input (`button.tsx:82-98` lists `label`, `variant`, `size`, - * `className`), so the Studio block designer does not offer the key. - * Unpublished is not unread — the header `icon` above is refused for the - * second, not the first, and this docblock exists to hold them apart. + * `icon` input (`button.tsx:70-87` lists `label`, `variant`, `size`, + * `className`; `:88-92` is `defaultProps`), so the Studio block designer + * does not offer the key. Unpublished is not unread — the header `icon` + * above is refused for the second, not the first, and this docblock exists + * to hold them apart. */ icon: z.string().optional().describe( - 'Lucide icon name rendered inside the button, left or right of the label per `iconPosition`. Read on this component — the renderer resolves it through `lucide-react`\'s `icons` map using its own PascalCase normaliser and rename map, NOT the `LazyIcon` slot the container icons use; the two paths accept different spellings, and an unknown name here renders nothing rather than a fallback glyph.', + 'Lucide icon name rendered inside the button, left or right of the label per `iconPosition`. Read on this component — the renderer resolves it through `lucide-react`\'s `icons` map via the shared `resolveIcon` helper every `action:*` site uses (a PascalCase normaliser plus a one-entry rename map), NOT the `LazyIcon` slot the container icons use; the two paths accept different spellings, and an unknown name here renders nothing rather than a fallback glyph.', ), iconPosition: z.enum(['left', 'right']) .optional().default('left').describe('Icon position relative to label'),