Skip to content

Commit c1e3a65

Browse files
os-zhuangclaude
andauthored
feat(spec): source the react-tier component contract from the spec schemas (#2478)
Per the platform-author steer: the standard components are already defined in the spec (FormView/ListView in view.zod, RecordDetails/Highlights/RelatedList/Path in component.zod, Chart in chart.zod) — so generate the AI contract FROM them instead of hand-authoring. - packages/spec/src/ui/react-blocks.ts — the component-type -> spec-schema index (the 'completion' that lets tooling enumerate a block's schema) + a thin React-interaction overlay (binding/controlled/callback — objectName, recordId, mode, onSuccess, onRowClick, …, which are inherently React and absent from the declarative schema). Curated via per-block dataProps allowlists (ADR-0080). - scripts/build-react-blocks-contract.ts — generator: z.toJSONSchema(spec schema) -> data props (authoritative, with the spec's own descriptions); merge overlay; emit the AI reference (.md) + machine contract (.json). - Replaces the Phase-1 hand-authored contract (drift-free, spec-sourced now). - api-surface snapshot updated (+4 additive exports, 0 breaking). Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent dc5ec46 commit c1e3a65

10 files changed

Lines changed: 910 additions & 165 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
Add the react-tier component contract index (`REACT_BLOCKS`, ADR-0081):
6+
`packages/spec/src/ui/react-blocks.ts` maps each curated public block injected
7+
into `kind:'react'` page source to the **spec zod schema** that defines its
8+
declarative config props (FormView, ListView, RecordDetails/Highlights/
9+
RelatedList/Path, Chart) plus a hand-authored React-interaction overlay
10+
(binding/controlled/callback — objectName, recordId, mode, onSuccess,
11+
onRowClick, …). `pnpm --filter @objectstack/spec gen:react-blocks` generates the
12+
AI-facing contract (skills/objectstack-ui/references/react-blocks.md + .json)
13+
from it — the `data` props come from the spec (single source, no re-authoring).

packages/spec/api-surface.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3266,6 +3266,10 @@
32663266
"PortalUrlNavItemSchema (const)",
32673267
"PortalViewNavItem (type)",
32683268
"PortalViewNavItemSchema (const)",
3269+
"REACT_BLOCKS (const)",
3270+
"ReactBlockDef (interface)",
3271+
"ReactInteractionProp (interface)",
3272+
"ReactPropKind (type)",
32693273
"RecordActivityProps (const)",
32703274
"RecordChatterProps (const)",
32713275
"RecordDetailsProps (const)",

packages/spec/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,8 @@
195195
"test": "vitest run",
196196
"test:watch": "vitest",
197197
"test:coverage": "vitest run --coverage",
198-
"check:liveness": "tsx scripts/liveness/check-liveness.mts"
198+
"check:liveness": "tsx scripts/liveness/check-liveness.mts",
199+
"gen:react-blocks": "tsx scripts/build-react-blocks-contract.ts"
199200
},
200201
"keywords": [
201202
"objectstack",
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Generates the react-tier component contract from packages/spec/src/ui/
4+
// react-blocks.ts: the `data` (config) props are read from each block's SPEC
5+
// zod schema via z.toJSONSchema (single source — no re-authoring); the
6+
// binding/controlled/callback props come from the hand-authored interaction
7+
// overlay. Emits:
8+
// - skills/objectstack-ui/contracts/react-blocks.contract.json (machine)
9+
// - skills/objectstack-ui/references/react-blocks.md (AI-facing)
10+
//
11+
// Run: pnpm --filter @objectstack/spec gen:react-blocks
12+
13+
process.env.OS_EAGER_SCHEMAS = '1';
14+
15+
import fs from 'fs';
16+
import path from 'path';
17+
import { z } from 'zod';
18+
import { REACT_BLOCKS, type ReactInteractionProp } from '../src/ui/react-blocks';
19+
20+
const REPO = path.resolve(__dirname, '../../..');
21+
const OUT_JSON = path.join(REPO, 'skills/objectstack-ui/contracts/react-blocks.contract.json');
22+
const OUT_MD = path.join(REPO, 'skills/objectstack-ui/references/react-blocks.md');
23+
24+
// ---- JSON-schema prop extraction -----------------------------------------
25+
function resolveRoot(js: any): any {
26+
// zod v4 may wrap the root in { $ref: '#/$defs/X', $defs: { X: {...} } }.
27+
if (js && js.$ref && js.$defs) {
28+
const key = String(js.$ref).split('/').pop()!;
29+
return js.$defs[key] ?? js;
30+
}
31+
return js;
32+
}
33+
34+
function renderType(node: any): string {
35+
if (!node || typeof node !== 'object') return 'any';
36+
if (Array.isArray(node.enum)) return node.enum.map((v: any) => (typeof v === 'string' ? `'${v}'` : String(v))).join(' | ');
37+
if (Array.isArray(node.anyOf) || Array.isArray(node.oneOf)) {
38+
const alts = (node.anyOf ?? node.oneOf).map(renderType).filter((t: string) => t && t !== 'any');
39+
return [...new Set(alts)].join(' | ') || 'any';
40+
}
41+
if (node.type === 'array') return `${renderType(node.items)}[]`;
42+
if (node.$ref) return String(node.$ref).split('/').pop() ?? 'object';
43+
if (node.type) return Array.isArray(node.type) ? node.type.join(' | ') : String(node.type);
44+
if (node.properties) return 'object';
45+
return 'any';
46+
}
47+
48+
const clip = (s: unknown, n = 160): string => {
49+
const t = String(s ?? '').replace(/\s+/g, ' ').trim();
50+
return t.length > n ? t.slice(0, n - 1) + '…' : t;
51+
};
52+
53+
interface Prop { name: string; type: string; kind: string; required: boolean; description: string }
54+
55+
function dataProps(schema: any, allow?: string[]): Prop[] {
56+
let js: any;
57+
try {
58+
js = resolveRoot(z.toJSONSchema(schema, { unrepresentable: 'any' } as any));
59+
} catch {
60+
return [];
61+
}
62+
const props = js?.properties ?? {};
63+
const required: string[] = Array.isArray(js?.required) ? js.required : [];
64+
const SKIP = new Set(['aria', 'type', 'id', 'className', 'style']);
65+
let entries = Object.entries(props).filter(([name]) => !SKIP.has(name));
66+
if (allow && allow.length) {
67+
const order = new Map(allow.map((n, i) => [n, i]));
68+
entries = entries.filter(([n]) => order.has(n)).sort((a, b) => order.get(a[0])! - order.get(b[0])!);
69+
}
70+
return entries
71+
.map(([name, node]: [string, any]) => ({
72+
name,
73+
type: renderType(node),
74+
kind: 'data',
75+
required: required.includes(name),
76+
description: clip(node?.description),
77+
}));
78+
}
79+
80+
function mergeProps(dataPs: Prop[], overlay: ReactInteractionProp[]): Prop[] {
81+
const out: Prop[] = overlay.map((o) => ({ name: o.name, type: o.type, kind: o.kind, required: !!o.required, description: o.description }));
82+
const seen = new Set(out.map((p) => p.name));
83+
for (const d of dataPs) if (!seen.has(d.name)) out.push(d);
84+
return out;
85+
}
86+
87+
// ---- build ----------------------------------------------------------------
88+
const KIND_ORDER: Record<string, number> = { binding: 0, controlled: 1, callback: 2, data: 3 };
89+
const blocks = REACT_BLOCKS.map((b) => {
90+
const props = mergeProps(b.schema ? dataProps(b.schema, b.dataProps) : [], b.interactions).sort(
91+
(a, z2) => (KIND_ORDER[a.kind] - KIND_ORDER[z2.kind]) || (Number(z2.required) - Number(a.required)),
92+
);
93+
return { tag: b.tag, schemaType: b.schemaType, summary: b.summary, specSchema: b.schema ? true : false, props };
94+
});
95+
96+
const contract = {
97+
version: 2,
98+
adr: 'ADR-0081',
99+
source: 'GENERATED from packages/spec/src/ui/react-blocks.ts — data props from the spec zod schemas, binding/controlled/callback from the React overlay.',
100+
note: "Props each component accepts in kind:'react' page source. Reference blocks by their PascalCase tag. kind: data=declarative config (from the spec schema) · binding=connects to data · controlled=React state · callback=React function. Layout = plain HTML+Tailwind; these blocks are for DATA. Live data: const adapter = useAdapter(); adapter.find/findOne/create/update.",
101+
blocks,
102+
};
103+
fs.writeFileSync(OUT_JSON, JSON.stringify(contract, null, 2) + '\n');
104+
105+
// markdown
106+
const esc = (s: string) => String(s).replace(/\|/g, '\\|');
107+
const L: string[] = [];
108+
L.push('---');
109+
L.push('title: React-tier component contract');
110+
L.push("description: Props each injected component accepts in kind:'react' page source (ADR-0081). GENERATED from packages/spec/src/ui/react-blocks.ts — do not edit by hand.");
111+
L.push('---');
112+
L.push('');
113+
L.push('{/* GENERATED by packages/spec/scripts/build-react-blocks-contract.ts — do not edit. */}');
114+
L.push('');
115+
L.push(`# React-tier component contract (${contract.adr})`);
116+
L.push('');
117+
L.push(contract.note);
118+
L.push('');
119+
L.push('**kind**: `data` = declarative config (from the spec schema — the authoritative source) · `binding` = connects the block to data · `controlled` = drive from React state · `callback` = a React function the block calls.');
120+
L.push('');
121+
for (const b of blocks) {
122+
L.push(`## \`<${b.tag}>\` — \`${b.schemaType}\`${b.specSchema ? '' : ' *(no spec schema — overlay only)*'}`);
123+
L.push('');
124+
L.push(b.summary);
125+
L.push('');
126+
L.push('| prop | type | kind | required | description |');
127+
L.push('|------|------|------|:--------:|-------------|');
128+
for (const p of b.props) {
129+
L.push(`| \`${p.name}\` | \`${esc(p.type)}\` | ${p.kind} | ${p.required ? '✓' : ''} | ${esc(p.description)} |`);
130+
}
131+
L.push('');
132+
}
133+
L.push('## Injected scope (closure variables, reference directly — not props)');
134+
L.push('');
135+
L.push('`React` · `useAdapter` · `data` · `variables` · `page`. Kanban/calendar/gantt/timeline/map of an object = `<ListView navigation={…} />` with the matching visualization, or `<Block type="object-kanban" …/>`.');
136+
L.push('');
137+
fs.writeFileSync(OUT_MD, L.join('\n'));
138+
139+
console.log(`✅ react-blocks contract: ${blocks.length} blocks → ${path.relative(REPO, OUT_JSON)} + ${path.relative(REPO, OUT_MD)}`);
140+
for (const b of blocks) console.log(` <${b.tag}> ${b.props.length} props`);

packages/spec/src/ui/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export * from './action.zod';
2929
export * from './page.zod';
3030
export * from './widget.zod';
3131
export * from './component.zod';
32+
export * from './react-blocks';
3233
export * from './theme.zod';
3334
export * from './touch.zod';
3435
export * from './offline.zod';
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// React-tier component index (ADR-0081). Maps each curated public block that is
4+
// injected into `kind:'react'` page source to (a) the SPEC zod schema that
5+
// already defines its declarative/config props — the authoritative source, do
6+
// not re-author — and (b) a thin hand-authored React-interaction overlay: the
7+
// binding/controlled/callback props that are inherently React (objectName,
8+
// recordId, mode, onSuccess, onRowClick, …) and so are absent from the
9+
// declarative metadata schema.
10+
//
11+
// The contract the AI authors against (skills/objectstack-ui/references/
12+
// react-blocks.md + .contract.json) is GENERATED from this index by
13+
// `scripts/build-react-blocks-contract.ts` — never hand-edited.
14+
15+
import type { ZodTypeAny } from 'zod';
16+
import { ListViewSchema, FormViewSchema } from './view.zod';
17+
import {
18+
RecordDetailsProps,
19+
RecordRelatedListProps,
20+
RecordHighlightsProps,
21+
RecordPathProps,
22+
} from './component.zod';
23+
import { ChartConfigSchema } from './chart.zod';
24+
25+
export type ReactPropKind = 'data' | 'binding' | 'controlled' | 'callback';
26+
27+
export interface ReactInteractionProp {
28+
name: string;
29+
type: string;
30+
kind: 'binding' | 'controlled' | 'callback';
31+
required?: boolean;
32+
description: string;
33+
}
34+
35+
export interface ReactBlockDef {
36+
/** PascalCase name the author writes in JSX, e.g. `<ObjectForm>`. */
37+
tag: string;
38+
/** The registry/render type, e.g. `object-form`. */
39+
schemaType: string;
40+
summary: string;
41+
/**
42+
* Spec zod schema that defines this block's declarative (config) props. The
43+
* generator extracts these as `data` props — authoritative, with descriptions.
44+
* Omit for blocks with no spec schema (then only the overlay is published).
45+
*/
46+
schema?: ZodTypeAny;
47+
/**
48+
* Curate which spec-schema props to surface (high-signal subset; ADR-0080
49+
* "capability ≠ contract"). Definitions still come from the schema — this only
50+
* selects + orders. Omit to surface all of the schema's props.
51+
*/
52+
dataProps?: string[];
53+
/** React-only props absent from the declarative schema (hand-authored). */
54+
interactions: ReactInteractionProp[];
55+
}
56+
57+
// Shared overlays ----------------------------------------------------------
58+
const OBJECT_NAME: ReactInteractionProp = {
59+
name: 'objectName',
60+
type: 'string',
61+
kind: 'binding',
62+
required: true,
63+
description: 'The object this block binds to (server-connected).',
64+
};
65+
66+
export const REACT_BLOCKS: ReactBlockDef[] = [
67+
{
68+
tag: 'ObjectForm',
69+
schemaType: 'object-form',
70+
summary: "Server-connected create/edit/view form for one object. Config props come from the spec FormView schema; bind + wire it with the React props below.",
71+
schema: FormViewSchema,
72+
dataProps: ['sections', 'subforms', 'submitBehavior', 'defaultSort'],
73+
interactions: [
74+
OBJECT_NAME,
75+
{ name: 'mode', type: "'create' | 'edit' | 'view'", kind: 'controlled', description: 'Create a new record, or edit/view an existing one — drive from React state.' },
76+
{ name: 'formType', type: "'simple' | 'tabbed' | 'wizard' | 'split' | 'drawer' | 'modal'", kind: 'binding', description: 'Form presentation; drawer/modal render the form in a built-in overlay (use drawerSide/drawerWidth/modalSize).' },
77+
{ name: 'recordId', type: 'string | number', kind: 'controlled', description: 'Which record to load (edit/view). The hook for master/detail.' },
78+
{ name: 'fields', type: 'string[]', kind: 'binding', description: 'Limit/order the fields shown (defaults to the object form fields).' },
79+
{ name: 'initialValues', type: 'Record<string, any>', kind: 'binding', description: 'Prefill values in create mode.' },
80+
{ name: 'onSuccess', type: '(record) => void', kind: 'callback', description: 'Called after a successful save with the saved record (e.g. close a panel + reload).' },
81+
{ name: 'onError', type: '(error: Error) => void', kind: 'callback', description: 'Called when the save fails.' },
82+
{ name: 'onCancel', type: '() => void', kind: 'callback', description: 'Called when the user cancels.' },
83+
{ name: 'submitHandler', type: '(values) => any | Promise<any>', kind: 'callback', description: 'Custom persistence instead of the default create/update.' },
84+
],
85+
},
86+
{
87+
tag: 'ListView',
88+
schemaType: 'list-view',
89+
summary: "Server-connected object table with toolbar and switchable visualizations (grid/kanban/calendar/gantt/…). Config props come from the spec ListView schema.",
90+
schema: ListViewSchema,
91+
dataProps: ['columns', 'sort', 'searchableFields', 'userFilters', 'pagination', 'grouping', 'rowHeight', 'selection', 'rowActions', 'inlineEdit'],
92+
interactions: [
93+
OBJECT_NAME,
94+
{ name: 'viewType', type: "'grid' | 'kanban' | 'gallery' | 'calendar' | 'timeline' | 'gantt' | 'map'", kind: 'binding', description: 'Which visualization to render (default grid). How you get a kanban/calendar/gantt of the object.' },
95+
{ name: 'filters', type: "FilterArray e.g. ['status','=','active']", kind: 'controlled', description: 'ObjectQL base filter; drive from React state for tabbed/searched lists. ([field, op, value]; ops =, !=, >, <, contains, in; compound: [\"and\", […], […]]).' },
96+
{ name: 'navigation', type: "{ mode: 'page' | 'drawer' | 'modal' | 'split' | 'none' }", kind: 'binding', description: 'What a row click does. Use { mode: \"none\" } when you handle clicks via onRowClick.' },
97+
{ name: 'onRowClick', type: '(record) => void', kind: 'callback', description: "Called with the clicked row's record — the hook for master/detail." },
98+
{ name: 'onNavigate', type: "(recordId, action: 'view' | 'edit') => void", kind: 'callback', description: 'Called for page-level navigation.' },
99+
],
100+
},
101+
{
102+
tag: 'ObjectChart',
103+
schemaType: 'object-chart',
104+
summary: 'Chart over an object’s aggregated data. Config props come from the spec Chart config schema.',
105+
schema: ChartConfigSchema,
106+
dataProps: ['title', 'series', 'xAxis', 'yAxis', 'colors', 'showLegend'],
107+
interactions: [
108+
OBJECT_NAME,
109+
{ name: 'filter', type: 'FilterArray', kind: 'controlled', description: 'ObjectQL filter scoping the data; drive from React state.' },
110+
{ name: 'aggregate', type: '{ field, function, groupBy }', kind: 'binding', description: 'Aggregation: function (sum/avg/count) over field, grouped by groupBy.' },
111+
],
112+
},
113+
{
114+
tag: 'RecordDetails',
115+
schemaType: 'record:details',
116+
summary: 'Field-detail panel for the bound record. Config props from the spec RecordDetails schema.',
117+
schema: RecordDetailsProps,
118+
interactions: [
119+
{ name: 'recordId', type: 'string | number', kind: 'controlled', description: 'The record to show.' },
120+
{ name: 'objectName', type: 'string', kind: 'binding', description: 'The record’s object.' },
121+
],
122+
},
123+
{
124+
tag: 'RecordHighlights',
125+
schemaType: 'record:highlights',
126+
summary: 'Highlights panel — a strip of key fields. Config props from the spec RecordHighlights schema.',
127+
schema: RecordHighlightsProps,
128+
interactions: [
129+
{ name: 'recordId', type: 'string | number', kind: 'controlled', description: 'The record to summarize.' },
130+
{ name: 'objectName', type: 'string', kind: 'binding', description: 'The record’s object.' },
131+
],
132+
},
133+
{
134+
tag: 'RecordRelatedList',
135+
schemaType: 'record:related_list',
136+
summary: 'Related child records via a lookup. Config props from the spec RecordRelatedList schema.',
137+
schema: RecordRelatedListProps,
138+
interactions: [
139+
{ name: 'recordId', type: 'string | number', kind: 'controlled', description: 'The parent record.' },
140+
{ name: 'objectName', type: 'string', kind: 'binding', description: 'The parent object.' },
141+
],
142+
},
143+
{
144+
tag: 'RecordPath',
145+
schemaType: 'record:path',
146+
summary: 'Stage/progress bar driven by a status field. Config props from the spec RecordPath schema.',
147+
schema: RecordPathProps,
148+
interactions: [
149+
{ name: 'recordId', type: 'string | number', kind: 'controlled', description: 'The record whose stage to show.' },
150+
{ name: 'objectName', type: 'string', kind: 'binding', description: 'The record’s object.' },
151+
],
152+
},
153+
{
154+
tag: 'Block',
155+
schemaType: '(any)',
156+
summary: 'Escape hatch — render any registered component by type. <Block type="object-kanban" objectName="task" /> etc.',
157+
interactions: [
158+
{ name: 'type', type: 'string', kind: 'binding', required: true, description: 'The registered component type to render.' },
159+
],
160+
},
161+
];

skills/objectstack-ui/SKILL.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -782,8 +782,10 @@ blocks for data. Real component props/callbacks flow through — e.g. `<ObjectFo
782782
> **[React-tier component contract](./references/react-blocks.md)**, generated from
783783
> [`contracts/react-blocks.contract.json`](./contracts/react-blocks.contract.json).
784784
> It is the authoritative answer to "what props does `<ObjectForm>`/`<ListView>`/…
785-
> take?" — author against it, not from memory. Regenerate after editing the JSON
786-
> with `node skills/objectstack-ui/contracts/build-ref.mjs`.
785+
> take?" — author against it, not from memory. The `data` props are sourced from the platform's spec schemas (FormView,
786+
> ListView, RecordDetails, Chart, …) — the same protocol the server validates;
787+
> `binding`/`controlled`/`callback` are the React overlay. Regenerate with
788+
> `pnpm --filter @objectstack/spec gen:react-blocks`.
787789
788790
Master/detail (click a row → edit it → save refreshes the list):
789791

0 commit comments

Comments
 (0)