From 3ae2b5916ca0968e425f88e9fff69ab9f4b5feea Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 19 Aug 2026 09:59:40 +0000 Subject: [PATCH] docs(spec): record the live read point of `page:accordion` items[].icon (#9881) 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, 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:851-853` renders `{item.icon && }` inside the AccordionTrigger, and `:898` publishes the key to the Studio block designer in the `items` input. Nothing about what parses changes — the key was already declared and optional. --- .changeset/accordion-item-icon-liveness.md | 41 +++++++++++++++++ packages/spec/src/ui/component.test.ts | 52 ++++++++++++++++++++++ packages/spec/src/ui/component.zod.ts | 27 ++++++++++- 3 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 .changeset/accordion-item-icon-liveness.md diff --git a/.changeset/accordion-item-icon-liveness.md b/.changeset/accordion-item-icon-liveness.md new file mode 100644 index 0000000000..f08b335c49 --- /dev/null +++ b/.changeset/accordion-item-icon-liveness.md @@ -0,0 +1,41 @@ +--- +"@objectstack/spec": patch +--- + +docs(spec): record the live read point of `page:accordion` `items[].icon` — a `.describe()` plus an accept-pin, so a liveness sweep stops re-deriving a false retirement candidate (#9881) + +`PageAccordionProps.items[].icon` parsed, rendered, and said nothing about +itself. A liveness sweep therefore read it as declared-but-unenforced and opened +a retirement candidate against it — which cost a full dispatch cycle before the +cross-repo read point was found and the candidate was closed premise-overtaken. +Nothing on the spec side recorded that liveness, so the next sweep would have +derived the same false candidate from the same absence. + +**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:851-853` — + `PageAccordionRenderer` renders `{item.icon && }` + inside the `AccordionTrigger`, grouped with the label in the trigger's single + wrapping span. +- `containers.tsx:898` — `ComponentRegistry.register('accordion', …)` publishes + the key to the Studio block designer in the `items` input, documented as + `[{ label, icon?, collapsed?, 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 `record:alert`'s own `icon` 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:accordion` 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 `value` prescribed against one line above is the deliberate contrast: +the same renderer overwrites that key with `panel-`, 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 64313c1af0..81b1bc07fc 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -269,6 +269,58 @@ describe('PageAccordionProps variant (#6776)', () => { }); }); +// #9881 — the accept-pin for `page:accordion` items[].icon, a key a liveness +// sweep once read as declared-but-unenforced. It has a live cross-repo consumer: +// objectui's `PageAccordionRenderer` renders `{item.icon && }` inside the `AccordionTrigger` +// (`packages/components/src/renderers/layout/containers.tsx:851-853`), and the +// 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` = 82a94170c. +// +// #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()` +// it pins are what stop the next sweep repeating it: the liveness verdict is +// now readable from the spec side alone, with no cross-repo hunt. +describe('PageAccordionProps items[].icon liveness (#9881)', () => { + const accordion = ComponentPropsMap['page:accordion']; + + it('accepts an icon on a panel item — the value objectui LazyIcon renders in the trigger', () => { + const result = accordion.safeParse({ + items: [{ label: 'Details', icon: 'circle-alert', children: [] }], + }); + expect(result.success).toBe(true); + const items = (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(items?.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 = accordion.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 = (PageAccordionProps as unknown as { + def: { shape: { items: { def: { element: { def: { shape: Record } } } } } }; + }).def.shape.items.def.element.def.shape; + expect(itemShape.icon?.description).toContain('LazyIcon'); + }); +}); + // #5775 — the two tab-item keys the renderer honours and the schema did not // declare. `value` is the load-bearing one: it is the `?tab=` token, and the // index-derived fallback (`tab-`) silently points at a different tab as soon diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 42570af696..55342832dc 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -1461,7 +1461,32 @@ export const PageAccordionProps = strictObject({ }, }, { label: I18nLabelSchema, - icon: z.string().optional(), + /** + * Panel-trigger icon, and the reason this key carries a docblock at all: a + * liveness sweep read it as declared-but-unenforced and opened a retirement + * candidate against it, which cost a full dispatch cycle before the + * cross-repo read point was found (#9397, closed premise-overtaken; #9881 + * is the rider that records the liveness here so the next sweep cannot + * re-derive the same false candidate). + * + * The key is LIVE at the objectui pin this repo builds against + * (`.objectui-sha` = `82a94170c`): `containers.tsx:851-853` renders + * `{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 + * block designer at `:898` (the `items` input, documented as + * `[{ label, icon?, collapsed?, 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 `value` prescribed against above: the same renderer OVERWRITES that + * one, and a read point is precisely what separates the two verdicts. + */ + icon: z.string().optional().describe( + 'Lucide icon name rendered in the panel trigger, left of the label. Read on this component — the renderer draws it via `LazyIcon`; contrast the item `value` beside it, which the renderer overwrites with `panel-`.', + ), collapsed: z.boolean().default(false), children: z.array(z.unknown()).describe('Child components'), })),