From e521aba3f9423b08d776e047f50adb4badf631d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 18:52:02 +0000 Subject: [PATCH] docs(spec): record the live read point of `page:tabs` items[].icon (#9972) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `.describe()` naming the objectui consumer plus an accept-pin, the exact sibling of the landed accordion record — so a liveness sweep stops re-deriving a false retirement candidate for a key that renders. Re-verified at the pin this repo builds against (`.objectui-sha` = 82a94170c): `containers.tsx:662-665` renders `{item.icon && }` inside the TabsTrigger, and `:721` publishes the key to the Studio block designer in the `items` input. Nothing about what parses changes — the key was already declared and optional. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01URCaKuNTuK3BKJvwqM74QU --- .changeset/tabs-item-icon-liveness.md | 41 ++++++++++++++++++++ packages/spec/src/ui/component.test.ts | 53 ++++++++++++++++++++++++++ packages/spec/src/ui/component.zod.ts | 30 ++++++++++++++- 3 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 .changeset/tabs-item-icon-liveness.md diff --git a/.changeset/tabs-item-icon-liveness.md b/.changeset/tabs-item-icon-liveness.md new file mode 100644 index 0000000000..bf8ae87e6d --- /dev/null +++ b/.changeset/tabs-item-icon-liveness.md @@ -0,0 +1,41 @@ +--- +"@objectstack/spec": patch +--- + +docs(spec): record the live read point of `page:tabs` `items[].icon` — a `.describe()` plus an accept-pin, the exact sibling of the accordion record (#9972) + +`PageTabsProps.items[].icon` parsed, rendered, and said nothing about itself — +the same state `page:accordion`'s item `icon` was in one component over. That +absence is what a liveness sweep reads as declared-but-unenforced: a retirement +candidate was opened against the accordion key, and a full dispatch cycle went +into re-deriving the cross-repo read point before the candidate was closed +premise-overtaken. Recording the accordion's liveness left the identical +absence on the tab item, from which the same false candidate is still derivable. + +**The key is live**, re-verified at the objectui pin this repo builds against +(`.objectui-sha` = `82a94170c`) rather than taken from the card: + +- `packages/components/src/renderers/layout/containers.tsx:662-665` — + `PageTabsRenderer` renders `{item.icon && }` + inside the `TabsTrigger`, left of the label span. +- `containers.tsx:721` — `ComponentRegistry.register('tabs', …)` publishes the + key to the Studio block designer in the `items` input, documented as + `[{ label, value?, icon?, count?, visibleWhen?, children }]`. + +**Nothing about what parses changes.** The key was already declared and already +optional; this adds the prose that makes its liveness readable, and the test that +keeps it readable: + +- a `.describe()` naming the consumer behaviourally, in the file's house idiom — + the same shape the landed accordion describe uses ("Read on this component — + contrast …"), with the file:line anchors and the measured pin in the docblock + above the key, where this file keeps them; +- an accept-pin asserting the key parses on a `page:tabs` item and survives to + the parsed output, that an undeclared sibling on the same item is still refused + (so the accept is not vacuous on a schema that stopped being strict), and that + the `.describe()` still names the consumer — deleting it is what re-opens the + false candidate, so it is pinned rather than left to review. + +The item `key` prescribed against in the same shape's alias table is the +deliberate contrast: that spelling reaches no read point at all, and a read point +is precisely what separates the two verdicts. diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index 81b1bc07fc..80606e88d8 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -351,6 +351,59 @@ describe('PageTabsProps items[].value / items[].count (#5775)', () => { }); }); +// #9972 — the accept-pin for `page:tabs` items[].icon, the exact sibling of the +// #9881 accordion key: same file, same renderer, same `LazyIcon` slot, and the +// same bare declaration a liveness sweep reads as declared-but-unenforced. +// objectui's `PageTabsRenderer` renders `{item.icon && }` inside the `TabsTrigger` +// (`packages/components/src/renderers/layout/containers.tsx:662-665`), and the +// 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` = 82a94170c. +// +// #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 +// pins are what stop that repeating one component over: the liveness verdict is +// readable from the spec side alone, with no cross-repo hunt. +describe('PageTabsProps items[].icon liveness (#9972)', () => { + const tabs = ComponentPropsMap['page:tabs']; + + it('accepts an icon on a tab item — the value objectui LazyIcon renders in the trigger', () => { + const result = tabs.safeParse({ + items: [{ label: 'Details', icon: 'circle-alert', children: [] }], + }); + expect(result.success).toBe(true); + const parsed = (result.success ? result.data : undefined) as + | { items: { icon?: string }[] } + | undefined; + // Carried through to the parsed output, not stripped: what the renderer + // reads is what an author writes. + expect(parsed?.items[0]?.icon).toBe('circle-alert'); + }); + + it('still refuses an undeclared sibling on the same item — the accept above is not vacuous', () => { + // Without this the green above would also be green on a schema that had + // stopped being strict, which is the failure mode an accept-pin exists to + // exclude. + const result = tabs.safeParse({ + items: [{ label: 'Details', iconName: 'circle-alert', children: [] }], + }); + expect(result.success).toBe(false); + expect(JSON.stringify(result.error?.issues)).toContain('unrecognized_keys'); + }); + + it('keeps a `.describe()` that names the consumer, so the read point survives a rename', () => { + // The describe is the artifact an auditor reads instead of hunting across + // repos; deleting it is what re-opens the false candidate, so it is pinned + // rather than left to review. + const itemShape = (PageTabsProps as unknown as { + def: { shape: { items: { def: { element: { def: { shape: Record } } } } } }; + }).def.shape.items.def.element.def.shape; + expect(itemShape.icon?.description).toContain('LazyIcon'); + }); +}); + describe('PageCardProps', () => { it('should accept empty card with defaults', () => { const result = PageCardProps.parse({}); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 55342832dc..9b5af3908e 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -624,7 +624,35 @@ export const PageTabsProps = strictObject({ }, }, { label: I18nLabelSchema, - icon: z.string().optional(), + /** + * Tab-trigger icon, and the reason this key carries a docblock at all: it + * presents to a liveness sweep exactly as `page:accordion`'s item `icon` + * did one component over — declared bare, asserted nowhere — and that + * absence cost a full dispatch cycle re-deriving the cross-repo read point + * before the retirement candidate was closed (#9397 closed + * premise-overtaken; #9881 recorded the accordion's liveness; this is the + * same record for the tab item, so the sweep cannot re-derive the same + * false candidate a component over). + * + * The key is LIVE at the objectui pin this repo builds against + * (`.objectui-sha` = `82a94170c`): `containers.tsx:662-665` renders + * `{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 + * the key to the Studio block designer at `:721` (the `items` input, + * documented as `[{ label, value?, icon?, count?, visibleWhen?, children + * }]`). + * + * Vocabulary is Lucide, resolved through objectui's `LazyIcon` + * (`lib/lazy-icon.tsx` — kebab-case or PascalCase, normalised to + * kebab-case, with a fallback when the name is not a real Lucide icon), the + * same slot every other authorable icon on this surface uses. Contrast the + * item `key` prescribed against above: that spelling reaches no read point + * at all, and a read point is precisely what separates the two verdicts. + */ + icon: z.string().optional().describe( + 'Lucide icon name rendered in the tab trigger, left of the label. Read on this component — the renderer draws it via `LazyIcon`; contrast the item `key` beside it, which no read point takes and which the alias table answers with `value`.', + ), /** * Conditional tab (CEL, #2606): when the predicate evaluates FALSE the * whole tab — header *and* panel — is omitted from the strip. This is the