Skip to content
Draft
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 number Diff line number Diff 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`.
200 changes: 200 additions & 0 deletions .changeset/console-9602dc820450.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion .objectui-sha
Original file line number Diff line number Diff line change
@@ -1 +1 @@
190fbd01d0615e2e168faf9e08b8ad7844bc039d
9602dc820450dda956843c6cfe5b329bcf88c757
2 changes: 1 addition & 1 deletion content/docs/references/ui/component.mdx
Original file line number Diff line number Diff 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
18 changes: 12 additions & 6 deletions packages/spec/src/data/api-methods-batch-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,18 @@ const SINGLE_RECORD_WRITE_ONLY: Record<string, string> = {
// `revoked` on ONE key. The multi-select surface this rule protects does not
// exist for API keys, and the shape a future one would take does not need
// `bulk` either — both read off the console build this release pins
// (`.objectui-sha` = `190fbd01d`, `packages/plugin-grid`; re-measured at
// that pin, 2026-08-22 — previously measured at `9a3daf8d3`, originally at
// `6314e87f2`. `ObjectGrid.tsx` DID change across that move, so both claims
// below were re-derived rather than carried over, and they now carry line
// anchors: `ObjectGrid.tsx:2492-2507` and `hooks/useBulkExecutor.ts:284-288`,
// the latter byte-identical at both pins):
// (`.objectui-sha` = `9602dc820`, `packages/plugin-grid`; re-measured at
// that pin, 2026-08-28 — previously measured at `190fbd01d`, before that at
// `9a3daf8d3`, originally at `6314e87f2`. `ObjectGrid.tsx` DID change across
// the move off `190fbd01d`, so both claims below were re-derived rather than
// carried over: `ObjectGrid.tsx:2586-2601`, whose block is byte-identical to
// the one cited at `190fbd01d:2492-2507` and shifted only by insertions above
// it, and `hooks/useBulkExecutor.ts:284-289`, in a file byte-identical at both
// pins. That second span is WIDENED BY ONE LINE from the `284-288` cited
// before: it stopped short of `label = 'bulk delete'`, truncating the second
// of the two branches it names — the #10274 class of anchor error, found here
// by re-reading rather than by the file changing, which is why byte-identity
// is never taken as proof an anchor is right):
//
// · No checkbox column is rendered. None of the object's four list views
// declares `bulkActions` / `bulkActionDefs` / `selection`, and `ObjectGrid`
Expand Down
56 changes: 37 additions & 19 deletions packages/spec/src/ui/component.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,9 +278,11 @@ describe('PageAccordionProps variant (#6776)', () => {
// same file's `ComponentRegistry.register('accordion', …)` publishes the key to
// the Studio block designer at `:898` (the `items` input, documented as
// `[{ label, icon?, collapsed?, children }]`). Measured at the pin this repo
// builds against — `.objectui-sha` = `190fbd01d`. Re-derived at that pin
// 2026-08-22: `containers.tsx` changed across the move from `9a3daf8d3`, and
// both anchors above landed on the same lines when re-counted.
// builds against — `.objectui-sha` = `9602dc820`. Re-derived at that pin
// 2026-08-28: `containers.tsx` is byte-identical to the one at `190fbd01d`, so
// both anchors above are unmoved — and both were re-READ at the new pin rather
// than inferred from that identity, because identity preserves a wrong anchor
// as faithfully as a right one (#10274).
//
// #9397 spent a full dispatch cycle re-deriving that read point from scratch
// after the sweep proposed retiring the key. This block plus the `.describe()`
Expand Down Expand Up @@ -363,9 +365,10 @@ describe('PageTabsProps items[].value / items[].count (#5775)', () => {
// same file's `ComponentRegistry.register('tabs', …)` publishes the key to the
// Studio block designer at `:721` (the `items` input, documented as
// `[{ label, value?, icon?, count?, visibleWhen?, children }]`). Measured at
// the pin this repo builds against — `.objectui-sha` = `190fbd01d`. Re-derived
// at that pin 2026-08-22: `containers.tsx` changed across the move from
// `9a3daf8d3`, and both anchors above landed on the same lines when re-counted.
// the pin this repo builds against — `.objectui-sha` = `9602dc820`. Re-derived
// at that pin 2026-08-28: `containers.tsx` is byte-identical to the one at
// `190fbd01d`, so both anchors above are unmoved — and both were re-READ at the
// new pin rather than inferred from that identity (#10274).
//
// #9397 spent a full dispatch cycle re-deriving the accordion's read point
// after the sweep proposed retiring it. This block plus the `.describe()` it
Expand Down Expand Up @@ -2433,22 +2436,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 +2478,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
72 changes: 45 additions & 27 deletions packages/spec/src/ui/component.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -639,9 +639,10 @@ export const PageTabsProps = strictObject({
* false candidate a component over).
*
* The key is LIVE at the objectui pin this repo builds against
* (`.objectui-sha` = `190fbd01d`; re-derived at that pin 2026-08-22 —
* `containers.tsx` changed across the move from `9a3daf8d3` but both
* anchors below landed on the same lines): `containers.tsx:662-668` renders
* (`.objectui-sha` = `9602dc820`; re-derived at that pin 2026-08-28 —
* `containers.tsx` is byte-identical to the one at `190fbd01d`, so both
* anchors below are unmoved, and both were re-READ there rather than
* inferred from that identity): `containers.tsx:662-668` renders
* `{item.icon && <LazyIcon name={item.icon} …/>}` inside the
* `TabsTrigger`, left of the label span (`mr-1.5 h-3.5 w-3.5 shrink-0
* opacity-70`, `aria-hidden`), and the renderer's registration publishes
Expand Down Expand Up @@ -1646,9 +1647,10 @@ export const PageAccordionProps = strictObject({
* re-derive the same false candidate).
*
* The key is LIVE at the objectui pin this repo builds against
* (`.objectui-sha` = `190fbd01d`; re-derived at that pin 2026-08-22 —
* `containers.tsx` changed across the move from `9a3daf8d3` but both
* anchors below landed on the same lines): `containers.tsx:851-857` renders
* (`.objectui-sha` = `9602dc820`; re-derived at that pin 2026-08-28 —
* `containers.tsx` is byte-identical to the one at `190fbd01d`, so both
* anchors below are unmoved, and both were re-READ there rather than
* inferred from that identity): `containers.tsx:851-857` renders
* `{item.icon && <LazyIcon name={item.icon} …/>}` inside the
* `AccordionTrigger`, grouped with the label in the trigger's one wrapping
* span, and the renderer's registration publishes the key to the Studio
Expand Down Expand Up @@ -1822,33 +1824,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 Expand Up @@ -2390,11 +2405,14 @@ export const ObjectMetricPropsSchema = lazySchema(() => strictObject({
* same record for the metric tile.
*
* The key is LIVE at the objectui pin this repo builds against
* (`.objectui-sha` = `190fbd01d`; re-derived at that pin 2026-08-22 — all
* four files in the chain below, plus `lazy-icon.tsx`, are byte-identical to
* the ones at `9a3daf8d3`, so every anchor is unmoved), and the chain runs
* (`.objectui-sha` = `9602dc820`; re-derived at that pin 2026-08-28 — of the
* files in the chain below, `index.tsx` DID change across the move off
* `190fbd01d` and its anchor moved `161` → `204`, while `ObjectMetricWidget
* .tsx`, `MetricWidget.tsx`, `MetricCard.tsx` and `lazy-icon.tsx` are
* byte-identical to the ones at `190fbd01d`; every anchor below was re-READ
* at the new pin rather than inferred from that identity), and the chain runs
* three files:
* `plugin-dashboard/src/index.tsx:161` publishes it as a designer input
* `plugin-dashboard/src/index.tsx:204` publishes it as a designer input
* (`Icon (Lucide name)`) on the registered `object-metric` block;
* `ObjectMetricWidget.tsx:142` destructures it and forwards it at `:474` to
* `MetricWidget`; `MetricWidget.tsx:312-321` resolves it via
Expand Down
Loading