diff --git a/.changeset/11933-preview-page-kind.md b/.changeset/11933-preview-page-kind.md
new file mode 100644
index 0000000000..912a4c8e36
--- /dev/null
+++ b/.changeset/11933-preview-page-kind.md
@@ -0,0 +1,9 @@
+---
+'@object-ui/app-shell': patch
+---
+
+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.
+
+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 3762360adf..6cf57f9be8 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 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
// carry one (objectui#9673). Written after `...page`, it also
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.pageKind-11933.test.tsx b/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.pageKind-11933.test.tsx
new file mode 100644
index 0000000000..8b4255301b
--- /dev/null
+++ b/packages/app-shell/src/views/metadata-admin/previews/SourcePageEditor.pageKind-11933.test.tsx
@@ -0,0 +1,154 @@
+/**
+ * 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, 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';
+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 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 });
+
+ 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..df91dfeb7b 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,14 @@ 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 keys, for the
+ // running app and for both Studio page previews.
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..1dcda9a9fb
--- /dev/null
+++ b/packages/app-shell/src/views/pageKindNode.ts
@@ -0,0 +1,46 @@
+/**
+ * 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 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` and
+ * `PagePreview.pageKind-11933` beside the previews (each 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 };
+}