Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/button-icon-describe-shared-resolver.md
Original file line numberDiff line numberDiff line change
@@ -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`.
2 changes: 1 addition & 1 deletion content/docs/references/ui/component.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -54,7 +54,7 @@ const result = AIChatWindowProps.parse(data);
| **label** | `string \| Record<string, string>` | ✅ | 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<string, string>; target?: string; … }` | optional | Inline action executed on click |
Expand Down
41 changes: 28 additions & 13 deletions packages/spec/src/ui/component.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand All@@ -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<string, { description?: string }> };
}).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');
});
});

Expand Down
47 changes: 30 additions & 17 deletions packages/spec/src/ui/component.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'),
Expand Down
Loading