Skip to content
Merged
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
41 changes: 41 additions & 0 deletions .changeset/tabs-item-icon-liveness.md
Original file line numberDiff line numberDiff line change
@@ -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 && <LazyIcon name={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.
53 changes: 53 additions & 0 deletions packages/spec/src/ui/component.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 && <LazyIcon
// name={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<string, { description?: string }> } } } } } };
}).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({});
Expand Down
30 changes: 29 additions & 1 deletion packages/spec/src/ui/component.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 && <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
* 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
Expand Down
Loading