From b3449083354857b7c230b4e697ffa7f6861711c6 Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 8 Oct 2026 10:32:38 +0000
Subject: [PATCH 1/4] fix(app-shell): a source page's live preview draws the
page's own kind (objectui#11933)
Studio's source-page preview wrote the page's kind into the node `type`
only. PageRenderer reads the kind from `pageType`, so every source page
previewed as a record page: record width, no title heading. The running
app's PageView wrote both keys.
Both writers now spread one module-private builder, `pageKindNode`, for
the `{ type, pageType }` pair, so they cannot drift again. PageView's
output is unchanged; data and context wiring stay with each writer.
Pin: SourcePageEditor.pageKind-11933 renders through the real
SchemaRenderer and PageRenderer: an app and a home page preview with the
title heading and a non-record width; a record page previews as the
renderer's record default, as before.
Claude-Session: https://claude.ai/code/session_01MgfduSkFrfM3eorB3UGfAU
Co-authored-by: Claude
---
.changeset/11933-preview-page-kind.md | 9 ++
packages/app-shell/src/views/PageView.tsx | 9 +-
.../SourcePageEditor.pageKind-11933.test.tsx | 145 ++++++++++++++++++
.../previews/SourcePageEditor.tsx | 8 +-
packages/app-shell/src/views/pageKindNode.ts | 44 ++++++
5 files changed, 212 insertions(+), 3 deletions(-)
create mode 100644 .changeset/11933-preview-page-kind.md
create mode 100644 packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.pageKind-11933.test.tsx
create mode 100644 packages/app-shell/src/views/pageKindNode.ts
diff --git a/.changeset/11933-preview-page-kind.md b/.changeset/11933-preview-page-kind.md
new file mode 100644
index 0000000000..55d60ab7cb
--- /dev/null
+++ b/.changeset/11933-preview-page-kind.md
@@ -0,0 +1,9 @@
+---
+'@object-ui/app-shell': patch
+---
+
+Studio's live preview of a source page draws the page's own kind, as the running app does (objectui#11933).
+
+The preview beside an `html` or `react` page's source wrote the page's kind into the node `type` only. `PageRenderer` reads the kind from `pageType`, so it fell back to a record page: an `app` or `home` page previewed at the record width and without its title heading, while the running app drew it with both. The preview and the running app's page view now build the `{ type, pageType }` pair with one shared builder, so the preview draws each kind's width and title exactly as the app does. A `record` page previews as before. The running app's page view renders exactly as before.
+
+Nothing is added to the package entry: no export, prop, type member or language-pack key.
diff --git a/packages/app-shell/src/views/PageView.tsx b/packages/app-shell/src/views/PageView.tsx
index 3762360adf..9d158ef957 100644
--- a/packages/app-shell/src/views/PageView.tsx
+++ b/packages/app-shell/src/views/PageView.tsx
@@ -28,6 +28,7 @@ import { ConsoleActionRuntimeProvider } from '../hooks/useConsoleActionRuntime.j
import { useCanAuthorMetadata } from '../hooks/useCanAuthorMetadata.js';
import { InterfaceListPage } from './InterfaceListPage.js';
import { pageRecordActionsValue } from './pageRecordActions.js';
+import { pageKindNode } from './pageKindNode.js';
/**
* After a successful page-level action, declare the change on the
@@ -182,8 +183,12 @@ export function PageView() {
// package, so its module graph cannot reach this file: gutting
// this mapping leaves it green, and deleting a registration
// turns it red. Both directions were measured on objectui#9718.
- type: (page as any).type || 'page',
- pageType: (page as any).type,
+ //
+ // The pair is built by `pageKindNode`, the ONE builder this
+ // writer shares with Studio's source-page live preview
+ // (objectui#11933): the preview used to write `type` alone,
+ // so it drew an app or home page as a record page.
+ ...pageKindNode(page),
// `context` is built here, never read off the page: `PageSchema`
// refuses a page-level `context` key, so no parsed page can
// carry one (objectui#9673). Written after `...page`, it also
diff --git a/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.pageKind-11933.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.pageKind-11933.test.tsx
new file mode 100644
index 0000000000..860a3ef575
--- /dev/null
+++ b/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.pageKind-11933.test.tsx
@@ -0,0 +1,145 @@
+/**
+ * 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.
+ */
+
+/**
+ * objectui#11933 — a source page's live preview draws the page's own KIND.
+ *
+ * `SourcePageEditor`'s preview is "a live preview rendered through the runtime
+ * SchemaRenderer". It wrote the page's kind into the node `type` and nowhere
+ * else, while the running app (`PageView`) also copies it onto `pageType` — the
+ * key `PageRenderer` reads for the page's width, its `data-page-type` and
+ * whether it draws the implicit title heading. So Studio drew an `app` or
+ * `home` page as a record page (record width, no title) while the running app
+ * drew it with both. Both writers now spread one builder, `pageKindNode`.
+ *
+ * Everything below renders through the REAL `SchemaRenderer`, the real
+ * `ComponentRegistry` and the real `PageRenderer` the preview uses — no mock
+ * stands in for the renderer. Widths are compared, never written down: the
+ * classes are `PageRenderer`'s choice, and what this card owns is only that a
+ * non-record page does not get the record one.
+ *
+ * - an `app` and a `home` source page preview with the title heading and a
+ * width that is not the record width;
+ * - every region-composed page kind reaches the renderer as itself;
+ * - CONTROL: a `record` page previews exactly as an untyped page does — the
+ * renderer's own record default, which is what every source page drew before.
+ *
+ * The running app's half of the pair is pinned by `page-kind-writing-end-9718`
+ * in `views/__tests__`, kind by kind; this file does not re-assert it.
+ */
+
+import * as React from 'react';
+import { afterEach, describe, expect, it } from 'vitest';
+import { cleanup, render } from '@testing-library/react';
+import { PageTypeSchema } from '@objectstack/spec/ui';
+// Registers `PageRenderer` under each page kind and the html tier's blocks the
+// preview compiles the source to. Module scope, not a hook (AGENTS.md's flaky
+// test discipline).
+import '@object-ui/components';
+import { SourcePageEditor } from './SourcePageEditor';
+
+const LABEL = 'Team handbook';
+const BODY = 'Welcome to the team.';
+
+/**
+ * Kinds that are interface mode in the running app (`PageView` short-circuits
+ * them to `InterfaceListPage`), so no `PageRenderer` registration answers for
+ * them. The same split `page-kind-writing-end-9718` reads.
+ */
+const INTERFACE_MODE_KINDS = ['list'] as const;
+
+function regionComposedKinds(): string[] {
+ // `PageTypeSchema` is a lazy schema; `.options` is the enum's member list.
+ const kinds = [...(PageTypeSchema as unknown as { options: readonly string[] }).options];
+ return kinds.filter((kind) => !(INTERFACE_MODE_KINDS as readonly string[]).includes(kind));
+}
+
+interface Drawn {
+ pageType: string | null;
+ /** The `max-w-*` classes on the page's content column. */
+ width: string;
+ /** The text of every level-one heading the page drew. */
+ headings: string[];
+}
+
+/** Render the Studio canvas's preview half over one source page document. */
+function preview(doc: Record): Drawn {
+ cleanup();
+ const { container } = render(
+ ${BODY}
`, ...doc }}
+ />,
+ );
+
+ // Firing controls: the page renderer was reached, and the source compiled.
+ // A compile error replaces the body with an error panel, and a missing page
+ // element means the preview drew something else — either would make every
+ // reading below a reading of the wrong thing.
+ const page = container.querySelector('[data-page-type]');
+ expect(page, 'the preview drew no PageRenderer at all; nothing below would be measured').not.toBeNull();
+ expect(container.textContent).not.toContain('failed to compile');
+ expect(container.textContent).toContain(BODY);
+
+ const column = page!.firstElementChild as HTMLElement | null;
+ const width = (column?.className ?? '')
+ .split(/\s+/)
+ .filter((c) => c.startsWith('max-w-'))
+ .join(' ');
+ expect(width, 'the page content column carries no max-width class to compare').not.toBe('');
+
+ return {
+ pageType: page!.getAttribute('data-page-type'),
+ width,
+ headings: [...container.querySelectorAll('h1')].map((h) => h.textContent ?? ''),
+ };
+}
+
+afterEach(() => cleanup());
+
+describe('objectui#11933 — a source page previews as its own page kind', () => {
+ it('CONTROL: a record page previews as before, the renderer\'s record default', () => {
+ const record = preview({ type: 'record' });
+ const untyped = preview({});
+
+ expect(record.pageType).toBe('record');
+ // An untyped document carries no kind, so the renderer falls back to its
+ // record default — which is what EVERY source page drew before this fix.
+ expect(untyped.pageType).toBe('record');
+ expect(record.width).toBe(untyped.width);
+ // A record page leaves its title to `page:header`, so it draws no implicit one.
+ expect(record.headings).toEqual([]);
+ expect(untyped.headings).toEqual([]);
+ });
+
+ it.each(['app', 'home'])('a %s page previews with its title heading and not the record width', (kind) => {
+ const recordWidth = preview({ type: 'record' }).width;
+ const drawn = preview({ type: kind });
+
+ expect(
+ drawn.pageType,
+ `the preview must hand PageRenderer the page kind '${kind}' on pageType, as the running app does`,
+ ).toBe(kind);
+ expect(drawn.headings).toEqual([LABEL]);
+ expect(
+ drawn.width,
+ `a '${kind}' page previewed at the record width (${recordWidth}); the running app draws it at its own`,
+ ).not.toBe(recordWidth);
+ });
+
+ it('every region-composed page kind reaches the renderer as itself', () => {
+ const kinds = regionComposedKinds();
+ // Firing control: a green below is a reading, not an empty loop.
+ expect(kinds.length).toBeGreaterThan(0);
+
+ for (const kind of kinds) {
+ expect(preview({ type: kind }).pageType, `page kind '${kind}'`).toBe(kind);
+ }
+ });
+});
diff --git a/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.tsx b/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.tsx
index 6c45ed6209..90ef1c7fa0 100644
--- a/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.tsx
+++ b/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.tsx
@@ -26,6 +26,7 @@ import { Skeleton } from '@object-ui/components';
import { SchemaRenderer } from '@object-ui/react';
import { PreviewShell, PreviewErrorBoundary } from './PreviewShell.js';
import { useMonacoFallback } from '../useMonacoFallback.js';
+import { pageKindNode } from '../../pageKindNode.js';
import { t as tr } from '../i18n.js';
// Lazy for the same reason as JsonSourceEditor — Monaco's core is ~3MB and
@@ -150,8 +151,13 @@ export function SourcePageEditor({
onPatch?.({ source: v });
};
+ // The page's KIND rides two keys, as the running app hands it (`PageView`):
+ // the node `type` the registry dispatches on and the `pageType` PageRenderer
+ // reads for the page width and the title heading. Writing `type` alone drew
+ // every source page as a record page — record width, no title — whatever
+ // its kind (objectui#11933). One shared builder writes both, for both.
const previewSchema = React.useMemo(
- () => ({ ...(draft as Record), type: (draft as { type?: string }).type ?? 'page' }),
+ () => ({ ...(draft as Record), ...pageKindNode(draft as { type?: string }) }),
[draft],
);
diff --git a/packages/app-shell/src/views/pageKindNode.ts b/packages/app-shell/src/views/pageKindNode.ts
new file mode 100644
index 0000000000..89839a4ffa
--- /dev/null
+++ b/packages/app-shell/src/views/pageKindNode.ts
@@ -0,0 +1,44 @@
+/**
+ * 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.
+ */
+
+/**
+ * The page-shape half of the schema node a stored page document is handed to
+ * `SchemaRenderer` as: the `{ type, pageType }` pair (objectui#11933).
+ *
+ * A stored page's `type` is the spec's page KIND (`PageTypeSchema` in
+ * `@objectstack/spec/ui`). It is written twice:
+ *
+ * - VERBATIM into `type`, the SchemaNode discriminator `ComponentRegistry`
+ * dispatches on — `@object-ui/components` registers `PageRenderer` under
+ * each region-composed kind, and under `'page'`, the fallback for a document
+ * that carries no `type` at all;
+ * - as a copy on `pageType`, which is what `PageRenderer` reads for the page's
+ * max-width, its `data-page-type`, and whether it draws the implicit title
+ * heading. Without it every page falls back to `'record'`: the record width
+ * and no title.
+ *
+ * ⭐ ONE builder, because there are two writers and they drifted: `PageView`
+ * (the running app) wrote both keys, while Studio's source-page live preview
+ * (`SourcePageEditor`) wrote only `type`, so an `app` or `home` page previewed
+ * as a record page while the running app drew it with its title and its own
+ * width. That broke the preview's contract — "a live preview rendered through
+ * the runtime SchemaRenderer". Both writers now spread this one builder, so
+ * the two cannot drift apart again.
+ *
+ * ⛔ Page shape only. Data and context wiring (`PageView`'s `context: { params }`
+ * and the providers around it) stay with each writer: a preview has no URL.
+ *
+ * Pins: `page-kind-writing-end-9718` in `views/__tests__` (the running app's
+ * write, kind by kind) and `SourcePageEditor.pageKind-11933` beside the
+ * preview (the preview's render, through the real `PageRenderer`).
+ *
+ * Module-private: not exported from the package entry.
+ */
+export function pageKindNode(page: { type?: string }): { type: string; pageType: string | undefined } {
+ return { type: page.type || 'page', pageType: page.type };
+}
From 36b3d33fb012df62deb8bb55187ba97d5b881d1c Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 8 Oct 2026 10:35:10 +0000
Subject: [PATCH 2/4] test(app-shell): the source-page preview pin keeps the
Monaco loader offline (objectui#11933)
The preview half never mounts the editor, but the component asks the
Monaco loader either way; the sibling source-page tests mock it offline,
and so does this pin, so no CDN script is requested.
Claude-Session: https://claude.ai/code/session_01MgfduSkFrfM3eorB3UGfAU
Co-authored-by: Claude
---
.../previews/SourcePageEditor.pageKind-11933.test.tsx | 11 ++++++++++-
1 file changed, 10 insertions(+), 1 deletion(-)
diff --git a/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.pageKind-11933.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.pageKind-11933.test.tsx
index 860a3ef575..203a565a9e 100644
--- a/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.pageKind-11933.test.tsx
+++ b/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.pageKind-11933.test.tsx
@@ -34,13 +34,22 @@
*/
import * as React from 'react';
-import { afterEach, describe, expect, it } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render } from '@testing-library/react';
import { PageTypeSchema } from '@objectstack/spec/ui';
// Registers `PageRenderer` under each page kind and the html tier's blocks the
// preview compiles the source to. Module scope, not a hook (AGENTS.md's flaky
// test discipline).
import '@object-ui/components';
+
+// The editor half is not under test (the canvas mounts the preview half only),
+// but the component asks the Monaco loader either way. Offline, as in the
+// sibling source-page tests, so no CDN script is requested.
+vi.mock('@monaco-editor/react', () => {
+ const Editor = () => null;
+ return { Editor, default: Editor, loader: { init: () => Promise.reject(new Error('offline')) } };
+});
+
import { SourcePageEditor } from './SourcePageEditor';
const LABEL = 'Team handbook';
From 20b279b2bac0858c648e5f42498d2fe026d628d3 Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 8 Oct 2026 10:35:59 +0000
Subject: [PATCH 3/4] test(app-shell): name the kind-parametrised preview cases
grammatically (objectui#11933)
Claude-Session: https://claude.ai/code/session_01MgfduSkFrfM3eorB3UGfAU
Co-authored-by: Claude
---
.../previews/SourcePageEditor.pageKind-11933.test.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.pageKind-11933.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.pageKind-11933.test.tsx
index 203a565a9e..8b4255301b 100644
--- a/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.pageKind-11933.test.tsx
+++ b/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.pageKind-11933.test.tsx
@@ -127,7 +127,7 @@ describe('objectui#11933 — a source page previews as its own page kind', () =>
expect(untyped.headings).toEqual([]);
});
- it.each(['app', 'home'])('a %s page previews with its title heading and not the record width', (kind) => {
+ it.each(['app', 'home'])('a page of kind %s previews with its title heading and not the record width', (kind) => {
const recordWidth = preview({ type: 'record' }).width;
const drawn = preview({ type: kind });
From 811475cd36d477aa74f06d36c120c7b0ae6fabfd Mon Sep 17 00:00:00 2001
From: Claude
Date: Thu, 8 Oct 2026 10:59:08 +0000
Subject: [PATCH 4/4] fix(app-shell): Studio's Run-mode page canvas draws the
page's own kind too (objectui#11933)
PagePreview's run-mode render handed SchemaRenderer `{ ...draft, type }`
with no `pageType`, so an app or home region page drew as a record page:
record width, no title heading. Its `schema` memo now spreads the same
`pageKindNode` builder PageView and the source-page preview use.
The empty-draft check counted the memo's keys (`<= 1`), which the new
`pageType` key would have moved; it now asks the draft's own keys, a
`type` aside, which is the same predicate the old count expressed.
Pin: PagePreview.pageKind-11933 renders through the real PageRenderer:
app and home draw the title and a non-record width; a record page draws
as the untyped record default; an empty draft, typed or not, still shows
the add-components message. Changeset and the builder's comments name
both previews.
Claude-Session: https://claude.ai/code/session_01MgfduSkFrfM3eorB3UGfAU
Co-authored-by: Claude
---
.changeset/11933-preview-page-kind.md | 4 +-
packages/app-shell/src/views/PageView.tsx | 6 +-
.../PagePreview.pageKind-11933.test.tsx | 133 ++++++++++++++++++
.../metadata-admin/previews/PagePreview.tsx | 24 ++--
.../previews/SourcePageEditor.tsx | 3 +-
packages/app-shell/src/views/pageKindNode.ts | 20 +--
6 files changed, 166 insertions(+), 24 deletions(-)
create mode 100644 packages/app-shell/src/views/metadata-admin/previews/PagePreview.pageKind-11933.test.tsx
diff --git a/.changeset/11933-preview-page-kind.md b/.changeset/11933-preview-page-kind.md
index 55d60ab7cb..912a4c8e36 100644
--- a/.changeset/11933-preview-page-kind.md
+++ b/.changeset/11933-preview-page-kind.md
@@ -2,8 +2,8 @@
'@object-ui/app-shell': patch
---
-Studio's live preview of a source page draws the page's own kind, as the running app does (objectui#11933).
+Studio's page previews draw the page's own kind, as the running app does (objectui#11933): the live preview beside an `html` or `react` page's source, and the Interfaces page canvas in Run mode.
-The preview beside an `html` or `react` page's source wrote the page's kind into the node `type` only. `PageRenderer` reads the kind from `pageType`, so it fell back to a record page: an `app` or `home` page previewed at the record width and without its title heading, while the running app drew it with both. The preview and the running app's page view now build the `{ type, pageType }` pair with one shared builder, so the preview draws each kind's width and title exactly as the app does. A `record` page previews as before. The running app's page view renders exactly as before.
+Both previews wrote the page's kind into the node `type` only. `PageRenderer` reads the kind from `pageType`, so it fell back to a record page: an `app` or `home` page previewed at the record width and without its title heading, while the running app drew it with both. The two previews and the running app's page view now build the `{ type, pageType }` pair with one shared builder, so each preview draws a page's width and title heading as the app does. A `record` page previews as before, and an empty page draft still shows the "add components" message. The running app's page view renders exactly as before.
Nothing is added to the package entry: no export, prop, type member or language-pack key.
diff --git a/packages/app-shell/src/views/PageView.tsx b/packages/app-shell/src/views/PageView.tsx
index 9d158ef957..6cf57f9be8 100644
--- a/packages/app-shell/src/views/PageView.tsx
+++ b/packages/app-shell/src/views/PageView.tsx
@@ -185,9 +185,9 @@ export function PageView() {
// turns it red. Both directions were measured on objectui#9718.
//
// The pair is built by `pageKindNode`, the ONE builder this
- // writer shares with Studio's source-page live preview
- // (objectui#11933): the preview used to write `type` alone,
- // so it drew an app or home page as a record page.
+ // writer shares with Studio's page previews (objectui#11933):
+ // they used to write `type` alone, so they drew an app or
+ // home page as a record page.
...pageKindNode(page),
// `context` is built here, never read off the page: `PageSchema`
// refuses a page-level `context` key, so no parsed page can
diff --git a/packages/app-shell/src/views/metadata-admin/previews/PagePreview.pageKind-11933.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/PagePreview.pageKind-11933.test.tsx
new file mode 100644
index 0000000000..2642306e43
--- /dev/null
+++ b/packages/app-shell/src/views/metadata-admin/previews/PagePreview.pageKind-11933.test.tsx
@@ -0,0 +1,133 @@
+/**
+ * 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.
+ */
+
+/**
+ * objectui#11933 — Studio's Run-mode page canvas draws the page's own KIND.
+ *
+ * `PagePreview` renders a region-composed page "using the runtime
+ * SchemaRenderer so authors see exactly what end-users would see". Out of
+ * design mode (the Studio Interfaces canvas in Run mode) it wrote the page's
+ * kind into the node `type` and nowhere else,
+ * while the running app (`PageView`) also copies it onto `pageType` — the key
+ * `PageRenderer` reads for the page's width, its `data-page-type` and whether
+ * it draws the implicit title heading. So an `app` or `home` page drew as a
+ * record page (record width, no title). It now spreads the builder `PageView`
+ * uses, `pageKindNode`.
+ *
+ * Everything below renders through the REAL `SchemaRenderer`, the real
+ * `ComponentRegistry` and the real `PageRenderer`. Widths are compared, never
+ * written down: the classes are `PageRenderer`'s choice, and what this card
+ * owns is only that a non-record page does not get the record one.
+ *
+ * - an `app` and a `home` page draw the title heading and a width that is not
+ * the record width;
+ * - CONTROL: a `record` page draws exactly as an untyped page does — the
+ * renderer's own record default, which is what every page drew here before;
+ * - CONTROL: an empty draft, typed or not, still draws no page but the
+ * "add components" message. The renderer's node now carries two keys the
+ * draft did not write, and neither counts as content.
+ *
+ * The source-page preview's half is `SourcePageEditor.pageKind-11933` beside
+ * this file; the running app's half is `page-kind-writing-end-9718`.
+ */
+
+import * as React from 'react';
+import { afterEach, describe, expect, it } from 'vitest';
+import { cleanup, render } from '@testing-library/react';
+// Registers `PageRenderer` under each page kind and the blocks the region
+// renders. Module scope, not a hook (AGENTS.md's flaky test discipline).
+import '@object-ui/components';
+import { PagePreview } from './PagePreview';
+import { t } from '../i18n';
+
+const LABEL = 'Team handbook';
+
+interface Drawn {
+ pageType: string | null;
+ /** The `max-w-*` classes on the page's content column. */
+ width: string;
+ /** The text of every level-one heading the page drew. */
+ headings: string[];
+}
+
+/** Render the Run-mode canvas (no design mode) over one region page document. */
+function runMode(doc: Record): Drawn {
+ cleanup();
+ const { container } = render(
+ ,
+ );
+
+ // Firing controls: the page renderer was reached and drew its region layout.
+ // Without them every reading below could be a reading of something else.
+ const page = container.querySelector('[data-page-type]');
+ expect(page, 'the canvas drew no PageRenderer at all; nothing below would be measured').not.toBeNull();
+ expect(page!.querySelector('[data-page-layout]'), 'the page drew no region layout').not.toBeNull();
+
+ const column = page!.firstElementChild as HTMLElement | null;
+ const width = (column?.className ?? '')
+ .split(/\s+/)
+ .filter((c) => c.startsWith('max-w-'))
+ .join(' ');
+ expect(width, 'the page content column carries no max-width class to compare').not.toBe('');
+
+ return {
+ pageType: page!.getAttribute('data-page-type'),
+ width,
+ headings: [...container.querySelectorAll('h1')].map((h) => h.textContent ?? ''),
+ };
+}
+
+afterEach(() => cleanup());
+
+describe('objectui#11933 — the Run-mode page canvas draws the page as its own kind', () => {
+ it('CONTROL: a record page draws as before, the renderer\'s record default', () => {
+ const record = runMode({ type: 'record' });
+ const untyped = runMode({});
+
+ expect(record.pageType).toBe('record');
+ // An untyped document carries no kind, so the renderer falls back to its
+ // record default — which is what EVERY page drew on this canvas before.
+ expect(untyped.pageType).toBe('record');
+ expect(record.width).toBe(untyped.width);
+ // A record page leaves its title to `page:header`, so it draws no implicit one.
+ expect(record.headings).toEqual([]);
+ expect(untyped.headings).toEqual([]);
+ });
+
+ it.each(['app', 'home'])('a page of kind %s draws its title heading and not the record width', (kind) => {
+ const recordWidth = runMode({ type: 'record' }).width;
+ const drawn = runMode({ type: kind });
+
+ expect(
+ drawn.pageType,
+ `the canvas must hand PageRenderer the page kind '${kind}' on pageType, as the running app does`,
+ ).toBe(kind);
+ expect(drawn.headings).toEqual([LABEL]);
+ expect(
+ drawn.width,
+ `a '${kind}' page drew at the record width (${recordWidth}); the running app draws it at its own`,
+ ).not.toBe(recordWidth);
+ });
+
+ it.each([{}, { type: 'app' }])('CONTROL: an empty draft %j still shows the add-components message', (draft) => {
+ cleanup();
+ const { container } = render();
+
+ expect(container.querySelector('[data-page-type]')).toBeNull();
+ expect(container.textContent).toContain(t('engine.pagePreview.addComponents'));
+ });
+});
diff --git a/packages/app-shell/src/views/metadata-admin/previews/PagePreview.tsx b/packages/app-shell/src/views/metadata-admin/previews/PagePreview.tsx
index e2f41984d3..b29ef4ae11 100644
--- a/packages/app-shell/src/views/metadata-admin/previews/PagePreview.tsx
+++ b/packages/app-shell/src/views/metadata-admin/previews/PagePreview.tsx
@@ -18,6 +18,7 @@ import type { MetadataPreviewProps } from '../preview-registry.js';
import { PreviewShell, PreviewErrorBoundary, PreviewMessage } from './PreviewShell.js';
import { OutlineStrip } from './OutlineStrip.js';
import { SourcePageEditor } from './SourcePageEditor.js';
+import { pageKindNode } from '../../pageKindNode.js';
import { PageBlockCanvas } from './PageBlockCanvas.js';
import { InterfaceListPage } from '../../InterfaceListPage.js';
import { t as tr, tFormat } from '../i18n.js';
@@ -25,13 +26,16 @@ import { t as tr, tFormat } from '../i18n.js';
interface Block { type?: string; id?: string; children?: Block[]; [k: string]: unknown }
export function PagePreview({ draft, editing, selection, onSelectionChange, onPatch, locale }: MetadataPreviewProps) {
- const schema = React.useMemo(() => {
- // SchemaRenderer needs a `type` discriminator. Page schemas may
- // omit it (Page is the implicit type at this metadata level), so
- // we inject it if missing while preserving any explicit override.
- const t = (draft as { type?: string }).type ?? 'page';
- return { ...(draft as Record), type: t };
- }, [draft]);
+ const schema = React.useMemo(
+ // SchemaRenderer needs a `type` discriminator (`'page'` when the draft
+ // declares no kind), and PageRenderer reads the page's KIND off
+ // `pageType` for its width and title heading — both written by the one
+ // builder the running app's PageView uses. Writing `type` alone drew an
+ // app or home page as a record page in the Run-mode canvas
+ // (objectui#11933).
+ () => ({ ...(draft as Record), ...pageKindNode(draft as { type?: string }) }),
+ [draft],
+ );
const designMode = !!(editing && onSelectionChange);
const canEdit = designMode && !!onPatch;
@@ -324,8 +328,10 @@ export function PagePreview({ draft, editing, selection, onSelectionChange, onPa
}
// Empty draft → no preview; but if we're in design mode show the
- // canvas so users can author from scratch.
- if (!schema || Object.keys(schema).length <= 1) {
+ // canvas so users can author from scratch. "Empty" is the DRAFT's own
+ // keys, a `type` aside: the keys `schema` adds for the renderer are not
+ // content (objectui#11933).
+ if (!schema || Object.keys(draft as Record).every((k) => k === 'type')) {
return (
{designMode && shape === 'regions' ? (
diff --git a/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.tsx b/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.tsx
index 90ef1c7fa0..df91dfeb7b 100644
--- a/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.tsx
+++ b/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.tsx
@@ -155,7 +155,8 @@ export function SourcePageEditor({
// the node `type` the registry dispatches on and the `pageType` PageRenderer
// reads for the page width and the title heading. Writing `type` alone drew
// every source page as a record page — record width, no title — whatever
- // its kind (objectui#11933). One shared builder writes both, for both.
+ // its kind (objectui#11933). One shared builder writes both keys, for the
+ // running app and for both Studio page previews.
const previewSchema = React.useMemo(
() => ({ ...(draft as Record), ...pageKindNode(draft as { type?: string }) }),
[draft],
diff --git a/packages/app-shell/src/views/pageKindNode.ts b/packages/app-shell/src/views/pageKindNode.ts
index 89839a4ffa..1dcda9a9fb 100644
--- a/packages/app-shell/src/views/pageKindNode.ts
+++ b/packages/app-shell/src/views/pageKindNode.ts
@@ -22,20 +22,22 @@
* heading. Without it every page falls back to `'record'`: the record width
* and no title.
*
- * ⭐ ONE builder, because there are two writers and they drifted: `PageView`
- * (the running app) wrote both keys, while Studio's source-page live preview
- * (`SourcePageEditor`) wrote only `type`, so an `app` or `home` page previewed
- * as a record page while the running app drew it with its title and its own
- * width. That broke the preview's contract — "a live preview rendered through
- * the runtime SchemaRenderer". Both writers now spread this one builder, so
- * the two cannot drift apart again.
+ * ⭐ ONE builder, because the writers drifted: `PageView` (the running app)
+ * wrote both keys, while Studio's two page previews wrote only `type` — the
+ * source-page live preview (`SourcePageEditor`) and the Run-mode page canvas
+ * (`PagePreview`). So an `app` or `home` page previewed as a record page while
+ * the running app drew it with its title and its own width. That broke the
+ * previews' contract — "a live preview rendered through the runtime
+ * SchemaRenderer". All three writers now spread this one builder, so they
+ * cannot drift apart again.
*
* ⛔ Page shape only. Data and context wiring (`PageView`'s `context: { params }`
* and the providers around it) stay with each writer: a preview has no URL.
*
* Pins: `page-kind-writing-end-9718` in `views/__tests__` (the running app's
- * write, kind by kind) and `SourcePageEditor.pageKind-11933` beside the
- * preview (the preview's render, through the real `PageRenderer`).
+ * write, kind by kind), and `SourcePageEditor.pageKind-11933` and
+ * `PagePreview.pageKind-11933` beside the previews (each preview's render,
+ * through the real `PageRenderer`).
*
* Module-private: not exported from the package entry.
*/