From 7b02e14deaf3fb1b003521a5d86bc719b2116af7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 29 Aug 2026 16:08:40 +0000 Subject: [PATCH] docs(context-menu): author the trigger card's text under the key ui:card reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `components-overlay-context-menu/basic-context-menu.json` authored its trigger as `{ "type": "card", "content": "Right-click here" }`. `packages/components/src/renderers/layout/card.tsx` reads `title`, `description`, `header`, `children || body` and `footer`, and `content` is not among the `ui:card` registration's declared `inputs` either — both re-verified on d06059f24. So the tile drew an empty dashed box and the instruction reached the DOM only as the leaked host attribute `content="Right-click here"` (the objectui#5574 class, for which `ui:card` is already ledgered in `widget-dom-leak-sweep.test.tsx`). `children`, not `body`: the renderer accepts both, but `BaseSchema` marks `body` legacy and objectui#6771 is retiring it as a `children` dialect, and objectui#6773 authored `children` in the four sibling `aspect-ratio` card demos. The renderer was NOT taught to read `content` — that would add a second dialect for one slot to a published surface (AGENTS.md #0.1). Adds `examples/schema-catalog/test/card-demo-content-6788.test.tsx`. Its key assertions run over EVERY `card` node in the catalog (93 nodes, 53 entries, 19 categories on d06059f24), not over this one entry, because this defect was found by hand while correcting its four siblings. Two assertions, because they fail for different reasons: keys must be DECLARED by the shipped `CardSchema` (read off its zod shape, so it follows the platform) and must be keys `card.tsx` READS (literals — `variant` is declared and read by nothing). The render half is category-scoped and asserts the acceptance criterion directly: the tile's text is non-empty through the real `SchemaRenderer`. Every assertion is paired with a counter-probe on the exact pre-fix shape. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CRJge11jso9TpXRWFt1Z49 --- .changeset/6788-context-menu-demo-content.md | 21 ++ .../basic-context-menu.json | 2 +- .../test/card-demo-content-6788.test.tsx | 338 ++++++++++++++++++ 3 files changed, 360 insertions(+), 1 deletion(-) create mode 100644 .changeset/6788-context-menu-demo-content.md create mode 100644 examples/schema-catalog/test/card-demo-content-6788.test.tsx diff --git a/.changeset/6788-context-menu-demo-content.md b/.changeset/6788-context-menu-demo-content.md new file mode 100644 index 0000000000..127c80a63c --- /dev/null +++ b/.changeset/6788-context-menu-demo-content.md @@ -0,0 +1,21 @@ +--- +--- + +docs(context-menu): the basic-context-menu demo authors its trigger text under +`children`, the key `ui:card` reads. + +The demo authored `{ "type": "card", "content": "Right-click here" }`. +`packages/components/src/renderers/layout/card.tsx` reads `title`, +`description`, `header`, `children || body` and `footer` — never `content` — +and `content` is not among the `ui:card` registration's declared `inputs` +either. So the published tile drew an empty dashed box and the instruction +reached the DOM only as the leaked host attribute `content="Right-click here"` +(the objectui#5574 class). + +`children` rather than `body`: `card.tsx` accepts both, but `body` is marked +legacy on `BaseSchema` and objectui#6771 is retiring it as a `children` +dialect, and objectui#6773 authored `children` in the four sibling +`aspect-ratio` card demos. The renderer was NOT widened to read `content` — +that would add a second dialect for one slot to a published surface. + +No package source changed, so this declares no release. diff --git a/examples/schema-catalog/src/schemas/components-overlay-context-menu/basic-context-menu.json b/examples/schema-catalog/src/schemas/components-overlay-context-menu/basic-context-menu.json index 78ba83bd75..1a466c50ae 100644 --- a/examples/schema-catalog/src/schemas/components-overlay-context-menu/basic-context-menu.json +++ b/examples/schema-catalog/src/schemas/components-overlay-context-menu/basic-context-menu.json @@ -2,7 +2,7 @@ "type": "context-menu", "trigger": { "type": "card", - "content": "Right-click here", + "children": "Right-click here", "className": "p-8 text-center border-dashed" }, "items": [ diff --git a/examples/schema-catalog/test/card-demo-content-6788.test.tsx b/examples/schema-catalog/test/card-demo-content-6788.test.tsx new file mode 100644 index 0000000000..9e716f04f9 --- /dev/null +++ b/examples/schema-catalog/test/card-demo-content-6788.test.tsx @@ -0,0 +1,338 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#6788 — a catalog `card` node carries its text in a key `ui:card` + * actually reads, and the context-menu demo draws the prompt it promises. + * + * ## The reading this file was written against + * + * `components-overlay-context-menu/basic-context-menu.json` authored its + * trigger as `{ "type": "card", "content": "Right-click here", "className": + * "p-8 text-center border-dashed" }`. + * `packages/components/src/renderers/layout/card.tsx` reads five slots — + * `title`, `description`, `header`, `children || body`, `footer` — plus + * `clickable`/`hoverable`, and `content` is none of them; nor is `content` + * among the `ui:card` registration's declared `inputs` (`title`, + * `description`, `className`). Both halves re-verified on this branch's base + * `d06059f24` before the fix was chosen. + * + * Rendered through the real `SchemaRenderer` the way the docs gallery renders + * it, the tile was 3 elements and its whole text content was the empty string; + * the instruction reached the DOM only as the leaked host attribute + * `content="Right-click here"` — the objectui#5574 class, for which `ui:card` + * is already ledgered in + * `packages/app-shell/src/__tests__/widget-dom-leak-sweep.test.tsx`. + * + * ## Why `children` and not `body` + * + * `card.tsx` reads `children || body` and `BaseSchema` marks `body` "Legacy, + * use children"; objectui#6771 is retiring `body` as a `children` dialect. So + * the two spellings the renderer accepts are not equal choices — one is being + * removed. `children` is also what objectui#6773 authored in the four + * `aspect-ratio` card demos (commit `dfb889f8d`), so the corpus converges on + * one spelling rather than acquiring a second. + * + * ⛔ What was NOT done: teaching `ui:card` to read `content`. That would widen + * a published surface to a second dialect for one slot — the shape #6771 is + * removing elsewhere. The renderer is the contract (AGENTS.md #0.1); the demo + * was wrong. + * + * ## Why the corpus sweep, and why it is the wider half + * + * objectui#6773 fixed four sibling demos and this was the fifth, found BY HAND + * while doing it. A pin scoped to this one entry would leave the next one to + * the next pair of eyes. So the assertions below run over EVERY `card` node in + * the catalog, at any depth, in any entry. + * + * Census on this branch's base, walking all 431 fixtures: + * + * card nodes 93 + * entries containing one 53 + * categories containing one 19 + * keys authored on those nodes type 93 · children 85 · className 60 · + * header 12 · title 8 · footer 1 · + * description 1 · content 1 + * + * `content` was the single outlier and is the defect. Every other key authored + * anywhere in the corpus is one `card.tsx` reads, so the sweep is green the + * moment this entry is corrected and red before it. + * + * ## Why two key assertions rather than one + * + * They fail for different reasons and a fix for one is not a fix for the other + * — the objectui#6157 class-3 shape, where a key is refused by neither zod nor + * tsc: + * + * 1. DECLARED — read off the shipped `CardSchema`'s own zod shape, not a + * hand-copied list, so it follows the platform instead of yesterday's + * vocabulary. `.success` is NOT the probe here and could not be: + * `BaseSchema` is `.passthrough()` and carries `[key: string]: any`, so + * `content` parses green and type-checks. The structural read is the only + * instrument that sees it. + * 2. READ — the keys `card.tsx` reads, copied as literals on purpose: they + * are the contract this file is about, and a renderer that starts reading + * a new key should turn this red for review rather than silently widen + * the set. This is the strictly stronger half: `variant` is DECLARED on + * `CardSchema` and read by nothing, so a demo authoring it would pass (1) + * and still draw nothing. + * + * `PIPELINE_KEYS` is the one allowance, and it is not a loophole: those keys + * are handled for every node by the render pipeline rather than by `card.tsx`, + * so a card authoring one is not this defect. The census above shows the + * corpus authors none of them today — it is headroom, not cover. + * + * ## What the render assertion adds, and why it is category-scoped + * + * The key assertions are static; they cannot see whether anything reached the + * screen. The acceptance criterion for this card is that the tile's text is + * non-empty when rendered through the real `SchemaRenderer`, so the + * context-menu category is also drawn and read. It is NOT widened to all 53 + * card-bearing entries: some of those sit inside grid/list/dashboard nodes + * that want a datasource, and `catalog-gallery-render.test.tsx` already owns + * the corpus-wide render sweep with its own documented exclusions. Breadth + * here comes from the key assertions; depth comes from this one. + * + * ## Why nothing already red covered it + * + * `catalog-gallery-render.test.tsx` renders this entry today and PASSES: its + * non-vacuity control is `elements > WRAPPER_ELEMENTS || text`, and the + * context-menu draws a bordered trigger box around the empty card, which + * clears it. Its stronger control — the entry's own authored strings on screen + * — is scoped to `NEWLY_REGISTERED_CATEGORIES`, which this family is not in. + * `check-doc-component-types.mjs` rules the question out by name ("NOT in + * scope, deliberately: whether the snippet's OTHER keys are read by the + * renderer the type resolves to"). + * + * Every assertion is paired with a counter-probe that renders or judges the + * exact pre-fix shape — the objectui#6157 discipline. What is pinned is not + * "the text is somewhere on screen" but "the key that carries it is one the + * renderer reads". + * + * Module-scope import of `@object-ui/components`, not `beforeAll` (AGENTS.md + * §测试纪律): registering the renderers is an unbounded module load and must + * not be billed to a bounded hook timeout. + */ +import { describe, it, expect } from 'vitest'; +import { render } from '@testing-library/react'; +import '@object-ui/components'; +import { SchemaRenderer, toRenderableSchema } from '@object-ui/react'; +import { CardSchema } from '@object-ui/types/zod'; +import { allExamples } from '../src/index.js'; + +type Json = Record; + +/** + * The keys `packages/components/src/renderers/layout/card.tsx` reads off the + * schema, plus the `type` discriminator. COPIED as literals — see the header. + */ +const RENDERER_READ_KEYS = [ + 'type', + 'className', + 'title', + 'description', + 'header', + 'children', + 'body', + 'footer', + 'clickable', + 'hoverable', +]; + +/** + * Node-level keys the render pipeline handles for EVERY node, whatever its + * type — so a card authoring one is not the phantom-key defect this file is + * about. No card node in the corpus authors any of them today. + */ +const PIPELINE_KEYS = ['id', 'name', 'bind', 'data', 'events', 'style', 'props', 'testId', 'visible', 'hidden', 'ariaLabel']; + +/** Keys that carry visible text in the nodes this catalog authors. */ +const TEXT_SLOTS = ['children', 'body', 'title', 'description']; + +const CONTEXT_MENU_CATEGORY = 'components-overlay-context-menu'; + +/** Every key the SHIPPED `CardSchema` declares — read off its zod shape. */ +function declaredCardKeys(): string[] { + const carrier = CardSchema as unknown as { shape?: Json; _def?: { shape?: Json } }; + const shape = carrier.shape ?? carrier._def?.shape; + if (!shape) throw new Error('CardSchema exposes no readable shape'); + return Object.keys(shape); +} + +type Located = { where: string; node: Json }; + +/** Every `card` node in one entry, at any depth, with a readable location. */ +function collectCards(node: unknown, where: string, into: Located[]): Located[] { + if (Array.isArray(node)) { + node.forEach((child, i) => collectCards(child, `${where}[${i}]`, into)); + return into; + } + if (!node || typeof node !== 'object') return into; + const record = node as Json; + if (record.type === 'card') into.push({ where, node: record }); + for (const [key, value] of Object.entries(record)) { + collectCards(value, `${where}.${key}`, into); + } + return into; +} + +const entries = allExamples(); +const cards: Located[] = entries.flatMap((entry) => + collectCards(entry.schema, entry.id, []), +); + +/** Render one entry the way `SchemaThumbnail` does. */ +function draw(schema: unknown) { + const { container, unmount } = render( +
+ +
, + ); + return { text: container.textContent ?? '', container, unmount }; +} + +/** Every string this node authors in a content slot, at any depth. */ +function authoredText(node: unknown, inSlot = false): string[] { + if (typeof node === 'string') return inSlot ? [node] : []; + if (Array.isArray(node)) return node.flatMap((child) => authoredText(child, inSlot)); + if (!node || typeof node !== 'object') return []; + return Object.entries(node as Json).flatMap(([key, value]) => + authoredText(value, TEXT_SLOTS.includes(key)), + ); +} + +/** The offending keys on one card node, against an allowed set. */ +function unreadKeys(node: Json, allowed: readonly string[]): string[] { + return Object.keys(node).filter((key) => !allowed.includes(key)); +} + +describe('catalog corpus: every `card` node authors keys `ui:card` reads (objectui#6788)', () => { + it('the walk is not vacuous — a broken collector would pass every case below', () => { + // Measured on d06059f24; these are floors, not equalities, so growth of + // the catalog does not fail the file — only a walk that stopped working. + expect(entries.length).toBeGreaterThanOrEqual(431); + expect(cards.length).toBeGreaterThanOrEqual(93); + expect(new Set(cards.map((c) => c.where.split('/')[0])).size).toBeGreaterThanOrEqual(19); + }); + + it('every authored key is DECLARED by the shipped CardSchema', () => { + const declared = declaredCardKeys(); + // The declared set is read, not assumed: if the shape ever comes back + // empty this assertion would pass vacuously in the other direction. + expect(declared).toContain('children'); + expect(declared).not.toContain('content'); + + const offenders = cards + .map(({ where, node }) => ({ where, keys: unreadKeys(node, declared) })) + .filter((hit) => hit.keys.length > 0); + expect(offenders).toEqual([]); + }); + + it('every authored key is one `card.tsx` READS', () => { + const allowed = [...RENDERER_READ_KEYS, ...PIPELINE_KEYS]; + const offenders = cards + .map(({ where, node }) => ({ where, keys: unreadKeys(node, allowed) })) + .filter((hit) => hit.keys.length > 0); + expect(offenders).toEqual([]); + }); + + it('counter-probe: the pre-#6788 node fails BOTH key assertions, so both bite', () => { + const preFix: Json = { + type: 'card', + content: 'Right-click here', + className: 'p-8 text-center border-dashed', + }; + expect(unreadKeys(preFix, declaredCardKeys())).toEqual(['content']); + expect(unreadKeys(preFix, [...RENDERER_READ_KEYS, ...PIPELINE_KEYS])).toEqual(['content']); + + // And the corrected node clears both — the judge is not simply strict. + const fixed: Json = { + type: 'card', + children: 'Right-click here', + className: 'p-8 text-center border-dashed', + }; + expect(unreadKeys(fixed, declaredCardKeys())).toEqual([]); + expect(unreadKeys(fixed, [...RENDERER_READ_KEYS, ...PIPELINE_KEYS])).toEqual([]); + }); + + it('counter-probe: the READ assertion is strictly stronger than the DECLARED one', () => { + // `variant` is a declared `CardSchema` member that `card.tsx` never reads, + // so a demo authoring it draws nothing different. Assertion (1) accepts + // it; assertion (2) is what would catch it. + const declaredButUnread: Json = { type: 'card', variant: 'outline', children: 'x' }; + expect(unreadKeys(declaredButUnread, declaredCardKeys())).toEqual([]); + expect(unreadKeys(declaredButUnread, [...RENDERER_READ_KEYS, ...PIPELINE_KEYS])).toEqual([ + 'variant', + ]); + }); +}); + +const contextMenuEntries = entries.filter((e) => e.meta.category === CONTEXT_MENU_CATEGORY); + +describe(`${CONTEXT_MENU_CATEGORY} demos draw their trigger text (objectui#6788)`, () => { + it('the category is not empty — a vacuous sweep would pass every case below', () => { + expect(contextMenuEntries.length).toBeGreaterThanOrEqual(1); + }); + + it.each(contextMenuEntries.map((e) => [e.id, e] as const))( + '%s renders a trigger tile with non-empty text', + (_id, entry) => { + const schema = entry.schema as unknown as Json; + const drawn = draw(schema); + try { + // The acceptance criterion for this card, stated as the card states + // it: the tile's text content is non-empty when rendered through the + // real `SchemaRenderer`. + expect(drawn.text.trim()).not.toBe(''); + // And it is the author's own string, not incidental chrome. + const texts = authoredText(schema.trigger); + expect(texts.length).toBeGreaterThan(0); + for (const text of texts) expect(drawn.text).toContain(text); + // The string is a text node, not a leaked host attribute + // (objectui#5574): no element carries it as `content`. + expect(drawn.container.querySelector('[content]')).toBeNull(); + } finally { + drawn.unmount(); + } + }, + ); + + it('counter-probe: the pre-#6788 shape draws an EMPTY tile', () => { + // Verbatim the shape `basic-context-menu.json` carried before this change. + // The assertions above are satisfied by it only if they have stopped + // measuring anything. + const drawn = draw({ + type: 'context-menu', + trigger: { type: 'card', content: 'Right-click here', className: 'p-8 text-center border-dashed' }, + items: [{ label: 'Copy', icon: 'copy' }], + }); + try { + expect(drawn.text.trim()).toBe(''); + // ...and the string is in the DOM only as the leaked host attribute, + // which is what made this invisible to a red-tile sweep. + const leaked = drawn.container.querySelector('[content]'); + expect(leaked?.getAttribute('content')).toBe('Right-click here'); + } finally { + drawn.unmount(); + } + }); + + it('counter-probe: the judge sees text authored under a key the renderer reads', () => { + const drawn = draw({ + type: 'context-menu', + trigger: { type: 'card', children: 'Right-click here', className: 'p-8 text-center border-dashed' }, + items: [{ label: 'Copy', icon: 'copy' }], + }); + try { + expect(drawn.text).toContain('Right-click here'); + expect(drawn.container.querySelector('[content]')).toBeNull(); + } finally { + drawn.unmount(); + } + }); +});