From ef1c0725b03c029efa979abcbcd593748b59962b Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:05:46 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(spec):=20`FormSection.pane`=20?= =?UTF-8?q?=E2=80=94=20explicit=20split-pane=20placement=20(objectui#2153?= =?UTF-8?q?=20follow-up)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `type: 'split'` form view had no way to say which pane a section renders in: the renderer hardcoded "first section left, everything else right". That positional rule is invisible in the metadata, so reordering sections silently moved them across the divider, and an author (human or AI) could not place two sections on the left at all. FormSectionSchema gains an optional `pane: 'primary' | 'secondary'`: - explicit and PER SECTION, so placement survives reordering and an agent editing the view can see — and must preserve — where each section lives; - omitted → the legacy positional rule (first section primary, others secondary), so keyless metadata keeps its exact layout; - split-only, enforced loudly: a FormViewSchema refinement rejects `pane` on any other form type at parse (legacy `groups` alias and defaulted `type: 'simple'` included). "Accepted but ignored" is the failure mode this key must never have — a silent no-op reads as working, especially to an AI author. Verified that zod 4 keeps refinements through `.extend()`, so the flattened runtime-overlay variant in ViewMetadataSchema enforces it too; - strict two-value enum — a typo ('left') is a parse error, not free text. The 'split' enum comment claimed "Master-Detail split"; master-detail already has two homes (`subforms` on the form, related lists on record pages), so the comment now states split's non-redundant meaning: side-by-side resizable panes with sections placed via `section.pane`. The showcase task form's `split` view declared a single section — which renders as a plain, unsplit form — and now demonstrates the feature: two sections with explicit panes. `authorable-surface.json` regenerated (one new entry). Renderer support ships in ObjectUI (SplitForm → FormSchema.fieldPanes, whose pane keys are already named primary/secondary — a 1:1 mapping). Co-Authored-By: Claude Opus 5 --- .changeset/form-section-pane.md | 39 ++++++++++++ .../app-showcase/src/ui/views/task.view.ts | 7 ++- packages/spec/authorable-surface.json | 1 + packages/spec/src/ui/view.test.ts | 60 +++++++++++++++++++ packages/spec/src/ui/view.zod.ts | 36 ++++++++++- 5 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 .changeset/form-section-pane.md diff --git a/.changeset/form-section-pane.md b/.changeset/form-section-pane.md new file mode 100644 index 0000000000..b47d4daa91 --- /dev/null +++ b/.changeset/form-section-pane.md @@ -0,0 +1,39 @@ +--- +"@objectstack/spec": minor +"@objectstack/example-showcase": patch +--- + +feat(spec): `FormSection.pane` — explicit split-pane placement (objectui#2153 follow-up) + +A `type: 'split'` form view had no way to say which pane a section renders in: +the renderer hardcoded "first section left, everything else right". That +positional rule is invisible in the metadata — nothing in the JSON records the +assignment — so reordering sections silently moved them across the divider, and +an author (human or AI) could not place two sections side by side on the left at +all. + +`FormSectionSchema` gains an optional `pane: 'primary' | 'secondary'`: + +- **Explicit and per-section**, so placement survives reordering and an agent + editing the view can see — and must preserve — where each section lives. +- **Omitted → the legacy rule** (first section `primary`, others `secondary`), + so existing keyless metadata keeps its exact layout. +- **Split-only, enforced loudly**: a `FormViewSchema` refinement rejects `pane` + on any other form type at parse (covering the legacy `groups` alias and the + defaulted `type: 'simple'`). "Accepted but ignored" is the failure mode this + key must never have — a silent no-op reads as working, especially to an AI + author. zod 4 keeps refinements through `.extend()`, so the flattened + runtime-overlay variant in `ViewMetadataSchema` enforces it too. +- Strict two-value enum, not free text — a typo (`'left'`) is a parse error. + +The `'split'` type's enum comment claimed "Master-Detail split"; master-detail +already has two homes (`subforms` on the form, related lists on record pages), +so the comment now states split's actual, non-redundant meaning: side-by-side +resizable panes with sections placed via `section.pane`. + +The showcase task form's `split` view previously declared a single section — +which renders as a plain (unsplit) form — and now demonstrates the feature: +two sections with explicit panes. + +Renderer support ships in ObjectUI (`SplitForm` → `FormSchema.fieldPanes`, +whose pane keys are already named `primary`/`secondary` — a 1:1 mapping). diff --git a/examples/app-showcase/src/ui/views/task.view.ts b/examples/app-showcase/src/ui/views/task.view.ts index 98f4e0be4e..02ffe5cbba 100644 --- a/examples/app-showcase/src/ui/views/task.view.ts +++ b/examples/app-showcase/src/ui/views/task.view.ts @@ -305,11 +305,14 @@ export const TaskViews = defineView({ ], }, - // split ── master-detail split pane ────────────────────────────────── + // split ── side-by-side resizable panes; `pane` places each section ── split: { type: 'split', data, - sections: [{ label: 'Task', columns: 1, fields: ['title', 'status', 'assignee'] }], + sections: [ + { name: 'split_task', label: 'Task', pane: 'primary', columns: 1, fields: ['title', 'status', 'assignee'] }, + { name: 'split_schedule', label: 'Schedule', pane: 'secondary', columns: 1, fields: ['start_date', 'due_date', 'progress'] }, + ], }, // drawer ── side panel quick edit ──────────────────────────────────── diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index 0477710954..a477873c5a 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -7906,6 +7906,7 @@ "ui/FormSection:fields", "ui/FormSection:label", "ui/FormSection:name", + "ui/FormSection:pane", "ui/FormSection:visibleOn", "ui/FormSection:visibleWhen", "ui/FormView:allowSkip", diff --git a/packages/spec/src/ui/view.test.ts b/packages/spec/src/ui/view.test.ts index cb5a0bd782..d73fdc2cb5 100644 --- a/packages/spec/src/ui/view.test.ts +++ b/packages/spec/src/ui/view.test.ts @@ -591,6 +591,66 @@ describe('FormViewSchema', () => { sections: [{ fields: ['name'] }], })).not.toThrow(); }); + + // `section.pane` — explicit split placement. Explicit-per-section so the + // assignment is visible in the metadata and survives reordering (the legacy + // rule was positional: first section left, rest right — invisible, and a + // reorder silently moved sections across the divider). + describe('section.pane (split placement)', () => { + it('accepts pane assignments on a split form', () => { + const result = FormViewSchema.parse({ + type: 'split', + sections: [ + { label: 'Task', pane: 'primary', fields: ['title'] }, + { label: 'Schedule', pane: 'secondary', fields: ['due_date'] }, + // Omitted pane is fine — the renderer defaults by position. + { label: 'Notes', fields: ['notes'] }, + ], + }); + expect(result.sections?.map((s) => s.pane)).toEqual(['primary', 'secondary', undefined]); + }); + + it('rejects a typo pane value (strict enum, not free text)', () => { + expect(() => FormViewSchema.parse({ + type: 'split', + sections: [{ label: 'Task', pane: 'left', fields: ['title'] }], + })).toThrow(); + }); + + it('rejects pane on a non-split form instead of silently ignoring it', () => { + // "Accepted but ignored" is the failure mode this key must never have — + // especially for AI-authored metadata, where a no-op reads as working. + const result = FormViewSchema.safeParse({ + type: 'tabbed', + sections: [ + { label: 'Details', fields: ['name'] }, + { label: 'Advanced', pane: 'secondary', fields: ['settings'] }, + ], + }); + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues.find((i) => i.path.join('.') === 'sections.1.pane'); + expect(issue?.message).toMatch(/only valid on `type: 'split'`/); + } + }); + + it('rejects pane on the legacy `groups` alias the same way', () => { + const result = FormViewSchema.safeParse({ + type: 'simple', + groups: [{ label: 'Account', pane: 'primary', fields: ['account_name'] }], + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some((i) => i.path.join('.') === 'groups.0.pane')).toBe(true); + } + }); + + it('defaulted `type` counts as non-split (a forgotten type errors loudly)', () => { + expect(FormViewSchema.safeParse({ + sections: [{ label: 'Task', pane: 'primary', fields: ['title'] }], + }).success).toBe(false); + }); + }); }); describe('ViewSchema', () => { diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 8c2a048913..15e498285b 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -937,6 +937,19 @@ export const FormSectionSchema = lazySchema(() => z.object({ z.literal(3), z.literal(4), ]).default(1).transform(val => (typeof val === 'string' ? parseInt(val) : val) as 1 | 2 | 3 | 4), + /** + * Which pane of a split form this section renders in (`type: 'split'` only — + * any other form type rejects the key at parse, see the FormViewSchema + * refinement). Placement is explicit and PER SECTION so it survives + * reordering: with the old first-vs-rest positional rule the assignment was + * invisible in the metadata, and an edit that reordered sections silently + * moved them across the divider. Defaults by position when omitted: the + * first section renders in `primary`, every other section in `secondary` + * (exactly the legacy rule, so keyless metadata keeps its layout). + */ + pane: z.enum(['primary', 'secondary']).optional().describe( + "Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'.", + ), fields: z.array(z.union([ z.string(), // Legacy: simple field name FormFieldSchema, // Enhanced: detailed field config @@ -979,7 +992,7 @@ export const FormViewSchema = lazySchema(() => z.object({ 'simple', // Single column or sections 'tabbed', // Tabs 'wizard', // Step by step - 'split', // Master-Detail split + 'split', // Side-by-side resizable panes; sections placed via `section.pane` 'drawer', // Side panel 'modal' // Dialog ]).default('simple'), @@ -1115,6 +1128,27 @@ export const FormViewSchema = lazySchema(() => z.object({ 'Delete the key. The form renderer emits its own semantic markup; report gaps as ' + 'renderer issues rather than per-view attribute overrides.', ), +}).superRefine((view, ctx) => { + // `section.pane` is split-only vocabulary. On any other form type it would + // be a silent no-op — the worst failure mode for authored (and especially + // AI-authored) metadata, where "accepted but ignored" reads as working. + // Reject it loudly at parse instead. `.extend()` keeps this check (zod 4 + // attaches refinements to the schema), so the flattened runtime-overlay + // variant in ViewMetadataSchema enforces it too. + if (view.type === 'split') return; + for (const [key, sections] of [['sections', view.sections], ['groups', view.groups]] as const) { + sections?.forEach((section, index) => { + if (section?.pane != null) { + ctx.addIssue({ + code: 'custom', + path: [key, index, 'pane'], + message: + `\`pane\` places a section in a split pane and is only valid on \`type: 'split'\` ` + + `form views (this form is '${view.type}'). Remove the key or change the form type.`, + }); + } + }); + } })); /** From fb2a90a0e0ca4e630474de4c5363302c862d1f0c Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:08:30 +0800 Subject: [PATCH 2/2] docs(spec): regenerate the view reference for FormSection.pane check:docs gates generated reference docs against the spec; the new key needs its generated row committed alongside the schema change. Co-Authored-By: Claude Opus 5 --- content/docs/references/ui/view.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 2ac0cd981d..e273a5e1ed 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -182,6 +182,7 @@ Column footer summary configuration | **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — section shown only when TRUE. Root: `record`+`current_user` (runtime forms) or `data` (metadata forms). | | **visibleOn** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | optional | [DEPRECATED → `visibleWhen`] Visibility predicate (CEL). Hides the whole section when false. Normalized to `visibleWhen` at parse. | | **columns** | `Enum<'1' \| '2' \| '3' \| '4'> \| '1' \| '2' \| '3' \| '4'` | optional | | +| **pane** | `Enum<'primary' \| 'secondary'>` | optional | Split pane this section renders in (split forms only; a parse error elsewhere). Omitted → first section 'primary', others 'secondary'. | | **fields** | `string \| { field: string; type?: Enum<'text' \| 'textarea' \| 'email' \| 'url' \| 'phone' \| 'password' \| 'secret' \| 'markdown' \| 'html' \| 'richtext' \| 'number' \| 'currency' \| 'percent' \| 'date' \| 'datetime' \| 'time' \| 'boolean' \| 'toggle' \| 'select' \| 'multiselect' \| 'radio' \| 'checkboxes' \| 'lookup' \| 'master_detail' \| 'tree' \| 'user' \| 'image' \| 'file' \| 'avatar' \| 'video' \| 'audio' \| 'formula' \| 'summary' \| 'autonumber' \| 'composite' \| 'repeater' \| 'record' \| 'location' \| 'address' \| 'code' \| 'json' \| 'color' \| 'rating' \| 'slider' \| 'signature' \| 'qrcode' \| 'progress' \| 'tags' \| 'vector'>; options?: { label: string; value: string; color?: string; default?: boolean; … }[]; reference?: string; … }[]` | ✅ | |