From f57da16e6b70be3a2fa36d4cfff0b9cafaf382d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 15:11:42 +0000 Subject: [PATCH 01/12] wip(spec): refuse isDefault off a record page; close page template to the registered set, refuse unplaced regions Claude-Session: https://claude.ai/code/session_01TC6DKN349m4i7okGWPe4RG Co-authored-by: Claude --- packages/spec/src/ui/default-record-page.ts | 35 ++++ packages/spec/src/ui/index.ts | 5 +- packages/spec/src/ui/page-print.test.ts | 6 +- packages/spec/src/ui/page.zod.ts | 203 +++++++++++++++++--- 4 files changed, 223 insertions(+), 26 deletions(-) diff --git a/packages/spec/src/ui/default-record-page.ts b/packages/spec/src/ui/default-record-page.ts index 5530aac923d..c4cee6a3b13 100644 --- a/packages/spec/src/ui/default-record-page.ts +++ b/packages/spec/src/ui/default-record-page.ts @@ -72,3 +72,38 @@ export function defaultRecordPageObject(page: unknown): string | undefined { if (type !== 'record') return undefined; return typeof p.object === 'string' && p.object !== '' ? p.object : undefined; } + +/** + * The refusal of `isDefault: true` on a page {@link defaultRecordPageObject} + * does not place, or `undefined` when the page may carry what it declares. + * + * `isDefault` has one meaning: the default record page of the object a record + * page binds. On any other page nothing reads it — an app opens on its first + * navigation item and the root landing follows the APP's `isDefault`, never a + * page's — so the flag was accepted, stored and served while it decided + * nothing (maintainer ruling A on objectstack#23610, record 6190536588). Every + * door that admits a page asks this one function, so `os validate`, package + * registration and the metadata save door refuse the same pages with the same + * sentence: `PageSchema`'s parse (and so `defineStack` and the save door), and + * `@objectstack/objectql`'s registry for the pages a package registers + * without a parse. + * + * Reads a parsed page and an unparsed one alike, by the same declared keys + * {@link defaultRecordPageObject} reads. + */ +export function misplacedDefaultPageRefusal(page: unknown): string | undefined { + if (!page || typeof page !== 'object' || Array.isArray(page)) return undefined; + const p = page as Record; + if (p.isDefault !== true || defaultRecordPageObject(page) !== undefined) return undefined; + const type = p.type === undefined ? PAGE_TYPE_DEFAULT : p.type; + if (type === 'record') { + return '`isDefault: true` is refused on a record page bound to no `object`: it makes a record page the ' + + 'default record page of the object it binds, the page that object\'s record view opens. Bind the ' + + 'page (`object: \'\'`), or delete `isDefault`.'; + } + return `\`isDefault: true\` is refused on a \`type: '${String(type)}'\` page: it marks the default record page ` + + 'of an object, and nothing reads it on any other page. A page does not choose where an app opens — an ' + + 'app\'s landing page is its first navigation item (by `order`), so to land on this page put its entry ' + + 'first in the app\'s `navigation`; the root landing follows the app\'s own `isDefault`. Delete ' + + '`isDefault` from this page.'; +} diff --git a/packages/spec/src/ui/index.ts b/packages/spec/src/ui/index.ts index 995ecebdb48..2767063acdd 100644 --- a/packages/spec/src/ui/index.ts +++ b/packages/spec/src/ui/index.ts @@ -26,8 +26,9 @@ export * from './view.zod'; export * from './view-container-object'; // [#23015] Which object a page is the default record page of — the one // reading the stack contract, the registry and the metadata save door judge -// "one default record page per object per deployment" by. -export { defaultRecordPageObject } from './default-record-page'; +// "one default record page per object per deployment" by — and [#23610] the +// one refusal of `isDefault: true` on a page that reading does not place. +export { defaultRecordPageObject, misplacedDefaultPageRefusal } from './default-record-page'; // [#14556] List-view grouping is SERVER-SIDE (ruling A on objectui#7189): the // group header aggregate query and the per-group row page, compiled from the // view's `grouping` + column summaries + composed filter in the query AST's diff --git a/packages/spec/src/ui/page-print.test.ts b/packages/spec/src/ui/page-print.test.ts index 3aa2ffe1876..f2483b14e6f 100644 --- a/packages/spec/src/ui/page-print.test.ts +++ b/packages/spec/src/ui/page-print.test.ts @@ -128,10 +128,12 @@ describe('checkPagePrintComposition — a print page prints exactly the blocks i expect(issues[0].message).toMatch(/interfaceConfig\.allowPrinting/); }); - it('refuses `print` on a `utility` page — a floating panel, not a document — and names the admitted types', () => { + it('refuses `print` on a `utility` page — a tool page, not a document — and names the admitted types', () => { const issues = issuesOf({ ...BASE, type: 'utility', regions: REGIONS, print: {} }); expect(issues.map((i) => [i.path, i.code])).toEqual([['print', 'custom']]); - expect(issues[0].message).toMatch(/type: 'utility'.*floating panel.*not a document/s); + expect(issues[0].message).toMatch(/type: 'utility'.*holds a tool.*not a document/s); + // The console has no floating shell slot, so the refusal promises none. + expect(issues[0].message).not.toMatch(/floating/); expect(issues[0].message).toContain("`type: 'record'`, `'home'` or `'app'`"); }); diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index 4ceaa5dffaf..a6232a36d48 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -24,7 +24,7 @@ import { lazySchema } from '../shared/lazy-schema'; import { strictObject } from '../shared/strict-object'; import { ruleArrayFilterError } from './filter-rule-array'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; -import { PAGE_TYPE_DEFAULT } from './default-record-page'; +import { PAGE_TYPE_DEFAULT, misplacedDefaultPageRefusal } from './default-record-page'; /** * Shared history for this file (#4001). @@ -38,6 +38,61 @@ const PAGE_HISTORY = 'Until this shape was closed these were dropped silently — the page still rendered, ' + 'without whatever the key was meant to configure.'; +/** + * The page layout templates the console registers, and the regions each one + * places — the layout vocabulary, declared here so an author and `os validate` + * can see it (maintainer ruling A on objectstack#23610, record 6190536588). + * + * Measured against the renderer, objectui + * `packages/components/src/renderers/layout/page.tsx` (`TEMPLATE_REGISTRY` and + * each template's `findRegion` calls; region names match case-insensitively): + * + * - `default` — header / sidebar / main / aside / footer (sidebar and aside + * beside main, header and footer spanning it); + * - `header-sidebar-main` — header / sidebar / main; + * - `three-column` — the same five as `default`; + * - `dashboard` — header / footer, and an OPEN GRID: every other region, + * whatever its name, is a grid cell. By design it places any name. + * + * A region a template does not place is not dropped by the renderer — it is + * stacked under `main` at full width with its declared `width` ignored, which + * is the silent mis-layout this table exists to refuse + * ({@link checkPageRegionPlacement}). `full-width` is retired: it was a second + * name for `default` (the same component) — {@link PAGE_TEMPLATE_RETIRED_GUIDANCE}. + */ +export const PAGE_TEMPLATE_SLOTS = { + default: ['header', 'sidebar', 'main', 'aside', 'footer'], + 'header-sidebar-main': ['header', 'sidebar', 'main'], + 'three-column': ['header', 'sidebar', 'main', 'aside', 'footer'], + dashboard: ['header', 'footer'], +} as const satisfies Readonly>; + +/** The templates whose every other region is a grid cell — they place any region name. */ +export const PAGE_TEMPLATE_OPEN_GRID: readonly PageTemplate[] = ['dashboard']; + +/** A page layout template name — a key of {@link PAGE_TEMPLATE_SLOTS}. */ +export type PageTemplate = keyof typeof PAGE_TEMPLATE_SLOTS; + +// Retired template names → the prescription (an enum-value narrowing: no +// tombstone, the enum's own error map carries it). `//`, not JSDoc, on +// purpose — build-docs takes a file's first JSDoc per exported symbol. +const PAGE_TEMPLATE_RETIRED_GUIDANCE = { + 'full-width': + "Page `template: 'full-width'` was removed in @objectstack/spec 18 — it was a second name for " + + "`'default'`, drawn by the same layout. Use `template: 'default'` (or leave `template` out: it is " + + 'the default); the page renders exactly as before.', +} as const; + +/** + * Page Template Schema — the closed set of layout templates the console + * registers ({@link PAGE_TEMPLATE_SLOTS}). An unregistered name used to fall + * back to the page-type layout in silence; it is refused at the parse now. + */ +export const PageTemplateSchema = lazySchema(() => enumWithRetiredValues( + Object.keys(PAGE_TEMPLATE_SLOTS) as [PageTemplate, ...PageTemplate[]], + PAGE_TEMPLATE_RETIRED_GUIDANCE, +).describe("Page layout template — one the console registers: 'default' (header, sidebar, main, aside, footer), 'header-sidebar-main' (header, sidebar, main), 'three-column' (header, sidebar, main, aside, footer) or 'dashboard' (header, footer, and every other region a grid cell).")); + /** * Page Region Schema * A named region in the template where components are dropped. @@ -47,7 +102,7 @@ export const PageRegionSchema = lazySchema(() => strictObject({ history: PAGE_HISTORY, aliases: { id: 'name', region: 'name', children: 'components', items: 'components', content: 'components', size: 'width', span: 'width' }, }, { - name: z.string().describe('Region name (e.g. "sidebar", "main", "header")').meta({ title: 'Region' }), + name: z.string().describe("Region name — a slot the page's `template` places: header, sidebar, main, aside or footer on 'default' and 'three-column'; header, sidebar or main on 'header-sidebar-main'; any name on 'dashboard' (header and footer span it, every other region is a grid cell). A name the template does not place is refused.").meta({ title: 'Region' }), width: z.enum(['small', 'medium', 'large', 'full']).optional().meta({ title: 'Width' }), components: z.array(z.lazy(() => PageComponentSchema)).describe('Components in this region').meta({ title: 'Components' }) })); @@ -783,12 +838,22 @@ export const PageVariableSchema = lazySchema(() => strictObject({ * - `record` vs `record_detail`: `record` is a component-based layout page (FlexiPage style with regions), * `record_detail` is a field-display page showing all fields of a single record (Airtable style). * Use `record` for custom record pages with regions/components, `record_detail` for auto-generated detail views. - * - `home` vs `overview`: `home` is the platform-level landing page (tab landing), - * `overview` is an interface-level navigation hub with links/instructions. - * Use `home` for app-level landing, `overview` for in-interface navigation hubs. - * - `app` vs `utility` vs `blank`: `app` is an app-level page with navigation context, - * `utility` is a floating utility panel (e.g. notes, phone), `blank` is a free-form canvas - * for custom composition. They serve distinct layout purposes. + * - `home`, `app` and `utility` render alike — the page's regions, laid out by + * its `template` — and differ only in the content width the console gives + * them: `home` the widest, then `app`, then `utility` (objectui + * `packages/components/src/renderers/layout/page.tsx`, `getPageMaxWidth`). + * - `home` is a wide region page. It is NOT a landing declaration: an app opens + * on its first navigation item (by `order`; `HOME_PAGE_ID_RETIRED`, + * `app.zod.ts`), and the root landing follows the APP's `isDefault`. To land + * on a `home` page, put its navigation entry first. `isDefault` on it is + * refused ({@link checkPageDefaultPlacement}). + * - `utility` is an ordinary inline page in the main column, the narrowest of + * the three. The console has no floating or docked shell slot for it — no + * utility bar, no panel beside the page a user is on — and none is declared. + * The type stays because two readers branch on it: the print door + * ({@link PRINT_PAGE_TYPES} leaves it out) and the page-type width. + * - `record` binds one record of its `object`; `list` draws a record list + * through `interfaceConfig`. * * **Liveness (ADR-0049 enforce-or-remove):** only types with a dedicated * renderer are authorizable. `record`, `home`, `app`, `utility`, and `list` are @@ -804,9 +869,9 @@ export const PageVariableSchema = lazySchema(() => strictObject({ export const PageTypeSchema = lazySchema(() => z.enum([ // Platform page types (Salesforce FlexiPage style) — region/component composition 'record', // Component-based record layout page with regions - 'home', // Platform-level home/landing page - 'app', // App-level page with navigation context - 'utility', // Floating utility panel (e.g. notes, phone dialer) + 'home', // Wide region page — NOT the landing: an app opens on its first navigation item + 'app', // Region page at app width + 'utility', // Narrow inline region page — no floating or docked shell slot exists // Interface page type (Airtable parity). NOTE: grid/kanban/calendar/gallery/ // timeline are NOT page types — they are visualizations of a `list` page // (interfaceConfig.appearance.allowedVisualizations). @@ -1457,7 +1522,8 @@ const PRINT_PAGE_KINDS: readonly string[] = ['full']; * The page types a print page may take — a record page (the document bound to * one record: an invoice, a delivery order) and a home or app page (a document * bound to no single record: a letter, a monthly report). `list` draws a paged - * grid and `utility` is a floating panel; neither is a document. + * grid, and `utility` is the narrow page for a tool beside the work (notes, a + * phone dialer); neither is a document. */ const PRINT_PAGE_TYPES: readonly string[] = ['record', 'home', 'app']; @@ -1478,7 +1544,7 @@ const PRINT_PAGE_TYPES_PHRASE = "of `type: 'record'`, `'home'` or `'app'` with i * judge; * - `print` on a `list` page — it draws its records through `interfaceConfig` * as a paged grid; the list's own print control is `allowPrinting`; - * - `print` on a `utility` page — a floating panel, not a document (the + * - `print` on a `utility` page — a tool page, not a document (the * admitted types are {@link PRINT_PAGE_TYPES}); * - `print` on a `full` page with no `regions` — it draws the synthesized * default layout instead; @@ -1525,7 +1591,7 @@ export function checkPagePrintComposition( const type = page.type ?? 'record'; if (!PRINT_PAGE_TYPES.includes(type)) { const why = type === 'utility' - ? 'a utility page is a floating panel beside the page a user is on (notes, a phone dialer), not a document' + ? 'a utility page holds a tool (notes, a phone dialer), not a document' : `a \`${type}\` page is not a document page`; ctx.addIssue({ code: 'custom', @@ -1564,6 +1630,88 @@ export function checkPagePrintComposition( } } +/** + * The `isDefault` placement check attached to {@link PageSchema} (maintainer + * ruling A on objectstack#23610, record 6190536588): `isDefault: true` is + * refused on a page {@link defaultRecordPageObject} does not place — any page + * that is not a record page bound to an `object`. + * + * `isDefault` has one meaning, the default record page of an object, and on + * every other page nothing read it: an app opens on its first navigation item + * and the root landing follows the APP's `isDefault`, so a `home` page marked + * `isDefault: true` validated, saved and was served while it decided nothing. + * The sentence is {@link misplacedDefaultPageRefusal}'s, the one the registry + * door prints for a page a package registers without a parse. + * + * Exported for the reason {@link checkPageSourceCompleteness} is, and + * attached by identifier — pinned in `object-refinement-check-exports.test.ts`. + */ +export function checkPageDefaultPlacement( + page: { isDefault?: unknown; type?: unknown; object?: unknown }, + ctx: z.RefinementCtx, +): void { + const refusal = misplacedDefaultPageRefusal(page); + if (refusal === undefined) return; + ctx.addIssue({ code: 'custom', path: ['isDefault'], message: refusal }); +} + +/** + * Role names an author reaches for, and the slot that plays the role — named + * in the refusal when the page's template places that slot. + */ +const REGION_NAME_HINTS: Readonly> = { + left_sidebar: 'sidebar', leftsidebar: 'sidebar', left: 'sidebar', nav: 'sidebar', + right_sidebar: 'aside', rightsidebar: 'aside', right: 'aside', rail: 'aside', + content: 'main', body: 'main', center: 'main', + top: 'header', bottom: 'footer', +}; + +/** + * The region placement check attached to {@link PageSchema} (maintainer ruling + * A on objectstack#23610, record 6190536588): on a page whose `regions` its + * template lays out, a region whose name the template does not place + * ({@link PAGE_TEMPLATE_SLOTS}) is refused at that region's `name`, naming the + * template's slots. + * + * The renderer never dropped such a region: it stacked it under `main` at full + * width and ignored its `width`, so a `left_sidebar` or `right_sidebar` on a + * `three-column` page drew as one column while every door reported success. + * Names match case-insensitively, as the renderer's do. A `dashboard` page + * places every name (its open grid). Judged on `kind: 'full'` pages only — + * the kind whose regions the template lays out: a `slotted` page renders its + * `slots`, and an `html` / `react` page its `source`. A template the enum + * refuses is that refusal's, not this check's. + * + * Exported for the reason {@link checkPageSourceCompleteness} is, and + * attached by identifier — pinned in `object-refinement-check-exports.test.ts`. + */ +export function checkPageRegionPlacement( + page: { kind?: unknown; template?: unknown; regions?: unknown }, + ctx: z.RefinementCtx, +): void { + if ((page.kind ?? 'full') !== 'full' || !Array.isArray(page.regions)) return; + const template = page.template ?? 'default'; + if (typeof template !== 'string' || !Object.prototype.hasOwnProperty.call(PAGE_TEMPLATE_SLOTS, template)) return; + if ((PAGE_TEMPLATE_OPEN_GRID as readonly string[]).includes(template)) return; + const slots: readonly string[] = PAGE_TEMPLATE_SLOTS[template as PageTemplate]; + const slotList = slots.map((slot) => `\`${slot}\``).join(', '); + page.regions.forEach((region, index) => { + const name = region && typeof region === 'object' ? (region as { name?: unknown }).name : undefined; + if (typeof name !== 'string' || slots.includes(name.toLowerCase())) return; + const hint = REGION_NAME_HINTS[name.toLowerCase()]; + const rename = hint !== undefined && slots.includes(hint) + ? `Rename it \`${hint}\`, ` + : 'Rename it to one of those slots, '; + ctx.addIssue({ + code: 'custom', + path: ['regions', index, 'name'], + message: `Region \`${name}\` is refused on a \`template: '${template}'\` page: that template places only ` + + `${slotList}, and a region it does not place is stacked under \`main\` at full width with its \`width\` ` + + `ignored. ${rename}or choose a template that places it (\`dashboard\` lays every other region out as a grid cell).`, + }); + }); +} + export const PageSchema = lazySchema(() => strictObject({ surface: 'this page', history: PAGE_HISTORY, @@ -1659,7 +1807,7 @@ export const PageSchema = lazySchema(() => strictObject({ // objectui dropped all references in objectui#1949. /** Layout Template */ - template: z.string().default('default').describe('Layout template name (e.g. "header-sidebar-main")'), + template: PageTemplateSchema.default('default').describe("Layout template — 'default', 'header-sidebar-main', 'three-column' or 'dashboard'. Each places its own regions (PAGE_TEMPLATE_SLOTS); a region the template does not place is refused at the region's name."), /** Regions & Content */ // Optional with an empty-array default. Not every page authors regions: @@ -1693,12 +1841,16 @@ export const PageSchema = lazySchema(() => strictObject({ * compose the deployment without the package that ships the other. Shipping * a second default is not an override path. * - * `isDefault` on a page that is not a record page bound to an object is - * outside this rule. The one reading every door uses is - * `defaultRecordPageObject` (`default-record-page.ts`). + * `isDefault: true` on a page that is not a record page bound to an object + * is REFUSED, at the same doors (maintainer ruling A on objectstack#23610, + * record 6190536588): nothing reads it there. A page does not choose where + * an app opens — the app's first navigation item is its landing, and the + * root landing follows the APP's `isDefault`. The one reading every door + * uses is `defaultRecordPageObject` (`default-record-page.ts`), and the one + * refusal `misplacedDefaultPageRefusal` ({@link checkPageDefaultPlacement}). */ isDefault: z.boolean().default(false) - .describe('Whether this record page is the default record page of its `object` — the page the record view opens. At most one `isDefault: true` record page may exist for an object in a deployment: a second is refused (by defineStack / os validate, at package registration and at metadata save), naming both pages. Saving a page under the existing default\'s own name is that page, not a second default. Outside this rule for a page that is not a record page bound to an object.'), + .describe('Whether this record page is the default record page of its `object` — the page the record view opens. At most one `isDefault: true` record page may exist for an object in a deployment: a second is refused (by defineStack / os validate, at package registration and at metadata save), naming both pages. Saving a page under the existing default\'s own name is that page, not a second default. Refused on any other page — a page that is not a record page bound to an object: nothing reads it there. An app opens on its first navigation item, and the root landing follows the app\'s own isDefault.'), // `assignedProfiles` REMOVED (ADR-0090 D2 / ADR-0049) — it named the concept // D2 deleted and nothing anywhere enforced it, so a page that "assigned // profiles" was open to everyone who could reach it. Page audience is the @@ -1860,11 +2012,18 @@ export const PageSchema = lazySchema(() => strictObject({ // #23357: a component's `data` prop naming the retired view-level `api` // provider is refused by the page parse, as a view's own `data` is. // Attached by identifier for the same reason as the checks above. - .superRefine(checkPageComponentViewData)); -// PageSchema's cross-field rules are the five checks above: the + .superRefine(checkPageComponentViewData) + // #23610 (ruling A, record 6190536588): `isDefault: true` on a page that is + // not a record page bound to an object, and a region the page's template + // does not place, are refused. Attached by identifier for the same reason + // as the checks above. + .superRefine(checkPageDefaultPlacement) + .superRefine(checkPageRegionPlacement)); +// PageSchema's cross-field rules are the seven checks above: the // ADR-0080/0081 source completeness check, the compiled-kind `requires` // check, the print-page composition check, the `details` + `tabs` slot -// pair check and the component view-data check. It once also required +// pair check, the component view-data check, the `isDefault` placement +// check and the region placement check. It once also required // `recordReview`/`blankLayout` and `slots` (all removed — unrendered roadmap / // "required-but-unauthorable" Studio traps). From 598cf758329c210b023487704e8332ddfe4549b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 15:30:17 +0000 Subject: [PATCH 02/12] wip: registry door, D3 entries, liveness rows, docs, example, pins Claude-Session: https://claude.ai/code/session_01TC6DKN349m4i7okGWPe4RG Co-authored-by: Claude --- content/docs/ui/pages.mdx | 44 +++- .../src/ui/pages/capability-map.page.ts | 3 +- .../src/default-record-page-save-door.test.ts | 60 ++++- packages/objectql/src/default-record-page.ts | 37 ++- .../src/registry-default-record-page.test.ts | 50 +++- packages/objectql/src/registry.ts | 61 ++++- packages/spec/json-schema.manifest/ui.json | 1 + packages/spec/liveness/page.json | 14 +- ...page-is-default-off-record-page-refused.ts | 54 +++++ ...template-closed-region-unplaced-refused.ts | 55 +++++ .../src/stack-default-record-page.test.ts | 12 +- ...record-approvals-attachments-22537.test.ts | 6 +- .../ui/expression-scope-app-root.pin.test.ts | 12 +- .../object-refinement-check-exports.test.ts | 41 +++- .../page-default-and-region-placement.test.ts | 227 ++++++++++++++++++ packages/spec/src/ui/page.test.ts | 9 +- 16 files changed, 646 insertions(+), 40 deletions(-) create mode 100644 packages/spec/src/migrations/entries/semantic/18.page-is-default-off-record-page-refused.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.page-template-closed-region-unplaced-refused.ts create mode 100644 packages/spec/src/ui/page-default-and-region-placement.test.ts diff --git a/content/docs/ui/pages.mdx b/content/docs/ui/pages.mdx index a9b6a8d5348..24b624e0906 100644 --- a/content/docs/ui/pages.mdx +++ b/content/docs/ui/pages.mdx @@ -61,23 +61,30 @@ const homePage = { | `description` | `string` | optional | Page description | | `type` | `enum` | optional | Page type (see below; default `'record'`) | | `object` | `string` | optional | Associated object (for `record` type) | -| `template` | `string` | optional | Layout template name (default: `'default'`) | +| `template` | `enum` | optional | Layout template: `'default'` (the default), `'header-sidebar-main'`, `'three-column'` or `'dashboard'` — see [Templates](#templates). Any other name is refused | | `kind` | `enum` | optional | Page override mode (default `'full'`). `full` \| `slotted` = structured authoring; `html` = author-written constrained JSX/HTML compiled (parsed, never executed) to the tree (ADR-0080; ADR-0081 named it `html`); `react` = real-React source executed at render by the runtime (ADR-0081) — it runs author JS, so it is gated by a host capability that defaults ON and is disabled server-side via `OS_PAGE_REACT=off`. See [Authoring modes](#authoring-modes) | | `regions` | `PageRegion[]` | optional | Layout regions with components (default `[]` — `list` pages render via `interfaceConfig`, and an empty `record`/`home`/`app` page falls back to the synthesized default layout) | | `source` | `string` | optional | Page source text — required (and authoritative over `regions`) when `kind` is `'html'` or `'react'`. For `kind: 'html'` it is constrained JSX/HTML compiled to the tree by `@objectstack/sdui-parser` at save time (parse, never execute). For `kind: 'react'` it is real React/JSX executed at render by `@object-ui/react-runtime` (trusted tier). See [React Pages](/docs/ui/react-pages) | | `variables` | `PageVariable[]` | optional | Local state variables | -| `isDefault` | `boolean` | optional | The default record page of its `object` — the page that object's record view opens. At most one `isDefault: true` record page per object per deployment: a second is refused by `defineStack` / `os validate`, at package registration and at metadata save, naming both pages. Saving a page under the existing default's own name is that page, not a second default. Outside the rule on a page that is not a record page bound to an object | +| `isDefault` | `boolean` | optional | The default record page of its `object` — the page that object's record view opens. At most one `isDefault: true` record page per object per deployment: a second is refused by `defineStack` / `os validate`, at package registration and at metadata save, naming both pages. Saving a page under the existing default's own name is that page, not a second default. **Refused on any other page** (a `home`, `app`, `utility` or `list` page, or a record page with no `object`): nothing reads it there. A page does not choose where an app opens — see [Page Types](#page-types) | ### Page Types | Type | Description | Use Case | | :--- | :--- | :--- | | `record` | Tied to a specific object record | Custom detail pages | -| `home` | Landing/home page | App entry points | -| `app` | General application page | Custom layouts | -| `utility` | Utility/helper panel | Tools, settings, wizards | +| `home` | Region page at the widest content width | Overviews and start pages | +| `app` | Region page at app content width | Custom layouts | +| `utility` | Region page at the narrowest content width, inline in the main column | Tools, settings, wizards | | `list` | Record list/interface surface | Data-driven interface pages | +`home`, `app` and `utility` render the same way — the page's regions, laid out by its +`template` — and differ only in how wide the content runs. **A `home` page is not a landing +declaration:** an app opens on its **first navigation item** (by `order`), so to make a page +the one an app opens, put its navigation entry first; the root landing follows the app's own +`isDefault`. A `utility` page is an ordinary inline page — there is no floating panel or +utility bar that hosts it — and it cannot be a print page. + Earlier roadmap types (`dashboard`, `form`, `record_detail`, `record_review`, `overview`, `blank`) were **removed from the schema** because they never shipped a renderer (ADR-0049 enforce-or-remove); only the five types above are valid. ## Authoring modes [#authoring-modes] @@ -120,7 +127,8 @@ by `os validate`, `os lint` and `os build` alike. See ## Regions -Regions define layout zones on the page. Each region contains components. +Regions define layout zones on the page. Each region contains components, and its `name` +is the slot of the page's `template` it fills. {/* os:check */} ```typescript @@ -131,7 +139,7 @@ regions: [ components: [/* ... */], }, { - name: 'content', + name: 'main', width: 'large', components: [/* ... */], }, @@ -140,10 +148,30 @@ regions: [ | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| `name` | `string` | ✅ | Region identifier | +| `name` | `string` | ✅ | The template slot the region fills (see [Templates](#templates)); a name the template does not place is refused | | `width` | `enum` | optional | `'small'`, `'medium'`, `'large'`, `'full'` | | `components` | `PageComponent[]` | ✅ | Components in this region | +### Templates + +A page's `template` decides where each region is drawn. Each template places a fixed set of +region names (`PAGE_TEMPLATE_SLOTS` in `@objectstack/spec/ui`); names match +case-insensitively. + +| Template | Regions it places | +| :--- | :--- | +| `default` | `header`, `sidebar`, `main`, `aside`, `footer` — `sidebar` and `aside` beside `main`, `header` and `footer` spanning it | +| `header-sidebar-main` | `header`, `sidebar`, `main` | +| `three-column` | `header`, `sidebar`, `main`, `aside`, `footer` | +| `dashboard` | `header`, `footer`, and **any other name** — every other region is a grid cell | + +On a `kind: 'full'` page, a region its template does not place — `left_sidebar`, +`right_sidebar`, `content` — is refused by `os validate`, `os build` and the metadata save door, +naming the template's slots: the console would stack it under `main` at full width and ignore +its `width`. Use `sidebar` for the left column and `aside` for the right. `full-width` is no +longer a template name: it was a second name for `default`, and is refused with that +replacement. + ## Components Components are the building blocks placed inside regions. diff --git a/examples/app-showcase/src/ui/pages/capability-map.page.ts b/examples/app-showcase/src/ui/pages/capability-map.page.ts index 53b28b81d84..ddcdbf390dd 100644 --- a/examples/app-showcase/src/ui/pages/capability-map.page.ts +++ b/examples/app-showcase/src/ui/pages/capability-map.page.ts @@ -43,7 +43,8 @@ export const CapabilityMapPage = definePage({ label: 'Capability Map', type: 'home', kind: 'html', - isDefault: true, + // No `isDefault`: a page does not choose where an app opens. This page is the + // showcase's landing because it is the first item of `ShowcaseApp.navigation`. source: ` diff --git a/packages/objectql/src/default-record-page-save-door.test.ts b/packages/objectql/src/default-record-page-save-door.test.ts index f1d4c0e09ae..6897260d428 100644 --- a/packages/objectql/src/default-record-page-save-door.test.ts +++ b/packages/objectql/src/default-record-page-save-door.test.ts @@ -166,7 +166,13 @@ async function boot(topology: Topology) { (result: any) => ({ ok: true as const, result }), (error: any) => ({ ok: false as const, error }), ); - return { engine, protocol, storedPages, save }; + /** A stored ACTIVE environment page row, laid in the store directly — no write door in front of it. */ + const storeRow = (name: string, body: Record) => + driver.create('sys_metadata', { + type: 'page', name, organization_id: null, package_id: null, + metadata: JSON.stringify(body), state: 'active', version: 1, + }); + return { engine, protocol, storedPages, save, storeRow }; } /** The envelope, the object and both pages of one refusal, by its first sentence. */ @@ -261,9 +267,19 @@ describe.each(TOPOLOGIES)('%s kernel — a page saved as a second default record expect(outcome.ok, String((outcome as any).error?.message)).toBe(true); }); - it('control: an isDefault page that is not a record page is outside the rule', async () => { - const { save } = await boot(topology); + it('an isDefault page that is not a record page holds no default — the page parse refuses its flag (#23610), and nothing is stored', async () => { + const { save, storedPages } = await boot(topology); const outcome = await save('env_home', { name: 'env_home', label: 'Home', type: 'home', isDefault: true, regions: [] }); + expect(outcome.ok, 'the save was refused').toBe(false); + const err = (outcome as { error: any }).error; + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + expect(String(err.message)).toContain("`isDefault: true` is refused on a `type: 'home'` page"); + expect(storedPages()).toEqual([]); + }); + + it('control: the same home page without the flag saves', async () => { + const { save } = await boot(topology); + const outcome = await save('env_home', { name: 'env_home', label: 'Home', type: 'home', regions: [] }); expect(outcome.ok, String((outcome as any).error?.message)).toBe(true); }); @@ -276,3 +292,41 @@ describe.each(TOPOLOGIES)('%s kernel — a page saved as a second default record expect(storedPages()).toEqual(['env_task_a', 'env_task_b']); }); }); + +// #23610 (maintainer ruling A on Q1 and Q2, record 6190536588) — the save door +// is the page parse's: a non-record page's `isDefault: true`, the retired +// `full-width` template and a region the template does not place are each +// refused 422 `INVALID_METADATA` at save. A row ALREADY stored in one of those +// shapes is read as it was (no conversion touches it and the read path does +// not refuse it); saving it again is refused until it is fixed. +const PLACEMENT_SHAPES: ReadonlyArray<[label: string, body: Record, says: string]> = [ + ['a home page with `isDefault: true`', { name: 'env_shape', label: 'Shape', type: 'home', isDefault: true, regions: [] }, '`isDefault: true` is refused'], + ['the retired `full-width` template', { name: 'env_shape', label: 'Shape', type: 'record', object: 'acme_task', template: 'full-width', regions: [{ name: 'main', components: [] }] }, "`template: 'full-width'` was removed"], + ['a `left_sidebar` region on `three-column`', { name: 'env_shape', label: 'Shape', type: 'home', template: 'three-column', regions: [{ name: 'left_sidebar', components: [] }, { name: 'main', components: [] }] }, 'Region `left_sidebar` is refused'], +]; + +describe.each(TOPOLOGIES)('%s kernel — the page parse at the save door, and rows stored before it (#23610)', (topology) => { + it.each(PLACEMENT_SHAPES)('%s: the save is refused 422 INVALID_METADATA, and nothing is stored', async (_label, body, says) => { + const { save, storedPages } = await boot(topology); + const outcome = await save('env_shape', body); + expect(outcome.ok, 'the save was refused').toBe(false); + const err = (outcome as { error: any }).error; + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'INVALID_METADATA', status: 422 }); + expect(String(err.message)).toContain(says); + expect(storedPages()).toEqual([]); + }); + + it.each(PLACEMENT_SHAPES)('%s, ALREADY stored: the read serves the row as stored, and saving it again is refused', async (_label, body) => { + const { protocol, save, storeRow } = await boot(topology); + await storeRow('env_shape', body); + const read: any = await protocol.getMetaItem({ type: 'page', name: 'env_shape' }); + expect(read?.item?.name).toBe('env_shape'); + for (const key of ['isDefault', 'template'] as const) { + if (key in body) expect(read.item[key]).toEqual(body[key]); + } + expect(read.item.regions).toEqual(body.regions); + const again = await save('env_shape', body); + expect(again.ok, 'the re-save was refused').toBe(false); + expect((again as { error: any }).error?.status).toBe(422); + }); +}); diff --git a/packages/objectql/src/default-record-page.ts b/packages/objectql/src/default-record-page.ts index 0ab6527df74..ab16db96369 100644 --- a/packages/objectql/src/default-record-page.ts +++ b/packages/objectql/src/default-record-page.ts @@ -52,6 +52,9 @@ * is that package's own residue, not another holder. * - **A page that is not a default record page** — `isDefault` on a `home`, * `app`, `utility` or `list` page, or on a record page bound to no object. + * It holds nothing; it is refused on its own account instead + * ({@link misplacedDefaultPages}; maintainer ruling A on objectstack#23610, + * record 6190536588), at the package door and the item seam alike. * * ## What it deliberately does not judge * @@ -68,7 +71,7 @@ * namespace gate only. */ -import { defaultRecordPageObject } from '@objectstack/spec/ui'; +import { defaultRecordPageObject, misplacedDefaultPageRefusal } from '@objectstack/spec/ui'; /** Who holds the default record page of an object: a page, and where it comes from. */ export type DefaultRecordPageHolder = @@ -121,6 +124,38 @@ export function declaredDefaultRecordPages(manifest: unknown): DeclaredDefaultRe return out; } +/** One page a manifest declares `isDefault: true` on that is not a record page bound to an object. */ +export interface MisplacedDefaultPage { + /** The page's name, or `undefined` for a page that carries none. */ + readonly page: string | undefined; + /** `misplacedDefaultPageRefusal`'s sentence — the one the page parse prints. */ + readonly refusal: string; +} + +/** + * Every page a manifest declares `isDefault: true` on that + * `defaultRecordPageObject` does not place, read from the same sources as + * {@link declaredDefaultRecordPages}, in declaration order. A package's pages + * reach the registry without a `PageSchema` parse, so this is the package + * door's half of the refusal the parse makes at `os validate` and at save. + */ +export function misplacedDefaultPages(manifest: unknown): MisplacedDefaultPage[] { + const out: MisplacedDefaultPage[] = []; + const read = (source: unknown) => { + if (!source || typeof source !== 'object') return; + const pages = (source as { pages?: unknown }).pages; + if (!Array.isArray(pages)) return; + for (const item of pages) { + const refusal = misplacedDefaultPageRefusal(item); + if (refusal !== undefined) out.push({ page: pageName(item), refusal }); + } + }; + read(manifest); + const plugins = (manifest as { plugins?: unknown } | null | undefined)?.plugins; + if (Array.isArray(plugins)) for (const plugin of plugins) read(plugin); + return out; +} + /** How a refusal names a holder. */ export function describeDefaultRecordPageHolder(holder: DefaultRecordPageHolder): string { return holder.kind === 'package' diff --git a/packages/objectql/src/registry-default-record-page.test.ts b/packages/objectql/src/registry-default-record-page.test.ts index 88ffd719d45..65bf531f565 100644 --- a/packages/objectql/src/registry-default-record-page.test.ts +++ b/packages/objectql/src/registry-default-record-page.test.ts @@ -27,7 +27,8 @@ import { describe, it, expect, afterEach } from 'vitest'; import { ObjectKernel } from '@objectstack/core'; import { ObjectQLPlugin } from './plugin.js'; -import { DEFAULT_RECORD_PAGE_CONFLICT_CODE, SchemaRegistry } from './registry.js'; +import { DEFAULT_RECORD_PAGE_CONFLICT_CODE, MISPLACED_DEFAULT_PAGE_CODE, SchemaRegistry } from './registry.js'; +import { misplacedDefaultPageRefusal } from '@objectstack/spec/ui'; import type { ObjectQL } from './engine.js'; type ManifestService = { register(m: unknown): void | Promise }; @@ -186,6 +187,49 @@ describe('a second package registering a default record page for an object anoth }); }); +// #23610 (maintainer ruling A on Q1, record 6190536588): `isDefault: true` on a +// page that is not a record page bound to an object holds nothing and is +// refused on its own account, with the page parse's sentence. +const HOME_DEFAULT = { name: 'tenant_home', label: 'Home', type: 'home', isDefault: true, regions: [] }; + +/** The envelope and the one sentence of a misplaced-default refusal. */ +function expectMisplaced(err: Refusal | undefined, page: string, packageId: string) { + expect(err, 'the registration was refused').toBeDefined(); + expect({ code: err!.code, status: err!.status }).toEqual({ code: MISPLACED_DEFAULT_PAGE_CODE, status: 422 }); + expect(MISPLACED_DEFAULT_PAGE_CODE).toBe('INVALID_METADATA'); + expect((err as { name?: string }).name).toBe('MisplacedDefaultPageError'); + expect(err!.message.startsWith(`Page "${page}" of package "${packageId}": \`isDefault: true\` is refused on a \`type: 'home'\` page`)).toBe(true); + expect(err!.message).toContain('first navigation item'); +} + +describe('`isDefault: true` on a page that is not a record page bound to an object', () => { + it('is refused at the package door with the parse\'s sentence, and leaves no record behind', async () => { + const { registry, register } = await boot(); + const err = await register(pkg('com.acme.tenant', [recordPage('tenant_task_detail', 'acme_task'), HOME_DEFAULT])); + expectMisplaced(err, 'tenant_home', 'com.acme.tenant'); + expect(err!.message).toContain(misplacedDefaultPageRefusal(HOME_DEFAULT)!); + expect(registry.getPackage('com.acme.tenant')).toBeUndefined(); + expect(registry.listItems('page')).toEqual([]); + }); + + it('is refused on a nested plugin\'s pages too', async () => { + const { register } = await boot(); + expectMisplaced(await register(pkg('com.acme.tenant', [], { plugins: [{ id: 'com.acme.tenant.home', pages: [HOME_DEFAULT] }] })), 'tenant_home', 'com.acme.tenant'); + }); + + it('the item seam refuses a package-bound registration of one', async () => { + const { registry } = await boot(); + expectMisplaced(await caught(() => registry.registerItem('page', HOME_DEFAULT, 'name', 'com.acme.direct')), 'tenant_home', 'com.acme.direct'); + expect(registry.listItems('page')).toEqual([]); + }); + + it('control: the bare slot — a stored row hydrating — is not judged here; the save door refuses it before it is stored', async () => { + const { registry } = await boot(); + expect(await caught(() => registry.registerItem('page', HOME_DEFAULT))).toBeUndefined(); + expect(registry.listItems('page').map((p) => p.name)).toEqual(['tenant_home']); + }); +}); + describe('two default record pages for one object inside one manifest', () => { it('is refused at the package door, naming both of the package\'s pages', async () => { const { registry, register } = await boot(); @@ -240,10 +284,10 @@ describe('controls — what is not a second default', () => { expect(await register(pkg('com.acme.tenant', [recordPage('tenant_org_detail', 'acme_org', false)]))).toBeUndefined(); }); - it('an isDefault page that is not a record page is outside the rule', async () => { + it('the same `home` page without the flag registers beside another package\'s default', async () => { const { register } = await boot(); expect(await register(pkg('com.acme.auth', [recordPage('acme_org_detail', 'acme_org')]))).toBeUndefined(); - expect(await register(pkg('com.acme.tenant', [{ name: 'tenant_home', label: 'Home', type: 'home', isDefault: true, regions: [] }]))).toBeUndefined(); + expect(await register(pkg('com.acme.tenant', [{ name: 'tenant_home', label: 'Home', type: 'home', regions: [] }]))).toBeUndefined(); }); it('the same package again — a reload, and an upgrade that moves its default to another of its pages — is one holder', async () => { diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts index 8fb229d5bac..154295ad543 100644 --- a/packages/objectql/src/registry.ts +++ b/packages/objectql/src/registry.ts @@ -69,11 +69,13 @@ import { // place a manifest's default record pages are read from (module doc). import { declaredDefaultRecordPages, + misplacedDefaultPages, + type MisplacedDefaultPage, defaultRecordPageHolderKey, describeDefaultRecordPageHolder, type DefaultRecordPageHolder, } from './default-record-page.js'; -import { defaultRecordPageObject } from '@objectstack/spec/ui'; +import { defaultRecordPageObject, misplacedDefaultPageRefusal } from '@objectstack/spec/ui'; // [#14553] The ONE resolution of "does this nav group id exist?" and the ONE // wording of the diagnostic when it does not. `os build` calls the same two // (`checkNavContributionGroups`), so the compile-time door and this read-time @@ -1793,6 +1795,51 @@ export class DefaultRecordPageConflictError extends Error { } } +/** + * {@link MisplacedDefaultPageError}'s code — `INVALID_METADATA`, already this + * package's spelling for "a programmatically supplied metadata body does not + * satisfy the spec schema" (see {@link OBJECT_FIELD_TYPE_REFUSED_CODE}): the + * page parse refuses the same body at `os validate` and at save. ⛔ Not a new + * code. The condition is named by the error's `name`. + */ +export const MISPLACED_DEFAULT_PAGE_CODE = 'INVALID_METADATA' as const; + +/** + * Raised when a package registers a page declaring `isDefault: true` that is + * not a record page bound to an object (maintainer ruling A on + * objectstack#23610, record 6190536588). Nothing reads the flag there — an app + * opens on its first navigation item and the root landing follows the app's + * own `isDefault` — so the registry refuses it as the page parse does, with + * the parse's own sentence (`misplacedDefaultPageRefusal`, + * `@objectstack/spec/ui`). A package's pages reach the registry without a + * parse, which is why this door needs its own throw. + * + * The ADR-0112 envelope (`code` + `status: 422`). Every misplaced page the + * registration carries is listed — `pages` — so one boot reports all of them. + */ +export class MisplacedDefaultPageError extends Error { + readonly code = MISPLACED_DEFAULT_PAGE_CODE; + readonly status = 422; + /** The same number under ADR-0112 D5's spelling — what a consumer holding the THROWN error reads (the CLI `--json` envelope). `status` stays for the HTTP doors, which read it. */ + readonly httpStatus = 422; + /** The package whose registration this refusal stopped. */ + readonly packageId: string | undefined; + /** Every misplaced page, in declaration order. */ + readonly pages: readonly MisplacedDefaultPage[]; + + constructor(packageId: string | undefined, pages: readonly MisplacedDefaultPage[]) { + const owner = packageId === undefined ? 'a package' : `package "${packageId}"`; + super( + pages + .map(({ page, refusal }) => `Page "${page ?? '(unnamed)'}" of ${owner}: ${refusal}`) + .join(' '), + ); + this.name = 'MisplacedDefaultPageError'; + this.packageId = packageId; + this.pages = pages; + } +} + /** * Every package-held position and permission-set name the environment catalog * also holds, read off `registry` — the conflicts the engine plugin's cold-boot @@ -2662,6 +2709,12 @@ export class SchemaRegistry { * pages of each conflict. */ private refuseDefaultRecordPageConflicts(manifest: ObjectStackManifest, selfId: string | undefined): void { + // `isDefault: true` on a page that is not a record page bound to an object + // is refused on its own account first (maintainer ruling A on + // objectstack#23610) — whatever the package's identity, since the page + // itself, not a holder, is what is wrong. + const misplaced = misplacedDefaultPages(manifest); + if (misplaced.length > 0) throw new MisplacedDefaultPageError(selfId, misplaced); if (selfId === undefined) return; const conflicts: DefaultRecordPageConflict[] = []; // object → the first page this manifest declares for it. @@ -4591,6 +4644,12 @@ export class SchemaRegistry { // judged here: the metadata save door refuses a second default before it // is stored. if (packageId && type === 'page') { + // A page declaring `isDefault: true` that is not a record page bound to + // an object is refused before it is judged as a holder (maintainer + // ruling A on objectstack#23610) — the same sentence the page parse + // prints at `os validate` and at save. + const refusal = misplacedDefaultPageRefusal(item); + if (refusal !== undefined) throw new MisplacedDefaultPageError(packageId, [{ page: baseName, refusal }]); const object = defaultRecordPageObject(item); if (object !== undefined) { const holders = this.otherDefaultRecordPageHolders(object, baseName, packageId); diff --git a/packages/spec/json-schema.manifest/ui.json b/packages/spec/json-schema.manifest/ui.json index 41e9f032637..3e53599717e 100644 --- a/packages/spec/json-schema.manifest/ui.json +++ b/packages/spec/json-schema.manifest/ui.json @@ -122,6 +122,7 @@ "ui/PagePrint", "ui/PageRegion", "ui/PageTabsProps", + "ui/PageTemplate", "ui/PageType", "ui/PageVariable", "ui/PaginationConfig", diff --git a/packages/spec/liveness/page.json b/packages/spec/liveness/page.json index d1a769a6122..6524304654b 100644 --- a/packages/spec/liveness/page.json +++ b/packages/spec/liveness/page.json @@ -32,7 +32,7 @@ }, "type": { "status": "live", - "note": "objectui PageView (app-shell/src/views/PageView.tsx) + components/renderers/layout/page.tsx dispatch on the page kind (record/home/app/utility/list). Only these render — roadmap types removed (PAGE_TYPE_ROADMAP, framework#2265)." + "note": "objectui PageView (app-shell/src/views/PageView.tsx) + components/renderers/layout/page.tsx dispatch on the page kind (record/home/app/utility/list). Only these render — roadmap types removed (PAGE_TYPE_ROADMAP, framework#2265). WHAT RUNS (#23610, ruling 6190536588, re-read objectui @0656b0e3c0 packages/components/src/renderers/layout/page.tsx#getPageMaxWidth and #HomePageLayout / #AppPageLayout / #UtilityPageLayout): home, app and utility are the same region layout and differ only in content width (home widest, utility narrowest). home is NOT a landing declaration — an app opens on its first navigation item (HOME_PAGE_ID_RETIRED, app.zod.ts) — and utility is an ordinary inline page: the console has no floating or docked shell slot. utility stays live because two readers branch on it: the print door (PRINT_PAGE_TYPES leaves it out) and the page-type width." }, "variables": { "status": "live", @@ -44,16 +44,16 @@ }, "template": { "status": "live", - "verifiedAt": "2026-08-10", + "verifiedAt": "2026-10-15", "evidenceScope": "cross-repo", - "evidence": "objectui: packages/components/src/renderers/layout/page.tsx:397-398 resolves schema.template through TEMPLATE_REGISTRY (header-sidebar-main / three-column / dashboard, defined at :285-386) and :531 selects that layout variant for the render — measured objectui @11c1e71e", - "note": "layout template (e.g. header-sidebar-main) selects the region arrangement. RE-CITED 2026-08-10 (#7133/#7142): the old citation was split across 'page.tsx + containers.tsx' and the containers.tsx half is dead for this key — its only `template` occurrences (:240-248) are a `{field.path}` string-interpolation helper, an unrelated sense of the word. Verdict unchanged: LIVE." + "evidence": "objectui @0656b0e3c0 packages/components/src/renderers/layout/page.tsx#resolveTemplate resolves schema.template through #TEMPLATE_REGISTRY (default, full-width, header-sidebar-main, three-column, dashboard; default and full-width are the same #FullWidthTemplate) and the page renderer selects that layout ahead of the page-type layout — re-read 2026-10-15", + "note": "layout template selects the region arrangement. CLOSED 2026-10-15 (#23610, ruling 6190536588 Q2 A): PageTemplateSchema is the registered set less full-width (retired by name, a second name for default), and each template declares the regions it places in PAGE_TEMPLATE_SLOTS (page.zod.ts), which checkPageRegionPlacement enforces at the region name. RE-CITED 2026-08-10 (#7133/#7142): the old citation was split across 'page.tsx + containers.tsx' and the containers.tsx half is dead for this key — its only `template` occurrences (:240-248) are a `{field.path}` string-interpolation helper, an unrelated sense of the word. Verdict unchanged: LIVE." }, "regions": { "status": "live", - "verifiedAt": "2026-08-10", + "verifiedAt": "2026-10-15", "evidenceScope": "cross-repo", - "evidence": "objectui: packages/components/src/renderers/layout/page.tsx:290 branches on schema.regions, :197-204 resolves the named slots (header/sidebar/main/aside/footer) and appends the remainder below main at :211, and :157 flattens their components for the JSX-source path — measured objectui @11c1e71e", + "evidence": "objectui @0656b0e3c0 packages/components/src/renderers/layout/page.tsx#RegionLayout resolves the named slots (header/sidebar/main/aside/footer, by #findRegion, case-insensitive) and appends the remainder below main (#getRemainingRegions); #HeaderSidebarMainTemplate places header/sidebar/main, #ThreeColumnTemplate the five, #DashboardTemplate header/footer and grids the rest — re-read 2026-10-15. The spec declares the same slots per template (PAGE_TEMPLATE_SLOTS) and refuses a region its template does not place (checkPageRegionPlacement, #23610)", "note": "region → component tree rendering (header/main/sidebar/footer). RE-CITED 2026-08-10 (#7133/#7142): same split-citation pruning as `template` — at objectui @11c1e71e the containers.tsx half names `regions` only in a comment (:798, 'page:section — thin wrapper used inside regions'). Verdict unchanged: LIVE. Component-level liveness stays unclassified (undrilled container, #4956); one component key carries a verdict OUTSIDE this file: `components[].responsive` is a retiredKey tombstone (#11027, ADR-0049 D2 — dead on a two-repo measurement; see the file _note). COMPONENT-MEMBER READ POINTS (moved out of packages/spec/src, #23434; ruling 6165388570 letter C, Q1 A as the seat read it): an EVIDENCE LIST only — no verdict is asserted, so component-level liveness stays unclassified as above. Each entry names the ComponentPropsMap row and member it reads, then its read points in the spelling `objectui @SHA FILE#SYMBOL` (the SHA is the objectui tree the point was last read in; a pin hop moves nothing here, and a renamed or deleted symbol is the event that re-measures it). Inside one entry, a letter defined at its head abbreviates a repeated file: with `P = objectui @SHA FILE`, `P#a P#b` reads `objectui @SHA FILE#a`, `objectui @SHA FILE#b`. Entries, in source order: [1] page:header (H = objectui @b9814b334 packages/components/src/renderers/layout/containers.tsx#PageHeaderRenderer): title (`schema?.title ?? schema?.properties?.title`), the refused `icon` (resolved only per header action) and `breadcrumb` (an empty slot), `actions` (resolvedHeaderActions then headerActionPlan), recordChrome (`=== false` on either carrier, branching the header), showStar and showCopyId (the record chip), maxVisible and mobileMaxVisible (`?? 3` / `?? 1`), all in H; re-read 2026-10-15, first cited sha-less; [2] page:tabs (T = objectui @b9814b334 packages/components/src/renderers/layout/containers.tsx#PageTabsRenderer): tabStyle (`schema?.properties?.type || schema?.tabStyle || 'line'`), alwaysShowStrip (`schema?.alwaysShowStrip === true || schema?.properties?.alwaysShowStrip === true`), items[].value (`it.value`, falling back to `tab-${idx}`; the refused `key` is the designer's `page:tabs.items.itemFields` input in objectui @b9814b334 packages/app-shell/src/views/metadata-admin/previews/block-config.ts#BLOCK_CONFIG), items[].label (`translateLabel(pickLocalized(it.label, language), language, ttLabel)`: the inline locale map first, then the bundle, the order packages/spec/src/ui/i18n-label-resolver.ts mirrors, whose module docblock cited it by line, sha-less, until #23434; objectui @b9814b334 packages/components/src/renderers/layout/containers.tsx#PageAccordionRenderer composes an accordion item's label the same way), all in T; the retired props key `type` (the reason for the rename): left unhoisted by objectui @b9814b334 packages/react/src/SchemaRenderer.tsx#HOIST_PROTECTED_KEYS, and skipped as a base prop by objectui @b9814b334 packages/sdui-parser/src/validate.ts#BASE_PROPS; re-read 2026-10-15, first cited sha-less; [3] page:tabs items[].icon (C = objectui @b9814b334 packages/components/src/renderers/layout/containers.tsx): C#triggerClass (`LazyIcon` inside the TabsTrigger) in C#PageTabsRenderer; published by its registration's `items` input; vocabulary objectui @b9814b334 packages/components/src/lib/lazy-icon.tsx#getLazyIcon; [4] page:tabs items[].visibleWhen (the tab-item evaluator: `data` is the row, bare fields spread flat, ambient roots after it): objectui @1fb1ced36243 packages/components/src/renderers/layout/containers.tsx#isItemVisible (its ExpressionEvaluator) in PageTabsRenderer; [5] record:details / record:highlights / record:related_list / record:quick_actions .requiredPermissions (RECORD_BLOCK_REQUIRED_PERMISSIONS_DESCRIPTION; R = objectui @b9814b334 packages/plugin-detail/src/renderers/, P = objectui @b9814b334 packages/permissions/src/): the gate `perms.hasCapabilities(required)` and the notice in R record-details.tsx#RecordDetailsBody, R record-highlights.tsx#highlightsAllowed (RecordHighlightsBody), R record-related-list.tsx#RecordRelatedListBody, R record-quick-actions.tsx#RecordQuickActionsBody; earlier withholding: no record R record-details.tsx#RecordDetailsBody, no object and the related read gate R record-related-list.tsx#RecordRelatedListBody; a gated highlights registers no fields R record-highlights.tsx#RecordHighlightsBody (useRegisterHighlightFields); ALL OF THEM and fail-open on unreported P MePermissionsProvider.tsx#hasCapabilities; fail-open P PermissionProvider.tsx#ALL_CAPABILITIES and P usePermissions.ts#NO_PROVIDER_PERMISSIONS; the binding wrapper (objectui#12294) objectui @b9814b334 packages/plugin-detail/src/renderers/recordBinding.tsx#recordBindingBlock; [6] record:details sections[].headerColor (closed enum, #12126): the six class literals of objectui @b9814b334 packages/plugin-detail/src/headerColor.ts#headerColorClass (objectui#6294, merged 2026-08-25); re-read 2026-10-15, first cited sha-less; [7] record:details sections[] hideEmpty, collapsible, showBorder and the #11661 trio (renderer defaults measured; the trio at objectui @190fbd01): objectui @190fbd01 packages/plugin-detail/src/renderers/record-details.tsx#RecordDetailsRenderer and objectui @190fbd01 packages/plugin-detail/src/DetailSection.tsx#DetailSection (`hideEmpty ?? true`; the undeclared `s.title ?? s.label` limb); [8] record:related_list.parentDataSource (the name): the sibling record blocks' binding read objectui @f0532d8a packages/plugin-detail/src/renderers/recordBinding.tsx#recordBindingBlock (`isElementDataSourceConfig(schema.dataSource)`), measured on objectui main; [9] record:details sections[].count (read since objectui c08c9332a, objectui#12192): objectui @b9814b334 packages/plugin-detail/src/renderers/record-details.tsx#countField (inside filteredSections; fills every `{{ count }}` hole); [10] record:related_list.parentDataSource (PLANNED): the objectui measurement is the file _note's (objectui @f0532d8a, the record context read in RecordRelatedListBody); no new evidence here; [11] record:activity guidance `items` / `loading` (host feed slot; R = objectui @89cad75d5570 packages/plugin-detail/src/renderers/): R record-activity.tsx#hostItems and R record-activity.tsx#loading in RecordActivityRenderer; record:chatter / record:discussion `feed` reads neither, R record-chatter.tsx#RecordChatterRenderer; [12] record:chatter.position (vocabulary bottom / right / left, #8762): objectui @665661ab0932 packages/plugin-detail/src/RecordChatterPanel.tsx#position (`right`/`left` dock vs `bottom` in-flow); the designer's CHATTER_INPUTS and the merge fallback in objectui @665661ab0932 packages/plugin-detail/src/renderers/record-chatter.tsx#RecordChatterRenderer; [13] record:reference_rail (RecordReferenceRailProps): objectui @b9814b334 packages/plugin-detail/src/renderers/record-reference-rail.tsx#RecordReferenceRailRenderer ( an entry `icon` is unread, `title` a raw React child); the synthesizer emitting `icon` objectui @b9814b334 packages/plugin-detail/src/synth/buildDefaultPageSchema.ts#buildDefaultPageSchema; re-read 2026-10-15, first cited sha-less; [14] record:alert, record:quick_actions, record:history: objectui @b9814b334 packages/plugin-detail/src/renderers/record-alert.tsx#RecordAlertRenderer, objectui @b9814b334 packages/plugin-detail/src/renderers/record-quick-actions.tsx#RecordQuickActionsRenderer, objectui @b9814b334 packages/plugin-detail/src/renderers/record-history.tsx#RecordHistoryRenderer and objectui @b9814b334 packages/plugin-detail/src/HistoryTimeline.tsx#HistoryTimeline (each renderer's ACTUAL reads, against its registration's declared inputs); re-read 2026-10-15, first cited sha-less; [15] record:line_items (RecordLineItemsProps; L = objectui @31971ff1e28f packages/plugin-form/src/LineItemsPanel.tsx): registered as `line_items` (namespace `record`) in objectui @31971ff1e28f packages/plugin-form/src/index.tsx with its mapping objectui @31971ff1e28f packages/plugin-form/src/index.tsx#RECORD_LINE_ITEMS_DATA_SOURCE; the fifteen `schema.KEY` reads in L#LineItemsPanel (childObject L#heldReloadRef; parentObject L#parentObject; parentId, recordId L#parentId; amountField, totalField L#parentPatch; filter L#scopeFilter; limit L#message; columns L#gridField (applyColumnPermissions)); the gate objectui @31971ff1e28f packages/react/src/element-data-source/ElementDataSourceGate.tsx#useElementDataSourceSchema; read set unchanged at objectui main d59f11c0d3dc; [16] record:line_items guidance (addLabel, sortField, onRowExpand not handed to the grid): objectui @9dfaca654311 packages/plugin-form/src/LineItemsPanel.tsx#gridField; [17] record:approvals (RecordApprovalsProps, EMPTY key set; A = objectui @47b1f0bb71 packages/app-shell/src/views/): registered as `approvals` (namespace `record`) in A record-approvals-renderer.tsx#RecordApprovalsRenderer; approvals A record-approvals-renderer.tsx#hostApprovals, threaded by A RecordDetailView.tsx#RecordDetailView and emitted by objectui @47b1f0bb71 packages/plugin-detail/src/synth/buildDefaultPageSchema.ts#buildDefaultPageSchema; currentUserId falls back in A RecordApprovalsPanel.tsx#isSubmitter; Studio page create seeds without options in A metadata-admin/anchors.ts#synth; [18] page:accordion items[].value (refused): objectui @b9814b334 packages/components/src/renderers/layout/containers.tsx#PageAccordionRenderer overwrites it with `panel-${idx}`; the designer input `page:accordion.items.itemFields` in objectui @b9814b334 packages/app-shell/src/views/metadata-admin/previews/block-config.ts#BLOCK_CONFIG (objectui#7973); re-read 2026-10-15, first cited sha-less; [19] page:accordion items[].icon (C = objectui @b9814b334 packages/components/src/renderers/layout/containers.tsx): C#commonChildren (`LazyIcon` in the AccordionTrigger) inside C#PageAccordionRenderer; published by its `accordion` registration's `items` input; vocabulary objectui @b9814b334 packages/components/src/lib/lazy-icon.tsx#getLazyIcon; [20] page:accordion.variant: objectui @b9814b334 packages/components/src/renderers/layout/containers.tsx#PageAccordionRenderer (`schema?.variant ?? schema?.properties?.variant ?? 'flush'`); re-read 2026-10-15, first cited sha-less; [21] element:text.variant retired `heading` / `subheading`: objectui @89cad75d55 packages/components/src/renderers/basic/elements.tsx#VARIANT_TAG; [22] element:button.icon (B = objectui @b9814b334 packages/components/src/renderers/form/button.tsx, R = objectui @b9814b334 packages/components/src/renderers/action/resolve-icon.ts): B#Icon (resolveIcon) and its render on either side of the label in B#ButtonRenderer; R#resolveIcon R#describeIconLookup R#toPascalCase R#iconNameMap R#recordIconName (the vocabulary: the generated objectui @b9814b334 packages/components/src/lib/lucide-record-icon-names.ts#LUCIDE_RECORD_ICON_NAME_TABLE); contrast objectui @b9814b334 packages/components/src/lib/lazy-icon.tsx#getLazyIcon; no `icon` in the registration's inputs inside B#ButtonRenderer (objectui#9910 added the `children` slot input); history: the read point moved files on the hop onto objectui 9602dc820, and the registration ranges were first wrong-since-written at objectui a472b0716; [23] action:* rows, the effective executor and the runner's reads (R = objectui @b9814b334 packages/core/src/actions/ActionRunner.ts, first read at objectui @1fb1ced36243 with every anchor at the same symbols; A = objectui @b9814b334 packages/components/src/renderers/action/): executor resolution R#execute (`action.type || action.actionType || action.name`); forwarded executor A action-button.tsx#forwarded, A action-icon.tsx#forwarded (`type: schema.actionType`), A action-group.tsx#paramsPayload, A action-menu.tsx#paramsPayload (`type: action.type`); the name leg documented in A action-button.tsx#forwarded; built-in executors and the `navigation` alias R#ActionRunner (`builtinExecutors`), url navigation executeUrl / executeNavigation / navigateTo in R#ActionRunner; no console host handler under `url` / `navigation`: objectui @b9814b334 packages/app-shell/src/console/ConsoleShell.tsx, packages/app-shell/src/hooks/useConsoleActionRuntime.tsx, packages/app-shell/src/console/AppContent.tsx, packages/app-shell/src/hooks/useObjectActions.ts; onSuccess R#handlePostExecution R#readOnSuccessNavigation; operation read before any executor R#execute (isUpdateOperationAction), forwarded A action-button.tsx#forwarded; patch merged only when a plain object (executeUpdateOperation in R#ActionRunner); unchanged at objectui main 866763bd and 72594f29; [24] section 4b, the action:* quartet and element:* lists (shared reads; A = objectui @b9814b334 packages/components/src/renderers/action/): publication objectui @b9814b334 packages/core/src/registry/public-blocks.ts#PUBLIC_BLOCKS; `autoTrigger` \"NOT persisted metadata\" in the header of A auto-trigger.ts#useAutoTriggerOnce; the Button primitive vocabulary objectui @b9814b334 packages/components/src/ui/button.tsx#buttonVariants; `objectName` forwarded by A action-button.tsx#forwarded and A action-icon.tsx#forwarded, and per member by A action-group.tsx#paramsPayload and A action-menu.tsx#paramsPayload; history: objectui#11168 slices 1-2, objectui#11212 (fail-closed `visible`), objectui#11182 (host `disabled` verdict), objectui#11344 (`outcomeMessages`), objectui#11638 (member `properties.params` read retired), objectui#11839 (disabled reason), objectui#11880 (repeater binding), objectui#12085 (repeater publishes only the binding); [25] action:button (ActionButtonPropsSchema; B = objectui @b9814b334 packages/components/src/renderers/action/action-button.tsx; the retired gate's quoted first line `type: schema.actionType,` holds at B#forwarded): name B#isVisible (diagnostic label) B#forwarded; label B#button (React child) B#isVisible B#forwarded; icon B#Icon (resolveIcon); actionType B#forwarded (`type`); variant, size B#variant and its `size` twin; visible B#isVisible B#ActionButtonRenderer (hasDeclaredVisibilityGate); disabled B#isDisabled B#button; params B#staticValues (withUndoBaseline) and objectui @b9814b334 packages/components/src/renderers/action/static-params.ts#readStaticParamValues; the forwarded set B#forwarded (outcomeMessages forwarded since objectui#11344, left unpublished in the registration's inputs inside B#ActionButtonRenderer); undo baseline B#withUndoBaseline; [26] action:icon (ActionIconPropsSchema; I = objectui @b9814b334 packages/components/src/renderers/action/action-icon.tsx; the retired gate's quoted first line `type: schema.actionType,` holds at I#forwarded): size not read I#size (fixed `'icon'`); the forward I#forwarded (no undoable / recordIdField; outcomeMessages since objectui#11344); label I#isVisible I#forwarded I#button (aria-label, initial letter, DisabledReasonTrigger heading) I#ActionIconRenderer (tooltip); visible I#isVisible I#ActionIconRenderer; disabled I#isDisabledPred I#button; variant I#variant; params I#staticValues (readStaticParamValues); [27] action:group / action:menu members (actionContainerMember; G = objectui @2e818d0b51ec packages/components/src/renderers/action/action-group.tsx, M = objectui @2e818d0b51ec packages/components/src/renderers/action/action-menu.tsx, P = objectui @2e818d0b51ec packages/components/src/renderers/action/static-params.ts): drawn G#InlineActionButton G#DropdownActionItem M#ActionMenuItem; visible G#useMemberVisible M#useMenuActionVisible; size (group only) G#btnSize; tags G#showSeparator; locations G#actions (actionRendersAt); forwarded G#staticValues M#staticValues (the forward block); object params forwarded on `api` and dropped, with a development-build warning only, for every other `type` P#readActionEntryParamValues (first cited at objectui @0abd4f9f8, same symbol); refused: properties.params P#readMemberStaticParamValues, className G#handleClick, autoTrigger M#ActionAutoTrigger (useAutoTriggerOnce), onClick G#handleExecute M#handleExecute; the array forward (the retired gate's quoted first line `const paramsPayload: ActionDef = Array.isArray(action.params)`) objectui @b9814b334 packages/components/src/renderers/action/action-group.tsx#paramsPayload and objectui @b9814b334 packages/components/src/renderers/action/action-menu.tsx#paramsPayload, other values objectui @b9814b334 packages/components/src/renderers/action/static-params.ts#readActionEntryParamValues; the fork measured at objectui @ab1879721595, files byte-identical at objectui main 2abec3a96; [28] action:group (ActionGroupPropsSchema; G = objectui @b9814b334 packages/components/src/renderers/action/action-group.tsx): actions G#declaredActions G#actions (actionRendersAt) G#useMemberVisible G#paramsPayload; display G#display; label, icon, variant, size G#TriggerIcon (the trigger; size raw to inline members) G#btnSize; visible G#isVisible; the host `disabled` verdict (objectui#11182) G#ActionGroupRenderer; group `name` unread (the comment above G#ActionGroupRenderer, objectui#11168); the registration's inputs inside G#ActionGroupRenderer; [29] action:menu (ActionMenuPropsSchema; M = objectui @b9814b334 packages/components/src/renderers/action/action-menu.tsx): actions M#ActionMenuRenderer M#ActionMenuItem M#ActionAutoTrigger M#paramsPayload; label, icon, variant, size, visible and the host `disabled` verdict (objectui#11182) M#ActionMenuRenderer; the registration's inputs inside M#ActionMenuRenderer (objectui#11168 slice 1); [30] element:definition-list (ElementDefinitionListPropsSchema; L = objectui @b9814b334 packages/components/src/renderers/basic/data-list.tsx; the retired gate's quoted first line `const props = readProps<{` holds at L#props): items, columns (`=== 2`), inline, term, description L#DefinitionListRenderer (an item's `description` shown through L#toText: a string or number as text, an object as JSON; first cited at objectui @89cad75d55); the registration's number enum and optional `items` (objectui#11168 slice 2) at its `definition-list` registration in L; the designer's number control objectui @b9814b334 packages/app-shell/src/views/metadata-admin/previews/block-config.ts#BLOCK_CONFIG; [31] element:repeater (ElementRepeaterPropsSchema; L = objectui @b9814b334 packages/components/src/renderers/basic/data-list.tsx; the quoted first line `const props = readProps<{` holds at L#props in L#RepeaterRenderer): object, filter, sort, limit, emptyText, divided, titleField, fields L#RepeaterRenderer; the binding L#dataBinding (useElementDataSource, objectui#11880, aaba8655a); resolved filter tokens L#filterScope; delivery objectui @b9814b334 packages/data-objectstack/src/index.ts#ObjectStackAdapter (translateFilterArray arm) and objectui @b9814b334 packages/data-objectstack/src/index.ts#serializeOrderBy; the registration wrapped by elementDataSourceBlock publishes no `object` input since objectui#12085 (ce991bd70); the registration's `object` required flag read at objectui @20c6d351a; [32] object-grid.aggregations[] (GridAggregationSchema): objectui @89cad75d55 packages/plugin-grid/src/ObjectGrid.tsx#groupHeaders (useGroupedData) and objectui @89cad75d55 packages/plugin-grid/src/ObjectGrid.tsx#serverGroupSource (useServerGroupHeaders); the grid's own function vocabulary objectui @89cad75d55 packages/plugin-grid/src/useGroupedData.ts#AggregationType (unchanged at objectui @b9814b334); [33] object-grid (ObjectGridPropsSchema, per key; E = objectui @eb7f586b packages/plugin-grid/src/ObjectGrid.tsx): columns, fields E#schemaFields; filter E#schemaFilterSource (toFilterNode); defaultFilters E#cols (legacy fallback); sort E#schemaSort; defaultSort E#cols; pagination, pageSize, showPagination E#lastFindParamsRef E#schemaPageSize E#paginationEnabled; searchableFields, showSearch E#trimmedSearch E#searchEnabled; rowHeight E#ObjectGrid (resolveRowHeightMode); grouping, aggregations E#grouping E#groupValueFormatter; rowColor E#getRowClassName; conditionalFormatting E#predicateFields E#rules; selection, selectable E#ObjectGrid; rowActions E#rowActionsList; batchActions, bulkActions E#declaredBulkActions; bulkActionDefs E#legacyBulkNames; navigation E#navigation; editable, singleClickEdit, resizable, resizableColumns, reorderableColumns E#dataTableSchema; frozenColumns E#effectiveFrozenColumns; showColumnTypeIcons E#fieldMeta; exportOptions, operations E#declared; label, title E#fileNameFor E#dataTableSchema; data, staticData E#getDataConfig; [#20694] description objectui @db11afd4967c packages/plugin-grid/src/ObjectGrid.tsx#resolvedDescription (drawn as `gridDescription`); emptyState objectui @db11afd4967c packages/plugin-grid/src/ObjectGrid.tsx#authoredEmptyState (DataEmptyState in renderedGridContent); keyboardNavigation 0 read points at objectui @db11afd4967c, 15 hit lines at objectui @ab1879721595 and at objectui @b9814b334 against 3 then 4 for the control `schema.editable` (the fourth objectui @b9814b334 packages/plugin-grid/src/ObjectGrid.tsx#carriesConcurrencyToken); [34] object-grid.description, object-grid.emptyState (B = objectui @b9814b334 packages/plugin-grid/src/ObjectGrid.tsx): description objectui @db11afd4967c packages/plugin-grid/src/ObjectGrid.tsx#resolvedDescription; emptyState title/message B#authoredEmptyTitle (resolveInlineI18nLabel, objectui#11227 carried by 6158e4c93; raw at objectui @89cad75d5570) drawn in B#renderedGridContent, link (objectui#12270, b434f5f47) B#renderedGridContent (EmptyStateLink); [35] object-grid.columns (ListViewSchema.shape.columns; A = objectui @ab1879721595 packages/plugin-grid/src/ObjectGrid.tsx): A#normalizeColumns (first-entry dispatch) A#cols A#fieldName (resolvesToDataColumn); the retired `colOverride?.options` read: A#groupValueFormatter A#rawOptions (objectui b0bf413, objectui#11544); the footer summary's off-contract column keys objectui @ab1879721595 packages/plugin-grid/src/useColumnSummary.ts#columnHints; [36] object-grid.fields (O = objectui @89cad75d55 packages/plugin-grid/src/ObjectGrid.tsx): O#schemaFields O#fieldsToShow O#generateColumns; the host hand-off projection O#getSelectFields; [37] object-grid.filter: objectui @53ded82b packages/plugin-grid/src/ObjectGrid.tsx#ObjectGrid (toFilterNode before `$filter`); [38] object-grid.sort (S = objectui @53ded82b packages/plugin-grid/src/ObjectGrid.tsx): S#schemaSort S#cols (the fetch path's string arm and array arm) S#declaredSort (parseSchemaSort, `TableSortItem[]`); the registration's array input objectui @53ded82b packages/plugin-grid/src/index.tsx (`{ name: 'sort', type: 'array' }`); [39] object-grid.defaultSort (REMOVED, tombstone; D = objectui @190fbd01d packages/plugin-grid/src/ObjectGrid.tsx): D#cols (`$orderby` fallback) and D#declaredSort (`[schema.defaultSort]`); [40] object-grid.pagination (D = objectui @d18322415 packages/plugin-grid/src/ObjectGrid.tsx): pageSize D#lastFindParamsRef D#schemaPageSize D#pageSize; pageSizeOptions D#dataTableSchema; presence D#paginationEnabled; the registry input objectui @d18322415 packages/plugin-grid/src/index.tsx (`pagination`, type object); [41] object-grid members read at objectui @89cad75d55 (O = objectui @89cad75d55 packages/plugin-grid/src/ObjectGrid.tsx): rowHeight O#resolveRowHeightMode; aggregations O#groupHeaders (useGroupedData) O#serverGroupSource (useServerGroupHeaders); conditionalFormatting O#getRowStyle (resolveConditionalFormatting); rowColor O#getRowClassName (useRowColor); selection and selectable O#ObjectGrid (the selection-mode branch) O#dataTableSchema (`selectable`), read by objectui @89cad75d55 packages/components/src/renderers/complex/data-table.tsx#resolveSelectionMode; rowActions O#rowActionsDeclared; bulkActions, batchActions O#declaredBulkActions (`batchActions ?? bulkActions`); bulkActionDefs O#legacyBulkNames (resolveBulkActions); [42] object-grid.navigation: objectui @89cad75d55 packages/plugin-grid/src/ObjectGrid.tsx#navigation (useNavigationOverlay); [43] object-grid.keyboardNavigation (built by objectui 154075ab1, objectui#11068; declared ahead of its reader, which read nothing at objectui @db11afd4967c): objectui @b9814b334 packages/plugin-grid/src/ObjectGrid.tsx#dataTableSchema (`schema.keyboardNavigation ?? inlineEditable`) with objectui @b9814b334 packages/plugin-grid/src/ObjectGrid.tsx#inlineEditable; objectui @b9814b334 packages/components/src/renderers/complex/data-table.tsx#DataTableRenderer (default false, `role=\"grid\"`, the roving tabIndex), objectui @b9814b334 packages/components/src/renderers/complex/data-table.tsx#navigationTarget and objectui @b9814b334 packages/components/src/renderers/complex/data-table.tsx#handleCellKeyDown; [44] object-grid.resizableColumns (REMOVED, tombstone): objectui @89cad75d55 packages/plugin-grid/src/ObjectGrid.tsx#dataTableSchema (`schema.resizable ?? schema.resizableColumns ?? true`); [45] object-grid.operations (GridOperationsSchema; O = objectui @89cad75d55 packages/plugin-grid/src/ObjectGrid.tsx): create O#dataTableSchema (showAddRow); update, delete O#wantDeleteAction; export O#handleExport O#dataTableSchema (exportable) O#exportEnabled; a declared block replaces the default O#explicitOperations; [46] object-grid.data: the registry input objectui @b9814b334 packages/plugin-grid/src/index.tsx#GRID_QUERY_INPUTS (`{ name: 'data', type: 'object' }`, held by gridDataInputContract.test.ts); re-read 2026-10-15, first cited sha-less; [47] object-metric.aggregate (ObjectMetricAggregateSchema; M = objectui @89cad75d55 packages/plugin-dashboard/src/ObjectMetricWidget.tsx): field, function M#computeOne; function also M#fieldUnitApplies (answersInFieldUnit) M#inferredFormat (min/max width); groupBy M#computeOne (objectAggregateSpecQuery; floored at `'_all'`) typed on M#ObjectMetricWidgetProps (`ChartAggregate['groupBy']`); objectui's unit-rule pin ObjectMetricWidget.countNotCurrency-10356.test.tsx mounts one tile per AggregationFunction member; [48] object-metric.trend (ObjectMetricTrendSchema; W = objectui @89cad75d55 packages/plugin-dashboard/src/): drawn in W MetricWidget.tsx#MetricWidget (`{value}%`, direction arrow), caption W MetricWidget.tsx#localizedTrendLabel (pickLocalized); declared on W MetricWidget.tsx#MetricWidgetProps, mirrored on W ObjectMetricWidget.tsx#ObjectMetricWidgetProps; replaced by a derived trend in W ObjectMetricWidget.tsx#effectiveTrend; [49] object-metric.drillDown (ObjectMetricDrillDownSchema; A = objectui @ab1879721595): enabled A packages/plugin-dashboard/src/ObjectMetricWidget.tsx#drillEnabled (isDrillEnabled); title A packages/plugin-dashboard/src/ObjectMetricWidget.tsx#drawerTitle (resolveDrillTitle against an empty event); target, columns, maxRows A packages/plugin-dashboard/src/ObjectMetricWidget.tsx#drillDrawer (DrillDownDrawer); `filter` / `mode` refused as objectui's own A packages/types/src/data-display.ts#ObjectMetricDrillDownConfig refuses them; measured writers, objectui's drill-down member pins A packages/plugin-dashboard/src/__tests__/objectMetricDrillDownMembers-8071.test.tsx and A packages/plugin-dashboard/src/__tests__/ObjectMetricWidget.drillRoutedToSharedDrawer-8970.test.tsx; [50] object-metric.drillDown.report (R = objectui @2e818d0b51ec packages/plugin-dashboard/src/): handed verbatim in R ObjectMetricWidget.tsx#drillDrawer; drawn when R DrillDownDrawer.tsx#isDatasetBoundReport holds (R DrillDownDrawer.tsx#isReportDrill), joining the metric filter into `runtimeFilter` (R DrillDownDrawer.tsx#body); typed `SpecReportInput` in objectui @2e818d0b51ec packages/types/src/data-display.ts; the fork measured at objectui @ab1879721595, both files byte-identical at objectui main 2abec3a96; [51] object-metric.compareTo (ObjectMetricCompareToSchema; A = objectui @ab1879721595): A packages/plugin-dashboard/src/ObjectMetricWidget.tsx#comparisonFilter (second aggregate over shiftFilterByCompareTo) and A packages/plugin-dashboard/src/ObjectMetricWidget.tsx#derivedTrend (compareToTrendLabelKey); both dispatch on `kind === 'previousYear'` in A packages/core/src/utils/compare-to.ts#shiftFilterByCompareTo and A packages/core/src/utils/compare-to.ts#compareToTrendLabelKey; `dimension` \"deliberately never read on this path\" in that module's header; [52] object-metric (ObjectMetricPropsSchema): objectui @eb7f586b packages/plugin-dashboard/src/ObjectMetricWidget.tsx#ObjectMetricWidgetProps (the complete destructured read set); [53] object-metric.icon (B = objectui @b9814b334 packages/plugin-dashboard/src/): published by B index.tsx#ObjectMetricBlock (`{ name: 'icon', type: 'string' }`); destructured and forwarded by B ObjectMetricWidget.tsx#ObjectMetricWidget; resolved and drawn by B MetricWidget.tsx#MetricWidget (getLazyIcon, guarded on a string); vocabulary objectui @b9814b334 packages/components/src/lib/lazy-icon.tsx#getLazyIcon; not B MetricCard.tsx (off this key's path); [54] object-metric.filter: the analytics wire lowering objectui @53ded82b packages/data-objectstack/src/index.ts#lowerAnalyticsFilterForWire (objectui#7754); before it the adapter at objectui @a472b07 posted an array verbatim (a 400, #15828); [55] object-kanban.columns[] lane (ObjectKanbanLaneSchema; K = objectui @89cad75d55 packages/plugin-kanban/src/): id K index.tsx#bucketCardsIntoColumns; title K ObjectKanban.tsx#localizeColumn; cards K index.tsx#bucketCardsIntoColumns (kept ahead of the bucketed records); limit K KanbanImpl.tsx#isLimitExceeded; className K KanbanImpl.tsx#KanbanColumnView; collapsed K KanbanImpl.tsx#isColumnCollapsed; [56] object-kanban (ObjectKanbanPropsSchema; E = objectui @eb7f586b packages/plugin-kanban/src/ObjectKanban.tsx): columns E#effectiveColumns; filter E#fetchData (`$filter`); data E#boundData; cardTitle, titleField E#explicitTitleField; cardFields E#explicitCardFields; swimlaneField, grouping E#effectiveSwimlaneField; coverImageField, conditionalFormatting via objectui @eb7f586b packages/plugin-kanban/src/index.tsx#KanbanRenderer (the spread of the authored bag); limit objectui @b9814b334 packages/plugin-kanban/src/ObjectKanban.tsx#query (`$top: resolveRowLimit(schema.limit, DEFAULT_KANBAN_FETCH_BATCH_SIZE)`), first re-read at objectui @87af769e9 where `$top: schema.limit ?? DEFAULT_KANBAN_LIMIT` had become resolveRowLimit; [57] object-kanban.columns (the first-entry dispatch): objectui @89cad75d55 packages/plugin-kanban/src/ObjectKanban.tsx#effectiveColumns (string arm only without `groupBy`); [58] object-kanban.filter: objectui @53ded82b packages/plugin-kanban/src/ObjectKanban.tsx#ObjectKanban (`$filter: schema.filter`, verbatim, lowered by ObjectStackAdapter.convertQueryParams through translateFilterArray); [59] object-kanban.limit (B = objectui @b9814b334): query B packages/plugin-kanban/src/ObjectKanban.tsx#query (`$top: resolveRowLimit(schema.limit, DEFAULT_KANBAN_FETCH_BATCH_SIZE)`) with B packages/plugin-kanban/src/ObjectKanban.tsx#DEFAULT_KANBAN_FETCH_BATCH_SIZE and the resolved filter B packages/plugin-kanban/src/ObjectKanban.tsx#filterScope; mapping B packages/plugin-kanban/src/index.tsx#OBJECT_KANBAN_DATA_SOURCE (`limit: 'limit'`); type B packages/types/src/objectql.ts#ObjectKanbanSchema (`limit?: number`); docs B content/docs/plugins/plugin-kanban.mdx (`limit: 250` snippet and Properties row); precedence B packages/react/src/element-data-source/ElementDataSourceGate.tsx#useElementDataSourceSchema (binding cap over this key; a view's pagination.pageSize only when unset) and B packages/react/src/element-data-source/ElementDataSourceGate.tsx#isUsableRowLimit; the saved-view fallback B packages/core/src/data-scope/element-data-source.ts#savedViewRawLimit; first re-read at objectui @87af769e9 2026-09-20; [60] object-master-detail-form (ObjectMasterDetailFormPropsSchema): objectui @eb7f586b packages/plugin-form/src/MasterDetailForm.tsx#MasterDetailForm; auto-derivation objectui @eb7f586b packages/plugin-form/src/deriveMasterDetail.ts; the `formType` pair objectui's MasterDetailFormSchema declares; [61] object-kanban.quickAdd (RETIRED, tombstone; K = objectui @b9814b334 packages/plugin-kanban/src/): K index.tsx#KanbanRenderer passes `quickAdd` / `onQuickAdd` (the host-mounted React component); gated on both in K KanbanImpl.tsx#KanbanColumnView; K KanbanBoardCore.tsx#KanbanBoardCoreProps reads neither off `schema` (objectui#11234); K ObjectKanban.tsx#ObjectKanban names `quickAdd` and `onQuickAdd` 2 times each (both in comments) against 11 for `onCardClick` (method: occurrences in that file, re-counted 2026-10-15); no `kanban-ui` registration at that sha (objectui#8257; the name survives only in tests and the changelog); through objectui @db11afd49 the board's spread forwarded the key; [62] object-kanban.conditionalFormatting (A = objectui @ab1879721595): A packages/plugin-kanban/src/KanbanBoardCore.tsx#processedColumns hands it to the board; A packages/plugin-kanban/src/KanbanImpl.tsx#getCardStyles evaluates it through A packages/core/src/evaluator/listConditional.ts#resolveConditionalFormatting, the evaluator of A packages/plugin-grid/src/ObjectGrid.tsx#getRowStyle; objectui's declaration A packages/types/src/zod/objectql.zod.ts#KanbanConditionalFormattingRuleSchema and A packages/types/src/objectql.ts#KanbanConditionalFormattingRule (objectui#11522, landed by objectui#11532 c73cdb5695); the stored-dialect arms in A packages/plugin-kanban/src/KanbanImpl.tsx#getCardStyles; blank-predicate fallback A packages/core/src/evaluator/listConditional.ts#evalRowPredicate; the four reader files byte-identical at objectui main 2e818d0b51; the writer census also read objectui at main; [63] object-kanban.navigation (K = objectui @b9814b334 packages/plugin-kanban/src/ObjectKanban.tsx): K#navConfig (`schema.navigation ?? { mode: 'drawer' }`) K#navigation (useNavigationOverlay) K#boardView (card click) K#renderRecordOverlay (NavigationOverlay); first re-derived at objectui @87af769e9, where the `(schema as any)` cast was still spelled; [64] object-calendar flat field keys (OBJECT_CALENDAR_FLAT_FIELD_GUIDANCE): objectui @b9814b334 packages/plugin-calendar/src/ObjectCalendar.tsx#getCalendarConfig (the backward-compat flat fallback); re-read 2026-10-15, first cited sha-less; [65] object-calendar (ObjectCalendarPropsSchema; C = objectui @eb7f586b packages/plugin-calendar/src/ObjectCalendar.tsx): calendar C#getCalendarConfig; defaultView C#schemaDefaultView; locale, loading via the registry shell objectui @eb7f586b packages/plugin-calendar/src/index.tsx; [66] object-calendar.calendar (CalendarConfigSchema by reference; C = objectui @89cad75d55 packages/plugin-calendar/src/ObjectCalendar.tsx): C#getCalendarConfig returns the block; its five bindings read in C#queryFilter, C#handleEventDropDefault and C#submitQuickCreate; [67] object-calendar.filter: objectui @53ded82b packages/plugin-calendar/src/ObjectCalendar.tsx#fetchData (`$filter: schema.filter`, verbatim); [68] object-tree.navigation: objectui @89cad75d55 packages/plugin-tree/src/ObjectTree.tsx#navigation (useNavigationOverlay); [69] object-calendar.sort: objectui @53ded82b packages/plugin-calendar/src/ObjectCalendar.tsx#fetchData (`$orderby: convertSortToQueryParams(schema.sort)`), whose sink objectui @53ded82b packages/core/src/utils/sort-query.ts#convertSortToQueryParams still lowered the string clause there; no `sort` input on objectui @53ded82b packages/plugin-calendar/src/index.tsx; [70] object-timeline flat config keys (OBJECT_TIMELINE_FLAT_CONFIG_GUIDANCE): the backward-compat flat rung (with the `dateField` alias) in objectui @b9814b334 packages/plugin-timeline/src/ObjectTimeline.tsx#startDateField and its siblings; emitted by objectui @b9814b334 packages/plugin-list/src/ListView.tsx#viewComponentSchema (`case 'timeline'`); re-read 2026-10-15, first cited sha-less; [71] object-calendar.navigation (C = objectui @b9814b334 packages/plugin-calendar/src/ObjectCalendar.tsx): C#navConfig (`schema.navigation ?? { mode: 'drawer' }`) C#navigation (useNavigationOverlay) C#calendarView (event click) C#renderRecordOverlay (NavigationOverlay); [72] object-timeline.mapping (ObjectTimelineMappingSchema; A = objectui @ab1879721595 packages/plugin-timeline/src/ObjectTimeline.tsx; unchanged at objectui main 94985a92ba): title A#titleField; date A#startDateField; description A#descField; variant A#variantField; declared on A#ObjectTimelineProps (`mapping`) and A#TimelineMappingSchema; [73] object-timeline.items[] arms (R = objectui @2e818d0b51ec packages/plugin-timeline/src/renderer.tsx; unchanged at objectui main 2abec3a96): gantt bar startDate / endDate R#dimensions R#calculateDateRange, variant and title in R#TimelineRenderer (the bar and its tooltip); feed entry time R#dateLabel and title, description, variant, icon, content (renderChildren), className in R#TimelineRenderer; gantt row label R#TimelineRenderer (TimelineGanttLabel), items R#classifyGanttRows; vocabulary objectui @2e818d0b51ec packages/types/src/data-display.ts#TimelineItemVariant; element shape and refinement objectui @2e818d0b51ec packages/types/src/zod/data-display.zod.ts#TimelineItemSchema and objectui @2e818d0b51ec packages/types/src/zod/data-display.zod.ts#timelineItemsFitVariant; [74] object-form.layout retired values `inline` / `grid` (OBJECT_FORM_LAYOUT_RETIRED; P = objectui @f8a9d0fb0 packages/plugin-form/src/): folded to 'vertical' in P ObjectForm.tsx#SimpleObjectForm; only 'vertical' / 'horizontal' passed through by P ObjectForm.tsx#drawerLayout, ObjectForm.tsx#modalLayout, DrawerForm.tsx#formLayout, ModalForm.tsx#formLayout; 'vertical' hard-coded in P TabbedForm.tsx#TabbedForm, SplitForm.tsx#panes, WizardForm.tsx#WizardForm; [75] object-form.mobile (ObjectFormMobileSchema; F = objectui @89cad75d55 packages/plugin-form/src/ObjectForm.tsx): F#mobileOpts (the flat arm); stickyActions F#formSchema; stepper, stepperMinFields, stepperFieldsPerStep F#stepperMode; fullscreenLongText F#fieldsWithMobile; the `data-mobile-form` marker in F#SimpleObjectForm; [76] object-form.fields and object-master-detail-form.fields (formFieldNameList): objectui @ab1879721595 packages/plugin-form/src/MasterDetailForm.tsx#parentSchema (hands `fields` over verbatim), read by objectui @ab1879721595 packages/plugin-form/src/ObjectForm.tsx#fieldsToShow and objectui @ab1879721595 packages/plugin-form/src/flatFields.ts#buildFlatFields (a stored `{ name }` entry drawn by name; any other object entry warned by warnUnresolvedTopLevelField); unchanged at objectui main 94985a92ba; the retired inline field entry an author was taught by objectui @ab1879721595 skills/objectui/guides/page-builder.md (its object-form example; first cited sha-less); [77] object-form.customFields[] `grid` widget keys (minRows, maxRows, allowAdd, allowDelete, allowReorder, totalField, addLabel, sortField; W = objectui @9dfaca654311 packages/fields/src/widgets/GridField.tsx): declared on objectui @9dfaca654311 packages/types/src/field-types.ts#GridFieldMetadata, carried by objectui @9dfaca654311 packages/types/src/form.ts#FormField, mirrored in objectui @9dfaca654311 packages/types/src/zod/form.zod.ts#FormFieldSchema; read W#cfg W#allowAdd W#removeRow (minRows) W#totalField W#showTotal W#emit (sortField) W#GridFieldBody (maxRows, allowReorder, addLabel); objectui#11610 (merge 2abec3a96c) camelCased them; the parent-field `totalField` hand-off objectui @9dfaca654311 packages/plugin-form/src/LineItemsPanel.tsx#gridField and objectui @9dfaca654311 packages/plugin-form/src/MasterDetailForm.tsx#MasterDetailLines; the writer shape (one `grid` field carrying all eight camelCase keys) objectui @9dfaca654311 examples/schema-catalog/src/schemas/fields-grid/line-items-grid.json; [78] object-form.customFields[] retired snake_case `grid` keys (OBJECT_FORM_GRID_WIDGET_SNAKE_KEYS): objectui @9dfaca654311 packages/types/src/field-types.ts#GRID_FIELD_RETIRED_KEYS; refused by objectui @9dfaca654311 packages/fields/src/widgets/GridField.tsx#GridField, tombstoned on objectui @9dfaca654311 packages/types/src/field-types.ts#GridFieldMetadata and objectui @9dfaca654311 packages/types/src/form.ts#FormField, named in objectui @9dfaca654311 packages/types/src/zod/form.zod.ts#FormFieldSchema (retiredGridFieldKey); [79] object-form.customFields[].validation: objectui @2e818d0b51ec packages/components/src/renderers/form/form.tsx#rules (inside renderFormField: minLength/maxLength/min/max spread as `{ value, message }`, `required` read only as a message string); objectui's declaration objectui @2e818d0b51ec packages/types/src/form.ts#FieldValidationRules; [80] object-timeline.navigation: objectui @b9814b334 packages/plugin-timeline/src/ObjectTimeline.tsx#navigation (useNavigationOverlay) and objectui @b9814b334 packages/plugin-timeline/src/ObjectTimeline.tsx#effectiveSchema (entry click, overlay); re-read 2026-10-15, first cited sha-less, when the read still spelled `(schema as any).navigation`; [81] object-form.customFields[].options[] (buildObjectFormRuntimeOption; F = objectui @2e818d0b51ec packages/components/src/renderers/form/form.tsx): label, value F#BuiltinSelectControl and objectui @2e818d0b51ec packages/core/src/evaluator/optionRules.ts#optionDisplayLabel (the four option widgets); visibleWhen F#cascade (resolveCascadingOptions; each widget's useCascadingOptions); description objectui @2e818d0b51ec packages/fields/src/widgets/LookupField.tsx#filteredOptions; `value` a runtime value: F#BuiltinSelectControl (matchOptionValue), objectui @2e818d0b51ec packages/types/src/zod/form.zod.ts#SelectOptionSchema (deliberate divergence comment), objectui @2e818d0b51ec packages/plugin-designer/src/ObjectManager.tsx#formSchema (writes `{ label, value }` pairs); unchanged at objectui main fd060f076; [82] object-form.customFields[] and the inline section entry (buildObjectFormRuntimeField; F = objectui @2e818d0b51ec packages/components/src/renderers/form/form.tsx, W = objectui @2e818d0b51ec packages/fields/src/widgets/): merge objectui @2e818d0b51ec packages/plugin-form/src/customFieldsMerge.ts#mergeCustomFields from objectui @2e818d0b51ec packages/plugin-form/src/ObjectForm.tsx#generateField; carrier F#builtinBranchHasNoControl (`field.field || field`); field row F#renderFormField (name, label, description, type, required, disabled, readonly, hidden, colSpan via F#colSpanClass), widget F#resolvedType, validation F#rules, the three `*When` rules F#ruleState, placeholder and inputType F#builtinBranchHasNoControl, options and dependsOn F#cascade, multiple F#resolvedType; layout objectui @2e818d0b51ec packages/plugin-form/src/autoLayout.ts#resolveColSpan (span, colSpan) and objectui @2e818d0b51ec packages/plugin-form/src/fieldGroups.ts#deriveFieldGroupSections (group); widgets W TextAreaField.tsx#rows, FileField.tsx#multiple (accept, multiple), VectorField.tsx#dimensions, LookupField.tsx#referenceTo, NumberField.tsx#NumberField (min, max), F#renderFieldComponent (minLength, maxLength), FormulaField.tsx#returnType, SummaryField.tsx#summaryType, GridField.tsx#allColumns (columns); read and refused: visibleOn F#ruleState, condition F#legacyConditionCel, id F#fieldKey; a measured writer, the object-manager create / edit dialog (a `formType: 'modal'` block): objectui @b9814b334 packages/plugin-designer/src/ObjectManager.tsx#formSchema (first cited sha-less); [83] object-form / object-master-detail-form sections[].fields[] `{ field }` entry (buildObjectFormSectionFieldEntry): objectui @2e818d0b51ec packages/plugin-form/src/sectionFields.ts#resolveSectionEntry (names an object field by `field`, every other key overrides its generated definition; label/placeholder/helpText copied as they stand); [84] object-form / object-master-detail-form sections[] (buildObjectFormSection; F = objectui @2e818d0b51ec packages/plugin-form/src/ObjectForm.tsx): handed over verbatim by objectui @2e818d0b51ec packages/plugin-form/src/MasterDetailForm.tsx#parentSchema; name, label F#sectionCtx (heading) F#inertPredicateSections (per-formType maps); collapsible, collapsed F#collapse (resolveSectionCollapse); visibleWhen F#sectionCtx (divider-row predicate); columns F#secCols F#clampCol (a NUMBER only); pane objectui @2e818d0b51ec packages/plugin-form/src/SplitForm.tsx#paneOf; the inline runtime field drawn as it stands in objectui @2e818d0b51ec packages/plugin-form/src/sectionFields.ts#resolveSectionEntry; objectui's own declaration objectui @2e818d0b51ec packages/types/src/objectql.ts#ObjectFormSection; [85] object-form (ObjectFormPropsSchema: the UNION of `schema.*` reads of the simple form and the sub-forms it forwards the bag into): objectui @eb7f586b packages/plugin-form/src/ObjectForm.tsx#ObjectForm, with TabbedForm, WizardForm, SplitForm, DrawerForm, ModalForm under objectui @eb7f586b packages/plugin-form/src/; `layout` `inline` / `grid` folded to `vertical` there while the registry inputs and the designer palette offered them; [86] object-form.fields (the `{ name }` entry retired from the authoring faces by objectui#11550): a census of objectui @ab1879721595 found it written only by fixtures probing the stored read and the refusal; [87] object-form.customFields: objectui @2e818d0b51ec packages/plugin-form/src/customFieldsMerge.ts#mergeCustomFields (merged over the generated fields); at objectui @2e818d0b51ec the open runtime field spelled the eight `grid` widget keys in snake_case, camelCased by objectui#11610 (carried at objectui @9dfaca654311); [88] object-form.contentLayout: objectui @89cad75d55 packages/plugin-form/src/ModalForm.tsx#renderContent (`schema.contentLayout === 'tabbed' && groups.length > 1`), declared on objectui @89cad75d55 packages/plugin-form/src/ModalForm.tsx#ModalFormSchema; [89] object-form.submitBehavior: objectui @89cad75d55 packages/plugin-form/src/ObjectForm.tsx#handleSubmit and objectui @89cad75d55 packages/plugin-form/src/WizardForm.tsx#handleStepSubmit (switch on `kind`), redirect `url` judged by objectui @89cad75d55 packages/plugin-form/src/submitRedirect.ts#resolveSubmitRedirect (FormViewSchema.safeParse); [90] object-form.navigateOnSuccess: objectui @89cad75d55 packages/plugin-form/src/successBehavior.ts#resolveSuccessNavigate, called from objectui @89cad75d55 packages/plugin-form/src/ObjectForm.tsx#handleSubmit and objectui @89cad75d55 packages/plugin-form/src/WizardForm.tsx#handleStepSubmit; a refused value reported on the success toast in objectui @89cad75d55 packages/plugin-form/src/ObjectForm.tsx#handleSubmit; [91] object-master-detail-form.details[] (the eleven entry keys): objectui @31971ff1e28f packages/plugin-form/src/MasterDetailForm.tsx#MasterDetailDetailConfig and every `d.KEY` read in objectui @31971ff1e28f packages/plugin-form/src/MasterDetailForm.tsx#MasterDetailLines; re-read at objectui @89cad75d5570 for #21589; [92] object-master-detail-form.details[].sortField (REMOVED, tombstone): objectui @89cad75d5570 packages/plugin-form/src/MasterDetailForm.tsx#MasterDetailDetailConfig (no `sortField` member), derived by objectui @89cad75d5570 packages/plugin-form/src/deriveMasterDetail.ts#deriveDetail (SORT_FIELD_NAMES) and handed to the grid as `sort_field` in objectui @89cad75d5570 packages/plugin-form/src/MasterDetailForm.tsx#MasterDetailLines; at objectui @9dfaca654311 the same hand-off spells `sortField` (objectui#11610) from objectui @9dfaca654311 packages/plugin-form/src/deriveMasterDetail.ts#deriveDetail; an entry the renderer does not resolve loads no child schema (objectui @9dfaca654311 packages/plugin-form/src/MasterDetailForm.tsx#entries); [93] object-master-detail-form.sections, .fields: objectui @2e818d0b51ec packages/plugin-form/src/MasterDetailForm.tsx#parentSchema (handed to the parent object-form verbatim); [94] object-map flat config keys (OBJECT_MAP_FLAT_CONFIG_GUIDANCE): objectui @b9814b334 packages/plugin-map/src/ObjectMap.tsx#getMapConfig (branch 2) and objectui @b9814b334 packages/plugin-map/src/ObjectMap.tsx#warnOnShadowedFlatMapKeys and objectui @b9814b334 packages/plugin-map/src/ObjectMap.tsx#FLAT_MAP_CONFIG_KEYS; emitted by objectui @b9814b334 packages/plugin-view/src/ObjectView.tsx#ObjectView and objectui @b9814b334 packages/plugin-list/src/ListView.tsx#viewComponentSchema (`case 'map'`); re-read 2026-10-15, first cited sha-less at the then pin; [95] object-map (ObjectMapPropsSchema, per key; P = objectui @b9814b334 packages/plugin-map/src/ObjectMap.tsx, C = objectui @b9814b334 packages/core/src/utils/record-source.ts, I = objectui @b9814b334 packages/plugin-map/src/index.tsx): data P#getDataConfig C#resolveRecordSourceConfig (rung 1; the deleted array head and its carriers are recorded in the docblock above P#FLAT_MAP_CONFIG_KEYS); staticData C#resolveRecordSourceConfig (rung 2); objectName C#resolveRecordSourceConfig (rung 3) P#navigation P#warnOnLegacyFilterMapConfig P#warnOnTopLevelStyleUrl P#warnOnShadowedFlatMapKeys (cache keys) P#recordSourceObjectName (useSettledSchema); filter P#queryFilter P#fetchData (`$filter` on both fetches); sort P#fetchData (convertSortToQueryParams); map P#getMapConfig (branch 1; validates against ObjectMapConfigSchema) I#ObjectMapRenderer (its registration's `map` input); mapStyle P#getMapConfig (`schema.mapStyle || schema.map?.style`) I#ObjectMapRenderer; navigation P#navigation I#ObjectMapRenderer; enableClustering P#clusteredData I#ObjectMapRenderer; measured and not declared: style P#warnOnTopLevelStyleUrl (read only to say it is not a map style); [96] object-map.filter: objectui @b9814b334 packages/plugin-map/src/ObjectMap.tsx#queryFilter (useResolvedFilter) and objectui @b9814b334 packages/plugin-map/src/ObjectMap.tsx#fetchData (`$filter` on the inline and the object fetch); the registration's `filter` input objectui @b9814b334 packages/plugin-map/src/index.tsx#ObjectMapRenderer; [97] object-map.sort: objectui @b9814b334 packages/plugin-map/src/ObjectMap.tsx#fetchData (convertSortToQueryParams to `$orderby`, both fetches); the registration's array-form `sort` input (objectui#8220) objectui @b9814b334 packages/plugin-map/src/index.tsx#ObjectMapRenderer; [98] object-map.navigation: objectui @89cad75d55 packages/plugin-map/src/ObjectMap.tsx#navigation (hands `schema.navigation` to useNavigationOverlay) and objectui @89cad75d55 packages/react/src/hooks/useNavigationOverlay.ts#useNavigationOverlay (`navigation?.mode ?? 'page'`); [99] object-gantt flat config keys (OBJECT_GANTT_FLAT_CONFIG_GUIDANCE): objectui @b9814b334 packages/plugin-gantt/src/ObjectGantt.tsx#FLAT_GANTT_CONFIG_KEYS and objectui @b9814b334 packages/plugin-gantt/src/ObjectGantt.tsx#getGanttConfig (branch 2 reads them; a present `gantt` block is taken whole and the flat keys beside it named as ignored); re-read 2026-10-15, first cited sha-less at the then pin; [100] object-gantt.markers (objectGanttMarker): G = objectui @ab1879721595 packages/plugin-gantt/src/GanttView.tsx: objectui @ab1879721595 packages/plugin-gantt/src/ObjectGantt.tsx#ganttView (hands `schema.markers` to GanttView verbatim); date G#markers (time-zone re-base) G#resolvedMarkers (axis mapping, drops unparsable or out-of-range dates); color G#resolvedMarkers (`m.color || 'hsl(var(--primary))'`); drawn in G#GanttView and in G#buildExportSvg; the declaration taken: objectui @ab1879721595 packages/types/src/objectql.ts#ObjectGanttSchema and objectui @ab1879721595 packages/types/src/zod/objectql.zod.ts#ObjectGanttSchema (`markers`); unchanged at objectui main 94985a92ba; [101] object-gantt (ObjectGanttPropsSchema, per key; P = objectui @b9814b334 packages/plugin-gantt/src/ObjectGantt.tsx, C = objectui @b9814b334 packages/core/src/utils/record-source.ts): data P#rawDataConfig C#resolveRecordSourceConfig (rung 1); staticData C#resolveRecordSourceConfig (rung 2); objectName (rung 3) P#persistLayoutKey P#navigation; filter P#queryFilter P#reload (`$filter`); sort P#reload (convertSortToQueryParams to `$orderby`); gantt P#getGanttConfig (branch 1, validated against the spec's GanttConfigSchema) and objectui @b9814b334 packages/plugin-gantt/src/index.tsx#ObjectGanttRenderer (its registration's `gantt` object input); navigation P#navConfig P#navigation; label P#ganttView (resolveInlineI18nLabel, export file-name chain); skipWeekends, holidays P#workingCalendar; persistLayout, viewName P#persistLayoutKey; markers, criticalPath, showBaselines, readOnly, mobileReadOnly P#ganttView, readOnly also P#renderRecordOverlay; measured and not declared: title (absent from the P#ganttView export-name chain), row cap (P#reload spreads nonGridRowCeilingQuery, marked not authorable), search and searchableFields P#searchTerm P#reload (host-generated by objectui @b9814b334 packages/plugin-list/src/ListView.tsx#viewComponentSchema, its `case 'gantt'` arm); [102] object-gantt.navigation: objectui @89cad75d55 packages/plugin-gantt/src/ObjectGantt.tsx#navConfig (`schema.navigation ?? { mode: 'drawer' }`) and objectui @89cad75d55 packages/plugin-gantt/src/ObjectGantt.tsx#navigation (useNavigationOverlay); [103] object-tree flat config keys (OBJECT_TREE_FLAT_CONFIG_GUIDANCE: parentField, labelField, titleField, fields, defaultExpandedDepth): objectui @b9814b334 packages/plugin-tree/src/ObjectTree.tsx#getTreeConfig (reads the flat spellings ahead of the `tree` block; the `schema.titleField` rung's retirement is recorded in its docblock above `fieldKey`, objectui#8841), objectui @b9814b334 packages/plugin-list/src/ListView.tsx#viewComponentSchema (its `case 'tree'` arm emits parentField, `labelField: treeCfg.labelField || 'name'`, fields, defaultExpandedDepth and no titleField since objectui 3fd862510), objectui @b9814b334 packages/plugin-tree/src/index.tsx#treeInputs (`{ name: 'tree', type: 'object' }`, no flat input); [104] object-tree (ObjectTreePropsSchema, per key; P = objectui @b9814b334 packages/plugin-tree/src/ObjectTree.tsx, C = objectui @b9814b334 packages/core/src/utils/record-source.ts): data P#dataConfig (resolveRecordSourceConfig with the `view-data` arm) and C#resolveRecordSourceConfig (rung 1, authoredDataIsOnTheDeclaredArm); staticData C#resolveRecordSourceConfig (rung 2); objectName (rung 3) P#headerObjectName P#navigation P#renderRecordOverlay (each behind resolveRecordSourceObjectName) P#parentField (detectParentField); filter P#queryFilter (useResolvedFilter) P#fetchData (`$filter` on the object fetch and on the inline ValueDataSource query); tree P#getTreeConfig and objectui @b9814b334 packages/plugin-tree/src/index.tsx#treeInputs; navigation P#navigation (useNavigationOverlay); host-prop `data` (not the authored key) P#passed; measured and not declared: sort (no `$orderby` in P#fetchData), row cap (P#fetchData spreads nonGridRowCeilingQuery); [105] object-tree.objectName (optional because no ElementDataSourceGate wiring): objectui @b9814b334 packages/plugin-tree/src/index.tsx#ObjectTreeRenderer has 0 occurrences of `ElementDataSourceGate` (re-counted 2026-10-15), against controls objectui @b9814b334 packages/plugin-gantt/src/index.tsx#ObjectGanttRenderer, packages/plugin-grid/src/index.tsx, packages/plugin-calendar/src/index.tsx (3 each) and packages/plugin-map/src/index.tsx#ObjectMapRenderer (4); method: occurrences of the identifier in each `src/index.tsx`; [106] object-timeline (ObjectTimelinePropsSchema, per key; P = objectui @b9814b334 packages/plugin-timeline/src/ObjectTimeline.tsx, R = objectui @b9814b334 packages/plugin-timeline/src/renderer.tsx, I = objectui @b9814b334 packages/plugin-timeline/src/index.tsx): objectName P#ObjectTimeline (fetch gate) P#useSettledSchema P#fetchesForItself P#invalidationNonce P#fetchData P#handleRefresh P#effectiveItems P#navigation; timeline P#timelineConfig; filter P#queryFilter P#filterKey P#fetchData; sort P#sortKey P#fetchData (convertSortToQueryParams); limit P#fetchData (resolveRowLimit) P#DEFAULT_TIMELINE_LIMIT P#describeRefusedRowLimit; items P#hasInlineItems P#fetchesForItself P#effectiveItems P#hasAuthoredItems; data P#hasInlineData P#fetchesForItself P#rawData; descriptionField P#descField; mapping P#titleField P#startDateField P#descField P#variantField; variant P#hasAuthoredItems (the `timeline-unsupported-variant` refusal, objectui#6655) and I#OBJECT_TIMELINE_INPUTS; navigation P#navigation P#effectiveSchema; dateFormat, rowLabel, minDate, maxDate R#TimelineRenderer (rowLabel and minDate/maxDate in its gantt branch, R#findUnusableGanttDate) off P#effectiveSchema; measured and not declared: bind P#boundData (useDataScope), className P#effectiveSchema; [107] object-timeline.data (read off React props, not `schema`; the prop channel is SchemaRenderer's `properties.*` hoist, objectui @b9814b334 packages/react/src/SchemaRenderer.tsx#SchemaRenderer, with `object-timeline` absent from objectui @b9814b334 packages/core/src/utils/record-source.ts#RECORD_SOURCE_DATA_ARM_BY_TYPE): objectui @b9814b334 packages/plugin-timeline/src/ObjectTimeline.tsx#hasInlineData (loading seed), objectui @b9814b334 packages/plugin-timeline/src/ObjectTimeline.tsx#fetchesForItself (the query skip the fetch effect reads), objectui @b9814b334 packages/plugin-timeline/src/ObjectTimeline.tsx#rawData (row source `(props as any).data || boundData || fetchedData`), objectui @b9814b334 packages/plugin-timeline/src/ObjectTimeline.tsx#effectiveItems (the `timeline` field bindings a fetched row takes); [108] object-timeline.items: objectui @2e818d0b51ec packages/types/src/data-display.ts#TimelineFeedItem and objectui @2e818d0b51ec packages/types/src/data-display.ts#TimelineGanttItem (the declared authored entry, ruled on objectui#6356), handed to the rail verbatim at objectui @2e818d0b51ec packages/plugin-timeline/src/ObjectTimeline.tsx#effectiveItems (`if (schema.items) return schema.items`); [109] record:reference_rail, record:alert, record:quick_actions, record:history, record:line_items (rows): key sets measured from the renderers' read points; the per-key read points are recorded under each props schema below; [110] record:approval_decision (row, EMPTY key set): objectui @d03b022 packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.tsx#ApprovalDecisionRenderer (landed in objectui a368ccb, module-internal at d03b022; discards `schema`, draws from the record context); [111] record:attachments (row, EMPTY key set): objectui @47b1f0bb71 packages/app-shell/src/views/record-attachments-renderer.tsx#RecordAttachmentsRenderer (registered as `attachments` in the `record` namespace; discards `schema`); [112] cloud-connection:panel, marketplace:installed-list (rows, EMPTY key sets): objectui @b9814b334 packages/app-shell/src/console/cloud-connection/CloudConnectionPanel.tsx#CloudConnectionPanel and objectui @b9814b334 packages/app-shell/src/console/marketplace/InstalledListWidget.tsx#InstalledList (each registration is `() => `; re-read 2026-10-15, first measured sha-less at the then pin); [113] mcp:connect-agent (row, EMPTY key set): objectui @b9814b334 packages/app-shell/src/console/connect/ConnectAgentWidget.tsx#ConnectAgent (registration `() => `; re-read 2026-10-15, first measured sha-less at the then pin); [114] element:definition-list, element:repeater (rows: the three-part string-arm evidence): objectui @b9814b334 packages/components/src/renderers/basic/data-list.tsx#DefinitionListRenderer and objectui @b9814b334 packages/components/src/renderers/basic/data-list.tsx#RepeaterRenderer (both registered in the `element` namespace), objectui @b9814b334 packages/core/src/registry/public-blocks.ts#PUBLIC_BLOCKS (publication), objectui @b9814b334 packages/app-shell/src/views/metadata-admin/previews/block-types.ts#BLOCK_TYPE_META (palette), objectui @b9814b334 packages/app-shell/src/views/metadata-admin/previews/block-config.ts#BLOCK_CONFIG (each block's inspector); [115] action:button, action:group, action:menu, action:icon (rows): objectui @b9814b334 packages/core/src/registry/public-blocks.ts#PUBLIC_BLOCKS (the four `action:*` members), registered under objectui packages/components/src/renderers/action/ in the `action` namespace; [116] object-map, object-gantt, object-tree (row key sets; per-key read points under each props schema): objectui @b9814b334 packages/plugin-map/src/index.tsx#ObjectMapRenderer, objectui @b9814b334 packages/plugin-map/src/ObjectMap.tsx#ObjectMap, objectui @b9814b334 packages/plugin-gantt/src/index.tsx#ObjectGanttRenderer, objectui @b9814b334 packages/plugin-gantt/src/ObjectGantt.tsx#ObjectGantt, objectui @b9814b334 packages/plugin-tree/src/index.tsx#ObjectTreeRenderer, objectui @b9814b334 packages/plugin-tree/src/ObjectTree.tsx#ObjectTree; [117] object-timeline (row key set; per-key read points under ObjectTimelinePropsSchema): objectui @b9814b334 packages/plugin-timeline/src/index.tsx#ObjectTimelineRenderer (the `object-timeline` and `timeline` registrations), objectui @b9814b334 packages/plugin-timeline/src/ObjectTimeline.tsx#ObjectTimeline, objectui @b9814b334 packages/plugin-timeline/src/renderer.tsx#TimelineRenderer; [118] EXPRESSION_BINDABLE_TEXT_KEYS_BY_COMPONENT rows statistic, card, button (the top-level text keys each renderer reads back; R = objectui @82a9417 packages/components/src/renderers/, re-verified identical at objectui 6c68b13 on 2026-08-18): statistic `label`, `value`, `description` R data-display/statistic.tsx#StatisticRenderer; card `title`, `description` R layout/card.tsx#CardRenderer; button `label` R form/button.tsx#ButtonRenderer; the deliberate `text` omission, `schema.content || schema.value`, at its `text` registration in R basic/text.tsx; [119] PageComponent.dataSource, the PLANNED API binding (ElementApiDataSourceSchema; its record is this file's `_note`; R = objectui @7578323a544e packages/core/src/): `read.url`'s refusal of a `./` path, measured against the console's `apiBase` promotion of a root-relative `/api/…` path only, R actions/ActionRunner.ts#navigateTo; `read.method` GET alone because every read (find, findOne, aggregate) sends an explicit `method: 'GET'`, R adapters/ApiDataSource.ts#ApiDataSource; no renderer reads the binding yet, the hook admitting only a string `object`, R data-scope/element-data-source.ts#isElementDataSourceConfig; [120] PageComponent.visibleWhen (the component-level evaluator's roots): the ambient scope (`current_user`, `user`, `ctx.user`, `os.user`, `features`, nothing else) objectui @1fb1ced36243 packages/app-shell/src/providers/ExpressionProvider.tsx#buildExpressionScope; composed with `record` (only when the page has a row) and `page` by objectui @1fb1ced36243 packages/react/src/SchemaRenderer.tsx#configEvaluationScope; no `data` root (objectui#9308) in objectui @1fb1ced36243 packages/react/src/SchemaRenderer.tsx#evaluatedSchema; the files identical at objectui main f228d710; the `page:tabs` item-level evaluator is the `page:tabs items[].visibleWhen` entry;" }, "assignedProfiles": { @@ -63,7 +63,7 @@ }, "isDefault": { "status": "live", - "note": "default-page selection for an object/page-type — objectui page resolution." + "note": "the default record page of the object a record page binds — objectui page resolution (the one reading every door judges by is defaultRecordPageObject, default-record-page.ts). On any other page it is REFUSED (#23610, ruling 6190536588 Q1 A; checkPageDefaultPlacement and the registry door, one sentence misplacedDefaultPageRefusal): nothing read it there — an app opens on its first navigation item and the root landing follows the app's own isDefault." }, "interfaceConfig": { "status": "live", diff --git a/packages/spec/src/migrations/entries/semantic/18.page-is-default-off-record-page-refused.ts b/packages/spec/src/migrations/entries/semantic/18.page-is-default-off-record-page-refused.ts new file mode 100644 index 00000000000..d549ce705a5 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.page-is-default-off-record-page-refused.ts @@ -0,0 +1,54 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #23610, maintainer ruling A on Q1 (record 6190536588). An accept-set +// narrowing, not a key removal: `isDefault` stays live on a record page bound +// to an object, so there is no tombstone and no RETIRED_KEYS_BY_MAJOR row — the +// parse refuses the flag elsewhere through `checkPageDefaultPlacement` +// (ui/page.zod.ts), and the registry refuses it on a page a package registers +// without a parse. No D2 conversion and no grace window, per the ruling: the +// delete is lossless (nothing read the flag), but whether the page should be +// the app's landing — and so first in its navigation — is the author's call. +// No relevance question: the surface reaches pages a plugin registers in code +// and stored rows. +// +// No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it +// inside a code span and a table cell. +export const entry: SemanticMigration = { + id: 'page-is-default-off-record-page-refused', + surface: + 'pages[] — isDefault: true on a page that is not a record page bound to an object: a home, app, utility ' + + 'or list page, or a record page (type record, the default) with no object; in a stack, on a page a ' + + 'package registers (manifest pages, nested plugins[].pages, manifest.register), or in a page saved ' + + 'through the metadata API', + replacement: + 'Delete `isDefault` from the page. To make it the page an app opens, put its entry first in the app\'s ' + + '`navigation` (an app\'s landing page is its first navigation item, by `order`); the root landing ' + + 'follows the app\'s own `isDefault`. On a record page meant to be an object\'s default record page, ' + + 'bind it with `object` instead.', + reason: + 'PageSchema.isDefault has one meaning — the default record page of the object a record page binds, the ' + + 'page that object\'s record view opens. On every other page nothing read it: the console opens an app on ' + + 'its first navigation item and the root landing on the app marked isDefault, so a home page declared ' + + 'isDefault: true validated, saved and was served while it decided nothing. A shipped app set it on its ' + + 'home page to make a landing page that never landed; this repository\'s showcase example set it on a ' + + 'page that is its landing only because it is first in navigation, and dropped the flag with no change ' + + 'to where the app opens. The flag is now refused, with the landing\'s real spelling in the refusal: by ' + + 'the page parse (definePage; defineStack, so objectstack validate and objectstack build, with ' + + 'STACK_SCHEMA_INVALID 422; the metadata save door with 422 INVALID_METADATA) and at package ' + + 'registration (SchemaRegistry.installPackage and registerItem, 422 INVALID_METADATA). ' + + '⚠️ No D2 conversion: deleting the flag changes nothing at runtime, but where the app should open is the ' + + 'author\'s decision, made in its navigation. ' + + '⚠️ A page row already stored with the flag is served unchanged (no conversion touches it, and a stored ' + + 'row hydrates without a package, so the registry does not judge it), while saving it again is refused ' + + 'until the flag is deleted. ADR-0087.', + acceptanceCriteria: + 'Grep every page in defineStack pages sources, exported stacks, pages a plugin registers in code and every ' + + 'page row in sys_metadata for isDefault: true on a page whose type is not record, or that binds no ' + + 'object. Delete the flag; where the page should be the app\'s landing, make its navigation entry the ' + + 'first. Then objectstack validate reports nothing at pages.N.isDefault, the deployment boots without an ' + + 'INVALID_METADATA refusal naming isDefault, saving each formerly affected page through the metadata API ' + + 'succeeds, and every app opens where it opened before. A record page bound to an object parses, ' + + 'registers and saves exactly as before.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.page-template-closed-region-unplaced-refused.ts b/packages/spec/src/migrations/entries/semantic/18.page-template-closed-region-unplaced-refused.ts new file mode 100644 index 00000000000..a6ffad8738e --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.page-template-closed-region-unplaced-refused.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #23610, maintainer ruling A on Q2 (record 6190536588). Two accept-set +// narrowings of one layout vocabulary: `PageSchema.template` closes to the +// templates the console registers (`PageTemplateSchema`, the retired +// `full-width` refused by name through the enum's error map), and a region +// whose name the page's template does not place is refused through +// `checkPageRegionPlacement` (ui/page.zod.ts), against the declared slot table +// `PAGE_TEMPLATE_SLOTS`. No key is removed, so no tombstone. No D2 conversion, +// per the ruling (no alias window): `full-width` → `default` is a one-word +// edit, and a region's rename is a choice of which slot the author meant. +// +// No backticks and no pipes in `surface` — build-upgrade-guide.ts renders it +// inside a code span and a table cell. +export const entry: SemanticMigration = { + id: 'page-template-closed-region-unplaced-refused', + surface: + 'pages[].template — a name the console does not register (full-width, two-column, single-column and any ' + + 'other); and pages[].regions[].name on a kind full page — a name the page\'s template does not place ' + + '(left_sidebar, right_sidebar, content and any other name outside header, sidebar, main, aside, footer ' + + 'on default and three-column, or outside header, sidebar, main on header-sidebar-main)', + replacement: + 'A registered template: `default` (header, sidebar, main, aside, footer), `header-sidebar-main` (header, ' + + 'sidebar, main), `three-column` (header, sidebar, main, aside, footer) or `dashboard` (header, footer, ' + + 'and every other region a grid cell) — `full-width` is `default`. Name each region for the slot it ' + + 'fills: `left_sidebar` → `sidebar`, `right_sidebar` → `aside`, `content` → `main`; or choose `dashboard` ' + + 'when the regions are meant as a grid.', + reason: + 'PageSchema.template and regions[].name were free strings, and the slot names a template places were ' + + 'declared nowhere, so neither an author nor objectstack validate could know that left_sidebar is not a ' + + 'sidebar. The console laid an unregistered template out as the page type\'s default layout, and stacked ' + + 'every region its template does not place under main at full width, dropping its declared width — a ' + + 'shipped three-column home page with left_sidebar and right_sidebar drew as one column, and every door ' + + 'reported success. The template set and each template\'s slots are now part of the contract ' + + '(PageTemplateSchema, PAGE_TEMPLATE_SLOTS), measured against the console\'s template registry: an ' + + 'unregistered template is refused at template, the retired full-width (a second name for default, the ' + + 'same layout) by name with its replacement, and an unplaced region at regions.N.name, naming the ' + + 'template\'s slots — by the page parse: definePage, defineStack (so objectstack validate and objectstack ' + + 'build, STACK_SCHEMA_INVALID 422) and the metadata save door (422 INVALID_METADATA). Region names match ' + + 'case-insensitively, as the console\'s do; dashboard places every name by design; slotted, html and ' + + 'react pages, which do not lay out regions by template, are not judged on region names. ' + + '⚠️ No D2 conversion: the ruling keeps no alias window for full-width, and which slot a renamed region ' + + 'fills is the author\'s call. ' + + '⚠️ A page row already stored with an unregistered template or an unplaced region is served unchanged ' + + 'and renders as before, while saving it again is refused until it is renamed. ADR-0087.', + acceptanceCriteria: + 'Grep every page in defineStack pages sources, exported stacks and every page row in sys_metadata for a ' + + 'template outside default, header-sidebar-main, three-column and dashboard, and, on kind full pages, ' + + 'for a region name its template does not place. Rename them. Then objectstack validate reports nothing ' + + 'at pages.N.template or pages.N.regions.M.name, saving each formerly affected page through the metadata ' + + 'API succeeds, and each renamed region draws in its slot — a sidebar beside main at its declared width, ' + + 'not stacked under it. A page on full-width renders exactly as it did once it says default.', +}; diff --git a/packages/spec/src/stack-default-record-page.test.ts b/packages/spec/src/stack-default-record-page.test.ts index 7b9f34ab033..edb3bb408cc 100644 --- a/packages/spec/src/stack-default-record-page.test.ts +++ b/packages/spec/src/stack-default-record-page.test.ts @@ -122,9 +122,17 @@ describe('defineStack — two default record pages for one object in one stack', ).not.toThrow(); }); - it('control: an isDefault page that is not a record page is outside the rule', () => { - expect(() => + it('an isDefault page that is not a record page is no second default — the page parse refuses its flag (#23610)', () => { + const err = refusalOf(() => defineStack(stack([recordPage('task_a', 'drp_task', true), { name: 'task_home', label: 'Home', type: 'home', isDefault: true }])), + ); + expect({ code: err?.code, status: err?.status }).toEqual({ code: 'STACK_SCHEMA_INVALID', status: 422 }); + expect((err as { issues?: Array<{ path: unknown[] }> })!.issues!.map((i) => i.path.join('.'))).toEqual(['pages.1.isDefault']); + }); + + it('control: the same home page without the flag sits beside the default record page', () => { + expect(() => + defineStack(stack([recordPage('task_a', 'drp_task', true), { name: 'task_home', label: 'Home', type: 'home' }])), ).not.toThrow(); }); diff --git a/packages/spec/src/ui/component-record-approvals-attachments-22537.test.ts b/packages/spec/src/ui/component-record-approvals-attachments-22537.test.ts index e149813e4d9..97b4cf09b5a 100644 --- a/packages/spec/src/ui/component-record-approvals-attachments-22537.test.ts +++ b/packages/spec/src/ui/component-record-approvals-attachments-22537.test.ts @@ -45,7 +45,9 @@ const row = (type: (typeof TYPES)[number]) => ComponentPropsMap[type]; * `buildDefaultPageSchema(objectDef)`, called with no options (objectui * `app-shell/src/views/metadata-admin/anchors.ts`, `createSeed`). The * Attachments tab is the synthesizer's `buildDefaultAttachments()`, a bare - * `{ type: 'record:attachments' }`. + * `{ type: 'record:attachments' }`. The synthesizer still names the retired + * `full-width` template, a second name for `default` (#23610); the seed is + * spelled here as the closed template set requires. */ const seededRecordPage = (extraTabs: unknown[] = []) => ({ name: 'invoice_record', @@ -53,7 +55,7 @@ const seededRecordPage = (extraTabs: unknown[] = []) => ({ type: 'record', object: 'invoice', kind: 'full', - template: 'full-width', + template: 'default', regions: [ { name: 'main', diff --git a/packages/spec/src/ui/expression-scope-app-root.pin.test.ts b/packages/spec/src/ui/expression-scope-app-root.pin.test.ts index 5941f10270d..d6976f6e12f 100644 --- a/packages/spec/src/ui/expression-scope-app-root.pin.test.ts +++ b/packages/spec/src/ui/expression-scope-app-root.pin.test.ts @@ -156,9 +156,9 @@ describe('no UI prose face advertises `app` as an expression-scope root — the describe('probe controls — a zero above is only a reading if these hold', () => { /** - * LIT. `page.zod.ts` still says `app` twice, both times about the page - * TYPE (`app` vs `utility` vs `blank`) — a different word that this card - * deliberately did NOT touch. It is the standing proof that a bare /app/ + * LIT. `page.zod.ts` still says `app` about the page TYPE (`home`, `app` + * and `utility` render alike, and the liveness list) — a different word + * that this card deliberately did NOT touch. It is the standing proof that a bare /app/ * probe over this file cannot answer the scope-root question, and that the * anchored, sentence-scoped assertions above are the required shape. * @@ -167,9 +167,9 @@ describe('no UI prose face advertises `app` as an expression-scope root — the * from a file that simply stopped using the word. */ it('LIT: the `app` page TYPE prose survives, so a scoped probe is still required', () => { - expect(pageSource).toContain('`app` is an app-level page'); - // THREE occurrences on TWO lines — `grep -c` answers lines and reads 2, - // which is the whole reason this is asserted on occurrences instead. + expect(pageSource).toContain('`home`, `app` and `utility` render alike'); + // THREE occurrences on THREE lines of the page-type docblock, asserted + // on occurrences rather than lines (`grep -c`) all the same. expect(pageSource.match(/`app`/g) ?? []).toHaveLength(3); }); diff --git a/packages/spec/src/ui/object-refinement-check-exports.test.ts b/packages/spec/src/ui/object-refinement-check-exports.test.ts index 00a1cf93db6..a5ba67014b3 100644 --- a/packages/spec/src/ui/object-refinement-check-exports.test.ts +++ b/packages/spec/src/ui/object-refinement-check-exports.test.ts @@ -64,6 +64,8 @@ import { checkPagePrintComposition, checkPageSlotPair, checkPageComponentViewData, + checkPageDefaultPlacement, + checkPageRegionPlacement, } from './page.zod'; import { GlobalFilterSchema, @@ -346,6 +348,37 @@ const pageComponentViewDataFixtures: Fixture[] = [ }, ]; +// [#23610] `isDefault: true` off a record page bound to an object, and a +// region the page's template does not place. No fixture here carries `print`, +// `source`, `requires`, a `details` + `tabs` pair or a component `data`, so the +// siblings stay silent and each row exercises ONE check alone. `kind` and +// `template` are left out on the default rows on purpose: a direct call sees +// neither while the parse sees the applied `full` / `default` defaults. +const pageDefaultPlacementFixtures: Fixture[] = [ + { label: '`isDefault: true` on a `home` page', value: { ...PAGE_BASE, isDefault: true }, refusesAt: ['isDefault'] }, + { label: '`isDefault: true` on a `list` page', value: { ...PAGE_BASE, type: 'list', isDefault: true }, refusesAt: ['isDefault'] }, + { label: '`isDefault: true` on a record page bound to no object', value: { ...PAGE_BASE, type: 'record', isDefault: true }, refusesAt: ['isDefault'] }, + { label: '`isDefault: true` on a page with no `type` (the `record` default) and no object', value: { name: 'untyped_page', label: 'Untyped', isDefault: true }, refusesAt: ['isDefault'] }, + { label: '`isDefault: true` on a record page bound to an object', value: { ...PAGE_BASE, type: 'record', object: 'account', isDefault: true }, refusesAt: [] }, + { label: '`isDefault: false` on a `home` page', value: { ...PAGE_BASE, isDefault: false }, refusesAt: [] }, + { label: 'a `home` page with no `isDefault`', value: { ...PAGE_BASE }, refusesAt: [] }, +]; + +const region = (name: string, width?: string) => ({ name, ...(width ? { width } : {}), components: [] }); +const pageRegionPlacementFixtures: Fixture[] = [ + { + label: '`left_sidebar` and `right_sidebar` on `three-column`', + value: { ...PAGE_BASE, template: 'three-column', regions: [region('header'), region('left_sidebar', 'small'), region('main'), region('right_sidebar', 'medium')] }, + refusesAt: ['regions.1.name', 'regions.3.name'], + }, + { label: '`content` on a page with no `template` (the `default` default)', value: { ...PAGE_BASE, regions: [region('content')] }, refusesAt: ['regions.0.name'] }, + { label: '`aside` on `header-sidebar-main`', value: { ...PAGE_BASE, template: 'header-sidebar-main', regions: [region('main'), region('aside')] }, refusesAt: ['regions.1.name'] }, + { label: 'the five slots on `default`', value: { ...PAGE_BASE, template: 'default', regions: ['header', 'sidebar', 'main', 'aside', 'footer'].map((n) => region(n)) }, refusesAt: [] }, + { label: 'a slot name in another case (`Main`) — the renderer matches case-insensitively', value: { ...PAGE_BASE, regions: [region('Main')] }, refusesAt: [] }, + { label: 'any name on `dashboard` — its open grid', value: { ...PAGE_BASE, template: 'dashboard', regions: [region('header'), region('kpis'), region('pipeline')] }, refusesAt: [] }, + { label: 'an unplaced name on a `slotted` page — it renders its slots, not its regions', value: { ...PAGE_BASE, type: 'record', object: 'account', kind: 'slotted', regions: [region('left_sidebar')] }, refusesAt: [] }, +]; + const DATE_FILTER = { field: 'created_at', type: 'date' } as const; const dateDefaultFixtures: Fixture[] = [ @@ -562,6 +595,8 @@ const MIRRORED: MirroredSchema[] = [ { name: 'checkPagePrintComposition', check: checkPagePrintComposition, fixtures: pagePrintFixtures }, { name: 'checkPageSlotPair', check: checkPageSlotPair, fixtures: pageSlotPairFixtures }, { name: 'checkPageComponentViewData', check: checkPageComponentViewData, fixtures: pageComponentViewDataFixtures }, + { name: 'checkPageDefaultPlacement', check: checkPageDefaultPlacement, fixtures: pageDefaultPlacementFixtures }, + { name: 'checkPageRegionPlacement', check: checkPageRegionPlacement, fixtures: pageRegionPlacementFixtures }, ], cleanFixtures: [{ ...PAGE_BASE }], }, @@ -716,9 +751,9 @@ describe('each schema attaches its export BY IDENTIFIER — no inline copy', () expect(attachments(src, 'checkListViewPageMount')).toBe(0); }); - it('page.zod.ts declares all five exports and attaches each to PageSchema', () => { + it('page.zod.ts declares all seven exports and attaches each to PageSchema', () => { const src = read('page.zod.ts'); - for (const name of ['checkPageSourceCompleteness', 'checkPageRequiresKind', 'checkPagePrintComposition', 'checkPageSlotPair', 'checkPageComponentViewData']) { + for (const name of ['checkPageSourceCompleteness', 'checkPageRequiresKind', 'checkPagePrintComposition', 'checkPageSlotPair', 'checkPageComponentViewData', 'checkPageDefaultPlacement', 'checkPageRegionPlacement']) { expect(src).toContain(`export function ${name}(`); expect(declarations(src, name)).toBe(1); expect(attachments(src, name)).toBe(1); @@ -754,6 +789,8 @@ describe('`./index` (the `@objectstack/spec/ui` surface) exports the same functi ['checkPagePrintComposition', checkPagePrintComposition], ['checkPageSlotPair', checkPageSlotPair], ['checkPageComponentViewData', checkPageComponentViewData], + ['checkPageDefaultPlacement', checkPageDefaultPlacement], + ['checkPageRegionPlacement', checkPageRegionPlacement], ['checkGlobalFilterDateDefaultValue', checkGlobalFilterDateDefaultValue], ] as const)('%s — reference identity, and the `(value, ctx)` arity', (name, fn) => { expect((ui as Record)[name]).toBe(fn); diff --git a/packages/spec/src/ui/page-default-and-region-placement.test.ts b/packages/spec/src/ui/page-default-and-region-placement.test.ts new file mode 100644 index 00000000000..0329b089378 --- /dev/null +++ b/packages/spec/src/ui/page-default-and-region-placement.test.ts @@ -0,0 +1,227 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A page declares nothing the console does not honour (#23610, maintainer + * ruling A on Q1 and Q2, record 6190536588). + * + * - **Q1.** `isDefault: true` is refused on a page `defaultRecordPageObject` + * does not place — any page that is not a record page bound to an object. + * Nothing read it there: an app opens on its first navigation item and the + * root landing follows the APP's `isDefault`. The refusal names both. + * - **Q2.** `template` closes to the templates the console registers + * (`PageTemplateSchema`; `full-width`, a second name for `default`, retired + * by name), and on a `kind: 'full'` page a region whose name the template + * does not place (`PAGE_TEMPLATE_SLOTS`) is refused at that region's name, + * naming the template's slots. The console stacked such a region under + * `main` at full width and dropped its `width`. + * + * Bookkeeping shapes, pinned below: both refusals are exported object-level + * checks attached to `PageSchema` (parity in + * `object-refinement-check-exports.test.ts`); neither has a D2 conversion, so a + * stored row replays unchanged and the parse still refuses it; each has a D3 + * entry. + * + * On the assertion set: a bare `PageSchema` parse raises zod issues, pinned by + * `code`, `path` and the message's named subjects; the stack door wraps them + * in the ADR-0112 envelope, pinned by `code` and `status`. + */ + +import { describe, expect, it } from 'vitest'; + +import { applyConversionsToStoredItem } from '../conversions/stored'; +import type { ConversionNotice } from '../conversions/types'; +import { MIGRATIONS_BY_MAJOR } from '../migrations/registry'; +import { defineStack } from '../stack.zod'; +import { misplacedDefaultPageRefusal } from './default-record-page'; +import { + PAGE_TEMPLATE_OPEN_GRID, + PAGE_TEMPLATE_SLOTS, + PageSchema, + PageTemplateSchema, + definePage, +} from './page.zod'; + +const Q1_D3 = 'page-is-default-off-record-page-refused'; +const Q2_D3 = 'page-template-closed-region-unplaced-refused'; + +const region = (name: string, width?: 'small' | 'medium' | 'large' | 'full') => ({ + name, + ...(width ? { width } : {}), + components: [], +}); + +function refusalOf(value: unknown) { + const r = PageSchema.safeParse(value); + expect(r.success, 'expected a refusal').toBe(false); + return r.success ? [] : r.error.issues; +} + +function stackRefusalOf(pages: unknown[]) { + let refusal: { code?: unknown; status?: unknown; issues?: Array<{ path: unknown[]; code: string }> } | undefined; + try { + defineStack({ + manifest: { id: 'com.example.pages', name: 'pages', version: '1.0.0', type: 'app', namespace: 'pgs' }, + pages, + } as never); + } catch (e) { + refusal = e as typeof refusal; + } + expect(refusal, 'defineStack must refuse').toBeDefined(); + return refusal!; +} + +describe('Q1 — `isDefault: true` on a page that is not a record page bound to an object', () => { + it.each(['home', 'app', 'utility', 'list'] as const)( + 'refuses it on a `%s` page, at `isDefault`, naming the navigation order and the app\'s `isDefault`', + (type) => { + const issues = refusalOf({ name: 'sales_home_page', label: 'Home', type, isDefault: true }); + expect(issues).toHaveLength(1); + const [issue] = issues; + expect(issue!.code).toBe('custom'); + expect(issue!.path).toEqual(['isDefault']); + expect(issue!.message.startsWith(`\`isDefault: true\` is refused on a \`type: '${type}'\` page`)).toBe(true); + // Where a landing really is set: the app's first navigation item, and + // the APP's `isDefault` for the root landing. + expect(issue!.message).toContain('first navigation item'); + expect(issue!.message).toContain("app's own `isDefault`"); + // Runtime prose carries no tracker number (`check:doc-authoring`). + expect(issue!.message).not.toMatch(/#\d/); + }, + ); + + it('refuses it on a record page bound to no object — `type` left out (the `record` default) too — naming `object`', () => { + for (const page of [ + { name: 'orphan_record', label: 'Orphan', type: 'record', isDefault: true }, + { name: 'orphan_record', label: 'Orphan', isDefault: true }, + ]) { + const issues = refusalOf(page); + expect(issues.map((i) => i.path.join('.'))).toEqual(['isDefault']); + expect(issues[0]!.message.startsWith('`isDefault: true` is refused on a record page bound to no `object`')).toBe(true); + } + }); + + it('the parse and the registry door print the one sentence (`misplacedDefaultPageRefusal`)', () => { + const page = { name: 'sales_home_page', label: 'Home', type: 'home', isDefault: true }; + expect(refusalOf(page)[0]!.message).toBe(misplacedDefaultPageRefusal(page)); + }); + + it('`definePage` throws, and the stack door wraps the refusal at `pages.N.isDefault`', () => { + expect(() => definePage({ name: 'pgs_home', label: 'Home', type: 'home', isDefault: true })).toThrow(/`isDefault: true` is refused/); + const refusal = stackRefusalOf([ + { name: 'pgs_landing', label: 'Landing', type: 'home' }, + { name: 'pgs_home', label: 'Home', type: 'home', isDefault: true }, + ]); + expect({ code: refusal.code, status: refusal.status }).toEqual({ code: 'STACK_SCHEMA_INVALID', status: 422 }); + expect(refusal.issues!.map((i) => ({ path: i.path.join('.'), code: i.code }))).toEqual([ + { path: 'pages.1.isDefault', code: 'custom' }, + ]); + }); + + it.each([ + ['a default record page bound to an object', { name: 'acct_detail', label: 'Account', type: 'record', object: 'account', isDefault: true }], + ['a `home` page with `isDefault: false`', { name: 'pgs_home', label: 'Home', type: 'home', isDefault: false }], + ['a `home` page that leaves `isDefault` out', { name: 'pgs_home', label: 'Home', type: 'home' }], + ])('CONTROL — %s parses', (_label, page) => { + const r = PageSchema.safeParse(page); + expect(r.success, JSON.stringify(r.error?.issues ?? [])).toBe(true); + }); +}); + +describe('Q2 — the template set is closed', () => { + it('declares exactly the ruled templates and slots', () => { + expect(PAGE_TEMPLATE_SLOTS).toEqual({ + default: ['header', 'sidebar', 'main', 'aside', 'footer'], + 'header-sidebar-main': ['header', 'sidebar', 'main'], + 'three-column': ['header', 'sidebar', 'main', 'aside', 'footer'], + dashboard: ['header', 'footer'], + }); + expect(PAGE_TEMPLATE_OPEN_GRID).toEqual(['dashboard']); + expect(PageTemplateSchema.options).toEqual(Object.keys(PAGE_TEMPLATE_SLOTS)); + expect(PageSchema.parse({ name: 'p', label: 'P', type: 'home' }).template).toBe('default'); + }); + + it('refuses the retired `full-width` by name, prescribing `default`', () => { + const issues = refusalOf({ name: 'p', label: 'P', type: 'home', template: 'full-width' }); + expect(issues.map((i) => i.path.join('.'))).toEqual(['template']); + expect(issues[0]!.message.startsWith("Page `template: 'full-width'` was removed in @objectstack/spec 18")).toBe(true); + expect(issues[0]!.message).toContain("Use `template: 'default'`"); + }); + + it.each(['two-column', 'single-column', 'three-column-layout'])('refuses an unregistered `%s` at `template`', (template) => { + const issues = refusalOf({ name: 'p', label: 'P', type: 'home', template }); + expect(issues.map((i) => ({ path: i.path.join('.'), code: i.code }))).toEqual([{ path: 'template', code: 'invalid_value' }]); + }); +}); + +describe('Q2 — a region the template does not place', () => { + it('refuses `left_sidebar` / `right_sidebar` on a `three-column` page, each at its name, naming the slots and the rename', () => { + const issues = refusalOf({ + name: 'sales_home_page', + label: 'Home', + type: 'home', + template: 'three-column', + regions: [region('header'), region('left_sidebar', 'small'), region('main'), region('right_sidebar', 'medium')], + }); + expect(issues.map((i) => ({ path: i.path.join('.'), code: i.code }))).toEqual([ + { path: 'regions.1.name', code: 'custom' }, + { path: 'regions.3.name', code: 'custom' }, + ]); + const [left, right] = issues; + expect(left!.message.startsWith("Region `left_sidebar` is refused on a `template: 'three-column'` page")).toBe(true); + expect(left!.message).toContain('`header`, `sidebar`, `main`, `aside`, `footer`'); + expect(left!.message).toContain('Rename it `sidebar`'); + expect(right!.message).toContain('Rename it `aside`'); + expect(left!.message).not.toMatch(/#\d/); + }); + + it('judges a page that leaves `template` out as `default`, and names only that template\'s slots on `header-sidebar-main`', () => { + expect(refusalOf({ name: 'p', label: 'P', type: 'home', regions: [region('content')] })[0]!.message) + .toContain('Rename it `main`'); + const hsm = refusalOf({ name: 'p', label: 'P', type: 'app', template: 'header-sidebar-main', regions: [region('main'), region('aside')] }); + expect(hsm.map((i) => i.path.join('.'))).toEqual(['regions.1.name']); + expect(hsm[0]!.message).toContain('places only `header`, `sidebar`, `main`,'); + // `aside` is not one of its slots, so no rename is offered for it. + expect(hsm[0]!.message).toContain('Rename it to one of those slots'); + }); + + it('the stack door wraps the refusal at `pages.N.regions.M.name`', () => { + const refusal = stackRefusalOf([ + { name: 'pgs_home', label: 'Home', type: 'home', template: 'three-column', regions: [region('main'), region('right_sidebar')] }, + ]); + expect({ code: refusal.code, status: refusal.status }).toEqual({ code: 'STACK_SCHEMA_INVALID', status: 422 }); + expect(refusal.issues!.map((i) => i.path.join('.'))).toEqual(['pages.0.regions.1.name']); + }); + + it.each([ + ['the five slots on `default`', { template: 'default', regions: ['header', 'sidebar', 'main', 'aside', 'footer'].map((n) => region(n)) }], + ['the five slots on `three-column`', { template: 'three-column', regions: ['header', 'sidebar', 'main', 'aside', 'footer'].map((n) => region(n)) }], + ['the renamed HotCRM shape (`sidebar` / `aside`) on `three-column`', { template: 'three-column', regions: [region('header'), region('sidebar', 'small'), region('main'), region('aside', 'medium')] }], + ['a slot in another case (`Sidebar`)', { regions: [region('Sidebar'), region('MAIN')] }], + ['any names on `dashboard` — the open grid', { template: 'dashboard', regions: [region('header'), region('kpis'), region('pipeline'), region('footer')] }], + ['an unplaced name on a `slotted` record page — it renders its slots', { type: 'record', object: 'account', kind: 'slotted', regions: [region('left_sidebar')] }], + ['an unplaced name on an `html` page — it renders its source', { kind: 'html', source: '', regions: [region('content')] }], + ])('CONTROL — %s parses', (_label, extra) => { + const r = PageSchema.safeParse({ name: 'p', label: 'P', type: 'home', ...extra }); + expect(r.success, JSON.stringify(r.error?.issues ?? [])).toBe(true); + }); +}); + +describe('the ledger — two D3 entries and no D2 conversion', () => { + it.each([Q1_D3, Q2_D3])('step 18 carries `%s`, judging no mechanical edit', (id) => { + const entry = MIGRATIONS_BY_MAJOR[18]!.semantic.find((e) => e.id === id); + expect(entry, id).toBeDefined(); + expect(entry!.conversionIds ?? []).toEqual([]); + }); + + it.each([ + ['a non-record `isDefault`', { name: 'sales_home_page', label: 'Home', type: 'home', isDefault: true, regions: [] }, ['isDefault']], + ['the retired `full-width`', { name: 'p', label: 'P', type: 'record', object: 'invoice', template: 'full-width', regions: [region('main')] }, ['template']], + ['an unplaced region', { name: 'p', label: 'P', type: 'home', template: 'three-column', regions: [region('main'), region('left_sidebar')] }, ['regions.1.name']], + ])('a STORED row with %s replays unchanged — no conversion touches it — and the parse still refuses it', (_label, row, paths) => { + const notices: ConversionNotice[] = []; + const rehydrated = applyConversionsToStoredItem('page', row, { onNotice: (n) => notices.push(n) }); + expect(rehydrated).toEqual(row); + expect(notices.map((n) => n.conversionId)).toEqual([]); + expect(refusalOf(rehydrated).map((i) => i.path.join('.'))).toEqual(paths); + }); +}); diff --git a/packages/spec/src/ui/page.test.ts b/packages/spec/src/ui/page.test.ts index c0260c3da98..a8532470dcc 100644 --- a/packages/spec/src/ui/page.test.ts +++ b/packages/spec/src/ui/page.test.ts @@ -344,21 +344,22 @@ describe('PageSchema', () => { expect(issue.message).not.toContain('assignedProfiles'); }); - it('should accept page with custom template', () => { + it('should accept page with a registered template', () => { const page = PageSchema.parse({ name: 'custom_layout_page', label: 'Custom Layout Page', - template: 'three-column-layout', + template: 'three-column', regions: [], }); - expect(page.template).toBe('three-column-layout'); + expect(page.template).toBe('three-column'); }); - it('should accept default page', () => { + it('should accept a default record page bound to an object', () => { const page = PageSchema.parse({ name: 'default_page', label: 'Default Page', + object: 'account', isDefault: true, regions: [], }); From 76546a50346a160b11a2a09490cba003d9f22b90 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 15:44:05 +0000 Subject: [PATCH 03/12] wip: regenerate spec artifacts, changeset Claude-Session: https://claude.ai/code/session_01TC6DKN349m4i7okGWPe4RG Co-authored-by: Claude --- .../23610-page-isdefault-template-slots.md | 36 ++++++ content/docs/references/index.mdx | 10 +- content/docs/references/ui/page.mdx | 28 +++-- packages/spec/api-surface/ui.json | 7 ++ packages/spec/declaration-map/ui.json | 2 + packages/spec/export-origins/ui.json | 7 ++ packages/spec/src/ui/page.zod.ts | 110 +++++++++--------- 7 files changed, 133 insertions(+), 67 deletions(-) create mode 100644 .changeset/23610-page-isdefault-template-slots.md diff --git a/.changeset/23610-page-isdefault-template-slots.md b/.changeset/23610-page-isdefault-template-slots.md new file mode 100644 index 00000000000..c7d09836766 --- /dev/null +++ b/.changeset/23610-page-isdefault-template-slots.md @@ -0,0 +1,36 @@ +--- +'@objectstack/spec': minor +'@objectstack/objectql': patch +--- + +feat(spec)!: a page declares nothing the console does not honour — `isDefault: true` is refused off a record page bound to an object, `template` closes to the registered layouts, and a region the template does not place is refused + +Clause-②: yes (narrowing) + +**BREAKING (authoring):** two accept sets of `PageSchema` narrow to what the console runs, and the `PageTypeSchema` text stops promising what it does not. + +| Writing | Before | Now | +| :--- | :--- | :--- | +| `isDefault: true` on a `home`, `app`, `utility` or `list` page, or on a record page with no `object` | accepted, stored and served; nothing read it | refused at `isDefault` — by `definePage`, `defineStack` (so `os validate` and `os build`, `STACK_SCHEMA_INVALID` 422), the metadata save door (`422 INVALID_METADATA`) and package registration (`SchemaRegistry.installPackage` / `registerItem`, `422 INVALID_METADATA`) | +| `template: 'full-width'` | accepted; drawn exactly as `default` | refused by name: "Page `template: 'full-width'` was removed in @objectstack/spec 18 … Use `template: 'default'`" | +| any other `template` outside `default`, `header-sidebar-main`, `three-column`, `dashboard` (`two-column`, `single-column`, …) | accepted; drawn as the page type's default layout | refused at `template` | +| on a `kind: 'full'` page, a region its template does not place (`left_sidebar`, `right_sidebar`, `content`, …) | accepted; stacked under `main` at full width, its `width` ignored | refused at `regions.N.name`, naming the template's slots and the rename | + +Migration: + +- **`isDefault` on a non-record page** → delete it. A page does not choose where an app opens: an app's landing page is its **first navigation item** (by `order`), so to land on the page put its entry first in the app's `navigation`; the root landing follows the app's own `isDefault`. A record page meant as an object's default record page binds that object with `object`. +- **`template: 'full-width'`** → `template: 'default'` (or delete the key — `default` is the default). The page renders exactly as before. +- **An unregistered template** → one of `default` (header, sidebar, main, aside, footer), `header-sidebar-main` (header, sidebar, main), `three-column` (header, sidebar, main, aside, footer) or `dashboard` (header, footer, and every other region a grid cell). +- **An unplaced region** → name it for the slot it fills: `left_sidebar` → `sidebar`, `right_sidebar` → `aside`, `content` → `main`; or choose `dashboard` when the regions are a grid. Names match case-insensitively, as the console's do. `slotted`, `html` and `react` pages are not judged on region names. + +The template set and each template's slots are now published: `PageTemplateSchema`, `PAGE_TEMPLATE_SLOTS` and `PAGE_TEMPLATE_OPEN_GRID` (`@objectstack/spec/ui`), with the two checks `checkPageDefaultPlacement` and `checkPageRegionPlacement` and the one refusal sentence `misplacedDefaultPageRefusal` that the parse and the registry share. + +`os migrate meta` lists both as hand edits — the D3 entries `page-is-default-off-record-page-refused` and `page-template-closed-region-unplaced-refused`. No D2 conversion rewrites them: where an app opens, and which slot a region fills, are the author's calls. + +**Stored metadata keeps loading.** No conversion touches these shapes and the read path does not refuse them, so a `sys_metadata` page row already stored with a non-record `isDefault: true`, `template: 'full-width'` or an unplaced region is served unchanged and renders as before (a stored row hydrates into the registry without a package, so the registry does not judge it either). Saving such a row again through the metadata API is refused until it is fixed. + +The text, without a contract change: `PageTypeSchema`'s docblock and enum comments now say that `home`, `app` and `utility` are the same region layout at three content widths — `home` is not a landing declaration, and `utility` is an ordinary inline page (the console has no floating or docked shell slot for it); `utility` stays a page type, read by the print door and the page-type width. The `print`-on-`utility` refusal no longer calls a utility page "a floating panel". + +`@objectstack/objectql`: the registry refuses `isDefault: true` on a non-record page a package registers (`MisplacedDefaultPageError`, `INVALID_METADATA` 422), at the package door and at the package-bound item seam, with the page parse's sentence. + + diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index f5833806bf1..70d4fa1983f 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1535 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1536 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -33,8 +33,8 @@ counts are sums of the rows they head. Regenerate with | [Shared Protocol](/docs/references/shared) | 10 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 36 | 284 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | -| [UI Protocol](/docs/references/ui) | 16 | 167 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **197** | **1535** | 14 protocol modules | +| [UI Protocol](/docs/references/ui) | 16 | 168 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | +| **Total** | **197** | **1536** | 14 protocol modules | --- @@ -364,7 +364,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a ## UI Protocol -**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 167 schemas** +**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 168 schemas** Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. @@ -381,7 +381,7 @@ Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI lay | [`expression-bindable-text-keys.zod.ts`](/docs/references/ui/expression-bindable-text-keys) | `ExpressionBindableTextKey` | | [`i18n.zod.ts`](/docs/references/ui/i18n) | `AriaProps`, `I18nLabel`, `InlineLocaleMap` | | [`notification.zod.ts`](/docs/references/ui/notification) | `NotificationPosition`, `NotificationSeverity`, `NotificationType` | -| [`page.zod.ts`](/docs/references/ui/page) | `ElementApiDataSource`, `ElementDataSource`, `InterfacePageConfig`, `Page`, `PageComponent`, `PageComponentType`, `PagePrint`, `PageRegion`, `PageType`, `PageVariable` | +| [`page.zod.ts`](/docs/references/ui/page) | `ElementApiDataSource`, `ElementDataSource`, `InterfacePageConfig`, `Page`, `PageComponent`, `PageComponentType`, `PagePrint`, `PageRegion`, `PageTemplate`, `PageType`, `PageVariable` | | [`report.zod.ts`](/docs/references/ui/report) | `JoinedReportBlock`, `Report`, `ReportChart`, `ReportSort`, `ReportType` | | [`responsive.zod.ts`](/docs/references/ui/responsive) | `ResponsiveStyles`, `StyleMap` | | [`sharing.zod.ts`](/docs/references/ui/sharing) | `SharingConfig` | diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 374314d87c7..ec63f7df362 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -1,7 +1,7 @@ --- title: Page schema — UI Protocol property reference navTitle: Page -description: "Page schemas of the ObjectStack UI Protocol: ElementApiDataSource, ElementDataSource and 8 more — each property with its type, default and a TypeScript example." +description: "Page schemas of the ObjectStack UI Protocol: ElementApiDataSource, ElementDataSource and 9 more — each property with its type, default and a TypeScript example." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -13,8 +13,8 @@ description: "Page schemas of the ObjectStack UI Protocol: ElementApiDataSource, ## TypeScript Usage ```typescript -import { ElementApiDataSourceSchema, ElementDataSourceSchema, InterfacePageConfigSchema, PageSchema, PageComponentSchema, PageComponentType, PagePrintSchema, PageRegionSchema, PageTypeSchema, PageVariableSchema } from '@objectstack/spec/ui'; -import type { ElementApiDataSource, ElementDataSource, InterfacePageConfig, Page, PageComponent, PageComponentType, PagePrint, PageRegion, PageType, PageVariable } from '@objectstack/spec/ui'; +import { ElementApiDataSourceSchema, ElementDataSourceSchema, InterfacePageConfigSchema, PageSchema, PageComponentSchema, PageComponentType, PagePrintSchema, PageRegionSchema, PageTemplateSchema, PageTypeSchema, PageVariableSchema } from '@objectstack/spec/ui'; +import type { ElementApiDataSource, ElementDataSource, InterfacePageConfig, Page, PageComponent, PageComponentType, PagePrint, PageRegion, PageTemplate, PageType, PageVariable } from '@objectstack/spec/ui'; // Validate data const result = ElementApiDataSourceSchema.parse(data); @@ -207,9 +207,9 @@ View filter rule | **type** | `Enum<'record' \| 'home' \| 'app' \| 'utility' \| 'list'>` | optional (default: `"record"`) | Page type | | **variables** | `{ name: string; type?: Enum<'string' \| 'number' \| 'boolean' \| 'object' \| 'array' \| 'record_id'>; defaultValue?: any; source?: string }[]` | optional | Local page state, exposed to expressions as `page.` and writable by interactive elements via `source` (master/detail, filtered dashboards). | | **object** | `string` | optional | Bound object (for Record pages) | -| **template** | `string` | optional (default: `"default"`) | Layout template name (e.g. "header-sidebar-main") | +| **template** | `Enum<'default' \| 'header-sidebar-main' \| 'three-column' \| 'dashboard'>` | optional (default: `"default"`) | Layout template — 'default', 'header-sidebar-main', 'three-column' or 'dashboard'. Each places its own regions (PAGE_TEMPLATE_SLOTS); a region the template does not place is refused at the region's name. | | **regions** | `{ name: string; width?: Enum<'small' \| 'medium' \| 'large' \| 'full'>; components: object[] }[]` | optional | Layout regions (header, main, sidebar, footer) with their components. Optional — list pages use interfaceConfig, slotted pages use slots, and an empty full page falls back to the synthesized default layout. | -| **isDefault** | `boolean` | optional (default: `false`) | Whether this record page is the default record page of its `object` — the page the record view opens. At most one `isDefault: true` record page may exist for an object in a deployment: a second is refused (by defineStack / os validate, at package registration and at metadata save), naming both pages. Saving a page under the existing default's own name is that page, not a second default. Outside this rule for a page that is not a record page bound to an object. | +| **isDefault** | `boolean` | optional (default: `false`) | Whether this record page is the default record page of its `object` — the page the record view opens. At most one `isDefault: true` record page may exist for an object in a deployment: a second is refused (by defineStack / os validate, at package registration and at metadata save), naming both pages. Saving a page under the existing default's own name is that page, not a second default. Refused on any other page — a page that is not a record page bound to an object: nothing reads it there. An app opens on its first navigation item, and the root landing follows the app's own isDefault. | | **assignedProfiles** | `never` | optional | [REMOVED] `page.assignedProfiles` was removed in @objectstack/spec 17.5.0 (ADR-0090 D2, ADR-0049 enforce-or-remove) — it was named for the Profile concept ADR-0090 D2 deleted, and it gated nothing: no renderer, route or metadata read door ever read the key, so a page that "assigned profiles" stayed open to every caller who could reach it. Delete the key. Page audience is the permission set's: gate the DATA the page shows with the object's permission sets, and bind those sets to people through positions (named in a position's `permissionSets`) — those are the checks the runtime actually runs. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **interfaceConfig** | `{ source?: string; columns?: string[] \| object[]; sort?: object[]; filterBy?: object[]; … }` | optional | Interface-level page configuration (for Airtable-style interface pages) | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | @@ -239,7 +239,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Region name (e.g. "sidebar", "main", "header") | +| **name** | `string` | ✅ | Region name — a slot the page's `template` places: header, sidebar, main, aside or footer on 'default' and 'three-column'; header, sidebar or main on 'header-sidebar-main'; any name on 'dashboard' (header and footer span it, every other region is a grid cell). A name the template does not place is refused. | | **width** | `Enum<'small' \| 'medium' \| 'large' \| 'full'>` | optional | | | **components** | `{ type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| …> \| string; id?: string; label?: string \| Record; properties?: Record; … }[]` | ✅ | Components in this region | @@ -424,7 +424,7 @@ Print declaration: paper size, orientation, margins, the running header and foot | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Region name (e.g. "sidebar", "main", "header") | +| **name** | `string` | ✅ | Region name — a slot the page's `template` places: header, sidebar, main, aside or footer on 'default' and 'three-column'; header, sidebar or main on 'header-sidebar-main'; any name on 'dashboard' (header and footer span it, every other region is a grid cell). A name the template does not place is refused. | | **width** | `Enum<'small' \| 'medium' \| 'large' \| 'full'>` | optional | | | **components** | `{ type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| …> \| string; id?: string; label?: string \| Record; properties?: Record; … }[]` | ✅ | Components in this region | @@ -447,6 +447,20 @@ Print declaration: paper size, orientation, margins, the running header and foot | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | +--- + +## PageTemplate + +Page layout template — one the console registers: 'default' (header, sidebar, main, aside, footer), 'header-sidebar-main' (header, sidebar, main), 'three-column' (header, sidebar, main, aside, footer) or 'dashboard' (header, footer, and every other region a grid cell). + +### Allowed Values + +* `default` +* `header-sidebar-main` +* `three-column` +* `dashboard` + + --- ## PageType diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index f82a10d0277..d1253cab225 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -313,6 +313,8 @@ "ObjectTreePropsParsed (type)", "ObjectTreePropsSchema (const)", "ObjectUserFiltersSchema (const)", + "PAGE_TEMPLATE_OPEN_GRID (const)", + "PAGE_TEMPLATE_SLOTS (const)", "PAGE_TYPE_ROADMAP (const)", "PRINTABLE_PAGE_COMPONENT_TYPES (const)", "PRINT_REFUSED_PAGE_COMPONENT_TYPES (const)", @@ -339,6 +341,8 @@ "PageRegionSchema (const)", "PageSchema (const)", "PageTabsProps (const)", + "PageTemplate (type)", + "PageTemplateSchema (const)", "PageType (type)", "PageTypeSchema (const)", "PageVariable (type)", @@ -497,7 +501,9 @@ "checkListViewCalendarVisualization (function)", "checkListViewChartBinding (function)", "checkPageComponentViewData (function)", + "checkPageDefaultPlacement (function)", "checkPagePrintComposition (function)", + "checkPageRegionPlacement (function)", "checkPageRequiresKind (function)", "checkPageSlotPair (function)", "checkPageSourceCompleteness (function)", @@ -529,6 +535,7 @@ "isRecordContextBlockType (function)", "isViewContainerShaped (function)", "listViewGroupKeyPredicate (function)", + "misplacedDefaultPageRefusal (function)", "normalizeFilterOperator (function)", "normalizeInlineAction (function)", "objectNavTargetExclusivity (function)", diff --git a/packages/spec/declaration-map/ui.json b/packages/spec/declaration-map/ui.json index 0c8d6887148..6c451181ba4 100644 --- a/packages/spec/declaration-map/ui.json +++ b/packages/spec/declaration-map/ui.json @@ -221,6 +221,8 @@ "PageRegionSchema": "ui/PageRegion", "PageSchema": "ui/Page", "PageTabsProps": "ui/PageTabsProps", + "PageTemplate": "ui/PageTemplate", + "PageTemplateSchema": "ui/PageTemplate", "PageType": "ui/PageType", "PageTypeSchema": "ui/PageType", "PageVariable": "ui/PageVariable", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 172775ef975..9854eac4674 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -309,6 +309,8 @@ "ObjectTreePropsParsed": "src/ui/component.zod.ts#ObjectTreePropsParsed (type)", "ObjectTreePropsSchema": "src/ui/component.zod.ts#ObjectTreePropsSchema (const)", "ObjectUserFiltersSchema": "src/ui/view.zod.ts#ObjectUserFiltersSchema (const)", + "PAGE_TEMPLATE_OPEN_GRID": "src/ui/page.zod.ts#PAGE_TEMPLATE_OPEN_GRID (const)", + "PAGE_TEMPLATE_SLOTS": "src/ui/page.zod.ts#PAGE_TEMPLATE_SLOTS (const)", "PAGE_TYPE_ROADMAP": "src/ui/page.zod.ts#PAGE_TYPE_ROADMAP (const)", "PRINTABLE_PAGE_COMPONENT_TYPES": "src/ui/page.zod.ts#PRINTABLE_PAGE_COMPONENT_TYPES (const)", "PRINT_REFUSED_PAGE_COMPONENT_TYPES": "src/ui/page.zod.ts#PRINT_REFUSED_PAGE_COMPONENT_TYPES (const)", @@ -333,6 +335,8 @@ "PageRegionSchema": "src/ui/page.zod.ts#PageRegionSchema (const)", "PageSchema": "src/ui/page.zod.ts#PageSchema (const)", "PageTabsProps": "src/ui/component.zod.ts#PageTabsProps (const)", + "PageTemplate": "src/ui/page.zod.ts#PageTemplate (type)", + "PageTemplateSchema": "src/ui/page.zod.ts#PageTemplateSchema (const)", "PageType": "src/ui/page.zod.ts#PageType (type)", "PageTypeSchema": "src/ui/page.zod.ts#PageTypeSchema (const)", "PageVariable": "src/ui/page.zod.ts#PageVariable (type)", @@ -482,7 +486,9 @@ "checkListViewCalendarVisualization": "src/ui/view.zod.ts#checkListViewCalendarVisualization (function)", "checkListViewChartBinding": "src/ui/view.zod.ts#checkListViewChartBinding (function)", "checkPageComponentViewData": "src/ui/page.zod.ts#checkPageComponentViewData (function)", + "checkPageDefaultPlacement": "src/ui/page.zod.ts#checkPageDefaultPlacement (function)", "checkPagePrintComposition": "src/ui/page.zod.ts#checkPagePrintComposition (function)", + "checkPageRegionPlacement": "src/ui/page.zod.ts#checkPageRegionPlacement (function)", "checkPageRequiresKind": "src/ui/page.zod.ts#checkPageRequiresKind (function)", "checkPageSlotPair": "src/ui/page.zod.ts#checkPageSlotPair (function)", "checkPageSourceCompleteness": "src/ui/page.zod.ts#checkPageSourceCompleteness (function)", @@ -514,6 +520,7 @@ "isRecordContextBlockType": "src/ui/react-blocks.ts#isRecordContextBlockType (function)", "isViewContainerShaped": "src/ui/assembled-views.zod.ts#isViewContainerShaped (function)", "listViewGroupKeyPredicate": "src/ui/view-grouping-query.ts#listViewGroupKeyPredicate (function)", + "misplacedDefaultPageRefusal": "src/ui/default-record-page.ts#misplacedDefaultPageRefusal (function)", "normalizeFilterOperator": "src/ui/view.zod.ts#normalizeFilterOperator (function)", "normalizeInlineAction": "src/ui/action.zod.ts#normalizeInlineAction (function)", "objectNavTargetExclusivity": "src/ui/app.zod.ts#objectNavTargetExclusivity (function)", diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index a6232a36d48..fd2c8c36209 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -38,61 +38,6 @@ const PAGE_HISTORY = 'Until this shape was closed these were dropped silently — the page still rendered, ' + 'without whatever the key was meant to configure.'; -/** - * The page layout templates the console registers, and the regions each one - * places — the layout vocabulary, declared here so an author and `os validate` - * can see it (maintainer ruling A on objectstack#23610, record 6190536588). - * - * Measured against the renderer, objectui - * `packages/components/src/renderers/layout/page.tsx` (`TEMPLATE_REGISTRY` and - * each template's `findRegion` calls; region names match case-insensitively): - * - * - `default` — header / sidebar / main / aside / footer (sidebar and aside - * beside main, header and footer spanning it); - * - `header-sidebar-main` — header / sidebar / main; - * - `three-column` — the same five as `default`; - * - `dashboard` — header / footer, and an OPEN GRID: every other region, - * whatever its name, is a grid cell. By design it places any name. - * - * A region a template does not place is not dropped by the renderer — it is - * stacked under `main` at full width with its declared `width` ignored, which - * is the silent mis-layout this table exists to refuse - * ({@link checkPageRegionPlacement}). `full-width` is retired: it was a second - * name for `default` (the same component) — {@link PAGE_TEMPLATE_RETIRED_GUIDANCE}. - */ -export const PAGE_TEMPLATE_SLOTS = { - default: ['header', 'sidebar', 'main', 'aside', 'footer'], - 'header-sidebar-main': ['header', 'sidebar', 'main'], - 'three-column': ['header', 'sidebar', 'main', 'aside', 'footer'], - dashboard: ['header', 'footer'], -} as const satisfies Readonly>; - -/** The templates whose every other region is a grid cell — they place any region name. */ -export const PAGE_TEMPLATE_OPEN_GRID: readonly PageTemplate[] = ['dashboard']; - -/** A page layout template name — a key of {@link PAGE_TEMPLATE_SLOTS}. */ -export type PageTemplate = keyof typeof PAGE_TEMPLATE_SLOTS; - -// Retired template names → the prescription (an enum-value narrowing: no -// tombstone, the enum's own error map carries it). `//`, not JSDoc, on -// purpose — build-docs takes a file's first JSDoc per exported symbol. -const PAGE_TEMPLATE_RETIRED_GUIDANCE = { - 'full-width': - "Page `template: 'full-width'` was removed in @objectstack/spec 18 — it was a second name for " - + "`'default'`, drawn by the same layout. Use `template: 'default'` (or leave `template` out: it is " - + 'the default); the page renders exactly as before.', -} as const; - -/** - * Page Template Schema — the closed set of layout templates the console - * registers ({@link PAGE_TEMPLATE_SLOTS}). An unregistered name used to fall - * back to the page-type layout in silence; it is refused at the parse now. - */ -export const PageTemplateSchema = lazySchema(() => enumWithRetiredValues( - Object.keys(PAGE_TEMPLATE_SLOTS) as [PageTemplate, ...PageTemplate[]], - PAGE_TEMPLATE_RETIRED_GUIDANCE, -).describe("Page layout template — one the console registers: 'default' (header, sidebar, main, aside, footer), 'header-sidebar-main' (header, sidebar, main), 'three-column' (header, sidebar, main, aside, footer) or 'dashboard' (header, footer, and every other region a grid cell).")); - /** * Page Region Schema * A named region in the template where components are dropped. @@ -1630,6 +1575,61 @@ export function checkPagePrintComposition( } } +/** + * The page layout templates the console registers, and the regions each one + * places — the layout vocabulary, declared here so an author and `os validate` + * can see it (maintainer ruling A on objectstack#23610, record 6190536588). + * + * Measured against the renderer, objectui + * `packages/components/src/renderers/layout/page.tsx` (`TEMPLATE_REGISTRY` and + * each template's `findRegion` calls; region names match case-insensitively): + * + * - `default` — header / sidebar / main / aside / footer (sidebar and aside + * beside main, header and footer spanning it); + * - `header-sidebar-main` — header / sidebar / main; + * - `three-column` — the same five as `default`; + * - `dashboard` — header / footer, and an OPEN GRID: every other region, + * whatever its name, is a grid cell. By design it places any name. + * + * A region a template does not place is not dropped by the renderer — it is + * stacked under `main` at full width with its declared `width` ignored, which + * is the silent mis-layout this table exists to refuse + * ({@link checkPageRegionPlacement}). `full-width` is retired: it was a second + * name for `default` (the same component) — {@link PAGE_TEMPLATE_RETIRED_GUIDANCE}. + */ +export const PAGE_TEMPLATE_SLOTS = { + default: ['header', 'sidebar', 'main', 'aside', 'footer'], + 'header-sidebar-main': ['header', 'sidebar', 'main'], + 'three-column': ['header', 'sidebar', 'main', 'aside', 'footer'], + dashboard: ['header', 'footer'], +} as const satisfies Readonly>; + +/** The templates whose every other region is a grid cell — they place any region name. */ +export const PAGE_TEMPLATE_OPEN_GRID: readonly PageTemplate[] = ['dashboard']; + +/** A page layout template name — a key of {@link PAGE_TEMPLATE_SLOTS}. */ +export type PageTemplate = keyof typeof PAGE_TEMPLATE_SLOTS; + +// Retired template names → the prescription (an enum-value narrowing: no +// tombstone, the enum's own error map carries it). `//`, not JSDoc, on +// purpose — build-docs takes a file's first JSDoc per exported symbol. +const PAGE_TEMPLATE_RETIRED_GUIDANCE = { + 'full-width': + "Page `template: 'full-width'` was removed in @objectstack/spec 18 — it was a second name for " + + "`'default'`, drawn by the same layout. Use `template: 'default'` (or leave `template` out: it is " + + 'the default); the page renders exactly as before.', +} as const; + +/** + * Page Template Schema — the closed set of layout templates the console + * registers ({@link PAGE_TEMPLATE_SLOTS}). An unregistered name used to fall + * back to the page-type layout in silence; it is refused at the parse now. + */ +export const PageTemplateSchema = lazySchema(() => enumWithRetiredValues( + Object.keys(PAGE_TEMPLATE_SLOTS) as [PageTemplate, ...PageTemplate[]], + PAGE_TEMPLATE_RETIRED_GUIDANCE, +).describe("Page layout template — one the console registers: 'default' (header, sidebar, main, aside, footer), 'header-sidebar-main' (header, sidebar, main), 'three-column' (header, sidebar, main, aside, footer) or 'dashboard' (header, footer, and every other region a grid cell).")); + /** * The `isDefault` placement check attached to {@link PageSchema} (maintainer * ruling A on objectstack#23610, record 6190536588): `isDefault: true` is From 3a3b0435eded70ce9dc10e1ff4130204c8395007 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 15:44:58 +0000 Subject: [PATCH 04/12] wip: pin fixture names Claude-Session: https://claude.ai/code/session_01TC6DKN349m4i7okGWPe4RG Co-authored-by: Claude --- .../ui/page-default-and-region-placement.test.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/spec/src/ui/page-default-and-region-placement.test.ts b/packages/spec/src/ui/page-default-and-region-placement.test.ts index 0329b089378..0a68198df8e 100644 --- a/packages/spec/src/ui/page-default-and-region-placement.test.ts +++ b/packages/spec/src/ui/page-default-and-region-placement.test.ts @@ -137,18 +137,18 @@ describe('Q2 — the template set is closed', () => { }); expect(PAGE_TEMPLATE_OPEN_GRID).toEqual(['dashboard']); expect(PageTemplateSchema.options).toEqual(Object.keys(PAGE_TEMPLATE_SLOTS)); - expect(PageSchema.parse({ name: 'p', label: 'P', type: 'home' }).template).toBe('default'); + expect(PageSchema.parse({ name: 'pg_page', label: 'P', type: 'home' }).template).toBe('default'); }); it('refuses the retired `full-width` by name, prescribing `default`', () => { - const issues = refusalOf({ name: 'p', label: 'P', type: 'home', template: 'full-width' }); + const issues = refusalOf({ name: 'pg_page', label: 'P', type: 'home', template: 'full-width' }); expect(issues.map((i) => i.path.join('.'))).toEqual(['template']); expect(issues[0]!.message.startsWith("Page `template: 'full-width'` was removed in @objectstack/spec 18")).toBe(true); expect(issues[0]!.message).toContain("Use `template: 'default'`"); }); it.each(['two-column', 'single-column', 'three-column-layout'])('refuses an unregistered `%s` at `template`', (template) => { - const issues = refusalOf({ name: 'p', label: 'P', type: 'home', template }); + const issues = refusalOf({ name: 'pg_page', label: 'P', type: 'home', template }); expect(issues.map((i) => ({ path: i.path.join('.'), code: i.code }))).toEqual([{ path: 'template', code: 'invalid_value' }]); }); }); @@ -175,9 +175,9 @@ describe('Q2 — a region the template does not place', () => { }); it('judges a page that leaves `template` out as `default`, and names only that template\'s slots on `header-sidebar-main`', () => { - expect(refusalOf({ name: 'p', label: 'P', type: 'home', regions: [region('content')] })[0]!.message) + expect(refusalOf({ name: 'pg_page', label: 'P', type: 'home', regions: [region('content')] })[0]!.message) .toContain('Rename it `main`'); - const hsm = refusalOf({ name: 'p', label: 'P', type: 'app', template: 'header-sidebar-main', regions: [region('main'), region('aside')] }); + const hsm = refusalOf({ name: 'pg_page', label: 'P', type: 'app', template: 'header-sidebar-main', regions: [region('main'), region('aside')] }); expect(hsm.map((i) => i.path.join('.'))).toEqual(['regions.1.name']); expect(hsm[0]!.message).toContain('places only `header`, `sidebar`, `main`,'); // `aside` is not one of its slots, so no rename is offered for it. @@ -201,7 +201,7 @@ describe('Q2 — a region the template does not place', () => { ['an unplaced name on a `slotted` record page — it renders its slots', { type: 'record', object: 'account', kind: 'slotted', regions: [region('left_sidebar')] }], ['an unplaced name on an `html` page — it renders its source', { kind: 'html', source: '', regions: [region('content')] }], ])('CONTROL — %s parses', (_label, extra) => { - const r = PageSchema.safeParse({ name: 'p', label: 'P', type: 'home', ...extra }); + const r = PageSchema.safeParse({ name: 'pg_page', label: 'P', type: 'home', ...extra }); expect(r.success, JSON.stringify(r.error?.issues ?? [])).toBe(true); }); }); @@ -215,8 +215,8 @@ describe('the ledger — two D3 entries and no D2 conversion', () => { it.each([ ['a non-record `isDefault`', { name: 'sales_home_page', label: 'Home', type: 'home', isDefault: true, regions: [] }, ['isDefault']], - ['the retired `full-width`', { name: 'p', label: 'P', type: 'record', object: 'invoice', template: 'full-width', regions: [region('main')] }, ['template']], - ['an unplaced region', { name: 'p', label: 'P', type: 'home', template: 'three-column', regions: [region('main'), region('left_sidebar')] }, ['regions.1.name']], + ['the retired `full-width`', { name: 'pg_page', label: 'P', type: 'record', object: 'invoice', template: 'full-width', regions: [region('main')] }, ['template']], + ['an unplaced region', { name: 'pg_page', label: 'P', type: 'home', template: 'three-column', regions: [region('main'), region('left_sidebar')] }, ['regions.1.name']], ])('a STORED row with %s replays unchanged — no conversion touches it — and the parse still refuses it', (_label, row, paths) => { const notices: ConversionNotice[] = []; const rehydrated = applyConversionsToStoredItem('page', row, { onNotice: (n) => notices.push(n) }); From f633433be85de889f6cb29e2458c5c31be3df173 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 15:48:25 +0000 Subject: [PATCH 05/12] wip: lint fixture spells the closed template Claude-Session: https://claude.ai/code/session_01TC6DKN349m4i7okGWPe4RG Co-authored-by: Claude --- .../src/validate-record-approvals-attachments-22537.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/lint/src/validate-record-approvals-attachments-22537.test.ts b/packages/lint/src/validate-record-approvals-attachments-22537.test.ts index 4b2b8a7fec3..2477529a353 100644 --- a/packages/lint/src/validate-record-approvals-attachments-22537.test.ts +++ b/packages/lint/src/validate-record-approvals-attachments-22537.test.ts @@ -38,6 +38,9 @@ const OBJECT = { * `app-shell/src/views/metadata-admin/anchors.ts`, `createSeed`). The * Attachments tab is the synthesizer's `buildDefaultAttachments()`, a bare * `{ type: 'record:attachments' }` at `components[2].properties.items[1]`. + * The synthesizer still names the retired `full-width` template, a second + * name for `default` (#23610); the seed is spelled here as the closed + * template set requires. */ const seededRecordPage = (extraTabs: unknown[] = []): AnyRec => ({ name: 'invoice_record', @@ -45,7 +48,7 @@ const seededRecordPage = (extraTabs: unknown[] = []): AnyRec => ({ type: 'record', object: 'invoice', kind: 'full', - template: 'full-width', + template: 'default', regions: [ { name: 'main', From 01d04635f897fb17faec1aa3d6132a6c91438aca Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 16:20:11 +0000 Subject: [PATCH 06/12] docs(objectui): layout-dsl examples name registered templates and their slots Claude-Session: https://claude.ai/code/session_01TC6DKN349m4i7okGWPe4RG Co-authored-by: Claude --- content/docs/protocol/objectui/layout-dsl.mdx | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/content/docs/protocol/objectui/layout-dsl.mdx b/content/docs/protocol/objectui/layout-dsl.mdx index 292e0e97c85..b97dba4e28f 100644 --- a/content/docs/protocol/objectui/layout-dsl.mdx +++ b/content/docs/protocol/objectui/layout-dsl.mdx @@ -72,7 +72,15 @@ Page ## Page Templates -Top-level layout structures that define macro organization. +Top-level layout structures that define macro organization. `template` is one of the +layouts the console registers — `default` (`header`, `sidebar`, `main`, `aside`, +`footer`), `header-sidebar-main` (`header`, `sidebar`, `main`), `three-column` (the same +five as `default`) or `dashboard` (`header`, `footer`, and every other region a grid cell) +— and each region's `name` is a slot that template places. Any other template name, and +on a `kind: 'full'` page any region name the template does not place, is refused when the +page is parsed (`os validate`, `os build`, the metadata save door): the console would draw +an unregistered template as the default layout, and stack an unplaced region under `main` +at full width with its `width` ignored. ### Standard Template @@ -102,7 +110,7 @@ name: account_record label: Account Record Page type: record object: account -template: standard +template: default regions: - name: header components: @@ -150,22 +158,23 @@ regions: High-density layout for power users (like Salesforce Service Console). +It is the `header-sidebar-main` template: the list in `sidebar`, the record in `main`. + {/* os:check-yaml page */} ```yaml name: service_console label: Service Console type: app -template: console +template: header-sidebar-main regions: - - name: left + - name: sidebar width: small components: - type: object-grid properties: objectName: case - - name: right - width: full + - name: main components: - type: page:tabs properties: @@ -218,9 +227,8 @@ regions: a matching `Multi-Step Onboarding Wizard` example further down. **`steps` exists on no schema.** `PageSchema` is `.strict()`, so it is refused by name (`Unrecognized key(s) on this page: steps`), and no other shape in the spec - declares it either. `template: wizard` compounds it quietly: `template` is a - free-form string, so that half *parses* while naming a layout template no - renderer provides. + declares it either. `template: wizard` is refused as well: `template` names + one of the layouts the console registers, and no renderer provides a wizard. Both are **removed rather than implemented** — multi-step authoring is an implementation card first, exactly like the deferred tab loading and the @@ -751,7 +759,7 @@ properties: **There is no page-level `layout:` block.** Earlier revisions of this page described three advanced layouts as a top-level `layout:` mapping — `split_view`, `card_grid` and `kanban`. On `PageSchema`, `layout` is an - **alias for `template`**, a plain string naming a layout template, so a mapping + **alias for `template`**, which names one of the registered layout templates, so a mapping written there is refused with exactly that rename hint (`Did you mean layout → template?`). None of `split_view`, `card_grid`, `list_view` or `detail_view` is a type on any schema. @@ -837,12 +845,12 @@ interface Page { // ever shipped). They live in PAGE_TYPE_ROADMAP; authoring one now fails // validation. See page.zod.ts (PageTypeSchema / PAGE_TYPE_ROADMAP). object?: string; // bound object for record pages - template?: string; // layout template name, default 'default' + template?: 'default' | 'header-sidebar-main' | 'three-column' | 'dashboard'; // default 'default' regions?: PageRegion[]; // named regions, each holding components (optional; defaults to []) } interface PageRegion { - name: string; // e.g. "header", "main", "sidebar" + name: string; // a slot the page's template places: header, sidebar, main, aside, footer width?: 'small' | 'medium' | 'large' | 'full'; components: PageComponent[]; } @@ -1010,7 +1018,7 @@ name: customer_360 label: Customer 360 type: record object: customer -template: standard +template: default regions: - name: header components: From 02f7178c47118f1831eeac211a88fc40c2996ed1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 16:30:59 +0000 Subject: [PATCH 07/12] fix(example-showcase): the visualization pages' shared base names its template as a literal Claude-Session: https://claude.ai/code/session_01TC6DKN349m4i7okGWPe4RG Co-authored-by: Claude --- .changeset/23610-page-isdefault-template-slots.md | 1 + examples/app-showcase/src/ui/pages/task-visualizations.pages.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/23610-page-isdefault-template-slots.md b/.changeset/23610-page-isdefault-template-slots.md index c7d09836766..2e337c5193f 100644 --- a/.changeset/23610-page-isdefault-template-slots.md +++ b/.changeset/23610-page-isdefault-template-slots.md @@ -20,6 +20,7 @@ Migration: - **`isDefault` on a non-record page** → delete it. A page does not choose where an app opens: an app's landing page is its **first navigation item** (by `order`), so to land on the page put its entry first in the app's `navigation`; the root landing follows the app's own `isDefault`. A record page meant as an object's default record page binds that object with `object`. - **`template: 'full-width'`** → `template: 'default'` (or delete the key — `default` is the default). The page renders exactly as before. +- **A `template` held in a widened `string`** (a shared base object, `{ template: 'default' }` spread into `definePage`) no longer type-checks against the closed set → `template: 'default' as const`. - **An unregistered template** → one of `default` (header, sidebar, main, aside, footer), `header-sidebar-main` (header, sidebar, main), `three-column` (header, sidebar, main, aside, footer) or `dashboard` (header, footer, and every other region a grid cell). - **An unplaced region** → name it for the slot it fills: `left_sidebar` → `sidebar`, `right_sidebar` → `aside`, `content` → `main`; or choose `dashboard` when the regions are a grid. Names match case-insensitively, as the console's do. `slotted`, `html` and `react` pages are not judged on region names. diff --git a/examples/app-showcase/src/ui/pages/task-visualizations.pages.ts b/examples/app-showcase/src/ui/pages/task-visualizations.pages.ts index 5755688e7fa..e561c71460f 100644 --- a/examples/app-showcase/src/ui/pages/task-visualizations.pages.ts +++ b/examples/app-showcase/src/ui/pages/task-visualizations.pages.ts @@ -18,7 +18,7 @@ const base = { type: 'list' as const, object: 'showcase_task', kind: 'full' as const, - template: 'default', + template: 'default' as const, isDefault: false, regions: [], }; From 313395b2723f7f966876c4bf648a732106110cc7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 18:18:55 +0000 Subject: [PATCH 08/12] feat(spec): refuse a second region filling a slot its template already filled The renderer draws the first region of a name and excludes that name from the rest, so a second main (on dashboard, a second header or footer) was never drawn. Part of ruling 6190536588 Q2: a region the template will not place is refused. Claude-Session: https://claude.ai/code/session_01TC6DKN349m4i7okGWPe4RG Co-authored-by: Claude --- .../23610-page-isdefault-template-slots.md | 2 + content/docs/ui/pages.mdx | 4 +- ...template-closed-region-unplaced-refused.ts | 11 ++++-- .../object-refinement-check-exports.test.ts | 2 + .../page-default-and-region-placement.test.ts | 20 ++++++++++ packages/spec/src/ui/page.zod.ts | 39 +++++++++++++++---- 6 files changed, 67 insertions(+), 11 deletions(-) diff --git a/.changeset/23610-page-isdefault-template-slots.md b/.changeset/23610-page-isdefault-template-slots.md index 2e337c5193f..553c4327f92 100644 --- a/.changeset/23610-page-isdefault-template-slots.md +++ b/.changeset/23610-page-isdefault-template-slots.md @@ -15,6 +15,7 @@ Clause-②: yes (narrowing) | `template: 'full-width'` | accepted; drawn exactly as `default` | refused by name: "Page `template: 'full-width'` was removed in @objectstack/spec 18 … Use `template: 'default'`" | | any other `template` outside `default`, `header-sidebar-main`, `three-column`, `dashboard` (`two-column`, `single-column`, …) | accepted; drawn as the page type's default layout | refused at `template` | | on a `kind: 'full'` page, a region its template does not place (`left_sidebar`, `right_sidebar`, `content`, …) | accepted; stacked under `main` at full width, its `width` ignored | refused at `regions.N.name`, naming the template's slots and the rename | +| on a `kind: 'full'` page, a second region filling a slot an earlier one fills (two `main` regions; on `dashboard`, a second `header` or `footer`) | accepted; not drawn at all — the console draws the first region of a name and leaves that name out of the rest | refused at `regions.N.name`, naming the region that already fills the slot | Migration: @@ -23,6 +24,7 @@ Migration: - **A `template` held in a widened `string`** (a shared base object, `{ template: 'default' }` spread into `definePage`) no longer type-checks against the closed set → `template: 'default' as const`. - **An unregistered template** → one of `default` (header, sidebar, main, aside, footer), `header-sidebar-main` (header, sidebar, main), `three-column` (header, sidebar, main, aside, footer) or `dashboard` (header, footer, and every other region a grid cell). - **An unplaced region** → name it for the slot it fills: `left_sidebar` → `sidebar`, `right_sidebar` → `aside`, `content` → `main`; or choose `dashboard` when the regions are a grid. Names match case-insensitively, as the console's do. `slotted`, `html` and `react` pages are not judged on region names. +- **A second region for a filled slot** → move its components into the first region of that name, or name it for an unfilled slot. On `dashboard`, two regions of any other one name stay two grid cells and are accepted. The template set and each template's slots are now published: `PageTemplateSchema`, `PAGE_TEMPLATE_SLOTS` and `PAGE_TEMPLATE_OPEN_GRID` (`@objectstack/spec/ui`), with the two checks `checkPageDefaultPlacement` and `checkPageRegionPlacement` and the one refusal sentence `misplacedDefaultPageRefusal` that the parse and the registry share. diff --git a/content/docs/ui/pages.mdx b/content/docs/ui/pages.mdx index 24b624e0906..227aba5e85d 100644 --- a/content/docs/ui/pages.mdx +++ b/content/docs/ui/pages.mdx @@ -168,7 +168,9 @@ case-insensitively. On a `kind: 'full'` page, a region its template does not place — `left_sidebar`, `right_sidebar`, `content` — is refused by `os validate`, `os build` and the metadata save door, naming the template's slots: the console would stack it under `main` at full width and ignore -its `width`. Use `sidebar` for the left column and `aside` for the right. `full-width` is no +its `width`. Use `sidebar` for the left column and `aside` for the right. Each slot takes one +region: a second `main` (or, on `dashboard`, a second `header` or `footer`) is not drawn at all, +and is refused the same way. `full-width` is no longer a template name: it was a second name for `default`, and is refused with that replacement. diff --git a/packages/spec/src/migrations/entries/semantic/18.page-template-closed-region-unplaced-refused.ts b/packages/spec/src/migrations/entries/semantic/18.page-template-closed-region-unplaced-refused.ts index a6ffad8738e..a57de6829b2 100644 --- a/packages/spec/src/migrations/entries/semantic/18.page-template-closed-region-unplaced-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.page-template-closed-region-unplaced-refused.ts @@ -20,13 +20,15 @@ export const entry: SemanticMigration = { 'pages[].template — a name the console does not register (full-width, two-column, single-column and any ' + 'other); and pages[].regions[].name on a kind full page — a name the page\'s template does not place ' + '(left_sidebar, right_sidebar, content and any other name outside header, sidebar, main, aside, footer ' - + 'on default and three-column, or outside header, sidebar, main on header-sidebar-main)', + + 'on default and three-column, or outside header, sidebar, main on header-sidebar-main), and a second region ' + + 'filling a slot an earlier region already fills (two main regions; on dashboard, a second header or footer)', replacement: 'A registered template: `default` (header, sidebar, main, aside, footer), `header-sidebar-main` (header, ' + 'sidebar, main), `three-column` (header, sidebar, main, aside, footer) or `dashboard` (header, footer, ' + 'and every other region a grid cell) — `full-width` is `default`. Name each region for the slot it ' + 'fills: `left_sidebar` → `sidebar`, `right_sidebar` → `aside`, `content` → `main`; or choose `dashboard` ' - + 'when the regions are meant as a grid.', + + 'when the regions are meant as a grid. A second region for a filled slot: move its components into the first ' + + 'region of that name, or name it for an unfilled slot.', reason: 'PageSchema.template and regions[].name were free strings, and the slot names a template places were ' + 'declared nowhere, so neither an author nor objectstack validate could know that left_sidebar is not a ' @@ -39,7 +41,10 @@ export const entry: SemanticMigration = { + 'same layout) by name with its replacement, and an unplaced region at regions.N.name, naming the ' + 'template\'s slots — by the page parse: definePage, defineStack (so objectstack validate and objectstack ' + 'build, STACK_SCHEMA_INVALID 422) and the metadata save door (422 INVALID_METADATA). Region names match ' - + 'case-insensitively, as the console\'s do; dashboard places every name by design; slotted, html and ' + + 'case-insensitively, as the console\'s do. A second region filling an already filled slot was not drawn at ' + + 'all — the console draws the first region of a name and excludes that name from the rest — and is refused ' + + 'at its name too, naming the region that fills the slot. dashboard places every other name by design (two ' + + 'regions of one other name are two grid cells); slotted, html and ' + 'react pages, which do not lay out regions by template, are not judged on region names. ' + '⚠️ No D2 conversion: the ruling keeps no alias window for full-width, and which slot a renamed region ' + 'fills is the author\'s call. ' diff --git a/packages/spec/src/ui/object-refinement-check-exports.test.ts b/packages/spec/src/ui/object-refinement-check-exports.test.ts index a5ba67014b3..f59ed682708 100644 --- a/packages/spec/src/ui/object-refinement-check-exports.test.ts +++ b/packages/spec/src/ui/object-refinement-check-exports.test.ts @@ -376,6 +376,8 @@ const pageRegionPlacementFixtures: Fixture[] = [ { label: 'the five slots on `default`', value: { ...PAGE_BASE, template: 'default', regions: ['header', 'sidebar', 'main', 'aside', 'footer'].map((n) => region(n)) }, refusesAt: [] }, { label: 'a slot name in another case (`Main`) — the renderer matches case-insensitively', value: { ...PAGE_BASE, regions: [region('Main')] }, refusesAt: [] }, { label: 'any name on `dashboard` — its open grid', value: { ...PAGE_BASE, template: 'dashboard', regions: [region('header'), region('kpis'), region('pipeline')] }, refusesAt: [] }, + { label: 'a second `main` on the `default` default — not drawn at all', value: { ...PAGE_BASE, regions: [region('main'), region('MAIN')] }, refusesAt: ['regions.1.name'] }, + { label: 'a second `header` on `dashboard`, beside two `kpis` cells', value: { ...PAGE_BASE, template: 'dashboard', regions: [region('header'), region('kpis'), region('kpis'), region('header')] }, refusesAt: ['regions.3.name'] }, { label: 'an unplaced name on a `slotted` page — it renders its slots, not its regions', value: { ...PAGE_BASE, type: 'record', object: 'account', kind: 'slotted', regions: [region('left_sidebar')] }, refusesAt: [] }, ]; diff --git a/packages/spec/src/ui/page-default-and-region-placement.test.ts b/packages/spec/src/ui/page-default-and-region-placement.test.ts index 0a68198df8e..1e731c30ea6 100644 --- a/packages/spec/src/ui/page-default-and-region-placement.test.ts +++ b/packages/spec/src/ui/page-default-and-region-placement.test.ts @@ -184,6 +184,26 @@ describe('Q2 — a region the template does not place', () => { expect(hsm[0]!.message).toContain('Rename it to one of those slots'); }); + it.each(['default', 'three-column', 'header-sidebar-main'] as const)( + 'refuses a SECOND region filling an already filled slot on `%s` — case-insensitively — naming the region that fills it', + (template) => { + const issues = refusalOf({ name: 'pg_page', label: 'P', type: 'home', template, regions: [region('main'), region('sidebar'), region('Main')] }); + expect(issues.map((i) => ({ path: i.path.join('.'), code: i.code }))).toEqual([{ path: 'regions.2.name', code: 'custom' }]); + expect(issues[0]!.message.startsWith(`Region \`Main\` is refused on a \`template: '${template}'\` page: \`regions[0]\` already fills its \`main\` slot`)).toBe(true); + expect(issues[0]!.message).toContain('Move its components into `regions[0]`'); + }, + ); + + it('on `dashboard` refuses a second `header` / `footer` but keeps two regions of any other name as two grid cells', () => { + const issues = refusalOf({ + name: 'pg_page', label: 'P', type: 'home', template: 'dashboard', + regions: [region('header'), region('kpis'), region('kpis'), region('footer'), region('HEADER'), region('footer')], + }); + expect(issues.map((i) => i.path.join('.'))).toEqual(['regions.4.name', 'regions.5.name']); + expect(issues[0]!.message).toContain('`regions[0]` already fills its `header` slot'); + expect(issues[1]!.message).toContain('`regions[3]` already fills its `footer` slot'); + }); + it('the stack door wraps the refusal at `pages.N.regions.M.name`', () => { const refusal = stackRefusalOf([ { name: 'pgs_home', label: 'Home', type: 'home', template: 'three-column', regions: [region('main'), region('right_sidebar')] }, diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index fd2c8c36209..ca9f4721312 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -47,7 +47,7 @@ export const PageRegionSchema = lazySchema(() => strictObject({ history: PAGE_HISTORY, aliases: { id: 'name', region: 'name', children: 'components', items: 'components', content: 'components', size: 'width', span: 'width' }, }, { - name: z.string().describe("Region name — a slot the page's `template` places: header, sidebar, main, aside or footer on 'default' and 'three-column'; header, sidebar or main on 'header-sidebar-main'; any name on 'dashboard' (header and footer span it, every other region is a grid cell). A name the template does not place is refused.").meta({ title: 'Region' }), + name: z.string().describe("Region name — a slot the page's `template` places: header, sidebar, main, aside or footer on 'default' and 'three-column'; header, sidebar or main on 'header-sidebar-main'; any name on 'dashboard' (header and footer span it, every other region is a grid cell). One region per slot. A name the template does not place, and a second region filling a slot already filled, are refused.").meta({ title: 'Region' }), width: z.enum(['small', 'medium', 'large', 'full']).optional().meta({ title: 'Width' }), components: z.array(z.lazy(() => PageComponentSchema)).describe('Components in this region').meta({ title: 'Components' }) })); @@ -1676,8 +1676,12 @@ const REGION_NAME_HINTS: Readonly> = { * The renderer never dropped such a region: it stacked it under `main` at full * width and ignored its `width`, so a `left_sidebar` or `right_sidebar` on a * `three-column` page drew as one column while every door reported success. - * Names match case-insensitively, as the renderer's do. A `dashboard` page - * places every name (its open grid). Judged on `kind: 'full'` pages only — + * Names match case-insensitively, as the renderer's do. A SECOND region + * filling a slot an earlier one already fills is refused too: the renderer + * draws the first (`findRegion`) and excludes every region of that name from + * the rest (`getRemainingRegions`), so the second is not drawn at all. A + * `dashboard` page places every other name (its open grid) — two `kpis` + * regions are two cells — but not a second `header` or `footer`. Judged on `kind: 'full'` pages only — * the kind whose regions the template lays out: a `slotted` page renders its * `slots`, and an `html` / `react` page its `source`. A template the enum * refuses is that refusal's, not this check's. @@ -1692,13 +1696,34 @@ export function checkPageRegionPlacement( if ((page.kind ?? 'full') !== 'full' || !Array.isArray(page.regions)) return; const template = page.template ?? 'default'; if (typeof template !== 'string' || !Object.prototype.hasOwnProperty.call(PAGE_TEMPLATE_SLOTS, template)) return; - if ((PAGE_TEMPLATE_OPEN_GRID as readonly string[]).includes(template)) return; const slots: readonly string[] = PAGE_TEMPLATE_SLOTS[template as PageTemplate]; + const openGrid = (PAGE_TEMPLATE_OPEN_GRID as readonly string[]).includes(template); const slotList = slots.map((slot) => `\`${slot}\``).join(', '); + // slot → the index of the region that fills it (the renderer's `findRegion` + // takes the FIRST match, case-insensitively, and drops every later one). + const filled = new Map(); page.regions.forEach((region, index) => { const name = region && typeof region === 'object' ? (region as { name?: unknown }).name : undefined; - if (typeof name !== 'string' || slots.includes(name.toLowerCase())) return; - const hint = REGION_NAME_HINTS[name.toLowerCase()]; + if (typeof name !== 'string') return; + const slot = name.toLowerCase(); + if (slots.includes(slot)) { + const first = filled.get(slot); + if (first === undefined) { + filled.set(slot, index); + return; + } + ctx.addIssue({ + code: 'custom', + path: ['regions', index, 'name'], + message: `Region \`${name}\` is refused on a \`template: '${template}'\` page: \`regions[${first}]\` already fills ` + + `its \`${slot}\` slot, and the template places one region per slot (${slotList}), so a second one is not ` + + `drawn at all. Move its components into \`regions[${first}]\`, or name it for an unfilled slot.`, + }); + return; + } + // An open-grid template draws every other region, whatever its name. + if (openGrid) return; + const hint = REGION_NAME_HINTS[slot]; const rename = hint !== undefined && slots.includes(hint) ? `Rename it \`${hint}\`, ` : 'Rename it to one of those slots, '; @@ -1807,7 +1832,7 @@ export const PageSchema = lazySchema(() => strictObject({ // objectui dropped all references in objectui#1949. /** Layout Template */ - template: PageTemplateSchema.default('default').describe("Layout template — 'default', 'header-sidebar-main', 'three-column' or 'dashboard'. Each places its own regions (PAGE_TEMPLATE_SLOTS); a region the template does not place is refused at the region's name."), + template: PageTemplateSchema.default('default').describe("Layout template — 'default', 'header-sidebar-main', 'three-column' or 'dashboard'. Each places its own regions (PAGE_TEMPLATE_SLOTS); a region the template does not place, or a second region filling the same slot, is refused at the region's name."), /** Regions & Content */ // Optional with an empty-array default. Not every page authors regions: From c2b9935b4c7a2c53210ae9e57cdc840a0dd366d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 18:24:21 +0000 Subject: [PATCH 09/12] chore(spec): regenerate the page reference after the merge Claude-Session: https://claude.ai/code/session_01TC6DKN349m4i7okGWPe4RG Co-authored-by: Claude --- content/docs/references/ui/page.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index ec63f7df362..795849c5326 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -207,7 +207,7 @@ View filter rule | **type** | `Enum<'record' \| 'home' \| 'app' \| 'utility' \| 'list'>` | optional (default: `"record"`) | Page type | | **variables** | `{ name: string; type?: Enum<'string' \| 'number' \| 'boolean' \| 'object' \| 'array' \| 'record_id'>; defaultValue?: any; source?: string }[]` | optional | Local page state, exposed to expressions as `page.` and writable by interactive elements via `source` (master/detail, filtered dashboards). | | **object** | `string` | optional | Bound object (for Record pages) | -| **template** | `Enum<'default' \| 'header-sidebar-main' \| 'three-column' \| 'dashboard'>` | optional (default: `"default"`) | Layout template — 'default', 'header-sidebar-main', 'three-column' or 'dashboard'. Each places its own regions (PAGE_TEMPLATE_SLOTS); a region the template does not place is refused at the region's name. | +| **template** | `Enum<'default' \| 'header-sidebar-main' \| 'three-column' \| 'dashboard'>` | optional (default: `"default"`) | Layout template — 'default', 'header-sidebar-main', 'three-column' or 'dashboard'. Each places its own regions (PAGE_TEMPLATE_SLOTS); a region the template does not place, or a second region filling the same slot, is refused at the region's name. | | **regions** | `{ name: string; width?: Enum<'small' \| 'medium' \| 'large' \| 'full'>; components: object[] }[]` | optional | Layout regions (header, main, sidebar, footer) with their components. Optional — list pages use interfaceConfig, slotted pages use slots, and an empty full page falls back to the synthesized default layout. | | **isDefault** | `boolean` | optional (default: `false`) | Whether this record page is the default record page of its `object` — the page the record view opens. At most one `isDefault: true` record page may exist for an object in a deployment: a second is refused (by defineStack / os validate, at package registration and at metadata save), naming both pages. Saving a page under the existing default's own name is that page, not a second default. Refused on any other page — a page that is not a record page bound to an object: nothing reads it there. An app opens on its first navigation item, and the root landing follows the app's own isDefault. | | **assignedProfiles** | `never` | optional | [REMOVED] `page.assignedProfiles` was removed in @objectstack/spec 17.5.0 (ADR-0090 D2, ADR-0049 enforce-or-remove) — it was named for the Profile concept ADR-0090 D2 deleted, and it gated nothing: no renderer, route or metadata read door ever read the key, so a page that "assigned profiles" stayed open to every caller who could reach it. Delete the key. Page audience is the permission set's: gate the DATA the page shows with the object's permission sets, and bind those sets to people through positions (named in a position's `permissionSets`) — those are the checks the runtime actually runs. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | @@ -239,7 +239,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Region name — a slot the page's `template` places: header, sidebar, main, aside or footer on 'default' and 'three-column'; header, sidebar or main on 'header-sidebar-main'; any name on 'dashboard' (header and footer span it, every other region is a grid cell). A name the template does not place is refused. | +| **name** | `string` | ✅ | Region name — a slot the page's `template` places: header, sidebar, main, aside or footer on 'default' and 'three-column'; header, sidebar or main on 'header-sidebar-main'; any name on 'dashboard' (header and footer span it, every other region is a grid cell). One region per slot. A name the template does not place, and a second region filling a slot already filled, are refused. | | **width** | `Enum<'small' \| 'medium' \| 'large' \| 'full'>` | optional | | | **components** | `{ type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| …> \| string; id?: string; label?: string \| Record; properties?: Record; … }[]` | ✅ | Components in this region | @@ -424,7 +424,7 @@ Print declaration: paper size, orientation, margins, the running header and foot | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Region name — a slot the page's `template` places: header, sidebar, main, aside or footer on 'default' and 'three-column'; header, sidebar or main on 'header-sidebar-main'; any name on 'dashboard' (header and footer span it, every other region is a grid cell). A name the template does not place is refused. | +| **name** | `string` | ✅ | Region name — a slot the page's `template` places: header, sidebar, main, aside or footer on 'default' and 'three-column'; header, sidebar or main on 'header-sidebar-main'; any name on 'dashboard' (header and footer span it, every other region is a grid cell). One region per slot. A name the template does not place, and a second region filling a slot already filled, are refused. | | **width** | `Enum<'small' \| 'medium' \| 'large' \| 'full'>` | optional | | | **components** | `{ type: Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| …> \| string; id?: string; label?: string \| Record; properties?: Record; … }[]` | ✅ | Components in this region | From 797aa2806e1811fd29f1d9f469e388a347833f0d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 00:14:13 +0000 Subject: [PATCH 10/12] chore(console): bump objectui pin f96f771140f1 -> daeb77fc84dd (seeds the default template) Claude-Session: https://claude.ai/code/session_01TC6DKN349m4i7okGWPe4RG Co-authored-by: Claude --- .changeset/console-daeb77fc84dd.md | 18 ++++++++++++++++++ .objectui-sha | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 .changeset/console-daeb77fc84dd.md diff --git a/.changeset/console-daeb77fc84dd.md b/.changeset/console-daeb77fc84dd.md new file mode 100644 index 00000000000..87c9c8e6933 --- /dev/null +++ b/.changeset/console-daeb77fc84dd.md @@ -0,0 +1,18 @@ +--- +"@objectstack/console": minor +--- + +Console (objectui) refreshed to `daeb77fc84dd`. Frontend changes in this range: + +Derived from the changesets objectui declared over the range — 4 releasing of 4 changesets added across 5 non-merge commits; omitted: 1 commit carrying no changeset (they ship no package code). + +- **minor** — Studio, Interfaces pillar: an html or react page reads like a code page. The source editor's plain-text fallback numbers its lines and wraps long ones as the code editor does, and… (objectui `830eb1f2d`) +- **patch** — A new record page seeded in Studio is saved with the `default` layout template instead of `full-width`, and the page node's `template` member is typed by reference to the spec's (… (objectui `daeb77fc8`) +- **patch** — Two Studio pickers are named by the labels shown beside them (objectui#12510). Nothing changes on screen. (objectui `3e09c1e8d`) +- **patch** — Studio's empty surfaces now carry the action that fills them (objectui#12514). (objectui `f23eb01cb`) + +**In this console build, declared nowhere** — objectui merged 1 commit in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared it, so it appears in no objectui CHANGELOG and in no entry above. Listed by subject rather than counted, because a count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); the upstream gate that would prevent this is objectui#3387. + +- _(no changeset)_ docs(studio-redesign): move the 61 PR screenshots under after/ to an evidence branch and remove them from main (#12583) (objectui `7066dae53`) + +objectui range: `f96f771140f1...daeb77fc84dd` diff --git a/.objectui-sha b/.objectui-sha index 5e46be55c54..a2299f5d309 100644 --- a/.objectui-sha +++ b/.objectui-sha @@ -1 +1 @@ -f96f771140f1ba488245e0d0d3e7420d720bff9a +daeb77fc84dd824e74c95b87cdede85afdb248e7 From d2b1d0a6e56ba6410e4ec55125e043e0c050a076 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 00:24:35 +0000 Subject: [PATCH 11/12] chore(console): re-record the SDUI manifest at pin daeb77fc84dd Claude-Session: https://claude.ai/code/session_01TC6DKN349m4i7okGWPe4RG Co-authored-by: Claude --- scripts/sdui-manifest.record.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index c49f07cb58a..9ae7d87590d 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -11,12 +11,12 @@ "a pin bump without regeneration goes RED there — that is the anti-rot half of the freshness gate.", "Regenerate + re-record: pnpm objectui:build && node scripts/gen-sdui-manifest-node.mjs" ], - "objectuiSha": "f96f771140f1ba488245e0d0d3e7420d720bff9a", + "objectuiSha": "daeb77fc84dd824e74c95b87cdede85afdb248e7", "source": "built-tree", - "modulesRoot": ".cache/objectui-f96f771140f1/apps/console", + "modulesRoot": ".cache/objectui-daeb77fc84dd/apps/console", "objectuiWorkspaceVersion": "17.7.0", "generator": "scripts/gen-sdui-manifest-node.mjs", - "generatedAt": "2026-10-15", + "generatedAt": "2026-10-16", "sha256": "370153544e9b8013beb95fd5e8af3eec91f911c2813ba82b216684d71c4a3f17", "components": 107 } From d36a804054a170573b12506d797277d6ecdba05f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 00:46:09 +0000 Subject: [PATCH 12/12] chore(sdui-parser): re-record the objectui lockstep at pin daeb77fc84dd Claude-Session: https://claude.ai/code/session_01TC6DKN349m4i7okGWPe4RG Co-authored-by: Claude --- packages/sdui-parser/objectui-lockstep.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/sdui-parser/objectui-lockstep.json b/packages/sdui-parser/objectui-lockstep.json index 653e0091dd9..a47e2b101af 100644 --- a/packages/sdui-parser/objectui-lockstep.json +++ b/packages/sdui-parser/objectui-lockstep.json @@ -6,8 +6,8 @@ ], "objectui": { "repo": "https://github.com/objectstack-ai/objectui.git", - "rev": "f96f771140f1ba488245e0d0d3e7420d720bff9a", - "revDate": "2026-10-15T21:57:44+00:00", + "rev": "daeb77fc84dd824e74c95b87cdede85afdb248e7", + "revDate": "2026-10-15T23:34:05+00:00", "source": "packages/sdui-parser/src", "files": [ "packages/sdui-parser/src/body-dialect.ts", @@ -21,7 +21,7 @@ "packages/sdui-parser/src/validate.ts" ] }, - "recordedAgainstPin": "f96f771140f1ba488245e0d0d3e7420d720bff9a", + "recordedAgainstPin": "daeb77fc84dd824e74c95b87cdede85afdb248e7", "grammarRegion": { "file": "packages/sdui-parser/src/parse.ts", "delimiter": "/* ---------------------- the JS literal subset (#6614) ---------------------- */",