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