diff --git a/.changeset/12476-page-template-slots.md b/.changeset/12476-page-template-slots.md
new file mode 100644
index 0000000000..5dd791df93
--- /dev/null
+++ b/.changeset/12476-page-template-slots.md
@@ -0,0 +1,21 @@
+---
+'@object-ui/components': minor
+---
+
+A page laid out by a slotted template draws only the regions that template places, and says so on the page for any other (objectui#12476, maintainer ruling A on objectstack-ai/objectstack#23610).
+
+Clause-②: no (narrowing)
+
+`@objectstack/spec` 18 declares each template's slots (`PAGE_TEMPLATE_SLOTS`): `default` and `three-column` place header, sidebar, main, aside and footer, `header-sidebar-main` places header, sidebar and main, and `dashboard` places header and footer with every other region a grid cell. It refuses, at `os validate` and at save, a region its template does not place, and the retired `full-width` template.
+
+The `page` renderer used to stack such a region under `main` at full width, ignoring its `width`, so a `left_sidebar` on a `three-column` page drew as one column. On the `default`, `header-sidebar-main` and `three-column` templates that region is now drawn nowhere and is not moved into another slot. Where it used to be stacked, the page shows a refusal that names the region and the slots the template places. A page row stored before the spec refusal is still served unchanged, so this is what it shows until the region is renamed.
+
+`template: 'full-width'` no longer resolves. It was a second name for `default`, drawn by the same component. A served stored row is rewritten to `default` on read by the platform. A page node that still names it, or any template the registry does not hold, draws a refusal that names the template instead of falling back to the page-type layout. The registry is now keyed by the spec's template set, so a template the spec adds or retires fails to compile here.
+
+Unchanged: the placed regions draw as before, each on its 12-column block grid (objectui#12624), with the narrow-region compact list (objectui#12703). `dashboard` keeps its open grid. A page that names no template takes the `pageType` layout, which still stacks an unplaced region under `main`.
+
+The renames the spec names: `full-width` → `default`; on `default` and `three-column`, `left_sidebar` → `sidebar`, `right_sidebar` → `aside`, `content` → `main`; or choose `dashboard` when the regions are meant as a grid.
+
+`@object-ui/components` now needs `@objectstack/spec` 18.0.0-next.2 or later: the renderer reads the spec's `PAGE_TEMPLATE_SLOTS`, which next.1 does not carry, so the package's declared range moves from `^18.0.0-next.1` to `^18.0.0-next.2`.
+
+No export, prop, type or language-pack key is added or removed. The narrowing is in what the renderer draws: `PageNodeSchema.regions[].name` is a string in `@object-ui/types`, and the renderer no longer draws a name its template does not place.
diff --git a/.changeset/23610-record-page-seed-default.md b/.changeset/23610-record-page-seed-default.md
index 5c3efd4878..0023e12d8f 100644
--- a/.changeset/23610-record-page-seed-default.md
+++ b/.changeset/23610-record-page-seed-default.md
@@ -7,8 +7,8 @@ A new record page seeded in Studio is saved with the `default` layout template i
`@objectstack/spec` closes `PageSchema.template` to four layout names — `default`, `header-sidebar-main`, `three-column`, `dashboard` — and retires `full-width` by name, naming `default` as its replacement; the save path refuses a page that still carries it.
-- **`buildDefaultPageSchema` seeds `template: 'default'`.** Studio's "new record page" seed takes its template from this synthesis, so with the old value the first save of a new record page was refused. The renderer maps `default` and `full-width` to the same `FullWidthTemplate`, so a seeded page lays out exactly as before.
+- **`buildDefaultPageSchema` seeds `template: 'default'`.** Studio's "new record page" seed takes its template from this synthesis, so with the old value the first save of a new record page was refused. `full-width` and `default` were drawn by the same renderer component, which `default` still names, so a seeded page lays out exactly as before.
- **`PageNodeSchema.template` is `SpecPage['template']`.** The member was re-declared as `string` over the spec's inherited one; once the spec closes the set, that widening is a TypeScript error (TS2430) on the interface. Against today's spec, whose member is still `string`, the type is unchanged.
- **The zod mirror's `template` is the spec's member too.** `PageNodeSchema` in the zod face declared `template: z.string()`, which accepts every name; once the spec closes the set it measures wider than the TS face, so `full-width` would still pass `objectui validate`. It now takes the spec's own member (defaults stripped, as for every imported member), and against today's spec it accepts exactly what it accepted before.
-The renderer still recognises `full-width`, so pages already stored with it keep rendering.
+A page already stored with `full-width` keeps rendering because the platform rewrites it to `default` on read (the spec's conversion `page-template-full-width-to-default`). The renderer itself no longer recognises `full-width` (objectui#12476).
diff --git a/packages/components/package.json b/packages/components/package.json
index df83bbf222..73583b9cae 100644
--- a/packages/components/package.json
+++ b/packages/components/package.json
@@ -39,7 +39,7 @@
"@object-ui/react-runtime": "workspace:*",
"@object-ui/sdui-parser": "workspace:*",
"@object-ui/types": "workspace:*",
- "@objectstack/spec": "^18.0.0-next.1",
+ "@objectstack/spec": "^18.0.0-next.2",
"@radix-ui/react-accordion": "^1.2.20",
"@radix-ui/react-alert-dialog": "^1.1.23",
"@radix-ui/react-aspect-ratio": "^1.1.15",
diff --git a/packages/components/src/__tests__/page-template-slots-12476.test.tsx b/packages/components/src/__tests__/page-template-slots-12476.test.tsx
new file mode 100644
index 0000000000..8348e04641
--- /dev/null
+++ b/packages/components/src/__tests__/page-template-slots-12476.test.tsx
@@ -0,0 +1,163 @@
+/**
+ * ObjectUI
+ * Copyright (c) 2024-present ObjectStack Inc.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * A slotted template draws only the regions it places (objectui#12476,
+ * maintainer ruling A on objectstack#23610).
+ *
+ * `@objectstack/spec` 18 declares each template's slots (`PAGE_TEMPLATE_SLOTS`)
+ * and refuses, at `os validate` and at save, a region its template does not
+ * place, and the retired `full-width` template. The page renderer used to stack
+ * such a region under `main` at full width. It no longer draws it anywhere and
+ * says so on the page instead; it never moves it into another slot. The
+ * `dashboard` template keeps its open grid, and a page that names no template
+ * (the `pageType` dispatch) is not judged here.
+ */
+
+import { describe, it, expect, afterEach } from 'vitest';
+import { render, cleanup } from '@testing-library/react';
+import { ActionProvider, SchemaRenderer } from '@object-ui/react';
+// Module scope, not a hook: registers PageRenderer and `statistic`
+// (AGENTS.md §测试纪律).
+import '../renderers';
+
+function tile(id: string) {
+ return { type: 'statistic', id, label: id, value: 1 };
+}
+
+function region(name: string) {
+ return { name, components: [tile(`in-${name}`)] };
+}
+
+function renderPage(schema: unknown) {
+ return render(
+
+
+ ,
+ );
+}
+
+/** The region element and its 12-column block grid (objectui#12624). */
+function expectDrawnOnGrid(container: HTMLElement, name: string) {
+ const el = container.querySelector(`[data-region="${name}"]`);
+ expect(el, `region ${name}`).not.toBeNull();
+ expect(el!.classList.contains('grid-cols-12')).toBe(true);
+ expect(container.textContent).toContain(`in-${name}`);
+}
+
+function refusals(container: HTMLElement, kind: string) {
+ return Array.from(container.querySelectorAll(`[data-page-refusal="${kind}"]`));
+}
+
+afterEach(() => cleanup());
+
+/** Each slotted template, its slots, and a name it does not place. */
+const SLOTTED: Array<[template: string, slots: string[], unplaced: string[]]> = [
+ ['default', ['header', 'sidebar', 'main', 'aside', 'footer'], ['left_sidebar', 'content']],
+ ['header-sidebar-main', ['header', 'sidebar', 'main'], ['aside', 'footer', 'right_sidebar']],
+ ['three-column', ['header', 'sidebar', 'main', 'aside', 'footer'], ['left_sidebar', 'right_sidebar']],
+];
+
+describe('a slotted template does not stack a region it does not place (objectui#12476)', () => {
+ it.each(SLOTTED)('%s: an unplaced region is drawn nowhere, and the page refuses it by name', (template, slots, unplaced) => {
+ const { container } = renderPage({
+ type: 'page',
+ template,
+ regions: [region('main'), ...unplaced.map(region)],
+ });
+ for (const name of unplaced) {
+ expect(container.querySelector(`[data-region="${name}"]`), name).toBeNull();
+ expect(container.textContent).not.toContain(`in-${name}`);
+ }
+ const [refusal, ...more] = refusals(container, 'unplaced-region');
+ expect(more).toHaveLength(0);
+ expect(refusal).toBeDefined();
+ const items = Array.from(refusal.querySelectorAll('li')).map((li) => li.textContent ?? '');
+ expect(items).toHaveLength(unplaced.length);
+ unplaced.forEach((name, i) => {
+ expect(items[i]).toContain(`'${name}'`);
+ expect(items[i]).toContain(`'${template}' places only ${slots.join(', ')}`);
+ });
+ // The page still draws what the template places.
+ expectDrawnOnGrid(container, 'main');
+ });
+
+ it('default: a page with no placed region draws only the refusal', () => {
+ const { container } = renderPage({
+ type: 'page',
+ template: 'default',
+ regions: [region('kpis'), region('charts')],
+ });
+ expect(container.querySelector('[data-region]')).toBeNull();
+ expect(refusals(container, 'unplaced-region')).toHaveLength(1);
+ expect(refusals(container, 'unplaced-region')[0].querySelectorAll('li')).toHaveLength(2);
+ });
+});
+
+describe('CONTROLS — what each template places still draws as before', () => {
+ it.each(SLOTTED)('%s: every placed slot draws on its block grid, with no refusal', (template, slots) => {
+ const { container } = renderPage({ type: 'page', template, regions: slots.map(region) });
+ for (const name of slots) expectDrawnOnGrid(container, name);
+ expect(container.querySelector('[data-page-refusal]')).toBeNull();
+ });
+
+ it('region names place case-insensitively, as the spec matches them', () => {
+ const { container } = renderPage({
+ type: 'page',
+ template: 'three-column',
+ regions: [region('Main'), region('ASIDE')],
+ });
+ expectDrawnOnGrid(container, 'Main');
+ expectDrawnOnGrid(container, 'ASIDE');
+ expect(container.querySelector('[data-page-refusal]')).toBeNull();
+ });
+
+ it('dashboard keeps its open grid: every non-header, non-footer region is a grid cell', () => {
+ const { container } = renderPage({
+ type: 'page',
+ template: 'dashboard',
+ regions: [region('header'), region('kpis'), region('charts'), region('main'), region('footer')],
+ });
+ const cells = container.querySelector('[data-template="dashboard"] .md\\:grid-cols-2')!;
+ expect(cells).not.toBeNull();
+ expect(Array.from(cells.children).map((c) => c.getAttribute('data-region'))).toEqual(['kpis', 'charts', 'main']);
+ for (const name of ['header', 'kpis', 'charts', 'main', 'footer']) expectDrawnOnGrid(container, name);
+ expect(container.querySelector('[data-page-refusal]')).toBeNull();
+ });
+
+ it.each(['record', 'home', 'app', 'utility'])(
+ 'a %s page that names no template still stacks an unnamed-slot region under main (the pageType dispatch)',
+ (pageType) => {
+ const { container } = renderPage({
+ type: 'page',
+ pageType,
+ regions: [region('main'), region('region_2')],
+ });
+ expectDrawnOnGrid(container, 'main');
+ expectDrawnOnGrid(container, 'region_2');
+ expect(container.querySelector('[data-page-refusal]')).toBeNull();
+ },
+ );
+});
+
+describe("the retired `full-width` template no longer resolves (objectui#12476)", () => {
+ it.each(['full-width', 'two-column', 'constructor'])(
+ '`template: %s` draws a refusal, not the page-type layout',
+ (template) => {
+ const { container } = renderPage({
+ type: 'page',
+ pageType: 'home',
+ template,
+ regions: [region('main')],
+ });
+ expect(container.querySelector('[data-region]')).toBeNull();
+ expect(container.querySelector('[data-page-layout]')).toBeNull();
+ const [refusal] = refusals(container, 'unregistered-template');
+ expect(refusal).toBeDefined();
+ expect(refusal.textContent).toContain(`'${template}'`);
+ },
+ );
+});
diff --git a/packages/components/src/renderers/layout/page.tsx b/packages/components/src/renderers/layout/page.tsx
index dad6a91a96..c464da3060 100644
--- a/packages/components/src/renderers/layout/page.tsx
+++ b/packages/components/src/renderers/layout/page.tsx
@@ -27,6 +27,7 @@ import {
import { ComponentRegistry, toDomProps } from '@object-ui/core';
import { useDisplayLocale } from '@object-ui/i18n';
import { compile, manifestFromConfigs, type Diagnostic } from '@object-ui/sdui-parser';
+import { PAGE_TEMPLATE_SLOTS, type PageTemplate } from '@objectstack/spec/ui';
import { ReactKindPage } from './react-page';
import { PRINT_PAGE_ATTRIBUTE, PRINT_RUNNING_ATTRIBUTE, buildPagePrintStylesheet, isPagePrintDeclaration } from './page-print';
import { cn } from '../../lib/utils';
@@ -91,7 +92,7 @@ function findRegion(regions: PageNodeRegion[] | undefined, name: string): PageNo
}
/** Get all regions that are NOT in the named set */
-function getRemainingRegions(regions: PageNodeRegion[] | undefined, exclude: string[]): PageNodeRegion[] {
+function getRemainingRegions(regions: PageNodeRegion[] | undefined, exclude: readonly string[]): PageNodeRegion[] {
if (!regions) return [];
const lowerSet = new Set(exclude.map((n) => n.toLowerCase()));
return regions.filter((r) => !lowerSet.has(r.name?.toLowerCase() ?? ''));
@@ -291,32 +292,115 @@ const RunningRegions: React.FC<{
);
};
+// ---------------------------------------------------------------------------
+// Layout refusals — a template shape the spec refuses (objectui#12476)
+// ---------------------------------------------------------------------------
+
+/** A template that places named slots only: every one but `dashboard`'s open grid. */
+type SlottedTemplate = Exclude;
+
+const TEMPLATE_NAMES = Object.keys(PAGE_TEMPLATE_SLOTS).map((name) => `'${name}'`).join(', ');
+
+/** A visible refusal in place of what the page asked for, styled like the html tier's compile error. */
+const PageLayoutRefusal: React.FC<{ kind: string; title: string; items: string[] }> = ({ kind, title, items }) => (
+
+
{title}
+
+ {items.map((item, i) => (
+
{item}
+ ))}
+
+
+);
+
+/**
+ * The regions a slotted template does not place (`PAGE_TEMPLATE_SLOTS`).
+ *
+ * They are not drawn, and not moved into another slot: the page says so where
+ * they used to be stacked, under `main` (objectui#12476). The spec refuses such
+ * a region at `os validate` and at save (`checkPageRegionPlacement`); a row
+ * stored before that refusal is served unchanged, so this is what it shows
+ * until its region is renamed.
+ */
+const UnplacedRegionsRefusal: React.FC<{ regions: PageNodeRegion[]; template: SlottedTemplate }> = ({
+ regions,
+ template,
+}) => {
+ if (regions.length === 0) return null;
+ const slots = PAGE_TEMPLATE_SLOTS[template].join(', ');
+ return (
+
+ `Region '${region.name ?? `#${idx}`}': '${template}' places only ${slots}. Rename it to one of those slots, `
+ + `or choose the 'dashboard' template, which lays every other region out as a grid cell.`,
+ )}
+ />
+ );
+};
+
+/** A template name the registry does not hold: the page is not laid out by another layout instead. */
+const UnregisteredTemplateRefusal: React.FC<{ schema: PageNodeSchema }> = ({ schema }) => (
+
+);
+
// ---------------------------------------------------------------------------
// RegionLayout — structured layout with named slots
// ---------------------------------------------------------------------------
+/**
+ * The `default` template's body, and the `pageType` layouts' body.
+ *
+ * Under `template` (the `default` template) a region the template does not
+ * place is refused on screen, never stacked (objectui#12476): the spec refuses
+ * it at `os validate` and at save (`checkPageRegionPlacement`). Without
+ * `template` (a page that names no template and takes the `pageType`
+ * dispatch, or a page app-shell builds in code) such a region is still
+ * stacked under `main`.
+ */
const RegionLayout: React.FC<{
regions: PageNodeRegion[];
pageType?: string;
className?: string;
-}> = ({ regions, pageType, className }) => {
+ template?: SlottedTemplate;
+}> = ({ regions, pageType, className, template }) => {
const header = findRegion(regions, 'header');
const sidebar = findRegion(regions, 'sidebar');
const main = findRegion(regions, 'main');
const aside = findRegion(regions, 'aside');
const footer = findRegion(regions, 'footer');
- // Remaining regions that don't match named slots → append below main
- const extras = getRemainingRegions(regions, ['header', 'sidebar', 'main', 'aside', 'footer']);
+ const remaining = getRemainingRegions(regions, ['header', 'sidebar', 'main', 'aside', 'footer']);
+ // Remaining regions that don't match named slots → append below main,
+ // on a page with no template only.
+ const extras = template ? [] : remaining;
+ const unplaced = template ? remaining : [];
- // If there's no named layout structure, just stack everything
const hasStructure = header || sidebar || main || aside || footer;
if (!hasStructure) {
+ // If there's no named layout structure, just stack everything — unless a
+ // template lays the page out, which places none of these regions.
return (