Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/7121-studio-canvas-leaf-affordances.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
---
'@object-ui/app-shell': patch
---

Studio Interfaces: no Design mode and no "click a block" rail on leaves that have
no block canvas (objectui#7121).

`registerStudioCanvasPreview(type, …)` opts a type into a surface-specific canvas
that renders the running app rather than an editable draft — a contract, not a
habit: `StudioCanvasPreviewProps` carries no `selection`, `onSelectionChange`,
`onPatch` or `editing`. Two affordances beside such a leaf ignored that.

- The Design/Run switch (objectui#5800) was still offered, though `editing` is
handed to exactly one canvas branch (`Preview`). On a studio-canvas leaf the
switch moved `canvasMode` and reached no renderer — a live-looking control
wired to nothing. It is now gated.
- The right rail fell through to "Click a block on the canvas, and edit its
properties right here." beside a canvas that has no blocks, so the instruction
could not be followed. It now states what the canvas is, and — because this
canvas has no blocks by contract — promises no recovery.
- The rail's new branch is ordered ahead of the selection branch, so a block
selected on a *different* leaf no longer opens a scoped inspector for a block
this canvas does not contain; the header's "clear selection" button is gated
with it.

The discriminator is `StudioCanvas`, not `isEditable`. `isEditable` is
`!!Preview && !StudioCanvas` — a conjunction of two independent causes — so
gating on it would also strip these affordances from leaves whose only fault is
that their own type has no designer, the state objectui#6795 part C pinned as
still deserving the ordinary rail. Behaviour on every leaf with a block canvas
is unchanged.
8 changes: 8 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1777,6 +1777,12 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.studio.inspector.tabSource': 'Source',
'engine.studio.inspector.emptyLine1': 'Click a block on the canvas,',
'engine.studio.inspector.emptyLine2': 'and edit its properties right here.',
// objectui#7121 — the rail beside a studio-canvas leaf (a
// `registerStudioCanvasPreview` type: the running app, not a block tree).
// States what the canvas IS; promises no recovery, because there is nothing
// to wait for — `StudioCanvasPreviewProps` carries no selection by contract.
'engine.studio.inspector.studioCanvasNoBlocks':
'This canvas renders the running app, not a block tree — it has no blocks to select, and nothing here is edited from this panel.',
'engine.studio.inspector.designersMissing':
'No metadata designers are registered in this session, so there is nothing to edit here.',
'engine.studio.inspector.noPageSchema': 'Page settings are unavailable — the page schema could not be loaded.',
Expand DownExpand Up@@ -3639,6 +3645,8 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.studio.inspector.tabSource': '源码',
'engine.studio.inspector.emptyLine1': '在画布里点选一个积木,',
'engine.studio.inspector.emptyLine2': '它的属性会在这里直接编辑。',
'engine.studio.inspector.studioCanvasNoBlocks':
'此画布渲染的是运行态应用,而不是积木树 —— 这里没有可选中的积木,也没有可在本面板编辑的内容。',
'engine.studio.inspector.designersMissing': '本次会话没有注册任何元数据设计器,这里没有可编辑的内容。',
'engine.studio.inspector.noPageSchema': '页面设置不可用——无法加载页面 schema。',
'engine.studio.inspector.sourcePageLine1': '这个页面是 {kind} 源码,不是积木树 ——',
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7121 — what the Interfaces pillar may OFFER beside a studio-canvas
* leaf.
*
* ## The mechanism
*
* `registerStudioCanvasPreview(type, …)` opts a type into a surface-specific
* canvas: the running app, not an editable draft. That is a contract, not a
* habit — `StudioCanvasPreviewProps` is deliberately a small read-only subset
* of `MetadataPreviewProps` with **no `selection`, no `onSelectionChange`, no
* `onPatch`, no `editing`**. So a studio-canvas leaf:
*
* - can never produce a block selection (there is no block tree), and
* - can never read the design/run mode (nothing is handed `editing`).
*
* Two affordances beside it ignored both facts: the Design/Run switch (#5800)
* was still offered though `canvasMode` reached no renderer, and the rail fell
* through to *"Click a block on the canvas, and edit its properties right
* here."* — an instruction that cannot be followed.
*
* ## ⚠️ This is NOT #6795 part C's cause, and the precondition says so
*
* Part C repaired what the pillar says when the designer registries are
* **empty**. Every test here asserts a **POPULATED** registry first
* (`listMetadataPreviewTypes()` non-empty), because a zero-registry reading
* would measure that other card instead. The cause here is an ungated
* affordance, not a missing registration.
*
* ## ⛔ The message promises no recovery
*
* Part C established by measurement that these registries are plain `Map`s read
* during render with no subscription, so a consumer that read an empty one
* never recovers ("late inspector rendered: false") — no "loading…", no "try
* again". Here the constraint is even stricter: the statement is not about
* registration at all. This canvas has no blocks **by contract**, so there is
* nothing to wait for, and the last test pins the absence of recovery language.
*
* ## ⚠️ The discriminator is `StudioCanvas`, NOT `isEditable`
*
* `isEditable = !!Preview && !StudioCanvas` is a conjunction of two independent
* causes. Gating on it would also strip these affordances from leaves whose
* only fault is that **their own type** has no designer — the exact state
* `StudioDesignSurface.designerRegistryPartial.test.tsx` pins as still deserving
* the ordinary "click a block" rail. That file is the live fence: gate on
* `isEditable` and it goes red.
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

const objectDef = {
name: 'showcase_task',
label: 'Task',
fields: [{ name: 'title', label: 'Title', type: 'text' }],
};

/**
* One leaf of each kind, in one app: `object` opts into the studio canvas,
* `dashboard` does not (it is an ordinary block-canvas designer). The contrast
* is the point — the second leaf is the regression fence.
*/
const NAV = [
{ id: 'nav_obj', type: 'object', label: 'Tasks', objectName: 'showcase_task' },
{ id: 'nav_dash', type: 'dashboard', label: 'Overview', dashboardName: 'sales_overview' },
];

const mockClient = {
save: vi.fn(async () => ({})),
list: vi.fn(async (type: string) => {
if (type === 'app') return [{ name: 'acme_app', label: 'Acme' }];
if (type === 'object') return [{ name: 'showcase_task', label: 'Task' }];
return [];
}),
listDrafts: vi.fn(async () => []),
layered: vi.fn(async (type: string, name: string) => {
if (type === 'app') return { effective: { name: 'acme_app', label: 'Acme', navigation: NAV } };
if (type === 'object') return { effective: objectDef, code: objectDef };
if (type === 'dashboard') return { effective: { name: 'sales_overview', label: 'Sales' } };
return { effective: { name } };
}),
getDraft: vi.fn(async () => null),
get: vi.fn(async () => undefined),
};

vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) };
});

vi.mock('./packages-io', async (importOriginal) => {
const mod = await importOriginal<typeof import('./packages-io')>();
return { ...mod, fetchPackages: vi.fn(async () => []) };
});

vi.mock('@object-ui/react', async (importOriginal) => {
const mod = await importOriginal<typeof import('@object-ui/react')>();
return { ...mod, useAdapter: () => ({}) };
});

import { InterfacesPillar } from './StudioDesignSurface';
import { listMetadataPreviewTypes, registerMetadataPreview } from '../metadata-admin/preview-registry';
import { listMetadataInspectorTypes, registerMetadataInspector } from '../metadata-admin/inspector-registry';
import { listStudioCanvasPreviewTypes } from './studio-canvas-preview';

const CLICK_A_BLOCK = 'Click a block on the canvas,';

/**
* A block-canvas designer for an unrelated type. Two jobs: it makes the
* designer registry demonstrably POPULATED (the card's precondition), and it
* is the regression fence's leaf. It reports the `editing` prop it was handed,
* so the Design/Run round trip is measured at the seam that actually carries
* the mode rather than through any one renderer's overlay internals.
*/
function StubDashboardPreview(props: Record<string, unknown>): React.ReactElement {
const onSel = props.onSelectionChange as ((s: unknown) => void) | undefined;
return (
<div data-testid="stub-dash" data-editing={String(props.editing)}>
<button
type="button"
data-testid="pick-block"
onClick={() => onSel?.({ kind: 'block', id: 'blk_1' })}
>
pick
</button>
</div>
);
}
registerMetadataPreview('dashboard', StubDashboardPreview as never);

/**
* Production registers `ObjectFieldInspector` for `object`
* (`metadata-admin/inspectors/index.ts`), so the scoped-inspector branch is
* reachable on the studio-canvas leaf whenever a `selection` survives a leaf
* change. Registering a stand-in here is what makes the last pin a measurement
* of that branch rather than of an accidentally-empty registry.
*/
function StubObjectInspector(props: Record<string, unknown>): React.ReactElement {
const sel = props.selection as { kind?: string; id?: string } | null;
return (
<div
data-testid="stub-object-inspector"
data-for={`${String(props.type)}:${String(props.name)}:${sel?.kind}:${sel?.id}`}
/>
);
}
registerMetadataInspector('object', StubObjectInspector as never);

afterEach(cleanup);

function mountPillar() {
return render(
<MemoryRouter initialEntries={['/studio/com.acme.app/interfaces']}>
<InterfacesPillar packageId="com.acme.app" />
</MemoryRouter>,
);
}

/** The precondition every test here shares, stated rather than assumed. */
function expectPopulatedRegistries() {
expect(listMetadataPreviewTypes()).toContain('dashboard');
expect(listMetadataPreviewTypes().length).toBeGreaterThan(0);
expect(listMetadataInspectorTypes()).toContain('object');
// ...and the leaf under test really is a studio-canvas leaf.
expect(listStudioCanvasPreviewTypes()).toContain('object');
}

async function openLeaf(title: string) {
mountPillar();
fireEvent.click(await screen.findByTitle(title));
}

const bodyText = () => (document.body.textContent ?? '').replace(/\s+/g, ' ');

describe('Interfaces pillar — affordances beside a studio-canvas leaf (#7121)', () => {
it('offers no Design/Run switch on a leaf whose canvas cannot read the mode', async () => {
expectPopulatedRegistries();
await openLeaf('object · showcase_task');

// Wait on the canvas itself, not on the toggle — the toggle's ABSENCE is
// the assertion, so waiting for it would deadlock the pin by construction.
await waitFor(() => expect(bodyText()).toContain('Runtime list preview'), { timeout: 4000 });

expect(screen.queryByTestId('canvas-mode-toggle')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Design' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Run' })).not.toBeInTheDocument();
});

it('replaces the impossible "click a block" invitation with what is true', async () => {
expectPopulatedRegistries();
await openLeaf('object · showcase_task');
await waitFor(() => expect(bodyText()).toContain('Runtime list preview'), { timeout: 4000 });

await waitFor(() =>
expect(bodyText()).toContain('This canvas renders the running app, not a block tree'),
);
// ⛔ The invitation that cannot be followed must be gone.
expect(bodyText()).not.toContain(CLICK_A_BLOCK);
// ⛔ And this must not be mistaken for #6795 part C's empty-registry state,
// which is demonstrably not the cause here.
expect(bodyText()).not.toContain('No metadata designers are registered');
});

it('promises no recovery — there is nothing to wait for', async () => {
expectPopulatedRegistries();
await openLeaf('object · showcase_task');
await waitFor(() => expect(bodyText()).toContain('This canvas renders the running app'), {
timeout: 4000,
});

// Scoped to the RAIL, not the document: the canvas beside it renders the
// real records grid, which in jsdom (no data source) says "Error loading
// grid" — its own honest state, and nothing this card may speak for. A
// document-wide scan would read that as the rail promising recovery.
const railBlock = screen.getByText(/This canvas renders the running app/).closest('div');
const rail = railBlock?.textContent ?? '';
// Control: the scoped read must actually have found the message.
expect(rail).toContain('This canvas renders the running app');

for (const promise of ['Loading', 'loading', 'try again', 'Try again', 'not yet', 'in progress']) {
expect(rail).not.toContain(promise);
}
});

it('does not open a scoped inspector for a block selected on a DIFFERENT leaf', async () => {
expectPopulatedRegistries();

// Select a block on the dashboard leaf — a real selection, made through the
// pillar's own `onSelectionChange`.
await openLeaf('dashboard · sales_overview');
await waitFor(() => expect(screen.getByTestId('stub-dash')).toBeInTheDocument(), {
timeout: 4000,
});
fireEvent.click(screen.getByTestId('pick-block'));
await waitFor(() => expect(screen.getByLabelText('Clear selection')).toBeInTheDocument(), {
timeout: 4000,
});

// ...then walk to the studio-canvas leaf. The pillar's load effect clears
// `selection` only on the editable path, so the selection is still live.
fireEvent.click(screen.getByTitle('object · showcase_task'));
await waitFor(() => expect(bodyText()).toContain('Runtime list preview'), { timeout: 4000 });

// The block belongs to another leaf's canvas and does not exist on this one.
expect(screen.queryByTestId('stub-object-inspector')).not.toBeInTheDocument();
expect(bodyText()).toContain('This canvas renders the running app, not a block tree');
// ...and the rail must not contradict itself by offering to clear a
// selection it just said cannot exist here.
expect(screen.queryByLabelText('Clear selection')).not.toBeInTheDocument();
});
});

describe('REGRESSION FENCE — a leaf WITH a block canvas is untouched (#7121)', () => {
it('still offers the Design/Run switch, and it still round-trips', async () => {
expectPopulatedRegistries();
await openLeaf('dashboard · sales_overview');
await waitFor(() => expect(screen.getByTestId('canvas-mode-toggle')).toBeInTheDocument(), {
timeout: 4000,
});

// #5800's acceptance: 设计⇄运行 is a round trip on the SAME renderer, and
// `editing` is the mode the renderer actually reads.
expect(screen.getByTestId('stub-dash')).toHaveAttribute('data-editing', 'true');
fireEvent.click(screen.getByRole('button', { name: 'Run' }));
await waitFor(() =>
expect(screen.getByTestId('stub-dash')).toHaveAttribute('data-editing', 'false'),
);
fireEvent.click(screen.getByRole('button', { name: 'Design' }));
await waitFor(() =>
expect(screen.getByTestId('stub-dash')).toHaveAttribute('data-editing', 'true'),
);
});

it('keeps the ordinary "click a block" rail where a block canvas exists', async () => {
expectPopulatedRegistries();
await openLeaf('dashboard · sales_overview');
await waitFor(() => expect(screen.getByTestId('stub-dash')).toBeInTheDocument(), {
timeout: 4000,
});

// The repair must not over-fire: this leaf HAS blocks to click.
await waitFor(() => expect(bodyText()).toContain(CLICK_A_BLOCK));
expect(bodyText()).not.toContain('This canvas renders the running app');
});

it('keeps the selection affordances where a block canvas exists', async () => {
expectPopulatedRegistries();
await openLeaf('dashboard · sales_overview');
await waitFor(() => expect(screen.getByTestId('stub-dash')).toBeInTheDocument(), {
timeout: 4000,
});

fireEvent.click(screen.getByTestId('pick-block'));
await waitFor(() => expect(screen.getByLabelText('Clear selection')).toBeInTheDocument());
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/7121-studio-canvas-leaf-affordances.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
---
'@object-ui/app-shell': patch
---

Studio Interfaces: no Design mode and no "click a block" rail on leaves that have
no block canvas (objectui#7121).

`registerStudioCanvasPreview(type, …)` opts a type into a surface-specific canvas
that renders the running app rather than an editable draft — a contract, not a
habit: `StudioCanvasPreviewProps` carries no `selection`, `onSelectionChange`,
`onPatch` or `editing`. Two affordances beside such a leaf ignored that.

- The Design/Run switch (objectui#5800) was still offered, though `editing` is
handed to exactly one canvas branch (`Preview`). On a studio-canvas leaf the
switch moved `canvasMode` and reached no renderer — a live-looking control
wired to nothing. It is now gated.
- The right rail fell through to "Click a block on the canvas, and edit its
properties right here." beside a canvas that has no blocks, so the instruction
could not be followed. It now states what the canvas is, and — because this
canvas has no blocks by contract — promises no recovery.
- The rail's new branch is ordered ahead of the selection branch, so a block
selected on a *different* leaf no longer opens a scoped inspector for a block
this canvas does not contain; the header's "clear selection" button is gated
with it.

The discriminator is `StudioCanvas`, not `isEditable`. `isEditable` is
`!!Preview && !StudioCanvas` — a conjunction of two independent causes — so
gating on it would also strip these affordances from leaves whose only fault is
that their own type has no designer, the state objectui#6795 part C pinned as
still deserving the ordinary rail. Behaviour on every leaf with a block canvas
is unchanged.
8 changes: 8 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1777,6 +1777,12 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.studio.inspector.tabSource': 'Source',
'engine.studio.inspector.emptyLine1': 'Click a block on the canvas,',
'engine.studio.inspector.emptyLine2': 'and edit its properties right here.',
// objectui#7121 — the rail beside a studio-canvas leaf (a
// `registerStudioCanvasPreview` type: the running app, not a block tree).
// States what the canvas IS; promises no recovery, because there is nothing
// to wait for — `StudioCanvasPreviewProps` carries no selection by contract.
'engine.studio.inspector.studioCanvasNoBlocks':
'This canvas renders the running app, not a block tree — it has no blocks to select, and nothing here is edited from this panel.',
'engine.studio.inspector.designersMissing':
'No metadata designers are registered in this session, so there is nothing to edit here.',
'engine.studio.inspector.noPageSchema': 'Page settings are unavailable — the page schema could not be loaded.',
Expand DownExpand Up@@ -3639,6 +3645,8 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.studio.inspector.tabSource': '源码',
'engine.studio.inspector.emptyLine1': '在画布里点选一个积木,',
'engine.studio.inspector.emptyLine2': '它的属性会在这里直接编辑。',
'engine.studio.inspector.studioCanvasNoBlocks':
'此画布渲染的是运行态应用,而不是积木树 —— 这里没有可选中的积木,也没有可在本面板编辑的内容。',
'engine.studio.inspector.designersMissing': '本次会话没有注册任何元数据设计器,这里没有可编辑的内容。',
'engine.studio.inspector.noPageSchema': '页面设置不可用——无法加载页面 schema。',
'engine.studio.inspector.sourcePageLine1': '这个页面是 {kind} 源码,不是积木树 ——',
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7121 — what the Interfaces pillar may OFFER beside a studio-canvas
* leaf.
*
* ## The mechanism
*
* `registerStudioCanvasPreview(type, …)` opts a type into a surface-specific
* canvas: the running app, not an editable draft. That is a contract, not a
* habit — `StudioCanvasPreviewProps` is deliberately a small read-only subset
* of `MetadataPreviewProps` with **no `selection`, no `onSelectionChange`, no
* `onPatch`, no `editing`**. So a studio-canvas leaf:
*
* - can never produce a block selection (there is no block tree), and
* - can never read the design/run mode (nothing is handed `editing`).
*
* Two affordances beside it ignored both facts: the Design/Run switch (#5800)
* was still offered though `canvasMode` reached no renderer, and the rail fell
* through to *"Click a block on the canvas, and edit its properties right
* here."* — an instruction that cannot be followed.
*
* ## ⚠️ This is NOT #6795 part C's cause, and the precondition says so
*
* Part C repaired what the pillar says when the designer registries are
* **empty**. Every test here asserts a **POPULATED** registry first
* (`listMetadataPreviewTypes()` non-empty), because a zero-registry reading
* would measure that other card instead. The cause here is an ungated
* affordance, not a missing registration.
*
* ## ⛔ The message promises no recovery
*
* Part C established by measurement that these registries are plain `Map`s read
* during render with no subscription, so a consumer that read an empty one
* never recovers ("late inspector rendered: false") — no "loading…", no "try
* again". Here the constraint is even stricter: the statement is not about
* registration at all. This canvas has no blocks **by contract**, so there is
* nothing to wait for, and the last test pins the absence of recovery language.
*
* ## ⚠️ The discriminator is `StudioCanvas`, NOT `isEditable`
*
* `isEditable = !!Preview && !StudioCanvas` is a conjunction of two independent
* causes. Gating on it would also strip these affordances from leaves whose
* only fault is that **their own type** has no designer — the exact state
* `StudioDesignSurface.designerRegistryPartial.test.tsx` pins as still deserving
* the ordinary "click a block" rail. That file is the live fence: gate on
* `isEditable` and it goes red.
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

const objectDef = {
name: 'showcase_task',
label: 'Task',
fields: [{ name: 'title', label: 'Title', type: 'text' }],
};

/**
* One leaf of each kind, in one app: `object` opts into the studio canvas,
* `dashboard` does not (it is an ordinary block-canvas designer). The contrast
* is the point — the second leaf is the regression fence.
*/
const NAV = [
{ id: 'nav_obj', type: 'object', label: 'Tasks', objectName: 'showcase_task' },
{ id: 'nav_dash', type: 'dashboard', label: 'Overview', dashboardName: 'sales_overview' },
];

const mockClient = {
save: vi.fn(async () => ({})),
list: vi.fn(async (type: string) => {
if (type === 'app') return [{ name: 'acme_app', label: 'Acme' }];
if (type === 'object') return [{ name: 'showcase_task', label: 'Task' }];
return [];
}),
listDrafts: vi.fn(async () => []),
layered: vi.fn(async (type: string, name: string) => {
if (type === 'app') return { effective: { name: 'acme_app', label: 'Acme', navigation: NAV } };
if (type === 'object') return { effective: objectDef, code: objectDef };
if (type === 'dashboard') return { effective: { name: 'sales_overview', label: 'Sales' } };
return { effective: { name } };
}),
getDraft: vi.fn(async () => null),
get: vi.fn(async () => undefined),
};

vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) };
});

vi.mock('./packages-io', async (importOriginal) => {
const mod = await importOriginal<typeof import('./packages-io')>();
return { ...mod, fetchPackages: vi.fn(async () => []) };
});

vi.mock('@object-ui/react', async (importOriginal) => {
const mod = await importOriginal<typeof import('@object-ui/react')>();
return { ...mod, useAdapter: () => ({}) };
});

import { InterfacesPillar } from './StudioDesignSurface';
import { listMetadataPreviewTypes, registerMetadataPreview } from '../metadata-admin/preview-registry';
import { listMetadataInspectorTypes, registerMetadataInspector } from '../metadata-admin/inspector-registry';
import { listStudioCanvasPreviewTypes } from './studio-canvas-preview';

const CLICK_A_BLOCK = 'Click a block on the canvas,';

/**
* A block-canvas designer for an unrelated type. Two jobs: it makes the
* designer registry demonstrably POPULATED (the card's precondition), and it
* is the regression fence's leaf. It reports the `editing` prop it was handed,
* so the Design/Run round trip is measured at the seam that actually carries
* the mode rather than through any one renderer's overlay internals.
*/
function StubDashboardPreview(props: Record<string, unknown>): React.ReactElement {
const onSel = props.onSelectionChange as ((s: unknown) => void) | undefined;
return (
<div data-testid="stub-dash" data-editing={String(props.editing)}>
<button
type="button"
data-testid="pick-block"
onClick={() => onSel?.({ kind: 'block', id: 'blk_1' })}
>
pick
</button>
</div>
);
}
registerMetadataPreview('dashboard', StubDashboardPreview as never);

/**
* Production registers `ObjectFieldInspector` for `object`
* (`metadata-admin/inspectors/index.ts`), so the scoped-inspector branch is
* reachable on the studio-canvas leaf whenever a `selection` survives a leaf
* change. Registering a stand-in here is what makes the last pin a measurement
* of that branch rather than of an accidentally-empty registry.
*/
function StubObjectInspector(props: Record<string, unknown>): React.ReactElement {
const sel = props.selection as { kind?: string; id?: string } | null;
return (
<div
data-testid="stub-object-inspector"
data-for={`${String(props.type)}:${String(props.name)}:${sel?.kind}:${sel?.id}`}
/>
);
}
registerMetadataInspector('object', StubObjectInspector as never);

afterEach(cleanup);

function mountPillar() {
return render(
<MemoryRouter initialEntries={['/studio/com.acme.app/interfaces']}>
<InterfacesPillar packageId="com.acme.app" />
</MemoryRouter>,
);
}

/** The precondition every test here shares, stated rather than assumed. */
function expectPopulatedRegistries() {
expect(listMetadataPreviewTypes()).toContain('dashboard');
expect(listMetadataPreviewTypes().length).toBeGreaterThan(0);
expect(listMetadataInspectorTypes()).toContain('object');
// ...and the leaf under test really is a studio-canvas leaf.
expect(listStudioCanvasPreviewTypes()).toContain('object');
}

async function openLeaf(title: string) {
mountPillar();
fireEvent.click(await screen.findByTitle(title));
}

const bodyText = () => (document.body.textContent ?? '').replace(/\s+/g, ' ');

describe('Interfaces pillar — affordances beside a studio-canvas leaf (#7121)', () => {
it('offers no Design/Run switch on a leaf whose canvas cannot read the mode', async () => {
expectPopulatedRegistries();
await openLeaf('object · showcase_task');

// Wait on the canvas itself, not on the toggle — the toggle's ABSENCE is
// the assertion, so waiting for it would deadlock the pin by construction.
await waitFor(() => expect(bodyText()).toContain('Runtime list preview'), { timeout: 4000 });

expect(screen.queryByTestId('canvas-mode-toggle')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Design' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Run' })).not.toBeInTheDocument();
});

it('replaces the impossible "click a block" invitation with what is true', async () => {
expectPopulatedRegistries();
await openLeaf('object · showcase_task');
await waitFor(() => expect(bodyText()).toContain('Runtime list preview'), { timeout: 4000 });

await waitFor(() =>
expect(bodyText()).toContain('This canvas renders the running app, not a block tree'),
);
// ⛔ The invitation that cannot be followed must be gone.
expect(bodyText()).not.toContain(CLICK_A_BLOCK);
// ⛔ And this must not be mistaken for #6795 part C's empty-registry state,
// which is demonstrably not the cause here.
expect(bodyText()).not.toContain('No metadata designers are registered');
});

it('promises no recovery — there is nothing to wait for', async () => {
expectPopulatedRegistries();
await openLeaf('object · showcase_task');
await waitFor(() => expect(bodyText()).toContain('This canvas renders the running app'), {
timeout: 4000,
});

// Scoped to the RAIL, not the document: the canvas beside it renders the
// real records grid, which in jsdom (no data source) says "Error loading
// grid" — its own honest state, and nothing this card may speak for. A
// document-wide scan would read that as the rail promising recovery.
const railBlock = screen.getByText(/This canvas renders the running app/).closest('div');
const rail = railBlock?.textContent ?? '';
// Control: the scoped read must actually have found the message.
expect(rail).toContain('This canvas renders the running app');

for (const promise of ['Loading', 'loading', 'try again', 'Try again', 'not yet', 'in progress']) {
expect(rail).not.toContain(promise);
}
});

it('does not open a scoped inspector for a block selected on a DIFFERENT leaf', async () => {
expectPopulatedRegistries();

// Select a block on the dashboard leaf — a real selection, made through the
// pillar's own `onSelectionChange`.
await openLeaf('dashboard · sales_overview');
await waitFor(() => expect(screen.getByTestId('stub-dash')).toBeInTheDocument(), {
timeout: 4000,
});
fireEvent.click(screen.getByTestId('pick-block'));
await waitFor(() => expect(screen.getByLabelText('Clear selection')).toBeInTheDocument(), {
timeout: 4000,
});

// ...then walk to the studio-canvas leaf. The pillar's load effect clears
// `selection` only on the editable path, so the selection is still live.
fireEvent.click(screen.getByTitle('object · showcase_task'));
await waitFor(() => expect(bodyText()).toContain('Runtime list preview'), { timeout: 4000 });

// The block belongs to another leaf's canvas and does not exist on this one.
expect(screen.queryByTestId('stub-object-inspector')).not.toBeInTheDocument();
expect(bodyText()).toContain('This canvas renders the running app, not a block tree');
// ...and the rail must not contradict itself by offering to clear a
// selection it just said cannot exist here.
expect(screen.queryByLabelText('Clear selection')).not.toBeInTheDocument();
});
});

describe('REGRESSION FENCE — a leaf WITH a block canvas is untouched (#7121)', () => {
it('still offers the Design/Run switch, and it still round-trips', async () => {
expectPopulatedRegistries();
await openLeaf('dashboard · sales_overview');
await waitFor(() => expect(screen.getByTestId('canvas-mode-toggle')).toBeInTheDocument(), {
timeout: 4000,
});

// #5800's acceptance: 设计⇄运行 is a round trip on the SAME renderer, and
// `editing` is the mode the renderer actually reads.
expect(screen.getByTestId('stub-dash')).toHaveAttribute('data-editing', 'true');
fireEvent.click(screen.getByRole('button', { name: 'Run' }));
await waitFor(() =>
expect(screen.getByTestId('stub-dash')).toHaveAttribute('data-editing', 'false'),
);
fireEvent.click(screen.getByRole('button', { name: 'Design' }));
await waitFor(() =>
expect(screen.getByTestId('stub-dash')).toHaveAttribute('data-editing', 'true'),
);
});

it('keeps the ordinary "click a block" rail where a block canvas exists', async () => {
expectPopulatedRegistries();
await openLeaf('dashboard · sales_overview');
await waitFor(() => expect(screen.getByTestId('stub-dash')).toBeInTheDocument(), {
timeout: 4000,
});

// The repair must not over-fire: this leaf HAS blocks to click.
await waitFor(() => expect(bodyText()).toContain(CLICK_A_BLOCK));
expect(bodyText()).not.toContain('This canvas renders the running app');
});

it('keeps the selection affordances where a block canvas exists', async () => {
expectPopulatedRegistries();
await openLeaf('dashboard · sales_overview');
await waitFor(() => expect(screen.getByTestId('stub-dash')).toBeInTheDocument(), {
timeout: 4000,
});

fireEvent.click(screen.getByTestId('pick-block'));
await waitFor(() => expect(screen.getByLabelText('Clear selection')).toBeInTheDocument());
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/7121-studio-canvas-leaf-affordances.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
---
'@object-ui/app-shell': patch
---

Studio Interfaces: no Design mode and no "click a block" rail on leaves that have
no block canvas (objectui#7121).

`registerStudioCanvasPreview(type, …)` opts a type into a surface-specific canvas
that renders the running app rather than an editable draft — a contract, not a
habit: `StudioCanvasPreviewProps` carries no `selection`, `onSelectionChange`,
`onPatch` or `editing`. Two affordances beside such a leaf ignored that.

- The Design/Run switch (objectui#5800) was still offered, though `editing` is
handed to exactly one canvas branch (`Preview`). On a studio-canvas leaf the
switch moved `canvasMode` and reached no renderer — a live-looking control
wired to nothing. It is now gated.
- The right rail fell through to "Click a block on the canvas, and edit its
properties right here." beside a canvas that has no blocks, so the instruction
could not be followed. It now states what the canvas is, and — because this
canvas has no blocks by contract — promises no recovery.
- The rail's new branch is ordered ahead of the selection branch, so a block
selected on a *different* leaf no longer opens a scoped inspector for a block
this canvas does not contain; the header's "clear selection" button is gated
with it.

The discriminator is `StudioCanvas`, not `isEditable`. `isEditable` is
`!!Preview && !StudioCanvas` — a conjunction of two independent causes — so
gating on it would also strip these affordances from leaves whose only fault is
that their own type has no designer, the state objectui#6795 part C pinned as
still deserving the ordinary rail. Behaviour on every leaf with a block canvas
is unchanged.
8 changes: 8 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1777,6 +1777,12 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.studio.inspector.tabSource': 'Source',
'engine.studio.inspector.emptyLine1': 'Click a block on the canvas,',
'engine.studio.inspector.emptyLine2': 'and edit its properties right here.',
// objectui#7121 — the rail beside a studio-canvas leaf (a
// `registerStudioCanvasPreview` type: the running app, not a block tree).
// States what the canvas IS; promises no recovery, because there is nothing
// to wait for — `StudioCanvasPreviewProps` carries no selection by contract.
'engine.studio.inspector.studioCanvasNoBlocks':
'This canvas renders the running app, not a block tree — it has no blocks to select, and nothing here is edited from this panel.',
'engine.studio.inspector.designersMissing':
'No metadata designers are registered in this session, so there is nothing to edit here.',
'engine.studio.inspector.noPageSchema': 'Page settings are unavailable — the page schema could not be loaded.',
Expand DownExpand Up@@ -3639,6 +3645,8 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.studio.inspector.tabSource': '源码',
'engine.studio.inspector.emptyLine1': '在画布里点选一个积木,',
'engine.studio.inspector.emptyLine2': '它的属性会在这里直接编辑。',
'engine.studio.inspector.studioCanvasNoBlocks':
'此画布渲染的是运行态应用,而不是积木树 —— 这里没有可选中的积木,也没有可在本面板编辑的内容。',
'engine.studio.inspector.designersMissing': '本次会话没有注册任何元数据设计器,这里没有可编辑的内容。',
'engine.studio.inspector.noPageSchema': '页面设置不可用——无法加载页面 schema。',
'engine.studio.inspector.sourcePageLine1': '这个页面是 {kind} 源码,不是积木树 ——',
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7121 — what the Interfaces pillar may OFFER beside a studio-canvas
* leaf.
*
* ## The mechanism
*
* `registerStudioCanvasPreview(type, …)` opts a type into a surface-specific
* canvas: the running app, not an editable draft. That is a contract, not a
* habit — `StudioCanvasPreviewProps` is deliberately a small read-only subset
* of `MetadataPreviewProps` with **no `selection`, no `onSelectionChange`, no
* `onPatch`, no `editing`**. So a studio-canvas leaf:
*
* - can never produce a block selection (there is no block tree), and
* - can never read the design/run mode (nothing is handed `editing`).
*
* Two affordances beside it ignored both facts: the Design/Run switch (#5800)
* was still offered though `canvasMode` reached no renderer, and the rail fell
* through to *"Click a block on the canvas, and edit its properties right
* here."* — an instruction that cannot be followed.
*
* ## ⚠️ This is NOT #6795 part C's cause, and the precondition says so
*
* Part C repaired what the pillar says when the designer registries are
* **empty**. Every test here asserts a **POPULATED** registry first
* (`listMetadataPreviewTypes()` non-empty), because a zero-registry reading
* would measure that other card instead. The cause here is an ungated
* affordance, not a missing registration.
*
* ## ⛔ The message promises no recovery
*
* Part C established by measurement that these registries are plain `Map`s read
* during render with no subscription, so a consumer that read an empty one
* never recovers ("late inspector rendered: false") — no "loading…", no "try
* again". Here the constraint is even stricter: the statement is not about
* registration at all. This canvas has no blocks **by contract**, so there is
* nothing to wait for, and the last test pins the absence of recovery language.
*
* ## ⚠️ The discriminator is `StudioCanvas`, NOT `isEditable`
*
* `isEditable = !!Preview && !StudioCanvas` is a conjunction of two independent
* causes. Gating on it would also strip these affordances from leaves whose
* only fault is that **their own type** has no designer — the exact state
* `StudioDesignSurface.designerRegistryPartial.test.tsx` pins as still deserving
* the ordinary "click a block" rail. That file is the live fence: gate on
* `isEditable` and it goes red.
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

const objectDef = {
name: 'showcase_task',
label: 'Task',
fields: [{ name: 'title', label: 'Title', type: 'text' }],
};

/**
* One leaf of each kind, in one app: `object` opts into the studio canvas,
* `dashboard` does not (it is an ordinary block-canvas designer). The contrast
* is the point — the second leaf is the regression fence.
*/
const NAV = [
{ id: 'nav_obj', type: 'object', label: 'Tasks', objectName: 'showcase_task' },
{ id: 'nav_dash', type: 'dashboard', label: 'Overview', dashboardName: 'sales_overview' },
];

const mockClient = {
save: vi.fn(async () => ({})),
list: vi.fn(async (type: string) => {
if (type === 'app') return [{ name: 'acme_app', label: 'Acme' }];
if (type === 'object') return [{ name: 'showcase_task', label: 'Task' }];
return [];
}),
listDrafts: vi.fn(async () => []),
layered: vi.fn(async (type: string, name: string) => {
if (type === 'app') return { effective: { name: 'acme_app', label: 'Acme', navigation: NAV } };
if (type === 'object') return { effective: objectDef, code: objectDef };
if (type === 'dashboard') return { effective: { name: 'sales_overview', label: 'Sales' } };
return { effective: { name } };
}),
getDraft: vi.fn(async () => null),
get: vi.fn(async () => undefined),
};

vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) };
});

vi.mock('./packages-io', async (importOriginal) => {
const mod = await importOriginal<typeof import('./packages-io')>();
return { ...mod, fetchPackages: vi.fn(async () => []) };
});

vi.mock('@object-ui/react', async (importOriginal) => {
const mod = await importOriginal<typeof import('@object-ui/react')>();
return { ...mod, useAdapter: () => ({}) };
});

import { InterfacesPillar } from './StudioDesignSurface';
import { listMetadataPreviewTypes, registerMetadataPreview } from '../metadata-admin/preview-registry';
import { listMetadataInspectorTypes, registerMetadataInspector } from '../metadata-admin/inspector-registry';
import { listStudioCanvasPreviewTypes } from './studio-canvas-preview';

const CLICK_A_BLOCK = 'Click a block on the canvas,';

/**
* A block-canvas designer for an unrelated type. Two jobs: it makes the
* designer registry demonstrably POPULATED (the card's precondition), and it
* is the regression fence's leaf. It reports the `editing` prop it was handed,
* so the Design/Run round trip is measured at the seam that actually carries
* the mode rather than through any one renderer's overlay internals.
*/
function StubDashboardPreview(props: Record<string, unknown>): React.ReactElement {
const onSel = props.onSelectionChange as ((s: unknown) => void) | undefined;
return (
<div data-testid="stub-dash" data-editing={String(props.editing)}>
<button
type="button"
data-testid="pick-block"
onClick={() => onSel?.({ kind: 'block', id: 'blk_1' })}
>
pick
</button>
</div>
);
}
registerMetadataPreview('dashboard', StubDashboardPreview as never);

/**
* Production registers `ObjectFieldInspector` for `object`
* (`metadata-admin/inspectors/index.ts`), so the scoped-inspector branch is
* reachable on the studio-canvas leaf whenever a `selection` survives a leaf
* change. Registering a stand-in here is what makes the last pin a measurement
* of that branch rather than of an accidentally-empty registry.
*/
function StubObjectInspector(props: Record<string, unknown>): React.ReactElement {
const sel = props.selection as { kind?: string; id?: string } | null;
return (
<div
data-testid="stub-object-inspector"
data-for={`${String(props.type)}:${String(props.name)}:${sel?.kind}:${sel?.id}`}
/>
);
}
registerMetadataInspector('object', StubObjectInspector as never);

afterEach(cleanup);

function mountPillar() {
return render(
<MemoryRouter initialEntries={['/studio/com.acme.app/interfaces']}>
<InterfacesPillar packageId="com.acme.app" />
</MemoryRouter>,
);
}

/** The precondition every test here shares, stated rather than assumed. */
function expectPopulatedRegistries() {
expect(listMetadataPreviewTypes()).toContain('dashboard');
expect(listMetadataPreviewTypes().length).toBeGreaterThan(0);
expect(listMetadataInspectorTypes()).toContain('object');
// ...and the leaf under test really is a studio-canvas leaf.
expect(listStudioCanvasPreviewTypes()).toContain('object');
}

async function openLeaf(title: string) {
mountPillar();
fireEvent.click(await screen.findByTitle(title));
}

const bodyText = () => (document.body.textContent ?? '').replace(/\s+/g, ' ');

describe('Interfaces pillar — affordances beside a studio-canvas leaf (#7121)', () => {
it('offers no Design/Run switch on a leaf whose canvas cannot read the mode', async () => {
expectPopulatedRegistries();
await openLeaf('object · showcase_task');

// Wait on the canvas itself, not on the toggle — the toggle's ABSENCE is
// the assertion, so waiting for it would deadlock the pin by construction.
await waitFor(() => expect(bodyText()).toContain('Runtime list preview'), { timeout: 4000 });

expect(screen.queryByTestId('canvas-mode-toggle')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Design' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Run' })).not.toBeInTheDocument();
});

it('replaces the impossible "click a block" invitation with what is true', async () => {
expectPopulatedRegistries();
await openLeaf('object · showcase_task');
await waitFor(() => expect(bodyText()).toContain('Runtime list preview'), { timeout: 4000 });

await waitFor(() =>
expect(bodyText()).toContain('This canvas renders the running app, not a block tree'),
);
// ⛔ The invitation that cannot be followed must be gone.
expect(bodyText()).not.toContain(CLICK_A_BLOCK);
// ⛔ And this must not be mistaken for #6795 part C's empty-registry state,
// which is demonstrably not the cause here.
expect(bodyText()).not.toContain('No metadata designers are registered');
});

it('promises no recovery — there is nothing to wait for', async () => {
expectPopulatedRegistries();
await openLeaf('object · showcase_task');
await waitFor(() => expect(bodyText()).toContain('This canvas renders the running app'), {
timeout: 4000,
});

// Scoped to the RAIL, not the document: the canvas beside it renders the
// real records grid, which in jsdom (no data source) says "Error loading
// grid" — its own honest state, and nothing this card may speak for. A
// document-wide scan would read that as the rail promising recovery.
const railBlock = screen.getByText(/This canvas renders the running app/).closest('div');
const rail = railBlock?.textContent ?? '';
// Control: the scoped read must actually have found the message.
expect(rail).toContain('This canvas renders the running app');

for (const promise of ['Loading', 'loading', 'try again', 'Try again', 'not yet', 'in progress']) {
expect(rail).not.toContain(promise);
}
});

it('does not open a scoped inspector for a block selected on a DIFFERENT leaf', async () => {
expectPopulatedRegistries();

// Select a block on the dashboard leaf — a real selection, made through the
// pillar's own `onSelectionChange`.
await openLeaf('dashboard · sales_overview');
await waitFor(() => expect(screen.getByTestId('stub-dash')).toBeInTheDocument(), {
timeout: 4000,
});
fireEvent.click(screen.getByTestId('pick-block'));
await waitFor(() => expect(screen.getByLabelText('Clear selection')).toBeInTheDocument(), {
timeout: 4000,
});

// ...then walk to the studio-canvas leaf. The pillar's load effect clears
// `selection` only on the editable path, so the selection is still live.
fireEvent.click(screen.getByTitle('object · showcase_task'));
await waitFor(() => expect(bodyText()).toContain('Runtime list preview'), { timeout: 4000 });

// The block belongs to another leaf's canvas and does not exist on this one.
expect(screen.queryByTestId('stub-object-inspector')).not.toBeInTheDocument();
expect(bodyText()).toContain('This canvas renders the running app, not a block tree');
// ...and the rail must not contradict itself by offering to clear a
// selection it just said cannot exist here.
expect(screen.queryByLabelText('Clear selection')).not.toBeInTheDocument();
});
});

describe('REGRESSION FENCE — a leaf WITH a block canvas is untouched (#7121)', () => {
it('still offers the Design/Run switch, and it still round-trips', async () => {
expectPopulatedRegistries();
await openLeaf('dashboard · sales_overview');
await waitFor(() => expect(screen.getByTestId('canvas-mode-toggle')).toBeInTheDocument(), {
timeout: 4000,
});

// #5800's acceptance: 设计⇄运行 is a round trip on the SAME renderer, and
// `editing` is the mode the renderer actually reads.
expect(screen.getByTestId('stub-dash')).toHaveAttribute('data-editing', 'true');
fireEvent.click(screen.getByRole('button', { name: 'Run' }));
await waitFor(() =>
expect(screen.getByTestId('stub-dash')).toHaveAttribute('data-editing', 'false'),
);
fireEvent.click(screen.getByRole('button', { name: 'Design' }));
await waitFor(() =>
expect(screen.getByTestId('stub-dash')).toHaveAttribute('data-editing', 'true'),
);
});

it('keeps the ordinary "click a block" rail where a block canvas exists', async () => {
expectPopulatedRegistries();
await openLeaf('dashboard · sales_overview');
await waitFor(() => expect(screen.getByTestId('stub-dash')).toBeInTheDocument(), {
timeout: 4000,
});

// The repair must not over-fire: this leaf HAS blocks to click.
await waitFor(() => expect(bodyText()).toContain(CLICK_A_BLOCK));
expect(bodyText()).not.toContain('This canvas renders the running app');
});

it('keeps the selection affordances where a block canvas exists', async () => {
expectPopulatedRegistries();
await openLeaf('dashboard · sales_overview');
await waitFor(() => expect(screen.getByTestId('stub-dash')).toBeInTheDocument(), {
timeout: 4000,
});

fireEvent.click(screen.getByTestId('pick-block'));
await waitFor(() => expect(screen.getByLabelText('Clear selection')).toBeInTheDocument());
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/7121-studio-canvas-leaf-affordances.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
---
'@object-ui/app-shell': patch
---

Studio Interfaces: no Design mode and no "click a block" rail on leaves that have
no block canvas (objectui#7121).

`registerStudioCanvasPreview(type, …)` opts a type into a surface-specific canvas
that renders the running app rather than an editable draft — a contract, not a
habit: `StudioCanvasPreviewProps` carries no `selection`, `onSelectionChange`,
`onPatch` or `editing`. Two affordances beside such a leaf ignored that.

- The Design/Run switch (objectui#5800) was still offered, though `editing` is
handed to exactly one canvas branch (`Preview`). On a studio-canvas leaf the
switch moved `canvasMode` and reached no renderer — a live-looking control
wired to nothing. It is now gated.
- The right rail fell through to "Click a block on the canvas, and edit its
properties right here." beside a canvas that has no blocks, so the instruction
could not be followed. It now states what the canvas is, and — because this
canvas has no blocks by contract — promises no recovery.
- The rail's new branch is ordered ahead of the selection branch, so a block
selected on a *different* leaf no longer opens a scoped inspector for a block
this canvas does not contain; the header's "clear selection" button is gated
with it.

The discriminator is `StudioCanvas`, not `isEditable`. `isEditable` is
`!!Preview && !StudioCanvas` — a conjunction of two independent causes — so
gating on it would also strip these affordances from leaves whose only fault is
that their own type has no designer, the state objectui#6795 part C pinned as
still deserving the ordinary rail. Behaviour on every leaf with a block canvas
is unchanged.
8 changes: 8 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1777,6 +1777,12 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.studio.inspector.tabSource': 'Source',
'engine.studio.inspector.emptyLine1': 'Click a block on the canvas,',
'engine.studio.inspector.emptyLine2': 'and edit its properties right here.',
// objectui#7121 — the rail beside a studio-canvas leaf (a
// `registerStudioCanvasPreview` type: the running app, not a block tree).
// States what the canvas IS; promises no recovery, because there is nothing
// to wait for — `StudioCanvasPreviewProps` carries no selection by contract.
'engine.studio.inspector.studioCanvasNoBlocks':
'This canvas renders the running app, not a block tree — it has no blocks to select, and nothing here is edited from this panel.',
'engine.studio.inspector.designersMissing':
'No metadata designers are registered in this session, so there is nothing to edit here.',
'engine.studio.inspector.noPageSchema': 'Page settings are unavailable — the page schema could not be loaded.',
Expand DownExpand Up@@ -3639,6 +3645,8 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.studio.inspector.tabSource': '源码',
'engine.studio.inspector.emptyLine1': '在画布里点选一个积木,',
'engine.studio.inspector.emptyLine2': '它的属性会在这里直接编辑。',
'engine.studio.inspector.studioCanvasNoBlocks':
'此画布渲染的是运行态应用,而不是积木树 —— 这里没有可选中的积木,也没有可在本面板编辑的内容。',
'engine.studio.inspector.designersMissing': '本次会话没有注册任何元数据设计器,这里没有可编辑的内容。',
'engine.studio.inspector.noPageSchema': '页面设置不可用——无法加载页面 schema。',
'engine.studio.inspector.sourcePageLine1': '这个页面是 {kind} 源码,不是积木树 ——',
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7121 — what the Interfaces pillar may OFFER beside a studio-canvas
* leaf.
*
* ## The mechanism
*
* `registerStudioCanvasPreview(type, …)` opts a type into a surface-specific
* canvas: the running app, not an editable draft. That is a contract, not a
* habit — `StudioCanvasPreviewProps` is deliberately a small read-only subset
* of `MetadataPreviewProps` with **no `selection`, no `onSelectionChange`, no
* `onPatch`, no `editing`**. So a studio-canvas leaf:
*
* - can never produce a block selection (there is no block tree), and
* - can never read the design/run mode (nothing is handed `editing`).
*
* Two affordances beside it ignored both facts: the Design/Run switch (#5800)
* was still offered though `canvasMode` reached no renderer, and the rail fell
* through to *"Click a block on the canvas, and edit its properties right
* here."* — an instruction that cannot be followed.
*
* ## ⚠️ This is NOT #6795 part C's cause, and the precondition says so
*
* Part C repaired what the pillar says when the designer registries are
* **empty**. Every test here asserts a **POPULATED** registry first
* (`listMetadataPreviewTypes()` non-empty), because a zero-registry reading
* would measure that other card instead. The cause here is an ungated
* affordance, not a missing registration.
*
* ## ⛔ The message promises no recovery
*
* Part C established by measurement that these registries are plain `Map`s read
* during render with no subscription, so a consumer that read an empty one
* never recovers ("late inspector rendered: false") — no "loading…", no "try
* again". Here the constraint is even stricter: the statement is not about
* registration at all. This canvas has no blocks **by contract**, so there is
* nothing to wait for, and the last test pins the absence of recovery language.
*
* ## ⚠️ The discriminator is `StudioCanvas`, NOT `isEditable`
*
* `isEditable = !!Preview && !StudioCanvas` is a conjunction of two independent
* causes. Gating on it would also strip these affordances from leaves whose
* only fault is that **their own type** has no designer — the exact state
* `StudioDesignSurface.designerRegistryPartial.test.tsx` pins as still deserving
* the ordinary "click a block" rail. That file is the live fence: gate on
* `isEditable` and it goes red.
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

const objectDef = {
name: 'showcase_task',
label: 'Task',
fields: [{ name: 'title', label: 'Title', type: 'text' }],
};

/**
* One leaf of each kind, in one app: `object` opts into the studio canvas,
* `dashboard` does not (it is an ordinary block-canvas designer). The contrast
* is the point — the second leaf is the regression fence.
*/
const NAV = [
{ id: 'nav_obj', type: 'object', label: 'Tasks', objectName: 'showcase_task' },
{ id: 'nav_dash', type: 'dashboard', label: 'Overview', dashboardName: 'sales_overview' },
];

const mockClient = {
save: vi.fn(async () => ({})),
list: vi.fn(async (type: string) => {
if (type === 'app') return [{ name: 'acme_app', label: 'Acme' }];
if (type === 'object') return [{ name: 'showcase_task', label: 'Task' }];
return [];
}),
listDrafts: vi.fn(async () => []),
layered: vi.fn(async (type: string, name: string) => {
if (type === 'app') return { effective: { name: 'acme_app', label: 'Acme', navigation: NAV } };
if (type === 'object') return { effective: objectDef, code: objectDef };
if (type === 'dashboard') return { effective: { name: 'sales_overview', label: 'Sales' } };
return { effective: { name } };
}),
getDraft: vi.fn(async () => null),
get: vi.fn(async () => undefined),
};

vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) };
});

vi.mock('./packages-io', async (importOriginal) => {
const mod = await importOriginal<typeof import('./packages-io')>();
return { ...mod, fetchPackages: vi.fn(async () => []) };
});

vi.mock('@object-ui/react', async (importOriginal) => {
const mod = await importOriginal<typeof import('@object-ui/react')>();
return { ...mod, useAdapter: () => ({}) };
});

import { InterfacesPillar } from './StudioDesignSurface';
import { listMetadataPreviewTypes, registerMetadataPreview } from '../metadata-admin/preview-registry';
import { listMetadataInspectorTypes, registerMetadataInspector } from '../metadata-admin/inspector-registry';
import { listStudioCanvasPreviewTypes } from './studio-canvas-preview';

const CLICK_A_BLOCK = 'Click a block on the canvas,';

/**
* A block-canvas designer for an unrelated type. Two jobs: it makes the
* designer registry demonstrably POPULATED (the card's precondition), and it
* is the regression fence's leaf. It reports the `editing` prop it was handed,
* so the Design/Run round trip is measured at the seam that actually carries
* the mode rather than through any one renderer's overlay internals.
*/
function StubDashboardPreview(props: Record<string, unknown>): React.ReactElement {
const onSel = props.onSelectionChange as ((s: unknown) => void) | undefined;
return (
<div data-testid="stub-dash" data-editing={String(props.editing)}>
<button
type="button"
data-testid="pick-block"
onClick={() => onSel?.({ kind: 'block', id: 'blk_1' })}
>
pick
</button>
</div>
);
}
registerMetadataPreview('dashboard', StubDashboardPreview as never);

/**
* Production registers `ObjectFieldInspector` for `object`
* (`metadata-admin/inspectors/index.ts`), so the scoped-inspector branch is
* reachable on the studio-canvas leaf whenever a `selection` survives a leaf
* change. Registering a stand-in here is what makes the last pin a measurement
* of that branch rather than of an accidentally-empty registry.
*/
function StubObjectInspector(props: Record<string, unknown>): React.ReactElement {
const sel = props.selection as { kind?: string; id?: string } | null;
return (
<div
data-testid="stub-object-inspector"
data-for={`${String(props.type)}:${String(props.name)}:${sel?.kind}:${sel?.id}`}
/>
);
}
registerMetadataInspector('object', StubObjectInspector as never);

afterEach(cleanup);

function mountPillar() {
return render(
<MemoryRouter initialEntries={['/studio/com.acme.app/interfaces']}>
<InterfacesPillar packageId="com.acme.app" />
</MemoryRouter>,
);
}

/** The precondition every test here shares, stated rather than assumed. */
function expectPopulatedRegistries() {
expect(listMetadataPreviewTypes()).toContain('dashboard');
expect(listMetadataPreviewTypes().length).toBeGreaterThan(0);
expect(listMetadataInspectorTypes()).toContain('object');
// ...and the leaf under test really is a studio-canvas leaf.
expect(listStudioCanvasPreviewTypes()).toContain('object');
}

async function openLeaf(title: string) {
mountPillar();
fireEvent.click(await screen.findByTitle(title));
}

const bodyText = () => (document.body.textContent ?? '').replace(/\s+/g, ' ');

describe('Interfaces pillar — affordances beside a studio-canvas leaf (#7121)', () => {
it('offers no Design/Run switch on a leaf whose canvas cannot read the mode', async () => {
expectPopulatedRegistries();
await openLeaf('object · showcase_task');

// Wait on the canvas itself, not on the toggle — the toggle's ABSENCE is
// the assertion, so waiting for it would deadlock the pin by construction.
await waitFor(() => expect(bodyText()).toContain('Runtime list preview'), { timeout: 4000 });

expect(screen.queryByTestId('canvas-mode-toggle')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Design' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Run' })).not.toBeInTheDocument();
});

it('replaces the impossible "click a block" invitation with what is true', async () => {
expectPopulatedRegistries();
await openLeaf('object · showcase_task');
await waitFor(() => expect(bodyText()).toContain('Runtime list preview'), { timeout: 4000 });

await waitFor(() =>
expect(bodyText()).toContain('This canvas renders the running app, not a block tree'),
);
// ⛔ The invitation that cannot be followed must be gone.
expect(bodyText()).not.toContain(CLICK_A_BLOCK);
// ⛔ And this must not be mistaken for #6795 part C's empty-registry state,
// which is demonstrably not the cause here.
expect(bodyText()).not.toContain('No metadata designers are registered');
});

it('promises no recovery — there is nothing to wait for', async () => {
expectPopulatedRegistries();
await openLeaf('object · showcase_task');
await waitFor(() => expect(bodyText()).toContain('This canvas renders the running app'), {
timeout: 4000,
});

// Scoped to the RAIL, not the document: the canvas beside it renders the
// real records grid, which in jsdom (no data source) says "Error loading
// grid" — its own honest state, and nothing this card may speak for. A
// document-wide scan would read that as the rail promising recovery.
const railBlock = screen.getByText(/This canvas renders the running app/).closest('div');
const rail = railBlock?.textContent ?? '';
// Control: the scoped read must actually have found the message.
expect(rail).toContain('This canvas renders the running app');

for (const promise of ['Loading', 'loading', 'try again', 'Try again', 'not yet', 'in progress']) {
expect(rail).not.toContain(promise);
}
});

it('does not open a scoped inspector for a block selected on a DIFFERENT leaf', async () => {
expectPopulatedRegistries();

// Select a block on the dashboard leaf — a real selection, made through the
// pillar's own `onSelectionChange`.
await openLeaf('dashboard · sales_overview');
await waitFor(() => expect(screen.getByTestId('stub-dash')).toBeInTheDocument(), {
timeout: 4000,
});
fireEvent.click(screen.getByTestId('pick-block'));
await waitFor(() => expect(screen.getByLabelText('Clear selection')).toBeInTheDocument(), {
timeout: 4000,
});

// ...then walk to the studio-canvas leaf. The pillar's load effect clears
// `selection` only on the editable path, so the selection is still live.
fireEvent.click(screen.getByTitle('object · showcase_task'));
await waitFor(() => expect(bodyText()).toContain('Runtime list preview'), { timeout: 4000 });

// The block belongs to another leaf's canvas and does not exist on this one.
expect(screen.queryByTestId('stub-object-inspector')).not.toBeInTheDocument();
expect(bodyText()).toContain('This canvas renders the running app, not a block tree');
// ...and the rail must not contradict itself by offering to clear a
// selection it just said cannot exist here.
expect(screen.queryByLabelText('Clear selection')).not.toBeInTheDocument();
});
});

describe('REGRESSION FENCE — a leaf WITH a block canvas is untouched (#7121)', () => {
it('still offers the Design/Run switch, and it still round-trips', async () => {
expectPopulatedRegistries();
await openLeaf('dashboard · sales_overview');
await waitFor(() => expect(screen.getByTestId('canvas-mode-toggle')).toBeInTheDocument(), {
timeout: 4000,
});

// #5800's acceptance: 设计⇄运行 is a round trip on the SAME renderer, and
// `editing` is the mode the renderer actually reads.
expect(screen.getByTestId('stub-dash')).toHaveAttribute('data-editing', 'true');
fireEvent.click(screen.getByRole('button', { name: 'Run' }));
await waitFor(() =>
expect(screen.getByTestId('stub-dash')).toHaveAttribute('data-editing', 'false'),
);
fireEvent.click(screen.getByRole('button', { name: 'Design' }));
await waitFor(() =>
expect(screen.getByTestId('stub-dash')).toHaveAttribute('data-editing', 'true'),
);
});

it('keeps the ordinary "click a block" rail where a block canvas exists', async () => {
expectPopulatedRegistries();
await openLeaf('dashboard · sales_overview');
await waitFor(() => expect(screen.getByTestId('stub-dash')).toBeInTheDocument(), {
timeout: 4000,
});

// The repair must not over-fire: this leaf HAS blocks to click.
await waitFor(() => expect(bodyText()).toContain(CLICK_A_BLOCK));
expect(bodyText()).not.toContain('This canvas renders the running app');
});

it('keeps the selection affordances where a block canvas exists', async () => {
expectPopulatedRegistries();
await openLeaf('dashboard · sales_overview');
await waitFor(() => expect(screen.getByTestId('stub-dash')).toBeInTheDocument(), {
timeout: 4000,
});

fireEvent.click(screen.getByTestId('pick-block'));
await waitFor(() => expect(screen.getByLabelText('Clear selection')).toBeInTheDocument());
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/7121-studio-canvas-leaf-affordances.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
---
'@object-ui/app-shell': patch
---

Studio Interfaces: no Design mode and no "click a block" rail on leaves that have
no block canvas (objectui#7121).

`registerStudioCanvasPreview(type, …)` opts a type into a surface-specific canvas
that renders the running app rather than an editable draft — a contract, not a
habit: `StudioCanvasPreviewProps` carries no `selection`, `onSelectionChange`,
`onPatch` or `editing`. Two affordances beside such a leaf ignored that.

- The Design/Run switch (objectui#5800) was still offered, though `editing` is
handed to exactly one canvas branch (`Preview`). On a studio-canvas leaf the
switch moved `canvasMode` and reached no renderer — a live-looking control
wired to nothing. It is now gated.
- The right rail fell through to "Click a block on the canvas, and edit its
properties right here." beside a canvas that has no blocks, so the instruction
could not be followed. It now states what the canvas is, and — because this
canvas has no blocks by contract — promises no recovery.
- The rail's new branch is ordered ahead of the selection branch, so a block
selected on a *different* leaf no longer opens a scoped inspector for a block
this canvas does not contain; the header's "clear selection" button is gated
with it.

The discriminator is `StudioCanvas`, not `isEditable`. `isEditable` is
`!!Preview && !StudioCanvas` — a conjunction of two independent causes — so
gating on it would also strip these affordances from leaves whose only fault is
that their own type has no designer, the state objectui#6795 part C pinned as
still deserving the ordinary rail. Behaviour on every leaf with a block canvas
is unchanged.
8 changes: 8 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1777,6 +1777,12 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.studio.inspector.tabSource': 'Source',
'engine.studio.inspector.emptyLine1': 'Click a block on the canvas,',
'engine.studio.inspector.emptyLine2': 'and edit its properties right here.',
// objectui#7121 — the rail beside a studio-canvas leaf (a
// `registerStudioCanvasPreview` type: the running app, not a block tree).
// States what the canvas IS; promises no recovery, because there is nothing
// to wait for — `StudioCanvasPreviewProps` carries no selection by contract.
'engine.studio.inspector.studioCanvasNoBlocks':
'This canvas renders the running app, not a block tree — it has no blocks to select, and nothing here is edited from this panel.',
'engine.studio.inspector.designersMissing':
'No metadata designers are registered in this session, so there is nothing to edit here.',
'engine.studio.inspector.noPageSchema': 'Page settings are unavailable — the page schema could not be loaded.',
Expand DownExpand Up@@ -3639,6 +3645,8 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.studio.inspector.tabSource': '源码',
'engine.studio.inspector.emptyLine1': '在画布里点选一个积木,',
'engine.studio.inspector.emptyLine2': '它的属性会在这里直接编辑。',
'engine.studio.inspector.studioCanvasNoBlocks':
'此画布渲染的是运行态应用,而不是积木树 —— 这里没有可选中的积木,也没有可在本面板编辑的内容。',
'engine.studio.inspector.designersMissing': '本次会话没有注册任何元数据设计器,这里没有可编辑的内容。',
'engine.studio.inspector.noPageSchema': '页面设置不可用——无法加载页面 schema。',
'engine.studio.inspector.sourcePageLine1': '这个页面是 {kind} 源码,不是积木树 ——',
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7121 — what the Interfaces pillar may OFFER beside a studio-canvas
* leaf.
*
* ## The mechanism
*
* `registerStudioCanvasPreview(type, …)` opts a type into a surface-specific
* canvas: the running app, not an editable draft. That is a contract, not a
* habit — `StudioCanvasPreviewProps` is deliberately a small read-only subset
* of `MetadataPreviewProps` with **no `selection`, no `onSelectionChange`, no
* `onPatch`, no `editing`**. So a studio-canvas leaf:
*
* - can never produce a block selection (there is no block tree), and
* - can never read the design/run mode (nothing is handed `editing`).
*
* Two affordances beside it ignored both facts: the Design/Run switch (#5800)
* was still offered though `canvasMode` reached no renderer, and the rail fell
* through to *"Click a block on the canvas, and edit its properties right
* here."* — an instruction that cannot be followed.
*
* ## ⚠️ This is NOT #6795 part C's cause, and the precondition says so
*
* Part C repaired what the pillar says when the designer registries are
* **empty**. Every test here asserts a **POPULATED** registry first
* (`listMetadataPreviewTypes()` non-empty), because a zero-registry reading
* would measure that other card instead. The cause here is an ungated
* affordance, not a missing registration.
*
* ## ⛔ The message promises no recovery
*
* Part C established by measurement that these registries are plain `Map`s read
* during render with no subscription, so a consumer that read an empty one
* never recovers ("late inspector rendered: false") — no "loading…", no "try
* again". Here the constraint is even stricter: the statement is not about
* registration at all. This canvas has no blocks **by contract**, so there is
* nothing to wait for, and the last test pins the absence of recovery language.
*
* ## ⚠️ The discriminator is `StudioCanvas`, NOT `isEditable`
*
* `isEditable = !!Preview && !StudioCanvas` is a conjunction of two independent
* causes. Gating on it would also strip these affordances from leaves whose
* only fault is that **their own type** has no designer — the exact state
* `StudioDesignSurface.designerRegistryPartial.test.tsx` pins as still deserving
* the ordinary "click a block" rail. That file is the live fence: gate on
* `isEditable` and it goes red.
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

const objectDef = {
name: 'showcase_task',
label: 'Task',
fields: [{ name: 'title', label: 'Title', type: 'text' }],
};

/**
* One leaf of each kind, in one app: `object` opts into the studio canvas,
* `dashboard` does not (it is an ordinary block-canvas designer). The contrast
* is the point — the second leaf is the regression fence.
*/
const NAV = [
{ id: 'nav_obj', type: 'object', label: 'Tasks', objectName: 'showcase_task' },
{ id: 'nav_dash', type: 'dashboard', label: 'Overview', dashboardName: 'sales_overview' },
];

const mockClient = {
save: vi.fn(async () => ({})),
list: vi.fn(async (type: string) => {
if (type === 'app') return [{ name: 'acme_app', label: 'Acme' }];
if (type === 'object') return [{ name: 'showcase_task', label: 'Task' }];
return [];
}),
listDrafts: vi.fn(async () => []),
layered: vi.fn(async (type: string, name: string) => {
if (type === 'app') return { effective: { name: 'acme_app', label: 'Acme', navigation: NAV } };
if (type === 'object') return { effective: objectDef, code: objectDef };
if (type === 'dashboard') return { effective: { name: 'sales_overview', label: 'Sales' } };
return { effective: { name } };
}),
getDraft: vi.fn(async () => null),
get: vi.fn(async () => undefined),
};

vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) };
});

vi.mock('./packages-io', async (importOriginal) => {
const mod = await importOriginal<typeof import('./packages-io')>();
return { ...mod, fetchPackages: vi.fn(async () => []) };
});

vi.mock('@object-ui/react', async (importOriginal) => {
const mod = await importOriginal<typeof import('@object-ui/react')>();
return { ...mod, useAdapter: () => ({}) };
});

import { InterfacesPillar } from './StudioDesignSurface';
import { listMetadataPreviewTypes, registerMetadataPreview } from '../metadata-admin/preview-registry';
import { listMetadataInspectorTypes, registerMetadataInspector } from '../metadata-admin/inspector-registry';
import { listStudioCanvasPreviewTypes } from './studio-canvas-preview';

const CLICK_A_BLOCK = 'Click a block on the canvas,';

/**
* A block-canvas designer for an unrelated type. Two jobs: it makes the
* designer registry demonstrably POPULATED (the card's precondition), and it
* is the regression fence's leaf. It reports the `editing` prop it was handed,
* so the Design/Run round trip is measured at the seam that actually carries
* the mode rather than through any one renderer's overlay internals.
*/
function StubDashboardPreview(props: Record<string, unknown>): React.ReactElement {
const onSel = props.onSelectionChange as ((s: unknown) => void) | undefined;
return (
<div data-testid="stub-dash" data-editing={String(props.editing)}>
<button
type="button"
data-testid="pick-block"
onClick={() => onSel?.({ kind: 'block', id: 'blk_1' })}
>
pick
</button>
</div>
);
}
registerMetadataPreview('dashboard', StubDashboardPreview as never);

/**
* Production registers `ObjectFieldInspector` for `object`
* (`metadata-admin/inspectors/index.ts`), so the scoped-inspector branch is
* reachable on the studio-canvas leaf whenever a `selection` survives a leaf
* change. Registering a stand-in here is what makes the last pin a measurement
* of that branch rather than of an accidentally-empty registry.
*/
function StubObjectInspector(props: Record<string, unknown>): React.ReactElement {
const sel = props.selection as { kind?: string; id?: string } | null;
return (
<div
data-testid="stub-object-inspector"
data-for={`${String(props.type)}:${String(props.name)}:${sel?.kind}:${sel?.id}`}
/>
);
}
registerMetadataInspector('object', StubObjectInspector as never);

afterEach(cleanup);

function mountPillar() {
return render(
<MemoryRouter initialEntries={['/studio/com.acme.app/interfaces']}>
<InterfacesPillar packageId="com.acme.app" />
</MemoryRouter>,
);
}

/** The precondition every test here shares, stated rather than assumed. */
function expectPopulatedRegistries() {
expect(listMetadataPreviewTypes()).toContain('dashboard');
expect(listMetadataPreviewTypes().length).toBeGreaterThan(0);
expect(listMetadataInspectorTypes()).toContain('object');
// ...and the leaf under test really is a studio-canvas leaf.
expect(listStudioCanvasPreviewTypes()).toContain('object');
}

async function openLeaf(title: string) {
mountPillar();
fireEvent.click(await screen.findByTitle(title));
}

const bodyText = () => (document.body.textContent ?? '').replace(/\s+/g, ' ');

describe('Interfaces pillar — affordances beside a studio-canvas leaf (#7121)', () => {
it('offers no Design/Run switch on a leaf whose canvas cannot read the mode', async () => {
expectPopulatedRegistries();
await openLeaf('object · showcase_task');

// Wait on the canvas itself, not on the toggle — the toggle's ABSENCE is
// the assertion, so waiting for it would deadlock the pin by construction.
await waitFor(() => expect(bodyText()).toContain('Runtime list preview'), { timeout: 4000 });

expect(screen.queryByTestId('canvas-mode-toggle')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Design' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Run' })).not.toBeInTheDocument();
});

it('replaces the impossible "click a block" invitation with what is true', async () => {
expectPopulatedRegistries();
await openLeaf('object · showcase_task');
await waitFor(() => expect(bodyText()).toContain('Runtime list preview'), { timeout: 4000 });

await waitFor(() =>
expect(bodyText()).toContain('This canvas renders the running app, not a block tree'),
);
// ⛔ The invitation that cannot be followed must be gone.
expect(bodyText()).not.toContain(CLICK_A_BLOCK);
// ⛔ And this must not be mistaken for #6795 part C's empty-registry state,
// which is demonstrably not the cause here.
expect(bodyText()).not.toContain('No metadata designers are registered');
});

it('promises no recovery — there is nothing to wait for', async () => {
expectPopulatedRegistries();
await openLeaf('object · showcase_task');
await waitFor(() => expect(bodyText()).toContain('This canvas renders the running app'), {
timeout: 4000,
});

// Scoped to the RAIL, not the document: the canvas beside it renders the
// real records grid, which in jsdom (no data source) says "Error loading
// grid" — its own honest state, and nothing this card may speak for. A
// document-wide scan would read that as the rail promising recovery.
const railBlock = screen.getByText(/This canvas renders the running app/).closest('div');
const rail = railBlock?.textContent ?? '';
// Control: the scoped read must actually have found the message.
expect(rail).toContain('This canvas renders the running app');

for (const promise of ['Loading', 'loading', 'try again', 'Try again', 'not yet', 'in progress']) {
expect(rail).not.toContain(promise);
}
});

it('does not open a scoped inspector for a block selected on a DIFFERENT leaf', async () => {
expectPopulatedRegistries();

// Select a block on the dashboard leaf — a real selection, made through the
// pillar's own `onSelectionChange`.
await openLeaf('dashboard · sales_overview');
await waitFor(() => expect(screen.getByTestId('stub-dash')).toBeInTheDocument(), {
timeout: 4000,
});
fireEvent.click(screen.getByTestId('pick-block'));
await waitFor(() => expect(screen.getByLabelText('Clear selection')).toBeInTheDocument(), {
timeout: 4000,
});

// ...then walk to the studio-canvas leaf. The pillar's load effect clears
// `selection` only on the editable path, so the selection is still live.
fireEvent.click(screen.getByTitle('object · showcase_task'));
await waitFor(() => expect(bodyText()).toContain('Runtime list preview'), { timeout: 4000 });

// The block belongs to another leaf's canvas and does not exist on this one.
expect(screen.queryByTestId('stub-object-inspector')).not.toBeInTheDocument();
expect(bodyText()).toContain('This canvas renders the running app, not a block tree');
// ...and the rail must not contradict itself by offering to clear a
// selection it just said cannot exist here.
expect(screen.queryByLabelText('Clear selection')).not.toBeInTheDocument();
});
});

describe('REGRESSION FENCE — a leaf WITH a block canvas is untouched (#7121)', () => {
it('still offers the Design/Run switch, and it still round-trips', async () => {
expectPopulatedRegistries();
await openLeaf('dashboard · sales_overview');
await waitFor(() => expect(screen.getByTestId('canvas-mode-toggle')).toBeInTheDocument(), {
timeout: 4000,
});

// #5800's acceptance: 设计⇄运行 is a round trip on the SAME renderer, and
// `editing` is the mode the renderer actually reads.
expect(screen.getByTestId('stub-dash')).toHaveAttribute('data-editing', 'true');
fireEvent.click(screen.getByRole('button', { name: 'Run' }));
await waitFor(() =>
expect(screen.getByTestId('stub-dash')).toHaveAttribute('data-editing', 'false'),
);
fireEvent.click(screen.getByRole('button', { name: 'Design' }));
await waitFor(() =>
expect(screen.getByTestId('stub-dash')).toHaveAttribute('data-editing', 'true'),
);
});

it('keeps the ordinary "click a block" rail where a block canvas exists', async () => {
expectPopulatedRegistries();
await openLeaf('dashboard · sales_overview');
await waitFor(() => expect(screen.getByTestId('stub-dash')).toBeInTheDocument(), {
timeout: 4000,
});

// The repair must not over-fire: this leaf HAS blocks to click.
await waitFor(() => expect(bodyText()).toContain(CLICK_A_BLOCK));
expect(bodyText()).not.toContain('This canvas renders the running app');
});

it('keeps the selection affordances where a block canvas exists', async () => {
expectPopulatedRegistries();
await openLeaf('dashboard · sales_overview');
await waitFor(() => expect(screen.getByTestId('stub-dash')).toBeInTheDocument(), {
timeout: 4000,
});

fireEvent.click(screen.getByTestId('pick-block'));
await waitFor(() => expect(screen.getByLabelText('Clear selection')).toBeInTheDocument());
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/7121-studio-canvas-leaf-affordances.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
---
'@object-ui/app-shell': patch
---

Studio Interfaces: no Design mode and no "click a block" rail on leaves that have
no block canvas (objectui#7121).

`registerStudioCanvasPreview(type, …)` opts a type into a surface-specific canvas
that renders the running app rather than an editable draft — a contract, not a
habit: `StudioCanvasPreviewProps` carries no `selection`, `onSelectionChange`,
`onPatch` or `editing`. Two affordances beside such a leaf ignored that.

- The Design/Run switch (objectui#5800) was still offered, though `editing` is
handed to exactly one canvas branch (`Preview`). On a studio-canvas leaf the
switch moved `canvasMode` and reached no renderer — a live-looking control
wired to nothing. It is now gated.
- The right rail fell through to "Click a block on the canvas, and edit its
properties right here." beside a canvas that has no blocks, so the instruction
could not be followed. It now states what the canvas is, and — because this
canvas has no blocks by contract — promises no recovery.
- The rail's new branch is ordered ahead of the selection branch, so a block
selected on a *different* leaf no longer opens a scoped inspector for a block
this canvas does not contain; the header's "clear selection" button is gated
with it.

The discriminator is `StudioCanvas`, not `isEditable`. `isEditable` is
`!!Preview && !StudioCanvas` — a conjunction of two independent causes — so
gating on it would also strip these affordances from leaves whose only fault is
that their own type has no designer, the state objectui#6795 part C pinned as
still deserving the ordinary rail. Behaviour on every leaf with a block canvas
is unchanged.
8 changes: 8 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1777,6 +1777,12 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.studio.inspector.tabSource': 'Source',
'engine.studio.inspector.emptyLine1': 'Click a block on the canvas,',
'engine.studio.inspector.emptyLine2': 'and edit its properties right here.',
// objectui#7121 — the rail beside a studio-canvas leaf (a
// `registerStudioCanvasPreview` type: the running app, not a block tree).
// States what the canvas IS; promises no recovery, because there is nothing
// to wait for — `StudioCanvasPreviewProps` carries no selection by contract.
'engine.studio.inspector.studioCanvasNoBlocks':
'This canvas renders the running app, not a block tree — it has no blocks to select, and nothing here is edited from this panel.',
'engine.studio.inspector.designersMissing':
'No metadata designers are registered in this session, so there is nothing to edit here.',
'engine.studio.inspector.noPageSchema': 'Page settings are unavailable — the page schema could not be loaded.',
Expand DownExpand Up@@ -3639,6 +3645,8 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.studio.inspector.tabSource': '源码',
'engine.studio.inspector.emptyLine1': '在画布里点选一个积木,',
'engine.studio.inspector.emptyLine2': '它的属性会在这里直接编辑。',
'engine.studio.inspector.studioCanvasNoBlocks':
'此画布渲染的是运行态应用,而不是积木树 —— 这里没有可选中的积木,也没有可在本面板编辑的内容。',
'engine.studio.inspector.designersMissing': '本次会话没有注册任何元数据设计器,这里没有可编辑的内容。',
'engine.studio.inspector.noPageSchema': '页面设置不可用——无法加载页面 schema。',
'engine.studio.inspector.sourcePageLine1': '这个页面是 {kind} 源码,不是积木树 ——',
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7121 — what the Interfaces pillar may OFFER beside a studio-canvas
* leaf.
*
* ## The mechanism
*
* `registerStudioCanvasPreview(type, …)` opts a type into a surface-specific
* canvas: the running app, not an editable draft. That is a contract, not a
* habit — `StudioCanvasPreviewProps` is deliberately a small read-only subset
* of `MetadataPreviewProps` with **no `selection`, no `onSelectionChange`, no
* `onPatch`, no `editing`**. So a studio-canvas leaf:
*
* - can never produce a block selection (there is no block tree), and
* - can never read the design/run mode (nothing is handed `editing`).
*
* Two affordances beside it ignored both facts: the Design/Run switch (#5800)
* was still offered though `canvasMode` reached no renderer, and the rail fell
* through to *"Click a block on the canvas, and edit its properties right
* here."* — an instruction that cannot be followed.
*
* ## ⚠️ This is NOT #6795 part C's cause, and the precondition says so
*
* Part C repaired what the pillar says when the designer registries are
* **empty**. Every test here asserts a **POPULATED** registry first
* (`listMetadataPreviewTypes()` non-empty), because a zero-registry reading
* would measure that other card instead. The cause here is an ungated
* affordance, not a missing registration.
*
* ## ⛔ The message promises no recovery
*
* Part C established by measurement that these registries are plain `Map`s read
* during render with no subscription, so a consumer that read an empty one
* never recovers ("late inspector rendered: false") — no "loading…", no "try
* again". Here the constraint is even stricter: the statement is not about
* registration at all. This canvas has no blocks **by contract**, so there is
* nothing to wait for, and the last test pins the absence of recovery language.
*
* ## ⚠️ The discriminator is `StudioCanvas`, NOT `isEditable`
*
* `isEditable = !!Preview && !StudioCanvas` is a conjunction of two independent
* causes. Gating on it would also strip these affordances from leaves whose
* only fault is that **their own type** has no designer — the exact state
* `StudioDesignSurface.designerRegistryPartial.test.tsx` pins as still deserving
* the ordinary "click a block" rail. That file is the live fence: gate on
* `isEditable` and it goes red.
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

const objectDef = {
name: 'showcase_task',
label: 'Task',
fields: [{ name: 'title', label: 'Title', type: 'text' }],
};

/**
* One leaf of each kind, in one app: `object` opts into the studio canvas,
* `dashboard` does not (it is an ordinary block-canvas designer). The contrast
* is the point — the second leaf is the regression fence.
*/
const NAV = [
{ id: 'nav_obj', type: 'object', label: 'Tasks', objectName: 'showcase_task' },
{ id: 'nav_dash', type: 'dashboard', label: 'Overview', dashboardName: 'sales_overview' },
];

const mockClient = {
save: vi.fn(async () => ({})),
list: vi.fn(async (type: string) => {
if (type === 'app') return [{ name: 'acme_app', label: 'Acme' }];
if (type === 'object') return [{ name: 'showcase_task', label: 'Task' }];
return [];
}),
listDrafts: vi.fn(async () => []),
layered: vi.fn(async (type: string, name: string) => {
if (type === 'app') return { effective: { name: 'acme_app', label: 'Acme', navigation: NAV } };
if (type === 'object') return { effective: objectDef, code: objectDef };
if (type === 'dashboard') return { effective: { name: 'sales_overview', label: 'Sales' } };
return { effective: { name } };
}),
getDraft: vi.fn(async () => null),
get: vi.fn(async () => undefined),
};

vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) };
});

vi.mock('./packages-io', async (importOriginal) => {
const mod = await importOriginal<typeof import('./packages-io')>();
return { ...mod, fetchPackages: vi.fn(async () => []) };
});

vi.mock('@object-ui/react', async (importOriginal) => {
const mod = await importOriginal<typeof import('@object-ui/react')>();
return { ...mod, useAdapter: () => ({}) };
});

import { InterfacesPillar } from './StudioDesignSurface';
import { listMetadataPreviewTypes, registerMetadataPreview } from '../metadata-admin/preview-registry';
import { listMetadataInspectorTypes, registerMetadataInspector } from '../metadata-admin/inspector-registry';
import { listStudioCanvasPreviewTypes } from './studio-canvas-preview';

const CLICK_A_BLOCK = 'Click a block on the canvas,';

/**
* A block-canvas designer for an unrelated type. Two jobs: it makes the
* designer registry demonstrably POPULATED (the card's precondition), and it
* is the regression fence's leaf. It reports the `editing` prop it was handed,
* so the Design/Run round trip is measured at the seam that actually carries
* the mode rather than through any one renderer's overlay internals.
*/
function StubDashboardPreview(props: Record<string, unknown>): React.ReactElement {
const onSel = props.onSelectionChange as ((s: unknown) => void) | undefined;
return (
<div data-testid="stub-dash" data-editing={String(props.editing)}>
<button
type="button"
data-testid="pick-block"
onClick={() => onSel?.({ kind: 'block', id: 'blk_1' })}
>
pick
</button>
</div>
);
}
registerMetadataPreview('dashboard', StubDashboardPreview as never);

/**
* Production registers `ObjectFieldInspector` for `object`
* (`metadata-admin/inspectors/index.ts`), so the scoped-inspector branch is
* reachable on the studio-canvas leaf whenever a `selection` survives a leaf
* change. Registering a stand-in here is what makes the last pin a measurement
* of that branch rather than of an accidentally-empty registry.
*/
function StubObjectInspector(props: Record<string, unknown>): React.ReactElement {
const sel = props.selection as { kind?: string; id?: string } | null;
return (
<div
data-testid="stub-object-inspector"
data-for={`${String(props.type)}:${String(props.name)}:${sel?.kind}:${sel?.id}`}
/>
);
}
registerMetadataInspector('object', StubObjectInspector as never);

afterEach(cleanup);

function mountPillar() {
return render(
<MemoryRouter initialEntries={['/studio/com.acme.app/interfaces']}>
<InterfacesPillar packageId="com.acme.app" />
</MemoryRouter>,
);
}

/** The precondition every test here shares, stated rather than assumed. */
function expectPopulatedRegistries() {
expect(listMetadataPreviewTypes()).toContain('dashboard');
expect(listMetadataPreviewTypes().length).toBeGreaterThan(0);
expect(listMetadataInspectorTypes()).toContain('object');
// ...and the leaf under test really is a studio-canvas leaf.
expect(listStudioCanvasPreviewTypes()).toContain('object');
}

async function openLeaf(title: string) {
mountPillar();
fireEvent.click(await screen.findByTitle(title));
}

const bodyText = () => (document.body.textContent ?? '').replace(/\s+/g, ' ');

describe('Interfaces pillar — affordances beside a studio-canvas leaf (#7121)', () => {
it('offers no Design/Run switch on a leaf whose canvas cannot read the mode', async () => {
expectPopulatedRegistries();
await openLeaf('object · showcase_task');

// Wait on the canvas itself, not on the toggle — the toggle's ABSENCE is
// the assertion, so waiting for it would deadlock the pin by construction.
await waitFor(() => expect(bodyText()).toContain('Runtime list preview'), { timeout: 4000 });

expect(screen.queryByTestId('canvas-mode-toggle')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Design' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Run' })).not.toBeInTheDocument();
});

it('replaces the impossible "click a block" invitation with what is true', async () => {
expectPopulatedRegistries();
await openLeaf('object · showcase_task');
await waitFor(() => expect(bodyText()).toContain('Runtime list preview'), { timeout: 4000 });

await waitFor(() =>
expect(bodyText()).toContain('This canvas renders the running app, not a block tree'),
);
// ⛔ The invitation that cannot be followed must be gone.
expect(bodyText()).not.toContain(CLICK_A_BLOCK);
// ⛔ And this must not be mistaken for #6795 part C's empty-registry state,
// which is demonstrably not the cause here.
expect(bodyText()).not.toContain('No metadata designers are registered');
});

it('promises no recovery — there is nothing to wait for', async () => {
expectPopulatedRegistries();
await openLeaf('object · showcase_task');
await waitFor(() => expect(bodyText()).toContain('This canvas renders the running app'), {
timeout: 4000,
});

// Scoped to the RAIL, not the document: the canvas beside it renders the
// real records grid, which in jsdom (no data source) says "Error loading
// grid" — its own honest state, and nothing this card may speak for. A
// document-wide scan would read that as the rail promising recovery.
const railBlock = screen.getByText(/This canvas renders the running app/).closest('div');
const rail = railBlock?.textContent ?? '';
// Control: the scoped read must actually have found the message.
expect(rail).toContain('This canvas renders the running app');

for (const promise of ['Loading', 'loading', 'try again', 'Try again', 'not yet', 'in progress']) {
expect(rail).not.toContain(promise);
}
});

it('does not open a scoped inspector for a block selected on a DIFFERENT leaf', async () => {
expectPopulatedRegistries();

// Select a block on the dashboard leaf — a real selection, made through the
// pillar's own `onSelectionChange`.
await openLeaf('dashboard · sales_overview');
await waitFor(() => expect(screen.getByTestId('stub-dash')).toBeInTheDocument(), {
timeout: 4000,
});
fireEvent.click(screen.getByTestId('pick-block'));
await waitFor(() => expect(screen.getByLabelText('Clear selection')).toBeInTheDocument(), {
timeout: 4000,
});

// ...then walk to the studio-canvas leaf. The pillar's load effect clears
// `selection` only on the editable path, so the selection is still live.
fireEvent.click(screen.getByTitle('object · showcase_task'));
await waitFor(() => expect(bodyText()).toContain('Runtime list preview'), { timeout: 4000 });

// The block belongs to another leaf's canvas and does not exist on this one.
expect(screen.queryByTestId('stub-object-inspector')).not.toBeInTheDocument();
expect(bodyText()).toContain('This canvas renders the running app, not a block tree');
// ...and the rail must not contradict itself by offering to clear a
// selection it just said cannot exist here.
expect(screen.queryByLabelText('Clear selection')).not.toBeInTheDocument();
});
});

describe('REGRESSION FENCE — a leaf WITH a block canvas is untouched (#7121)', () => {
it('still offers the Design/Run switch, and it still round-trips', async () => {
expectPopulatedRegistries();
await openLeaf('dashboard · sales_overview');
await waitFor(() => expect(screen.getByTestId('canvas-mode-toggle')).toBeInTheDocument(), {
timeout: 4000,
});

// #5800's acceptance: 设计⇄运行 is a round trip on the SAME renderer, and
// `editing` is the mode the renderer actually reads.
expect(screen.getByTestId('stub-dash')).toHaveAttribute('data-editing', 'true');
fireEvent.click(screen.getByRole('button', { name: 'Run' }));
await waitFor(() =>
expect(screen.getByTestId('stub-dash')).toHaveAttribute('data-editing', 'false'),
);
fireEvent.click(screen.getByRole('button', { name: 'Design' }));
await waitFor(() =>
expect(screen.getByTestId('stub-dash')).toHaveAttribute('data-editing', 'true'),
);
});

it('keeps the ordinary "click a block" rail where a block canvas exists', async () => {
expectPopulatedRegistries();
await openLeaf('dashboard · sales_overview');
await waitFor(() => expect(screen.getByTestId('stub-dash')).toBeInTheDocument(), {
timeout: 4000,
});

// The repair must not over-fire: this leaf HAS blocks to click.
await waitFor(() => expect(bodyText()).toContain(CLICK_A_BLOCK));
expect(bodyText()).not.toContain('This canvas renders the running app');
});

it('keeps the selection affordances where a block canvas exists', async () => {
expectPopulatedRegistries();
await openLeaf('dashboard · sales_overview');
await waitFor(() => expect(screen.getByTestId('stub-dash')).toBeInTheDocument(), {
timeout: 4000,
});

fireEvent.click(screen.getByTestId('pick-block'));
await waitFor(() => expect(screen.getByLabelText('Clear selection')).toBeInTheDocument());
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/7121-studio-canvas-leaf-affordances.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
---
'@object-ui/app-shell': patch
---

Studio Interfaces: no Design mode and no "click a block" rail on leaves that have
no block canvas (objectui#7121).

`registerStudioCanvasPreview(type, …)` opts a type into a surface-specific canvas
that renders the running app rather than an editable draft — a contract, not a
habit: `StudioCanvasPreviewProps` carries no `selection`, `onSelectionChange`,
`onPatch` or `editing`. Two affordances beside such a leaf ignored that.

- The Design/Run switch (objectui#5800) was still offered, though `editing` is
handed to exactly one canvas branch (`Preview`). On a studio-canvas leaf the
switch moved `canvasMode` and reached no renderer — a live-looking control
wired to nothing. It is now gated.
- The right rail fell through to "Click a block on the canvas, and edit its
properties right here." beside a canvas that has no blocks, so the instruction
could not be followed. It now states what the canvas is, and — because this
canvas has no blocks by contract — promises no recovery.
- The rail's new branch is ordered ahead of the selection branch, so a block
selected on a *different* leaf no longer opens a scoped inspector for a block
this canvas does not contain; the header's "clear selection" button is gated
with it.

The discriminator is `StudioCanvas`, not `isEditable`. `isEditable` is
`!!Preview && !StudioCanvas` — a conjunction of two independent causes — so
gating on it would also strip these affordances from leaves whose only fault is
that their own type has no designer, the state objectui#6795 part C pinned as
still deserving the ordinary rail. Behaviour on every leaf with a block canvas
is unchanged.
8 changes: 8 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1777,6 +1777,12 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.studio.inspector.tabSource': 'Source',
'engine.studio.inspector.emptyLine1': 'Click a block on the canvas,',
'engine.studio.inspector.emptyLine2': 'and edit its properties right here.',
// objectui#7121 — the rail beside a studio-canvas leaf (a
// `registerStudioCanvasPreview` type: the running app, not a block tree).
// States what the canvas IS; promises no recovery, because there is nothing
// to wait for — `StudioCanvasPreviewProps` carries no selection by contract.
'engine.studio.inspector.studioCanvasNoBlocks':
'This canvas renders the running app, not a block tree — it has no blocks to select, and nothing here is edited from this panel.',
'engine.studio.inspector.designersMissing':
'No metadata designers are registered in this session, so there is nothing to edit here.',
'engine.studio.inspector.noPageSchema': 'Page settings are unavailable — the page schema could not be loaded.',
Expand DownExpand Up@@ -3639,6 +3645,8 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.studio.inspector.tabSource': '源码',
'engine.studio.inspector.emptyLine1': '在画布里点选一个积木,',
'engine.studio.inspector.emptyLine2': '它的属性会在这里直接编辑。',
'engine.studio.inspector.studioCanvasNoBlocks':
'此画布渲染的是运行态应用,而不是积木树 —— 这里没有可选中的积木,也没有可在本面板编辑的内容。',
'engine.studio.inspector.designersMissing': '本次会话没有注册任何元数据设计器,这里没有可编辑的内容。',
'engine.studio.inspector.noPageSchema': '页面设置不可用——无法加载页面 schema。',
'engine.studio.inspector.sourcePageLine1': '这个页面是 {kind} 源码,不是积木树 ——',
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7121 — what the Interfaces pillar may OFFER beside a studio-canvas
* leaf.
*
* ## The mechanism
*
* `registerStudioCanvasPreview(type, …)` opts a type into a surface-specific
* canvas: the running app, not an editable draft. That is a contract, not a
* habit — `StudioCanvasPreviewProps` is deliberately a small read-only subset
* of `MetadataPreviewProps` with **no `selection`, no `onSelectionChange`, no
* `onPatch`, no `editing`**. So a studio-canvas leaf:
*
* - can never produce a block selection (there is no block tree), and
* - can never read the design/run mode (nothing is handed `editing`).
*
* Two affordances beside it ignored both facts: the Design/Run switch (#5800)
* was still offered though `canvasMode` reached no renderer, and the rail fell
* through to *"Click a block on the canvas, and edit its properties right
* here."* — an instruction that cannot be followed.
*
* ## ⚠️ This is NOT #6795 part C's cause, and the precondition says so
*
* Part C repaired what the pillar says when the designer registries are
* **empty**. Every test here asserts a **POPULATED** registry first
* (`listMetadataPreviewTypes()` non-empty), because a zero-registry reading
* would measure that other card instead. The cause here is an ungated
* affordance, not a missing registration.
*
* ## ⛔ The message promises no recovery
*
* Part C established by measurement that these registries are plain `Map`s read
* during render with no subscription, so a consumer that read an empty one
* never recovers ("late inspector rendered: false") — no "loading…", no "try
* again". Here the constraint is even stricter: the statement is not about
* registration at all. This canvas has no blocks **by contract**, so there is
* nothing to wait for, and the last test pins the absence of recovery language.
*
* ## ⚠️ The discriminator is `StudioCanvas`, NOT `isEditable`
*
* `isEditable = !!Preview && !StudioCanvas` is a conjunction of two independent
* causes. Gating on it would also strip these affordances from leaves whose
* only fault is that **their own type** has no designer — the exact state
* `StudioDesignSurface.designerRegistryPartial.test.tsx` pins as still deserving
* the ordinary "click a block" rail. That file is the live fence: gate on
* `isEditable` and it goes red.
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

const objectDef = {
name: 'showcase_task',
label: 'Task',
fields: [{ name: 'title', label: 'Title', type: 'text' }],
};

/**
* One leaf of each kind, in one app: `object` opts into the studio canvas,
* `dashboard` does not (it is an ordinary block-canvas designer). The contrast
* is the point — the second leaf is the regression fence.
*/
const NAV = [
{ id: 'nav_obj', type: 'object', label: 'Tasks', objectName: 'showcase_task' },
{ id: 'nav_dash', type: 'dashboard', label: 'Overview', dashboardName: 'sales_overview' },
];

const mockClient = {
save: vi.fn(async () => ({})),
list: vi.fn(async (type: string) => {
if (type === 'app') return [{ name: 'acme_app', label: 'Acme' }];
if (type === 'object') return [{ name: 'showcase_task', label: 'Task' }];
return [];
}),
listDrafts: vi.fn(async () => []),
layered: vi.fn(async (type: string, name: string) => {
if (type === 'app') return { effective: { name: 'acme_app', label: 'Acme', navigation: NAV } };
if (type === 'object') return { effective: objectDef, code: objectDef };
if (type === 'dashboard') return { effective: { name: 'sales_overview', label: 'Sales' } };
return { effective: { name } };
}),
getDraft: vi.fn(async () => null),
get: vi.fn(async () => undefined),
};

vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) };
});

vi.mock('./packages-io', async (importOriginal) => {
const mod = await importOriginal<typeof import('./packages-io')>();
return { ...mod, fetchPackages: vi.fn(async () => []) };
});

vi.mock('@object-ui/react', async (importOriginal) => {
const mod = await importOriginal<typeof import('@object-ui/react')>();
return { ...mod, useAdapter: () => ({}) };
});

import { InterfacesPillar } from './StudioDesignSurface';
import { listMetadataPreviewTypes, registerMetadataPreview } from '../metadata-admin/preview-registry';
import { listMetadataInspectorTypes, registerMetadataInspector } from '../metadata-admin/inspector-registry';
import { listStudioCanvasPreviewTypes } from './studio-canvas-preview';

const CLICK_A_BLOCK = 'Click a block on the canvas,';

/**
* A block-canvas designer for an unrelated type. Two jobs: it makes the
* designer registry demonstrably POPULATED (the card's precondition), and it
* is the regression fence's leaf. It reports the `editing` prop it was handed,
* so the Design/Run round trip is measured at the seam that actually carries
* the mode rather than through any one renderer's overlay internals.
*/
function StubDashboardPreview(props: Record<string, unknown>): React.ReactElement {
const onSel = props.onSelectionChange as ((s: unknown) => void) | undefined;
return (
<div data-testid="stub-dash" data-editing={String(props.editing)}>
<button
type="button"
data-testid="pick-block"
onClick={() => onSel?.({ kind: 'block', id: 'blk_1' })}
>
pick
</button>
</div>
);
}
registerMetadataPreview('dashboard', StubDashboardPreview as never);

/**
* Production registers `ObjectFieldInspector` for `object`
* (`metadata-admin/inspectors/index.ts`), so the scoped-inspector branch is
* reachable on the studio-canvas leaf whenever a `selection` survives a leaf
* change. Registering a stand-in here is what makes the last pin a measurement
* of that branch rather than of an accidentally-empty registry.
*/
function StubObjectInspector(props: Record<string, unknown>): React.ReactElement {
const sel = props.selection as { kind?: string; id?: string } | null;
return (
<div
data-testid="stub-object-inspector"
data-for={`${String(props.type)}:${String(props.name)}:${sel?.kind}:${sel?.id}`}
/>
);
}
registerMetadataInspector('object', StubObjectInspector as never);

afterEach(cleanup);

function mountPillar() {
return render(
<MemoryRouter initialEntries={['/studio/com.acme.app/interfaces']}>
<InterfacesPillar packageId="com.acme.app" />
</MemoryRouter>,
);
}

/** The precondition every test here shares, stated rather than assumed. */
function expectPopulatedRegistries() {
expect(listMetadataPreviewTypes()).toContain('dashboard');
expect(listMetadataPreviewTypes().length).toBeGreaterThan(0);
expect(listMetadataInspectorTypes()).toContain('object');
// ...and the leaf under test really is a studio-canvas leaf.
expect(listStudioCanvasPreviewTypes()).toContain('object');
}

async function openLeaf(title: string) {
mountPillar();
fireEvent.click(await screen.findByTitle(title));
}

const bodyText = () => (document.body.textContent ?? '').replace(/\s+/g, ' ');

describe('Interfaces pillar — affordances beside a studio-canvas leaf (#7121)', () => {
it('offers no Design/Run switch on a leaf whose canvas cannot read the mode', async () => {
expectPopulatedRegistries();
await openLeaf('object · showcase_task');

// Wait on the canvas itself, not on the toggle — the toggle's ABSENCE is
// the assertion, so waiting for it would deadlock the pin by construction.
await waitFor(() => expect(bodyText()).toContain('Runtime list preview'), { timeout: 4000 });

expect(screen.queryByTestId('canvas-mode-toggle')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Design' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Run' })).not.toBeInTheDocument();
});

it('replaces the impossible "click a block" invitation with what is true', async () => {
expectPopulatedRegistries();
await openLeaf('object · showcase_task');
await waitFor(() => expect(bodyText()).toContain('Runtime list preview'), { timeout: 4000 });

await waitFor(() =>
expect(bodyText()).toContain('This canvas renders the running app, not a block tree'),
);
// ⛔ The invitation that cannot be followed must be gone.
expect(bodyText()).not.toContain(CLICK_A_BLOCK);
// ⛔ And this must not be mistaken for #6795 part C's empty-registry state,
// which is demonstrably not the cause here.
expect(bodyText()).not.toContain('No metadata designers are registered');
});

it('promises no recovery — there is nothing to wait for', async () => {
expectPopulatedRegistries();
await openLeaf('object · showcase_task');
await waitFor(() => expect(bodyText()).toContain('This canvas renders the running app'), {
timeout: 4000,
});

// Scoped to the RAIL, not the document: the canvas beside it renders the
// real records grid, which in jsdom (no data source) says "Error loading
// grid" — its own honest state, and nothing this card may speak for. A
// document-wide scan would read that as the rail promising recovery.
const railBlock = screen.getByText(/This canvas renders the running app/).closest('div');
const rail = railBlock?.textContent ?? '';
// Control: the scoped read must actually have found the message.
expect(rail).toContain('This canvas renders the running app');

for (const promise of ['Loading', 'loading', 'try again', 'Try again', 'not yet', 'in progress']) {
expect(rail).not.toContain(promise);
}
});

it('does not open a scoped inspector for a block selected on a DIFFERENT leaf', async () => {
expectPopulatedRegistries();

// Select a block on the dashboard leaf — a real selection, made through the
// pillar's own `onSelectionChange`.
await openLeaf('dashboard · sales_overview');
await waitFor(() => expect(screen.getByTestId('stub-dash')).toBeInTheDocument(), {
timeout: 4000,
});
fireEvent.click(screen.getByTestId('pick-block'));
await waitFor(() => expect(screen.getByLabelText('Clear selection')).toBeInTheDocument(), {
timeout: 4000,
});

// ...then walk to the studio-canvas leaf. The pillar's load effect clears
// `selection` only on the editable path, so the selection is still live.
fireEvent.click(screen.getByTitle('object · showcase_task'));
await waitFor(() => expect(bodyText()).toContain('Runtime list preview'), { timeout: 4000 });

// The block belongs to another leaf's canvas and does not exist on this one.
expect(screen.queryByTestId('stub-object-inspector')).not.toBeInTheDocument();
expect(bodyText()).toContain('This canvas renders the running app, not a block tree');
// ...and the rail must not contradict itself by offering to clear a
// selection it just said cannot exist here.
expect(screen.queryByLabelText('Clear selection')).not.toBeInTheDocument();
});
});

describe('REGRESSION FENCE — a leaf WITH a block canvas is untouched (#7121)', () => {
it('still offers the Design/Run switch, and it still round-trips', async () => {
expectPopulatedRegistries();
await openLeaf('dashboard · sales_overview');
await waitFor(() => expect(screen.getByTestId('canvas-mode-toggle')).toBeInTheDocument(), {
timeout: 4000,
});

// #5800's acceptance: 设计⇄运行 is a round trip on the SAME renderer, and
// `editing` is the mode the renderer actually reads.
expect(screen.getByTestId('stub-dash')).toHaveAttribute('data-editing', 'true');
fireEvent.click(screen.getByRole('button', { name: 'Run' }));
await waitFor(() =>
expect(screen.getByTestId('stub-dash')).toHaveAttribute('data-editing', 'false'),
);
fireEvent.click(screen.getByRole('button', { name: 'Design' }));
await waitFor(() =>
expect(screen.getByTestId('stub-dash')).toHaveAttribute('data-editing', 'true'),
);
});

it('keeps the ordinary "click a block" rail where a block canvas exists', async () => {
expectPopulatedRegistries();
await openLeaf('dashboard · sales_overview');
await waitFor(() => expect(screen.getByTestId('stub-dash')).toBeInTheDocument(), {
timeout: 4000,
});

// The repair must not over-fire: this leaf HAS blocks to click.
await waitFor(() => expect(bodyText()).toContain(CLICK_A_BLOCK));
expect(bodyText()).not.toContain('This canvas renders the running app');
});

it('keeps the selection affordances where a block canvas exists', async () => {
expectPopulatedRegistries();
await openLeaf('dashboard · sales_overview');
await waitFor(() => expect(screen.getByTestId('stub-dash')).toBeInTheDocument(), {
timeout: 4000,
});

fireEvent.click(screen.getByTestId('pick-block'));
await waitFor(() => expect(screen.getByLabelText('Clear selection')).toBeInTheDocument());
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/7121-studio-canvas-leaf-affordances.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
---
'@object-ui/app-shell': patch
---

Studio Interfaces: no Design mode and no "click a block" rail on leaves that have
no block canvas (objectui#7121).

`registerStudioCanvasPreview(type, …)` opts a type into a surface-specific canvas
that renders the running app rather than an editable draft — a contract, not a
habit: `StudioCanvasPreviewProps` carries no `selection`, `onSelectionChange`,
`onPatch` or `editing`. Two affordances beside such a leaf ignored that.

- The Design/Run switch (objectui#5800) was still offered, though `editing` is
handed to exactly one canvas branch (`Preview`). On a studio-canvas leaf the
switch moved `canvasMode` and reached no renderer — a live-looking control
wired to nothing. It is now gated.
- The right rail fell through to "Click a block on the canvas, and edit its
properties right here." beside a canvas that has no blocks, so the instruction
could not be followed. It now states what the canvas is, and — because this
canvas has no blocks by contract — promises no recovery.
- The rail's new branch is ordered ahead of the selection branch, so a block
selected on a *different* leaf no longer opens a scoped inspector for a block
this canvas does not contain; the header's "clear selection" button is gated
with it.

The discriminator is `StudioCanvas`, not `isEditable`. `isEditable` is
`!!Preview && !StudioCanvas` — a conjunction of two independent causes — so
gating on it would also strip these affordances from leaves whose only fault is
that their own type has no designer, the state objectui#6795 part C pinned as
still deserving the ordinary rail. Behaviour on every leaf with a block canvas
is unchanged.
8 changes: 8 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1777,6 +1777,12 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.studio.inspector.tabSource': 'Source',
'engine.studio.inspector.emptyLine1': 'Click a block on the canvas,',
'engine.studio.inspector.emptyLine2': 'and edit its properties right here.',
// objectui#7121 — the rail beside a studio-canvas leaf (a
// `registerStudioCanvasPreview` type: the running app, not a block tree).
// States what the canvas IS; promises no recovery, because there is nothing
// to wait for — `StudioCanvasPreviewProps` carries no selection by contract.
'engine.studio.inspector.studioCanvasNoBlocks':
'This canvas renders the running app, not a block tree — it has no blocks to select, and nothing here is edited from this panel.',
'engine.studio.inspector.designersMissing':
'No metadata designers are registered in this session, so there is nothing to edit here.',
'engine.studio.inspector.noPageSchema': 'Page settings are unavailable — the page schema could not be loaded.',
Expand DownExpand Up@@ -3639,6 +3645,8 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.studio.inspector.tabSource': '源码',
'engine.studio.inspector.emptyLine1': '在画布里点选一个积木,',
'engine.studio.inspector.emptyLine2': '它的属性会在这里直接编辑。',
'engine.studio.inspector.studioCanvasNoBlocks':
'此画布渲染的是运行态应用,而不是积木树 —— 这里没有可选中的积木,也没有可在本面板编辑的内容。',
'engine.studio.inspector.designersMissing': '本次会话没有注册任何元数据设计器,这里没有可编辑的内容。',
'engine.studio.inspector.noPageSchema': '页面设置不可用——无法加载页面 schema。',
'engine.studio.inspector.sourcePageLine1': '这个页面是 {kind} 源码,不是积木树 ——',
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,300 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#7121 — what the Interfaces pillar may OFFER beside a studio-canvas
* leaf.
*
* ## The mechanism
*
* `registerStudioCanvasPreview(type, …)` opts a type into a surface-specific
* canvas: the running app, not an editable draft. That is a contract, not a
* habit — `StudioCanvasPreviewProps` is deliberately a small read-only subset
* of `MetadataPreviewProps` with **no `selection`, no `onSelectionChange`, no
* `onPatch`, no `editing`**. So a studio-canvas leaf:
*
* - can never produce a block selection (there is no block tree), and
* - can never read the design/run mode (nothing is handed `editing`).
*
* Two affordances beside it ignored both facts: the Design/Run switch (#5800)
* was still offered though `canvasMode` reached no renderer, and the rail fell
* through to *"Click a block on the canvas, and edit its properties right
* here."* — an instruction that cannot be followed.
*
* ## ⚠️ This is NOT #6795 part C's cause, and the precondition says so
*
* Part C repaired what the pillar says when the designer registries are
* **empty**. Every test here asserts a **POPULATED** registry first
* (`listMetadataPreviewTypes()` non-empty), because a zero-registry reading
* would measure that other card instead. The cause here is an ungated
* affordance, not a missing registration.
*
* ## ⛔ The message promises no recovery
*
* Part C established by measurement that these registries are plain `Map`s read
* during render with no subscription, so a consumer that read an empty one
* never recovers ("late inspector rendered: false") — no "loading…", no "try
* again". Here the constraint is even stricter: the statement is not about
* registration at all. This canvas has no blocks **by contract**, so there is
* nothing to wait for, and the last test pins the absence of recovery language.
*
* ## ⚠️ The discriminator is `StudioCanvas`, NOT `isEditable`
*
* `isEditable = !!Preview && !StudioCanvas` is a conjunction of two independent
* causes. Gating on it would also strip these affordances from leaves whose
* only fault is that **their own type** has no designer — the exact state
* `StudioDesignSurface.designerRegistryPartial.test.tsx` pins as still deserving
* the ordinary "click a block" rail. That file is the live fence: gate on
* `isEditable` and it goes red.
*/

import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

const objectDef = {
name: 'showcase_task',
label: 'Task',
fields: [{ name: 'title', label: 'Title', type: 'text' }],
};

/**
* One leaf of each kind, in one app: `object` opts into the studio canvas,
* `dashboard` does not (it is an ordinary block-canvas designer). The contrast
* is the point — the second leaf is the regression fence.
*/
const NAV = [
{ id: 'nav_obj', type: 'object', label: 'Tasks', objectName: 'showcase_task' },
{ id: 'nav_dash', type: 'dashboard', label: 'Overview', dashboardName: 'sales_overview' },
];

const mockClient = {
save: vi.fn(async () => ({})),
list: vi.fn(async (type: string) => {
if (type === 'app') return [{ name: 'acme_app', label: 'Acme' }];
if (type === 'object') return [{ name: 'showcase_task', label: 'Task' }];
return [];
}),
listDrafts: vi.fn(async () => []),
layered: vi.fn(async (type: string, name: string) => {
if (type === 'app') return { effective: { name: 'acme_app', label: 'Acme', navigation: NAV } };
if (type === 'object') return { effective: objectDef, code: objectDef };
if (type === 'dashboard') return { effective: { name: 'sales_overview', label: 'Sales' } };
return { effective: { name } };
}),
getDraft: vi.fn(async () => null),
get: vi.fn(async () => undefined),
};

vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) };
});

vi.mock('./packages-io', async (importOriginal) => {
const mod = await importOriginal<typeof import('./packages-io')>();
return { ...mod, fetchPackages: vi.fn(async () => []) };
});

vi.mock('@object-ui/react', async (importOriginal) => {
const mod = await importOriginal<typeof import('@object-ui/react')>();
return { ...mod, useAdapter: () => ({}) };
});

import { InterfacesPillar } from './StudioDesignSurface';
import { listMetadataPreviewTypes, registerMetadataPreview } from '../metadata-admin/preview-registry';
import { listMetadataInspectorTypes, registerMetadataInspector } from '../metadata-admin/inspector-registry';
import { listStudioCanvasPreviewTypes } from './studio-canvas-preview';

const CLICK_A_BLOCK = 'Click a block on the canvas,';

/**
* A block-canvas designer for an unrelated type. Two jobs: it makes the
* designer registry demonstrably POPULATED (the card's precondition), and it
* is the regression fence's leaf. It reports the `editing` prop it was handed,
* so the Design/Run round trip is measured at the seam that actually carries
* the mode rather than through any one renderer's overlay internals.
*/
function StubDashboardPreview(props: Record<string, unknown>): React.ReactElement {
const onSel = props.onSelectionChange as ((s: unknown) => void) | undefined;
return (
<div data-testid="stub-dash" data-editing={String(props.editing)}>
<button
type="button"
data-testid="pick-block"
onClick={() => onSel?.({ kind: 'block', id: 'blk_1' })}
>
pick
</button>
</div>
);
}
registerMetadataPreview('dashboard', StubDashboardPreview as never);

/**
* Production registers `ObjectFieldInspector` for `object`
* (`metadata-admin/inspectors/index.ts`), so the scoped-inspector branch is
* reachable on the studio-canvas leaf whenever a `selection` survives a leaf
* change. Registering a stand-in here is what makes the last pin a measurement
* of that branch rather than of an accidentally-empty registry.
*/
function StubObjectInspector(props: Record<string, unknown>): React.ReactElement {
const sel = props.selection as { kind?: string; id?: string } | null;
return (
<div
data-testid="stub-object-inspector"
data-for={`${String(props.type)}:${String(props.name)}:${sel?.kind}:${sel?.id}`}
/>
);
}
registerMetadataInspector('object', StubObjectInspector as never);

afterEach(cleanup);

function mountPillar() {
return render(
<MemoryRouter initialEntries={['/studio/com.acme.app/interfaces']}>
<InterfacesPillar packageId="com.acme.app" />
</MemoryRouter>,
);
}

/** The precondition every test here shares, stated rather than assumed. */
function expectPopulatedRegistries() {
expect(listMetadataPreviewTypes()).toContain('dashboard');
expect(listMetadataPreviewTypes().length).toBeGreaterThan(0);
expect(listMetadataInspectorTypes()).toContain('object');
// ...and the leaf under test really is a studio-canvas leaf.
expect(listStudioCanvasPreviewTypes()).toContain('object');
}

async function openLeaf(title: string) {
mountPillar();
fireEvent.click(await screen.findByTitle(title));
}

const bodyText = () => (document.body.textContent ?? '').replace(/\s+/g, ' ');

describe('Interfaces pillar — affordances beside a studio-canvas leaf (#7121)', () => {
it('offers no Design/Run switch on a leaf whose canvas cannot read the mode', async () => {
expectPopulatedRegistries();
await openLeaf('object · showcase_task');

// Wait on the canvas itself, not on the toggle — the toggle's ABSENCE is
// the assertion, so waiting for it would deadlock the pin by construction.
await waitFor(() => expect(bodyText()).toContain('Runtime list preview'), { timeout: 4000 });

expect(screen.queryByTestId('canvas-mode-toggle')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Design' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Run' })).not.toBeInTheDocument();
});

it('replaces the impossible "click a block" invitation with what is true', async () => {
expectPopulatedRegistries();
await openLeaf('object · showcase_task');
await waitFor(() => expect(bodyText()).toContain('Runtime list preview'), { timeout: 4000 });

await waitFor(() =>
expect(bodyText()).toContain('This canvas renders the running app, not a block tree'),
);
// ⛔ The invitation that cannot be followed must be gone.
expect(bodyText()).not.toContain(CLICK_A_BLOCK);
// ⛔ And this must not be mistaken for #6795 part C's empty-registry state,
// which is demonstrably not the cause here.
expect(bodyText()).not.toContain('No metadata designers are registered');
});

it('promises no recovery — there is nothing to wait for', async () => {
expectPopulatedRegistries();
await openLeaf('object · showcase_task');
await waitFor(() => expect(bodyText()).toContain('This canvas renders the running app'), {
timeout: 4000,
});

// Scoped to the RAIL, not the document: the canvas beside it renders the
// real records grid, which in jsdom (no data source) says "Error loading
// grid" — its own honest state, and nothing this card may speak for. A
// document-wide scan would read that as the rail promising recovery.
const railBlock = screen.getByText(/This canvas renders the running app/).closest('div');
const rail = railBlock?.textContent ?? '';
// Control: the scoped read must actually have found the message.
expect(rail).toContain('This canvas renders the running app');

for (const promise of ['Loading', 'loading', 'try again', 'Try again', 'not yet', 'in progress']) {
expect(rail).not.toContain(promise);
}
});

it('does not open a scoped inspector for a block selected on a DIFFERENT leaf', async () => {
expectPopulatedRegistries();

// Select a block on the dashboard leaf — a real selection, made through the
// pillar's own `onSelectionChange`.
await openLeaf('dashboard · sales_overview');
await waitFor(() => expect(screen.getByTestId('stub-dash')).toBeInTheDocument(), {
timeout: 4000,
});
fireEvent.click(screen.getByTestId('pick-block'));
await waitFor(() => expect(screen.getByLabelText('Clear selection')).toBeInTheDocument(), {
timeout: 4000,
});

// ...then walk to the studio-canvas leaf. The pillar's load effect clears
// `selection` only on the editable path, so the selection is still live.
fireEvent.click(screen.getByTitle('object · showcase_task'));
await waitFor(() => expect(bodyText()).toContain('Runtime list preview'), { timeout: 4000 });

// The block belongs to another leaf's canvas and does not exist on this one.
expect(screen.queryByTestId('stub-object-inspector')).not.toBeInTheDocument();
expect(bodyText()).toContain('This canvas renders the running app, not a block tree');
// ...and the rail must not contradict itself by offering to clear a
// selection it just said cannot exist here.
expect(screen.queryByLabelText('Clear selection')).not.toBeInTheDocument();
});
});

describe('REGRESSION FENCE — a leaf WITH a block canvas is untouched (#7121)', () => {
it('still offers the Design/Run switch, and it still round-trips', async () => {
expectPopulatedRegistries();
await openLeaf('dashboard · sales_overview');
await waitFor(() => expect(screen.getByTestId('canvas-mode-toggle')).toBeInTheDocument(), {
timeout: 4000,
});

// #5800's acceptance: 设计⇄运行 is a round trip on the SAME renderer, and
// `editing` is the mode the renderer actually reads.
expect(screen.getByTestId('stub-dash')).toHaveAttribute('data-editing', 'true');
fireEvent.click(screen.getByRole('button', { name: 'Run' }));
await waitFor(() =>
expect(screen.getByTestId('stub-dash')).toHaveAttribute('data-editing', 'false'),
);
fireEvent.click(screen.getByRole('button', { name: 'Design' }));
await waitFor(() =>
expect(screen.getByTestId('stub-dash')).toHaveAttribute('data-editing', 'true'),
);
});

it('keeps the ordinary "click a block" rail where a block canvas exists', async () => {
expectPopulatedRegistries();
await openLeaf('dashboard · sales_overview');
await waitFor(() => expect(screen.getByTestId('stub-dash')).toBeInTheDocument(), {
timeout: 4000,
});

// The repair must not over-fire: this leaf HAS blocks to click.
await waitFor(() => expect(bodyText()).toContain(CLICK_A_BLOCK));
expect(bodyText()).not.toContain('This canvas renders the running app');
});

it('keeps the selection affordances where a block canvas exists', async () => {
expectPopulatedRegistries();
await openLeaf('dashboard · sales_overview');
await waitFor(() => expect(screen.getByTestId('stub-dash')).toBeInTheDocument(), {
timeout: 4000,
});

fireEvent.click(screen.getByTestId('pick-block'));
await waitFor(() => expect(screen.getByLabelText('Clear selection')).toBeInTheDocument());
});
});
Loading
Loading