From dc7ca77581c50e95225399056b5aa1b7d3dfe4fa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 11:28:40 +0000 Subject: [PATCH] feat(record): declare inputs for the seven configurable record:* blocks, and curate six MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven record:* blocks shipped with renderers that read props but declared no inputs. That combination is the worst of both: the renderer honours `limit`, `severity`, `location` …, while every authoring surface — the designer panel, the AI vocabulary, the generated manifest — reports the block takes no configuration. #3013 recorded them as deliberately uncurated for exactly that reason. The declarations mirror what each renderer actually reads: record:activity 11 from RecordActivityComponentProps record:chatter 5 from RecordChatterComponentProps record:discussion 5 (same renderer, same inputs) record:alert 8 severity, title, body, visible, icon, action, dismissible, dismissKey record:quick_actions 7 actionNames, requiredPermissions, location, align, inline, variant, size record:history 3 limit, emptyText, unknownUserText record:reference_rail 1 hideEmpty inputs describe what an AUTHOR writes, which is a subset of what the renderer reads. `entries`, `loading` and resolved `actions` are injected by the host shell off RecordContext; declaring them would invite a model to hand-write the data the page is supposed to fetch. `aria` is omitted for the reason it is omitted on record:details — an accessibility escape hatch, not a layout choice. `location` takes its enum from the spec's ACTION_LOCATIONS rather than restating it, per #3019. Six of the seven join PUBLIC_BLOCKS: configurable and absent from the contract is the state #3006 was about. The contract goes 36 -> 42 tags, all resolving. record:chatter stays out — same renderer as record:discussion under a Salesforce-familiar name, kept for schemas already in the wild. Two spellings of one block is ambiguity an authoring model cannot resolve, so the vocabulary carries the spec's name. A test compares the two input lists, so the day they diverge the exclusion stops being justified and fails. A companion assertion requires every curated record:* tag to declare inputs. A curated tag with none reads as "takes no configuration" when the renderer in fact reads props — the same gap #3006 opened, pointed the other way. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01N4mrr1ihhwnfEHFSWmGoMp --- .changeset/record-blocks-inputs.md | 47 ++++++++++++ .../src/__tests__/public-contract.test.ts | 63 ++++++++++------ packages/core/src/registry/public-blocks.ts | 15 ++++ packages/plugin-detail/src/index.tsx | 75 +++++++++++++++++-- 4 files changed, 173 insertions(+), 27 deletions(-) create mode 100644 .changeset/record-blocks-inputs.md diff --git a/.changeset/record-blocks-inputs.md b/.changeset/record-blocks-inputs.md new file mode 100644 index 000000000..53de7fd75 --- /dev/null +++ b/.changeset/record-blocks-inputs.md @@ -0,0 +1,47 @@ +--- +"@object-ui/plugin-detail": minor +"@object-ui/core": minor +"@object-ui/console": patch +--- + +feat(record): declare inputs for the seven configurable record:\* blocks, and curate six + +Seven `record:*` blocks shipped with renderers that read props but declared no +`inputs`. That combination is the worst of both: the renderer honours +`limit`, `severity`, `location` …, while every authoring surface — the designer +panel, the AI vocabulary, the generated manifest — reports the block takes no +configuration. objectui#3013 recorded them as deliberately uncurated for +exactly that reason. + +The declarations mirror what each renderer actually reads: + +| block | inputs | +|---|---| +| `record:activity` | 11 — from `RecordActivityComponentProps` | +| `record:chatter` / `record:discussion` | 5 — from `RecordChatterComponentProps` | +| `record:alert` | 8 — severity, title, body, visible, icon, action, dismissible, dismissKey | +| `record:quick_actions` | 7 — actionNames, requiredPermissions, location, align, inline, variant, size | +| `record:history` | 3 — limit, emptyText, unknownUserText | +| `record:reference_rail` | 1 — hideEmpty | + +`inputs` describe what an AUTHOR writes, which is a subset of what the renderer +reads. `entries`, `loading` and resolved `actions` are injected by the host +shell off RecordContext; declaring them would invite a model to hand-write the +data the page is supposed to fetch. `aria` is omitted for the reason it is +omitted on `record:details` — an accessibility escape hatch, not a layout +choice. `location` takes its enum from the spec's `ACTION_LOCATIONS` rather +than restating it, per objectui#3019. + +Six of the seven are now in `PUBLIC_BLOCKS`: configurable and absent from the +contract is the state objectui#3006 was about. The contract goes 36 → 42 tags, +all resolving. + +`record:chatter` stays out — it is the same renderer as `record:discussion` +under a Salesforce-familiar name, kept for schemas already in the wild. Two +spellings of one block is ambiguity an authoring model cannot resolve, so the +vocabulary carries the spec's name. A test compares the two input lists, so the +day they diverge the exclusion stops being justified and fails. + +A companion assertion requires every curated `record:*` tag to declare inputs. +A curated tag with none reads as "takes no configuration" when the renderer in +fact reads props — the same gap objectui#3006 opened, pointed the other way. diff --git a/apps/console/src/__tests__/public-contract.test.ts b/apps/console/src/__tests__/public-contract.test.ts index 606a4b120..e8aa91c52 100644 --- a/apps/console/src/__tests__/public-contract.test.ts +++ b/apps/console/src/__tests__/public-contract.test.ts @@ -67,6 +67,12 @@ const EXPECTED_COVERED = [ 'record:related_list', 'record:path', 'record:line_items', + 'record:activity', + 'record:discussion', + 'record:history', + 'record:quick_actions', + 'record:reference_rail', + 'record:alert', 'flex', 'grid', 'stack', @@ -161,25 +167,23 @@ describe('console ↔ PUBLIC_BLOCKS coverage', () => { /** * `record:*` blocks that ship but are deliberately NOT in the AI vocabulary, - * each with the reason it stays out. Every one of these currently declares - * ZERO `inputs` — there is nothing for an author or a model to configure, so - * putting them in the contract would advertise a block that can only be - * emitted bare. + * each with the reason it stays out. * * This list exists to force a decision, not to park problems: registering a * new `record:*` block fails the test below until it is either curated or * added here with a reason. That is the property objectui#3006 needed and did * not have — `record:line_items` shipped fully configurable (5 inputs) and * simply never reached the contract, because nothing looked in this direction. + * + * It held seven entries until the blocks behind them declared `inputs`; six + * became authorable and moved into the contract. The one that stayed is here + * on its own merits rather than for want of a configuration surface. */ const DELIBERATELY_UNCURATED: Record = { - 'record:activity': 'no declared inputs — feed is derived from the record, nothing to author', - 'record:alert': 'no declared inputs — banner content comes from record state', - 'record:chatter': 'no declared inputs — feed is derived from the record', - 'record:discussion': 'no declared inputs — shares the chatter renderer', - 'record:history': 'no declared inputs — audit trail is derived from the record', - 'record:quick_actions': 'no declared inputs — actions come from object metadata', - 'record:reference_rail': 'no declared inputs — rail is derived from the record', + 'record:chatter': + 'same renderer as record:discussion under a Salesforce-familiar name, kept for ' + + 'schemas already in the wild — the vocabulary carries the spec name, since two ' + + 'spellings of one block is ambiguity an authoring model cannot resolve', }; describe('PUBLIC_BLOCKS ↔ console coverage (reverse direction)', () => { @@ -220,17 +224,34 @@ describe('PUBLIC_BLOCKS ↔ console coverage (reverse direction)', () => { } }); - it('keeps the deliberately-uncurated blocks unconfigurable', () => { - // The stated reason for every exclusion is "nothing to author". If one of - // these grows `inputs`, it became authorable and the exclusion needs - // re-deciding rather than inheriting. - for (const tag of Object.keys(DELIBERATELY_UNCURATED)) { - // A pending lazy stub reports `inputs: undefined` meaning "not known - // yet", which would make the assertion below vacuous. These are eager - // registrations in plugin-detail; pin that, so the day one goes lazy this - // fails loudly instead of silently passing. + it('keeps the chatter alias identical to the block it aliases', () => { + // `record:chatter` is excluded because it duplicates `record:discussion`, + // not because it is lesser. The moment the two configuration surfaces + // diverge, that reasoning stops holding: `chatter` would be its own block + // kept out of the vocabulary, which is the state this whole file exists to + // catch. Comparing inputs is what makes the exclusion falsifiable. + // + // Both are eager registrations, asserted first — a pending lazy stub + // reports `inputs: undefined` meaning "not known yet", which would make the + // comparison below pass vacuously on two blanks. + for (const tag of ['record:chatter', 'record:discussion']) { expect(ComponentRegistry.getConfig(tag)).toBeDefined(); - expect(ComponentRegistry.getMeta(tag)?.inputs ?? []).toEqual([]); + } + const chatter = ComponentRegistry.getMeta('record:chatter')?.inputs; + expect(chatter?.length).toBeGreaterThan(0); + expect(chatter).toEqual(ComponentRegistry.getMeta('record:discussion')?.inputs); + }); + + it('declares inputs for every curated record:* block', () => { + // What objectui#3006 cost was a configurable block sitting outside the + // contract. The inverse is just as bad for an authoring model: a curated + // tag with no declared inputs can only be emitted bare, so it reads as + // "this block takes no configuration" when the renderer in fact reads + // props. Curation and a configuration surface travel together. + const curatedRecordBlocks = PUBLIC_BLOCKS.filter((tag) => tag.startsWith(`${NS}:`)); + expect(curatedRecordBlocks.length).toBeGreaterThan(0); + for (const tag of curatedRecordBlocks) { + expect(contract.get(tag)?.inputs ?? []).not.toEqual([]); } }); diff --git a/packages/core/src/registry/public-blocks.ts b/packages/core/src/registry/public-blocks.ts index 5c2ab48f8..02068fd35 100644 --- a/packages/core/src/registry/public-blocks.ts +++ b/packages/core/src/registry/public-blocks.ts @@ -48,6 +48,21 @@ export const PUBLIC_BLOCKS: readonly string[] = [ // it was listed under here never existed — the block has shipped all along // but could never resolve through the contract (objectui#2953 follow-up). 'record:line_items', + // Configurable as of objectui#3023 follow-up: these shipped with renderers + // but no declared `inputs`, so a model could only emit them bare — which is + // why they sat outside the contract. Their inputs now mirror what the + // renderers actually read, so they are authorable and belong here. + // + // `record:chatter` is deliberately NOT in this list: it is the same renderer + // as `record:discussion` under a Salesforce-familiar name, kept for schemas + // already in the wild. Two spellings of one block is ambiguity an authoring + // model has no way to resolve, so the vocabulary carries the spec's name. + 'record:activity', + 'record:discussion', + 'record:history', + 'record:quick_actions', + 'record:reference_rail', + 'record:alert', // ── Tier B — layout / content primitives ────────────────────────────────── 'flex', 'grid', diff --git a/packages/plugin-detail/src/index.tsx b/packages/plugin-detail/src/index.tsx index 1d5cc9444..8ab0fd8bf 100644 --- a/packages/plugin-detail/src/index.tsx +++ b/packages/plugin-detail/src/index.tsx @@ -6,7 +6,7 @@ * LICENSE file in the root directory of this source tree. */ -import { ComponentRegistry } from '@object-ui/core'; +import { ComponentRegistry, type ComponentInput } from '@object-ui/core'; import { DetailView } from './DetailView'; import { DetailSection } from './DetailSection'; import { DetailTabs } from './DetailTabs'; @@ -23,6 +23,7 @@ import { RecordReferenceRailRenderer } from './renderers/record-reference-rail'; import { RecordAlertRenderer } from './renderers/record-alert'; import { PermissionFacetLink } from './renderers/PermissionFacetLink'; import type { DetailViewSchema } from '@object-ui/types'; +import { ACTION_LOCATIONS } from '@object-ui/types'; export { DetailView, DetailSection, DetailTabs, RelatedList }; export { @@ -282,33 +283,67 @@ ComponentRegistry.register('highlights', RecordHighlightsRenderer, { ], }); +// `inputs` on the blocks below describe what an AUTHOR writes, which is a +// subset of what the renderer reads. `entries`, `loading` and resolved +// `actions` are injected by the host shell (RecordDetailView and friends) off +// RecordContext — declaring those would invite a model to hand-write data the +// page is supposed to fetch. `aria` is omitted for the same reason it is +// omitted on `record:details` above: it is an accessibility escape hatch, not +// a layout choice. + ComponentRegistry.register('activity', RecordActivityRenderer, { namespace: 'record', skipFallback: true, category: 'record', label: 'Activity Timeline', icon: 'Activity', + // Mirrors RecordActivityComponentProps (@object-ui/types), itself aligned + // with @objectstack/spec RecordActivityProps. + inputs: [ + { name: 'types', type: 'array', label: 'Activity Types', description: 'Activity types to display (e.g. task, event, call, comment)' }, + { name: 'filterMode', type: 'string', label: 'Filter Mode', description: 'How the type filter combines with the feed query' }, + { name: 'showFilterToggle', type: 'boolean', label: 'Show Filter Toggle', description: 'Expose the activity-type filter UI' }, + { name: 'limit', type: 'number', label: 'Limit', description: 'Maximum activities to display' }, + { name: 'showCompleted', type: 'boolean', label: 'Show Completed', description: 'Include completed/resolved activities' }, + { name: 'unifiedTimeline', type: 'boolean', label: 'Unified Timeline', description: 'Merge all activity types into one timeline' }, + { name: 'showCommentInput', type: 'boolean', label: 'Show Comment Input' }, + { name: 'enableMentions', type: 'boolean', label: 'Enable @mentions' }, + { name: 'enableReactions', type: 'boolean', label: 'Enable Reactions' }, + { name: 'enableThreading', type: 'boolean', label: 'Enable Threaded Replies' }, + { name: 'showSubscriptionToggle', type: 'boolean', label: 'Show Subscribe Toggle' }, + ], }); +// `record:chatter` and `record:discussion` are the same renderer under two +// names. The spec prefers `discussion` for new Lightning-style record pages; +// `chatter` stays for Salesforce-familiar authors and for schemas already in +// the wild. Both carry the same inputs — an author who reaches for either gets +// the same configuration surface. +const CHATTER_INPUTS: ComponentInput[] = [ + { name: 'position', type: 'enum', label: 'Position', enum: ['bottom', 'right', 'left'], defaultValue: 'bottom', description: 'Where the panel docks relative to the record body' }, + { name: 'width', type: 'string', label: 'Width', description: 'Panel width as a CSS value (side positions only)' }, + { name: 'collapsible', type: 'boolean', label: 'Collapsible', defaultValue: false }, + { name: 'defaultCollapsed', type: 'boolean', label: 'Start Collapsed' }, + { name: 'feed', type: 'object', label: 'Feed Options', description: 'Activity-feed config nested inside the panel — same shape as record:activity' }, +]; + ComponentRegistry.register('chatter', RecordChatterRenderer, { namespace: 'record', skipFallback: true, category: 'record', label: 'Chatter Feed', icon: 'MessageSquare', + // Mirrors RecordChatterComponentProps (@object-ui/types). + inputs: CHATTER_INPUTS, }); -// `record:discussion` is the spec-compliant alias preferred for new -// Lightning-style record pages. The two names render identically and -// share the same DiscussionContext wiring; we keep `record:chatter` -// for Salesforce-familiar authors and for backward compatibility with -// schemas already in the wild. ComponentRegistry.register('discussion', RecordChatterRenderer, { namespace: 'record', skipFallback: true, category: 'record', label: 'Discussion', icon: 'MessageSquare', + inputs: CHATTER_INPUTS, }); ComponentRegistry.register('path', RecordPathRenderer, { @@ -330,6 +365,16 @@ ComponentRegistry.register('quick_actions', RecordQuickActionsRenderer, { category: 'record', label: 'Quick Actions', icon: 'Zap', + inputs: [ + { name: 'actionNames', type: 'array', label: 'Actions', description: 'Action names to expose, in order (else every action declared for the object at this location)' }, + { name: 'requiredPermissions', type: 'array', label: 'Required Permissions', description: 'Hide the whole bar unless the user holds these permissions' }, + // Derived from the spec's own vocabulary rather than restated — #3019. + { name: 'location', type: 'enum', label: 'Location', enum: [...ACTION_LOCATIONS], defaultValue: 'record_header', description: 'Which declared action location this bar renders' }, + { name: 'align', type: 'enum', label: 'Align', enum: ['start', 'center', 'end'], defaultValue: 'end' }, + { name: 'inline', type: 'boolean', label: 'Inline', description: 'Render in the flow instead of folding into the record header' }, + { name: 'variant', type: 'string', label: 'Button Variant', defaultValue: 'default', description: 'Passed to the Button primitive; a per-action variant overrides it' }, + { name: 'size', type: 'string', label: 'Button Size', defaultValue: 'sm', description: 'Passed to the Button primitive; a per-action size overrides it' }, + ], }); ComponentRegistry.register('history', RecordHistoryRenderer, { @@ -338,6 +383,11 @@ ComponentRegistry.register('history', RecordHistoryRenderer, { category: 'record', label: 'History Timeline', icon: 'Clock', + inputs: [ + { name: 'limit', type: 'number', label: 'Limit', defaultValue: 50, description: 'Maximum history entries to display' }, + { name: 'emptyText', type: 'string', label: 'Empty Text', description: 'Copy shown when the record has no history' }, + { name: 'unknownUserText', type: 'string', label: 'Unknown User Text', description: 'Copy substituted when an entry has no resolvable actor' }, + ], }); ComponentRegistry.register('reference_rail', RecordReferenceRailRenderer, { @@ -346,6 +396,9 @@ ComponentRegistry.register('reference_rail', RecordReferenceRailRenderer, { category: 'record', label: 'Reference Rail', icon: 'PanelRight', + inputs: [ + { name: 'hideEmpty', type: 'boolean', label: 'Hide When Empty', defaultValue: true, description: 'Drop the rail entirely when no entries resolve' }, + ], }); ComponentRegistry.register('alert', RecordAlertRenderer, { @@ -354,6 +407,16 @@ ComponentRegistry.register('alert', RecordAlertRenderer, { category: 'record', label: 'Alert Banner', icon: 'AlertTriangle', + inputs: [ + { name: 'severity', type: 'enum', label: 'Severity', enum: ['info', 'warning', 'error', 'success'], defaultValue: 'info' }, + { name: 'title', type: 'string', label: 'Title', description: 'Accepts an inline translation map ({ en, "zh-CN", … })' }, + { name: 'body', type: 'string', label: 'Body', description: 'Accepts an inline translation map ({ en, "zh-CN", … })' }, + { name: 'visible', type: 'string', label: 'Visible When', description: 'Expression gating the banner against the current record' }, + { name: 'icon', type: 'string', label: 'Icon', description: 'Lucide icon name; defaults to the severity icon' }, + { name: 'action', type: 'object', label: 'Call to Action', description: '{ actionName, label?, variant? } — the action the banner offers' }, + { name: 'dismissible', type: 'boolean', label: 'Dismissible' }, + { name: 'dismissKey', type: 'string', label: 'Dismiss Key', description: 'Stable key the dismissal is remembered under' }, + ], }); // ADR-0056 P1 — the `permission-facet-link` field widget renders a