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
21 changes: 21 additions & 0 deletions .changeset/6788-context-menu-demo-content.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
Expand Up@@ -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": [
Expand Down
338 changes: 338 additions & 0 deletions examples/schema-catalog/test/card-demo-content-6788.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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<string, unknown>;

/**
* 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(
<div className="w-full p-4">
<SchemaRenderer schema={toRenderableSchema(schema as never) as never} />
</div>,
);
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();
}
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
docs(context-menu): author the trigger card's text under the key ui:card reads by claude[bot] · Pull Request #6807 · objectstack-ai/objectui · GitHub
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
21 changes: 21 additions & 0 deletions .changeset/6788-context-menu-demo-content.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
Expand Up@@ -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": [
Expand Down
338 changes: 338 additions & 0 deletions examples/schema-catalog/test/card-demo-content-6788.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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<string, unknown>;

/**
* 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(
<div className="w-full p-4">
<SchemaRenderer schema={toRenderableSchema(schema as never) as never} />
</div>,
);
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();
}
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' docs(context-menu): author the trigger card's text under the key ui:card reads by claude[bot] · Pull Request #6807 · objectstack-ai/objectui · GitHub
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
21 changes: 21 additions & 0 deletions .changeset/6788-context-menu-demo-content.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
Expand Up@@ -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": [
Expand Down
338 changes: 338 additions & 0 deletions examples/schema-catalog/test/card-demo-content-6788.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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<string, unknown>;

/**
* 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(
<div className="w-full p-4">
<SchemaRenderer schema={toRenderableSchema(schema as never) as never} />
</div>,
);
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();
}
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' docs(context-menu): author the trigger card's text under the key ui:card reads by claude[bot] · Pull Request #6807 · objectstack-ai/objectui · GitHub
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
21 changes: 21 additions & 0 deletions .changeset/6788-context-menu-demo-content.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
Expand Up@@ -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": [
Expand Down
338 changes: 338 additions & 0 deletions examples/schema-catalog/test/card-demo-content-6788.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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<string, unknown>;

/**
* 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(
<div className="w-full p-4">
<SchemaRenderer schema={toRenderableSchema(schema as never) as never} />
</div>,
);
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();
}
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' docs(context-menu): author the trigger card's text under the key ui:card reads by claude[bot] · Pull Request #6807 · objectstack-ai/objectui · GitHub
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
21 changes: 21 additions & 0 deletions .changeset/6788-context-menu-demo-content.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
Expand Up@@ -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": [
Expand Down
338 changes: 338 additions & 0 deletions examples/schema-catalog/test/card-demo-content-6788.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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<string, unknown>;

/**
* 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(
<div className="w-full p-4">
<SchemaRenderer schema={toRenderableSchema(schema as never) as never} />
</div>,
);
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();
}
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' docs(context-menu): author the trigger card's text under the key ui:card reads by claude[bot] · Pull Request #6807 · objectstack-ai/objectui · GitHub
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
21 changes: 21 additions & 0 deletions .changeset/6788-context-menu-demo-content.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
Expand Up@@ -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": [
Expand Down
338 changes: 338 additions & 0 deletions examples/schema-catalog/test/card-demo-content-6788.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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<string, unknown>;

/**
* 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(
<div className="w-full p-4">
<SchemaRenderer schema={toRenderableSchema(schema as never) as never} />
</div>,
);
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();
}
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' docs(context-menu): author the trigger card's text under the key ui:card reads by claude[bot] · Pull Request #6807 · objectstack-ai/objectui · GitHub
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
21 changes: 21 additions & 0 deletions .changeset/6788-context-menu-demo-content.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
Expand Up@@ -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": [
Expand Down
338 changes: 338 additions & 0 deletions examples/schema-catalog/test/card-demo-content-6788.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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<string, unknown>;

/**
* 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(
<div className="w-full p-4">
<SchemaRenderer schema={toRenderableSchema(schema as never) as never} />
</div>,
);
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();
}
});
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); docs(context-menu): author the trigger card's text under the key ui:card reads by claude[bot] · Pull Request #6807 · objectstack-ai/objectui · GitHub
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
21 changes: 21 additions & 0 deletions .changeset/6788-context-menu-demo-content.md
Original file line numberDiff line numberDiff line change
@@ -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.
Original file line numberDiff line numberDiff line change
Expand Up@@ -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": [
Expand Down
338 changes: 338 additions & 0 deletions examples/schema-catalog/test/card-demo-content-6788.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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<string, unknown>;

/**
* 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(
<div className="w-full p-4">
<SchemaRenderer schema={toRenderableSchema(schema as never) as never} />
</div>,
);
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();
}
});
});
Loading