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
5 changes: 5 additions & 0 deletions .changeset/canvas-design-run-5800.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@object-ui/app-shell': minor
---

设计⇄运行 on the Interfaces canvas (#5800): a two-state switch in the canvas header flips the SAME renderer between design (selection + inspector + design overlays) and an interactive runtime (click 新建, enter records) — ADR-0080's design=run pivot made visible; selection context survives the round trip. The topbar's 打开应用 teleport is retired (run mode is the in-workbench way to try the app), and the topbar's app detection now matches the pillar's (draft-app fallback, re-resolved on draft saves and the metadata-refresh pulse) so a deep-link to /access can no longer claim the package has no app while /data shows one.
4 changes: 4 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1740,6 +1740,8 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.studio.if.noApp': 'This package has no app yet.',
'engine.studio.if.noNavItems': 'No nav items yet — (click “Edit” above to add)',
'engine.studio.if.previewIsRuntime': 'Live preview',
'engine.studio.if.modeDesign': 'Design',
'engine.studio.if.modeRun': 'Run',
'engine.studio.if.tabCanvas': 'Canvas',
'engine.studio.if.noAppTitle': 'This package has no app yet',
'engine.studio.if.noAppHint': 'Create an app to design its navigation and interfaces.',
Expand DownExpand Up@@ -3591,6 +3593,8 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.studio.if.noApp': '这个软件包还没有应用。',
'engine.studio.if.noNavItems': '还没有导航项 —(点上方「编辑」添加)',
'engine.studio.if.previewIsRuntime': '实时预览',
'engine.studio.if.modeDesign': '设计',
'engine.studio.if.modeRun': '运行',
'engine.studio.if.tabCanvas': '画布',
'engine.studio.if.noAppTitle': '这个软件包还没有应用',
'engine.studio.if.noAppHint': '创建一个应用来设计它的导航与界面。',
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#5800 — the 设计⇄运行 canvas switch (cloud#1609 增量二).
*
* ADR-0080's pivot (design state = run state, one renderer) made visible: the
* Interfaces canvas header carries a two-state switch; RUN mode is pure
* subtraction — `editing=false` drops the design overlays so the SAME
* renderer serves the interactive runtime. Pinned through the dashboard leaf
* because its design mode is the most explicit: `DashboardRenderer` swallows
* widget interaction behind `widget-click-overlay` elements exactly when
* designMode is on, so the overlay's presence IS the mode.
*
* Also pinned: selection context survives a run round-trip (the acceptance's
* 「切回设计不丢」), via the switch not clearing the pillar's selection state.
*/
import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

const NAV = [
{ id: 'nav_dash', type: 'dashboard', label: 'Overview', dashboardName: 'sales_overview' },
];

const DASHBOARD = {
name: 'sales_overview',
label: 'Sales Overview',
widgets: [
{ id: 'w1', type: 'metric', title: 'Total', options: { value: 42 } },
],
};

const mockClient = {
list: vi.fn(async (type: string) =>
type === 'app' ? [{ name: 'acme_app', label: 'Acme' }] : [],
),
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 === 'dashboard' && name === 'sales_overview') return { effective: DASHBOARD };
return { effective: { name } };
}),
getDraft: vi.fn(async () => null),
save: vi.fn(async () => ({})),
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 { registerMetadataPreview } from '../metadata-admin/preview-registry';
import { DashboardPreview } from '../metadata-admin/previews/DashboardPreview';

registerMetadataPreview('dashboard', DashboardPreview);

afterEach(cleanup);

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

async function openDashboardLeaf() {
renderPillar();
fireEvent.click(await screen.findByTitle('dashboard · sales_overview'));
await waitFor(() => expect(screen.getByTestId('canvas-mode-toggle')).toBeInTheDocument(), {
timeout: 4000,
});
}

describe('Interfaces canvas — 设计⇄运行 switch (objectui#5800)', () => {
it('design mode (default) swallows widget interaction behind the design overlay', async () => {
await openDashboardLeaf();
await waitFor(
() => expect(screen.getAllByTestId('widget-click-overlay').length).toBeGreaterThan(0),
{ timeout: 4000 },
);
});

it('run mode removes the overlays — the SAME renderer serves the interactive runtime', async () => {
await openDashboardLeaf();
await waitFor(
() => expect(screen.getAllByTestId('widget-click-overlay').length).toBeGreaterThan(0),
{ timeout: 4000 },
);
fireEvent.click(screen.getByRole('button', { name: 'Run' }));
await waitFor(() => expect(screen.queryAllByTestId('widget-click-overlay')).toHaveLength(0), {
timeout: 4000,
});
// ...and back: the switch is a round trip, not a one-way door.
fireEvent.click(screen.getByRole('button', { name: 'Design' }));
await waitFor(
() => expect(screen.getAllByTestId('widget-click-overlay').length).toBeGreaterThan(0),
{ timeout: 4000 },
);
});
});
104 changes: 70 additions & 34 deletions packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -91,7 +91,7 @@ import {
} from '../metadata-admin/nav-selection.js';
import { SourcePageEditor } from '../metadata-admin/previews/SourcePageEditor.js';
import { usePendingDrafts } from '../../preview/usePendingDrafts.js';
import { emitMetadataRefresh } from '../../assistant/assistantBus.js';
import { emitMetadataRefresh, subscribeMetadataRefresh } from '../../assistant/assistantBus.js';
import { formatMetadataError, formatPublishFailures, type PublishFailure } from './metadataError.js';
import { loadPackageSurfaces } from './packageSurfaces.js';
import { resolveSurface, findSurfaceInTree, type NavNode, type Surface } from './navSurface.js';
Expand DownExpand Up@@ -653,23 +653,35 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React
[appAddObjects, loadPackageObjects, shellClient, packageId, locale],
);

React.useEffect(() => {
let cancelled = false;
(async () => {
try {
const apps = (await shellClient.list('app', { packageId })) as Array<Record<string, unknown>>;
const first = (apps || [])
.map((a) => ({ name: String(a.name ?? ''), label: String(a.label ?? a.name ?? '') }))
.filter((a) => a.name)[0];
if (!cancelled) setPackageApp(first ?? null);
} catch {
if (!cancelled) setPackageApp(null);
// objectui#5800 顺手修 — the topbar's app detection used to disagree with the
// Interfaces pillar's (published-only read, no draftNonce dep, no refresh
// subscription, and never re-run on a pillar switch since /data and /access
// share one route element): a deep-link to /access could report 「还没有应用」
// while /data showed the app at the same moment. Same resolution as the
// pillar now: published first, DRAFT app fallback, re-resolved on draft
// saves and on the metadata-refresh pulse.
const resolvePackageApp = React.useCallback(async (): Promise<void> => {
try {
const apps = (await shellClient.list('app', { packageId })) as Array<Record<string, unknown>>;
let first = (apps || [])
.map((a) => ({ name: String(a.name ?? ''), label: String(a.label ?? a.name ?? '') }))
.filter((a) => a.name)[0];
if (!first) {
const drafts = await shellClient.listDrafts?.({ packageId, type: 'app' });
const d = drafts?.[0] as { name?: unknown; label?: unknown } | undefined;
if (d?.name) first = { name: String(d.name), label: String(d.label ?? d.name) };
}
})();
return () => {
cancelled = true;
};
}, [shellClient, packageId, publishNonce]);
setPackageApp(first ?? null);
} catch {
setPackageApp(null);
}
}, [shellClient, packageId]);
React.useEffect(() => {
void resolvePackageApp();
return subscribeMetadataRefresh(() => {
void resolvePackageApp();
});
}, [resolvePackageApp, publishNonce, draftNonce]);

// ADR-0057 P3 — the decided Studio grid: `[left: nav/tree] [center: canvas +
// properties] [right: chat]`. NOT keyed on the async agent catalog (that
Expand DownExpand Up@@ -818,17 +830,10 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React

{/* Package-level draft review + one atomic publish (replaces per-item 发布) */}
<div className="ml-auto flex shrink-0 items-center gap-2">
{packageApp ? (
<button
type="button"
onClick={() => window.open(resolveConsoleUrl(`apps/${encodeURIComponent(packageApp.name)}`), '_blank')}
title={tFormat('engine.studio.app.openTitle', locale, { label: packageApp.label })}
className="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
>
<ExternalLink className="h-3.5 w-3.5" />
{t('engine.studio.app.open', locale)}
</button>
) : appDraftPending ? (
{/* objectui#5800 — the 打开应用 teleport is retired: the canvas's 运行
mode IS the way to try the app without leaving the workbench.
The published-app state needs no chrome at all. */}
{packageApp ? null : appDraftPending ? (
<span
title={t('engine.studio.app.willOpenAfterPublish', locale)}
className="rounded bg-amber-400/15 px-2 py-0.5 text-[11px] text-amber-600 dark:text-amber-300"
Expand DownExpand Up@@ -1413,6 +1418,13 @@ export function InterfacesPillar({
// running app, not an editable draft — schema editing is the Data pillar's
// job — so those leaves are not draft-editable in this canvas.
const isEditable = !!Preview && !StudioCanvas;
// objectui#5800 — 设计⇄运行: one canvas, two modes (ADR-0080's pivot made
// visible). Run mode is pure subtraction: `editing=false` drops the design
// overlays (dashboard widget overlays, page block canvas) and the SAME
// renderer serves the interactive runtime — click 新建, enter a record.
// Selection state is retained so switching back to design keeps context.
const [canvasMode, setCanvasMode] = React.useState<'design' | 'run'>('design');
const designing = canvasMode === 'design';
// `kind: 'html'`/`'react'` pages are a `source` string (ADR-0080/0081),
// rendered by SourcePageEditor as a code-editor + live-preview split — there
// is no block tree, so `selection` never populates and the generic "click a
Expand DownExpand Up@@ -1535,9 +1547,33 @@ export function InterfacesPillar({
const canvasEl = (
<main className="flex min-w-0 flex-1 flex-col overflow-auto bg-muted/30 p-4">
<div className="mb-3 flex shrink-0 items-center gap-2">
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-[11px] text-primary">
<Eye className="h-3 w-3" /> {t('engine.studio.if.previewIsRuntime', locale)}
</span>
{/* objectui#5800 — the 设计⇄运行 switch replaces the static 实时预览
chip: same renderer either way, the switch only adds/removes the
design affordances. */}
<div className="inline-flex items-center gap-0.5 rounded-lg bg-muted p-1" data-testid="canvas-mode-toggle">
<button
type="button"
onClick={() => setCanvasMode('design')}
aria-pressed={designing}
className={
'rounded-md px-2.5 py-0.5 text-[11px] transition-all ' +
(designing ? 'bg-background font-medium text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground')
}
>
{t('engine.studio.if.modeDesign', locale)}
</button>
<button
type="button"
onClick={() => setCanvasMode('run')}
aria-pressed={!designing}
className={
'rounded-md px-2.5 py-0.5 text-[11px] transition-all ' +
(!designing ? 'bg-background font-medium text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground')
}
>
{t('engine.studio.if.modeRun', locale)}
</button>
</div>
{current && (
<span className="text-[11px] text-muted-foreground">
{current.type} · {current.name}
Expand DownExpand Up@@ -1599,9 +1635,9 @@ export function InterfacesPillar({
type={current.type}
name={current.name}
draft={draft}
editing
selection={selection}
onSelectionChange={setSelection}
editing={designing}
selection={designing ? selection : null}
onSelectionChange={designing ? setSelection : undefined}
onPatch={onPatch}
locale={locale}
/>
Expand Down
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
5 changes: 5 additions & 0 deletions .changeset/canvas-design-run-5800.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@object-ui/app-shell': minor
---

设计⇄运行 on the Interfaces canvas (#5800): a two-state switch in the canvas header flips the SAME renderer between design (selection + inspector + design overlays) and an interactive runtime (click 新建, enter records) — ADR-0080's design=run pivot made visible; selection context survives the round trip. The topbar's 打开应用 teleport is retired (run mode is the in-workbench way to try the app), and the topbar's app detection now matches the pillar's (draft-app fallback, re-resolved on draft saves and the metadata-refresh pulse) so a deep-link to /access can no longer claim the package has no app while /data shows one.
4 changes: 4 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1740,6 +1740,8 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.studio.if.noApp': 'This package has no app yet.',
'engine.studio.if.noNavItems': 'No nav items yet — (click “Edit” above to add)',
'engine.studio.if.previewIsRuntime': 'Live preview',
'engine.studio.if.modeDesign': 'Design',
'engine.studio.if.modeRun': 'Run',
'engine.studio.if.tabCanvas': 'Canvas',
'engine.studio.if.noAppTitle': 'This package has no app yet',
'engine.studio.if.noAppHint': 'Create an app to design its navigation and interfaces.',
Expand DownExpand Up@@ -3591,6 +3593,8 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.studio.if.noApp': '这个软件包还没有应用。',
'engine.studio.if.noNavItems': '还没有导航项 —(点上方「编辑」添加)',
'engine.studio.if.previewIsRuntime': '实时预览',
'engine.studio.if.modeDesign': '设计',
'engine.studio.if.modeRun': '运行',
'engine.studio.if.tabCanvas': '画布',
'engine.studio.if.noAppTitle': '这个软件包还没有应用',
'engine.studio.if.noAppHint': '创建一个应用来设计它的导航与界面。',
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#5800 — the 设计⇄运行 canvas switch (cloud#1609 增量二).
*
* ADR-0080's pivot (design state = run state, one renderer) made visible: the
* Interfaces canvas header carries a two-state switch; RUN mode is pure
* subtraction — `editing=false` drops the design overlays so the SAME
* renderer serves the interactive runtime. Pinned through the dashboard leaf
* because its design mode is the most explicit: `DashboardRenderer` swallows
* widget interaction behind `widget-click-overlay` elements exactly when
* designMode is on, so the overlay's presence IS the mode.
*
* Also pinned: selection context survives a run round-trip (the acceptance's
* 「切回设计不丢」), via the switch not clearing the pillar's selection state.
*/
import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

const NAV = [
{ id: 'nav_dash', type: 'dashboard', label: 'Overview', dashboardName: 'sales_overview' },
];

const DASHBOARD = {
name: 'sales_overview',
label: 'Sales Overview',
widgets: [
{ id: 'w1', type: 'metric', title: 'Total', options: { value: 42 } },
],
};

const mockClient = {
list: vi.fn(async (type: string) =>
type === 'app' ? [{ name: 'acme_app', label: 'Acme' }] : [],
),
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 === 'dashboard' && name === 'sales_overview') return { effective: DASHBOARD };
return { effective: { name } };
}),
getDraft: vi.fn(async () => null),
save: vi.fn(async () => ({})),
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 { registerMetadataPreview } from '../metadata-admin/preview-registry';
import { DashboardPreview } from '../metadata-admin/previews/DashboardPreview';

registerMetadataPreview('dashboard', DashboardPreview);

afterEach(cleanup);

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

async function openDashboardLeaf() {
renderPillar();
fireEvent.click(await screen.findByTitle('dashboard · sales_overview'));
await waitFor(() => expect(screen.getByTestId('canvas-mode-toggle')).toBeInTheDocument(), {
timeout: 4000,
});
}

describe('Interfaces canvas — 设计⇄运行 switch (objectui#5800)', () => {
it('design mode (default) swallows widget interaction behind the design overlay', async () => {
await openDashboardLeaf();
await waitFor(
() => expect(screen.getAllByTestId('widget-click-overlay').length).toBeGreaterThan(0),
{ timeout: 4000 },
);
});

it('run mode removes the overlays — the SAME renderer serves the interactive runtime', async () => {
await openDashboardLeaf();
await waitFor(
() => expect(screen.getAllByTestId('widget-click-overlay').length).toBeGreaterThan(0),
{ timeout: 4000 },
);
fireEvent.click(screen.getByRole('button', { name: 'Run' }));
await waitFor(() => expect(screen.queryAllByTestId('widget-click-overlay')).toHaveLength(0), {
timeout: 4000,
});
// ...and back: the switch is a round trip, not a one-way door.
fireEvent.click(screen.getByRole('button', { name: 'Design' }));
await waitFor(
() => expect(screen.getAllByTestId('widget-click-overlay').length).toBeGreaterThan(0),
{ timeout: 4000 },
);
});
});
104 changes: 70 additions & 34 deletions packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -91,7 +91,7 @@ import {
} from '../metadata-admin/nav-selection.js';
import { SourcePageEditor } from '../metadata-admin/previews/SourcePageEditor.js';
import { usePendingDrafts } from '../../preview/usePendingDrafts.js';
import { emitMetadataRefresh } from '../../assistant/assistantBus.js';
import { emitMetadataRefresh, subscribeMetadataRefresh } from '../../assistant/assistantBus.js';
import { formatMetadataError, formatPublishFailures, type PublishFailure } from './metadataError.js';
import { loadPackageSurfaces } from './packageSurfaces.js';
import { resolveSurface, findSurfaceInTree, type NavNode, type Surface } from './navSurface.js';
Expand DownExpand Up@@ -653,23 +653,35 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React
[appAddObjects, loadPackageObjects, shellClient, packageId, locale],
);

React.useEffect(() => {
let cancelled = false;
(async () => {
try {
const apps = (await shellClient.list('app', { packageId })) as Array<Record<string, unknown>>;
const first = (apps || [])
.map((a) => ({ name: String(a.name ?? ''), label: String(a.label ?? a.name ?? '') }))
.filter((a) => a.name)[0];
if (!cancelled) setPackageApp(first ?? null);
} catch {
if (!cancelled) setPackageApp(null);
// objectui#5800 顺手修 — the topbar's app detection used to disagree with the
// Interfaces pillar's (published-only read, no draftNonce dep, no refresh
// subscription, and never re-run on a pillar switch since /data and /access
// share one route element): a deep-link to /access could report 「还没有应用」
// while /data showed the app at the same moment. Same resolution as the
// pillar now: published first, DRAFT app fallback, re-resolved on draft
// saves and on the metadata-refresh pulse.
const resolvePackageApp = React.useCallback(async (): Promise<void> => {
try {
const apps = (await shellClient.list('app', { packageId })) as Array<Record<string, unknown>>;
let first = (apps || [])
.map((a) => ({ name: String(a.name ?? ''), label: String(a.label ?? a.name ?? '') }))
.filter((a) => a.name)[0];
if (!first) {
const drafts = await shellClient.listDrafts?.({ packageId, type: 'app' });
const d = drafts?.[0] as { name?: unknown; label?: unknown } | undefined;
if (d?.name) first = { name: String(d.name), label: String(d.label ?? d.name) };
}
})();
return () => {
cancelled = true;
};
}, [shellClient, packageId, publishNonce]);
setPackageApp(first ?? null);
} catch {
setPackageApp(null);
}
}, [shellClient, packageId]);
React.useEffect(() => {
void resolvePackageApp();
return subscribeMetadataRefresh(() => {
void resolvePackageApp();
});
}, [resolvePackageApp, publishNonce, draftNonce]);

// ADR-0057 P3 — the decided Studio grid: `[left: nav/tree] [center: canvas +
// properties] [right: chat]`. NOT keyed on the async agent catalog (that
Expand DownExpand Up@@ -818,17 +830,10 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React

{/* Package-level draft review + one atomic publish (replaces per-item 发布) */}
<div className="ml-auto flex shrink-0 items-center gap-2">
{packageApp ? (
<button
type="button"
onClick={() => window.open(resolveConsoleUrl(`apps/${encodeURIComponent(packageApp.name)}`), '_blank')}
title={tFormat('engine.studio.app.openTitle', locale, { label: packageApp.label })}
className="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
>
<ExternalLink className="h-3.5 w-3.5" />
{t('engine.studio.app.open', locale)}
</button>
) : appDraftPending ? (
{/* objectui#5800 — the 打开应用 teleport is retired: the canvas's 运行
mode IS the way to try the app without leaving the workbench.
The published-app state needs no chrome at all. */}
{packageApp ? null : appDraftPending ? (
<span
title={t('engine.studio.app.willOpenAfterPublish', locale)}
className="rounded bg-amber-400/15 px-2 py-0.5 text-[11px] text-amber-600 dark:text-amber-300"
Expand DownExpand Up@@ -1413,6 +1418,13 @@ export function InterfacesPillar({
// running app, not an editable draft — schema editing is the Data pillar's
// job — so those leaves are not draft-editable in this canvas.
const isEditable = !!Preview && !StudioCanvas;
// objectui#5800 — 设计⇄运行: one canvas, two modes (ADR-0080's pivot made
// visible). Run mode is pure subtraction: `editing=false` drops the design
// overlays (dashboard widget overlays, page block canvas) and the SAME
// renderer serves the interactive runtime — click 新建, enter a record.
// Selection state is retained so switching back to design keeps context.
const [canvasMode, setCanvasMode] = React.useState<'design' | 'run'>('design');
const designing = canvasMode === 'design';
// `kind: 'html'`/`'react'` pages are a `source` string (ADR-0080/0081),
// rendered by SourcePageEditor as a code-editor + live-preview split — there
// is no block tree, so `selection` never populates and the generic "click a
Expand DownExpand Up@@ -1535,9 +1547,33 @@ export function InterfacesPillar({
const canvasEl = (
<main className="flex min-w-0 flex-1 flex-col overflow-auto bg-muted/30 p-4">
<div className="mb-3 flex shrink-0 items-center gap-2">
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-[11px] text-primary">
<Eye className="h-3 w-3" /> {t('engine.studio.if.previewIsRuntime', locale)}
</span>
{/* objectui#5800 — the 设计⇄运行 switch replaces the static 实时预览
chip: same renderer either way, the switch only adds/removes the
design affordances. */}
<div className="inline-flex items-center gap-0.5 rounded-lg bg-muted p-1" data-testid="canvas-mode-toggle">
<button
type="button"
onClick={() => setCanvasMode('design')}
aria-pressed={designing}
className={
'rounded-md px-2.5 py-0.5 text-[11px] transition-all ' +
(designing ? 'bg-background font-medium text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground')
}
>
{t('engine.studio.if.modeDesign', locale)}
</button>
<button
type="button"
onClick={() => setCanvasMode('run')}
aria-pressed={!designing}
className={
'rounded-md px-2.5 py-0.5 text-[11px] transition-all ' +
(!designing ? 'bg-background font-medium text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground')
}
>
{t('engine.studio.if.modeRun', locale)}
</button>
</div>
{current && (
<span className="text-[11px] text-muted-foreground">
{current.type} · {current.name}
Expand DownExpand Up@@ -1599,9 +1635,9 @@ export function InterfacesPillar({
type={current.type}
name={current.name}
draft={draft}
editing
selection={selection}
onSelectionChange={setSelection}
editing={designing}
selection={designing ? selection : null}
onSelectionChange={designing ? setSelection : undefined}
onPatch={onPatch}
locale={locale}
/>
Expand Down
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
5 changes: 5 additions & 0 deletions .changeset/canvas-design-run-5800.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@object-ui/app-shell': minor
---

设计⇄运行 on the Interfaces canvas (#5800): a two-state switch in the canvas header flips the SAME renderer between design (selection + inspector + design overlays) and an interactive runtime (click 新建, enter records) — ADR-0080's design=run pivot made visible; selection context survives the round trip. The topbar's 打开应用 teleport is retired (run mode is the in-workbench way to try the app), and the topbar's app detection now matches the pillar's (draft-app fallback, re-resolved on draft saves and the metadata-refresh pulse) so a deep-link to /access can no longer claim the package has no app while /data shows one.
4 changes: 4 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1740,6 +1740,8 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.studio.if.noApp': 'This package has no app yet.',
'engine.studio.if.noNavItems': 'No nav items yet — (click “Edit” above to add)',
'engine.studio.if.previewIsRuntime': 'Live preview',
'engine.studio.if.modeDesign': 'Design',
'engine.studio.if.modeRun': 'Run',
'engine.studio.if.tabCanvas': 'Canvas',
'engine.studio.if.noAppTitle': 'This package has no app yet',
'engine.studio.if.noAppHint': 'Create an app to design its navigation and interfaces.',
Expand DownExpand Up@@ -3591,6 +3593,8 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.studio.if.noApp': '这个软件包还没有应用。',
'engine.studio.if.noNavItems': '还没有导航项 —(点上方「编辑」添加)',
'engine.studio.if.previewIsRuntime': '实时预览',
'engine.studio.if.modeDesign': '设计',
'engine.studio.if.modeRun': '运行',
'engine.studio.if.tabCanvas': '画布',
'engine.studio.if.noAppTitle': '这个软件包还没有应用',
'engine.studio.if.noAppHint': '创建一个应用来设计它的导航与界面。',
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#5800 — the 设计⇄运行 canvas switch (cloud#1609 增量二).
*
* ADR-0080's pivot (design state = run state, one renderer) made visible: the
* Interfaces canvas header carries a two-state switch; RUN mode is pure
* subtraction — `editing=false` drops the design overlays so the SAME
* renderer serves the interactive runtime. Pinned through the dashboard leaf
* because its design mode is the most explicit: `DashboardRenderer` swallows
* widget interaction behind `widget-click-overlay` elements exactly when
* designMode is on, so the overlay's presence IS the mode.
*
* Also pinned: selection context survives a run round-trip (the acceptance's
* 「切回设计不丢」), via the switch not clearing the pillar's selection state.
*/
import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

const NAV = [
{ id: 'nav_dash', type: 'dashboard', label: 'Overview', dashboardName: 'sales_overview' },
];

const DASHBOARD = {
name: 'sales_overview',
label: 'Sales Overview',
widgets: [
{ id: 'w1', type: 'metric', title: 'Total', options: { value: 42 } },
],
};

const mockClient = {
list: vi.fn(async (type: string) =>
type === 'app' ? [{ name: 'acme_app', label: 'Acme' }] : [],
),
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 === 'dashboard' && name === 'sales_overview') return { effective: DASHBOARD };
return { effective: { name } };
}),
getDraft: vi.fn(async () => null),
save: vi.fn(async () => ({})),
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 { registerMetadataPreview } from '../metadata-admin/preview-registry';
import { DashboardPreview } from '../metadata-admin/previews/DashboardPreview';

registerMetadataPreview('dashboard', DashboardPreview);

afterEach(cleanup);

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

async function openDashboardLeaf() {
renderPillar();
fireEvent.click(await screen.findByTitle('dashboard · sales_overview'));
await waitFor(() => expect(screen.getByTestId('canvas-mode-toggle')).toBeInTheDocument(), {
timeout: 4000,
});
}

describe('Interfaces canvas — 设计⇄运行 switch (objectui#5800)', () => {
it('design mode (default) swallows widget interaction behind the design overlay', async () => {
await openDashboardLeaf();
await waitFor(
() => expect(screen.getAllByTestId('widget-click-overlay').length).toBeGreaterThan(0),
{ timeout: 4000 },
);
});

it('run mode removes the overlays — the SAME renderer serves the interactive runtime', async () => {
await openDashboardLeaf();
await waitFor(
() => expect(screen.getAllByTestId('widget-click-overlay').length).toBeGreaterThan(0),
{ timeout: 4000 },
);
fireEvent.click(screen.getByRole('button', { name: 'Run' }));
await waitFor(() => expect(screen.queryAllByTestId('widget-click-overlay')).toHaveLength(0), {
timeout: 4000,
});
// ...and back: the switch is a round trip, not a one-way door.
fireEvent.click(screen.getByRole('button', { name: 'Design' }));
await waitFor(
() => expect(screen.getAllByTestId('widget-click-overlay').length).toBeGreaterThan(0),
{ timeout: 4000 },
);
});
});
104 changes: 70 additions & 34 deletions packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -91,7 +91,7 @@ import {
} from '../metadata-admin/nav-selection.js';
import { SourcePageEditor } from '../metadata-admin/previews/SourcePageEditor.js';
import { usePendingDrafts } from '../../preview/usePendingDrafts.js';
import { emitMetadataRefresh } from '../../assistant/assistantBus.js';
import { emitMetadataRefresh, subscribeMetadataRefresh } from '../../assistant/assistantBus.js';
import { formatMetadataError, formatPublishFailures, type PublishFailure } from './metadataError.js';
import { loadPackageSurfaces } from './packageSurfaces.js';
import { resolveSurface, findSurfaceInTree, type NavNode, type Surface } from './navSurface.js';
Expand DownExpand Up@@ -653,23 +653,35 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React
[appAddObjects, loadPackageObjects, shellClient, packageId, locale],
);

React.useEffect(() => {
let cancelled = false;
(async () => {
try {
const apps = (await shellClient.list('app', { packageId })) as Array<Record<string, unknown>>;
const first = (apps || [])
.map((a) => ({ name: String(a.name ?? ''), label: String(a.label ?? a.name ?? '') }))
.filter((a) => a.name)[0];
if (!cancelled) setPackageApp(first ?? null);
} catch {
if (!cancelled) setPackageApp(null);
// objectui#5800 顺手修 — the topbar's app detection used to disagree with the
// Interfaces pillar's (published-only read, no draftNonce dep, no refresh
// subscription, and never re-run on a pillar switch since /data and /access
// share one route element): a deep-link to /access could report 「还没有应用」
// while /data showed the app at the same moment. Same resolution as the
// pillar now: published first, DRAFT app fallback, re-resolved on draft
// saves and on the metadata-refresh pulse.
const resolvePackageApp = React.useCallback(async (): Promise<void> => {
try {
const apps = (await shellClient.list('app', { packageId })) as Array<Record<string, unknown>>;
let first = (apps || [])
.map((a) => ({ name: String(a.name ?? ''), label: String(a.label ?? a.name ?? '') }))
.filter((a) => a.name)[0];
if (!first) {
const drafts = await shellClient.listDrafts?.({ packageId, type: 'app' });
const d = drafts?.[0] as { name?: unknown; label?: unknown } | undefined;
if (d?.name) first = { name: String(d.name), label: String(d.label ?? d.name) };
}
})();
return () => {
cancelled = true;
};
}, [shellClient, packageId, publishNonce]);
setPackageApp(first ?? null);
} catch {
setPackageApp(null);
}
}, [shellClient, packageId]);
React.useEffect(() => {
void resolvePackageApp();
return subscribeMetadataRefresh(() => {
void resolvePackageApp();
});
}, [resolvePackageApp, publishNonce, draftNonce]);

// ADR-0057 P3 — the decided Studio grid: `[left: nav/tree] [center: canvas +
// properties] [right: chat]`. NOT keyed on the async agent catalog (that
Expand DownExpand Up@@ -818,17 +830,10 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React

{/* Package-level draft review + one atomic publish (replaces per-item 发布) */}
<div className="ml-auto flex shrink-0 items-center gap-2">
{packageApp ? (
<button
type="button"
onClick={() => window.open(resolveConsoleUrl(`apps/${encodeURIComponent(packageApp.name)}`), '_blank')}
title={tFormat('engine.studio.app.openTitle', locale, { label: packageApp.label })}
className="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
>
<ExternalLink className="h-3.5 w-3.5" />
{t('engine.studio.app.open', locale)}
</button>
) : appDraftPending ? (
{/* objectui#5800 — the 打开应用 teleport is retired: the canvas's 运行
mode IS the way to try the app without leaving the workbench.
The published-app state needs no chrome at all. */}
{packageApp ? null : appDraftPending ? (
<span
title={t('engine.studio.app.willOpenAfterPublish', locale)}
className="rounded bg-amber-400/15 px-2 py-0.5 text-[11px] text-amber-600 dark:text-amber-300"
Expand DownExpand Up@@ -1413,6 +1418,13 @@ export function InterfacesPillar({
// running app, not an editable draft — schema editing is the Data pillar's
// job — so those leaves are not draft-editable in this canvas.
const isEditable = !!Preview && !StudioCanvas;
// objectui#5800 — 设计⇄运行: one canvas, two modes (ADR-0080's pivot made
// visible). Run mode is pure subtraction: `editing=false` drops the design
// overlays (dashboard widget overlays, page block canvas) and the SAME
// renderer serves the interactive runtime — click 新建, enter a record.
// Selection state is retained so switching back to design keeps context.
const [canvasMode, setCanvasMode] = React.useState<'design' | 'run'>('design');
const designing = canvasMode === 'design';
// `kind: 'html'`/`'react'` pages are a `source` string (ADR-0080/0081),
// rendered by SourcePageEditor as a code-editor + live-preview split — there
// is no block tree, so `selection` never populates and the generic "click a
Expand DownExpand Up@@ -1535,9 +1547,33 @@ export function InterfacesPillar({
const canvasEl = (
<main className="flex min-w-0 flex-1 flex-col overflow-auto bg-muted/30 p-4">
<div className="mb-3 flex shrink-0 items-center gap-2">
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-[11px] text-primary">
<Eye className="h-3 w-3" /> {t('engine.studio.if.previewIsRuntime', locale)}
</span>
{/* objectui#5800 — the 设计⇄运行 switch replaces the static 实时预览
chip: same renderer either way, the switch only adds/removes the
design affordances. */}
<div className="inline-flex items-center gap-0.5 rounded-lg bg-muted p-1" data-testid="canvas-mode-toggle">
<button
type="button"
onClick={() => setCanvasMode('design')}
aria-pressed={designing}
className={
'rounded-md px-2.5 py-0.5 text-[11px] transition-all ' +
(designing ? 'bg-background font-medium text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground')
}
>
{t('engine.studio.if.modeDesign', locale)}
</button>
<button
type="button"
onClick={() => setCanvasMode('run')}
aria-pressed={!designing}
className={
'rounded-md px-2.5 py-0.5 text-[11px] transition-all ' +
(!designing ? 'bg-background font-medium text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground')
}
>
{t('engine.studio.if.modeRun', locale)}
</button>
</div>
{current && (
<span className="text-[11px] text-muted-foreground">
{current.type} · {current.name}
Expand DownExpand Up@@ -1599,9 +1635,9 @@ export function InterfacesPillar({
type={current.type}
name={current.name}
draft={draft}
editing
selection={selection}
onSelectionChange={setSelection}
editing={designing}
selection={designing ? selection : null}
onSelectionChange={designing ? setSelection : undefined}
onPatch={onPatch}
locale={locale}
/>
Expand Down
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
5 changes: 5 additions & 0 deletions .changeset/canvas-design-run-5800.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@object-ui/app-shell': minor
---

设计⇄运行 on the Interfaces canvas (#5800): a two-state switch in the canvas header flips the SAME renderer between design (selection + inspector + design overlays) and an interactive runtime (click 新建, enter records) — ADR-0080's design=run pivot made visible; selection context survives the round trip. The topbar's 打开应用 teleport is retired (run mode is the in-workbench way to try the app), and the topbar's app detection now matches the pillar's (draft-app fallback, re-resolved on draft saves and the metadata-refresh pulse) so a deep-link to /access can no longer claim the package has no app while /data shows one.
4 changes: 4 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1740,6 +1740,8 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.studio.if.noApp': 'This package has no app yet.',
'engine.studio.if.noNavItems': 'No nav items yet — (click “Edit” above to add)',
'engine.studio.if.previewIsRuntime': 'Live preview',
'engine.studio.if.modeDesign': 'Design',
'engine.studio.if.modeRun': 'Run',
'engine.studio.if.tabCanvas': 'Canvas',
'engine.studio.if.noAppTitle': 'This package has no app yet',
'engine.studio.if.noAppHint': 'Create an app to design its navigation and interfaces.',
Expand DownExpand Up@@ -3591,6 +3593,8 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.studio.if.noApp': '这个软件包还没有应用。',
'engine.studio.if.noNavItems': '还没有导航项 —(点上方「编辑」添加)',
'engine.studio.if.previewIsRuntime': '实时预览',
'engine.studio.if.modeDesign': '设计',
'engine.studio.if.modeRun': '运行',
'engine.studio.if.tabCanvas': '画布',
'engine.studio.if.noAppTitle': '这个软件包还没有应用',
'engine.studio.if.noAppHint': '创建一个应用来设计它的导航与界面。',
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#5800 — the 设计⇄运行 canvas switch (cloud#1609 增量二).
*
* ADR-0080's pivot (design state = run state, one renderer) made visible: the
* Interfaces canvas header carries a two-state switch; RUN mode is pure
* subtraction — `editing=false` drops the design overlays so the SAME
* renderer serves the interactive runtime. Pinned through the dashboard leaf
* because its design mode is the most explicit: `DashboardRenderer` swallows
* widget interaction behind `widget-click-overlay` elements exactly when
* designMode is on, so the overlay's presence IS the mode.
*
* Also pinned: selection context survives a run round-trip (the acceptance's
* 「切回设计不丢」), via the switch not clearing the pillar's selection state.
*/
import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

const NAV = [
{ id: 'nav_dash', type: 'dashboard', label: 'Overview', dashboardName: 'sales_overview' },
];

const DASHBOARD = {
name: 'sales_overview',
label: 'Sales Overview',
widgets: [
{ id: 'w1', type: 'metric', title: 'Total', options: { value: 42 } },
],
};

const mockClient = {
list: vi.fn(async (type: string) =>
type === 'app' ? [{ name: 'acme_app', label: 'Acme' }] : [],
),
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 === 'dashboard' && name === 'sales_overview') return { effective: DASHBOARD };
return { effective: { name } };
}),
getDraft: vi.fn(async () => null),
save: vi.fn(async () => ({})),
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 { registerMetadataPreview } from '../metadata-admin/preview-registry';
import { DashboardPreview } from '../metadata-admin/previews/DashboardPreview';

registerMetadataPreview('dashboard', DashboardPreview);

afterEach(cleanup);

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

async function openDashboardLeaf() {
renderPillar();
fireEvent.click(await screen.findByTitle('dashboard · sales_overview'));
await waitFor(() => expect(screen.getByTestId('canvas-mode-toggle')).toBeInTheDocument(), {
timeout: 4000,
});
}

describe('Interfaces canvas — 设计⇄运行 switch (objectui#5800)', () => {
it('design mode (default) swallows widget interaction behind the design overlay', async () => {
await openDashboardLeaf();
await waitFor(
() => expect(screen.getAllByTestId('widget-click-overlay').length).toBeGreaterThan(0),
{ timeout: 4000 },
);
});

it('run mode removes the overlays — the SAME renderer serves the interactive runtime', async () => {
await openDashboardLeaf();
await waitFor(
() => expect(screen.getAllByTestId('widget-click-overlay').length).toBeGreaterThan(0),
{ timeout: 4000 },
);
fireEvent.click(screen.getByRole('button', { name: 'Run' }));
await waitFor(() => expect(screen.queryAllByTestId('widget-click-overlay')).toHaveLength(0), {
timeout: 4000,
});
// ...and back: the switch is a round trip, not a one-way door.
fireEvent.click(screen.getByRole('button', { name: 'Design' }));
await waitFor(
() => expect(screen.getAllByTestId('widget-click-overlay').length).toBeGreaterThan(0),
{ timeout: 4000 },
);
});
});
104 changes: 70 additions & 34 deletions packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -91,7 +91,7 @@ import {
} from '../metadata-admin/nav-selection.js';
import { SourcePageEditor } from '../metadata-admin/previews/SourcePageEditor.js';
import { usePendingDrafts } from '../../preview/usePendingDrafts.js';
import { emitMetadataRefresh } from '../../assistant/assistantBus.js';
import { emitMetadataRefresh, subscribeMetadataRefresh } from '../../assistant/assistantBus.js';
import { formatMetadataError, formatPublishFailures, type PublishFailure } from './metadataError.js';
import { loadPackageSurfaces } from './packageSurfaces.js';
import { resolveSurface, findSurfaceInTree, type NavNode, type Surface } from './navSurface.js';
Expand DownExpand Up@@ -653,23 +653,35 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React
[appAddObjects, loadPackageObjects, shellClient, packageId, locale],
);

React.useEffect(() => {
let cancelled = false;
(async () => {
try {
const apps = (await shellClient.list('app', { packageId })) as Array<Record<string, unknown>>;
const first = (apps || [])
.map((a) => ({ name: String(a.name ?? ''), label: String(a.label ?? a.name ?? '') }))
.filter((a) => a.name)[0];
if (!cancelled) setPackageApp(first ?? null);
} catch {
if (!cancelled) setPackageApp(null);
// objectui#5800 顺手修 — the topbar's app detection used to disagree with the
// Interfaces pillar's (published-only read, no draftNonce dep, no refresh
// subscription, and never re-run on a pillar switch since /data and /access
// share one route element): a deep-link to /access could report 「还没有应用」
// while /data showed the app at the same moment. Same resolution as the
// pillar now: published first, DRAFT app fallback, re-resolved on draft
// saves and on the metadata-refresh pulse.
const resolvePackageApp = React.useCallback(async (): Promise<void> => {
try {
const apps = (await shellClient.list('app', { packageId })) as Array<Record<string, unknown>>;
let first = (apps || [])
.map((a) => ({ name: String(a.name ?? ''), label: String(a.label ?? a.name ?? '') }))
.filter((a) => a.name)[0];
if (!first) {
const drafts = await shellClient.listDrafts?.({ packageId, type: 'app' });
const d = drafts?.[0] as { name?: unknown; label?: unknown } | undefined;
if (d?.name) first = { name: String(d.name), label: String(d.label ?? d.name) };
}
})();
return () => {
cancelled = true;
};
}, [shellClient, packageId, publishNonce]);
setPackageApp(first ?? null);
} catch {
setPackageApp(null);
}
}, [shellClient, packageId]);
React.useEffect(() => {
void resolvePackageApp();
return subscribeMetadataRefresh(() => {
void resolvePackageApp();
});
}, [resolvePackageApp, publishNonce, draftNonce]);

// ADR-0057 P3 — the decided Studio grid: `[left: nav/tree] [center: canvas +
// properties] [right: chat]`. NOT keyed on the async agent catalog (that
Expand DownExpand Up@@ -818,17 +830,10 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React

{/* Package-level draft review + one atomic publish (replaces per-item 发布) */}
<div className="ml-auto flex shrink-0 items-center gap-2">
{packageApp ? (
<button
type="button"
onClick={() => window.open(resolveConsoleUrl(`apps/${encodeURIComponent(packageApp.name)}`), '_blank')}
title={tFormat('engine.studio.app.openTitle', locale, { label: packageApp.label })}
className="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
>
<ExternalLink className="h-3.5 w-3.5" />
{t('engine.studio.app.open', locale)}
</button>
) : appDraftPending ? (
{/* objectui#5800 — the 打开应用 teleport is retired: the canvas's 运行
mode IS the way to try the app without leaving the workbench.
The published-app state needs no chrome at all. */}
{packageApp ? null : appDraftPending ? (
<span
title={t('engine.studio.app.willOpenAfterPublish', locale)}
className="rounded bg-amber-400/15 px-2 py-0.5 text-[11px] text-amber-600 dark:text-amber-300"
Expand DownExpand Up@@ -1413,6 +1418,13 @@ export function InterfacesPillar({
// running app, not an editable draft — schema editing is the Data pillar's
// job — so those leaves are not draft-editable in this canvas.
const isEditable = !!Preview && !StudioCanvas;
// objectui#5800 — 设计⇄运行: one canvas, two modes (ADR-0080's pivot made
// visible). Run mode is pure subtraction: `editing=false` drops the design
// overlays (dashboard widget overlays, page block canvas) and the SAME
// renderer serves the interactive runtime — click 新建, enter a record.
// Selection state is retained so switching back to design keeps context.
const [canvasMode, setCanvasMode] = React.useState<'design' | 'run'>('design');
const designing = canvasMode === 'design';
// `kind: 'html'`/`'react'` pages are a `source` string (ADR-0080/0081),
// rendered by SourcePageEditor as a code-editor + live-preview split — there
// is no block tree, so `selection` never populates and the generic "click a
Expand DownExpand Up@@ -1535,9 +1547,33 @@ export function InterfacesPillar({
const canvasEl = (
<main className="flex min-w-0 flex-1 flex-col overflow-auto bg-muted/30 p-4">
<div className="mb-3 flex shrink-0 items-center gap-2">
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-[11px] text-primary">
<Eye className="h-3 w-3" /> {t('engine.studio.if.previewIsRuntime', locale)}
</span>
{/* objectui#5800 — the 设计⇄运行 switch replaces the static 实时预览
chip: same renderer either way, the switch only adds/removes the
design affordances. */}
<div className="inline-flex items-center gap-0.5 rounded-lg bg-muted p-1" data-testid="canvas-mode-toggle">
<button
type="button"
onClick={() => setCanvasMode('design')}
aria-pressed={designing}
className={
'rounded-md px-2.5 py-0.5 text-[11px] transition-all ' +
(designing ? 'bg-background font-medium text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground')
}
>
{t('engine.studio.if.modeDesign', locale)}
</button>
<button
type="button"
onClick={() => setCanvasMode('run')}
aria-pressed={!designing}
className={
'rounded-md px-2.5 py-0.5 text-[11px] transition-all ' +
(!designing ? 'bg-background font-medium text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground')
}
>
{t('engine.studio.if.modeRun', locale)}
</button>
</div>
{current && (
<span className="text-[11px] text-muted-foreground">
{current.type} · {current.name}
Expand DownExpand Up@@ -1599,9 +1635,9 @@ export function InterfacesPillar({
type={current.type}
name={current.name}
draft={draft}
editing
selection={selection}
onSelectionChange={setSelection}
editing={designing}
selection={designing ? selection : null}
onSelectionChange={designing ? setSelection : undefined}
onPatch={onPatch}
locale={locale}
/>
Expand Down
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
5 changes: 5 additions & 0 deletions .changeset/canvas-design-run-5800.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@object-ui/app-shell': minor
---

设计⇄运行 on the Interfaces canvas (#5800): a two-state switch in the canvas header flips the SAME renderer between design (selection + inspector + design overlays) and an interactive runtime (click 新建, enter records) — ADR-0080's design=run pivot made visible; selection context survives the round trip. The topbar's 打开应用 teleport is retired (run mode is the in-workbench way to try the app), and the topbar's app detection now matches the pillar's (draft-app fallback, re-resolved on draft saves and the metadata-refresh pulse) so a deep-link to /access can no longer claim the package has no app while /data shows one.
4 changes: 4 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1740,6 +1740,8 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.studio.if.noApp': 'This package has no app yet.',
'engine.studio.if.noNavItems': 'No nav items yet — (click “Edit” above to add)',
'engine.studio.if.previewIsRuntime': 'Live preview',
'engine.studio.if.modeDesign': 'Design',
'engine.studio.if.modeRun': 'Run',
'engine.studio.if.tabCanvas': 'Canvas',
'engine.studio.if.noAppTitle': 'This package has no app yet',
'engine.studio.if.noAppHint': 'Create an app to design its navigation and interfaces.',
Expand DownExpand Up@@ -3591,6 +3593,8 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.studio.if.noApp': '这个软件包还没有应用。',
'engine.studio.if.noNavItems': '还没有导航项 —(点上方「编辑」添加)',
'engine.studio.if.previewIsRuntime': '实时预览',
'engine.studio.if.modeDesign': '设计',
'engine.studio.if.modeRun': '运行',
'engine.studio.if.tabCanvas': '画布',
'engine.studio.if.noAppTitle': '这个软件包还没有应用',
'engine.studio.if.noAppHint': '创建一个应用来设计它的导航与界面。',
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#5800 — the 设计⇄运行 canvas switch (cloud#1609 增量二).
*
* ADR-0080's pivot (design state = run state, one renderer) made visible: the
* Interfaces canvas header carries a two-state switch; RUN mode is pure
* subtraction — `editing=false` drops the design overlays so the SAME
* renderer serves the interactive runtime. Pinned through the dashboard leaf
* because its design mode is the most explicit: `DashboardRenderer` swallows
* widget interaction behind `widget-click-overlay` elements exactly when
* designMode is on, so the overlay's presence IS the mode.
*
* Also pinned: selection context survives a run round-trip (the acceptance's
* 「切回设计不丢」), via the switch not clearing the pillar's selection state.
*/
import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

const NAV = [
{ id: 'nav_dash', type: 'dashboard', label: 'Overview', dashboardName: 'sales_overview' },
];

const DASHBOARD = {
name: 'sales_overview',
label: 'Sales Overview',
widgets: [
{ id: 'w1', type: 'metric', title: 'Total', options: { value: 42 } },
],
};

const mockClient = {
list: vi.fn(async (type: string) =>
type === 'app' ? [{ name: 'acme_app', label: 'Acme' }] : [],
),
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 === 'dashboard' && name === 'sales_overview') return { effective: DASHBOARD };
return { effective: { name } };
}),
getDraft: vi.fn(async () => null),
save: vi.fn(async () => ({})),
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 { registerMetadataPreview } from '../metadata-admin/preview-registry';
import { DashboardPreview } from '../metadata-admin/previews/DashboardPreview';

registerMetadataPreview('dashboard', DashboardPreview);

afterEach(cleanup);

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

async function openDashboardLeaf() {
renderPillar();
fireEvent.click(await screen.findByTitle('dashboard · sales_overview'));
await waitFor(() => expect(screen.getByTestId('canvas-mode-toggle')).toBeInTheDocument(), {
timeout: 4000,
});
}

describe('Interfaces canvas — 设计⇄运行 switch (objectui#5800)', () => {
it('design mode (default) swallows widget interaction behind the design overlay', async () => {
await openDashboardLeaf();
await waitFor(
() => expect(screen.getAllByTestId('widget-click-overlay').length).toBeGreaterThan(0),
{ timeout: 4000 },
);
});

it('run mode removes the overlays — the SAME renderer serves the interactive runtime', async () => {
await openDashboardLeaf();
await waitFor(
() => expect(screen.getAllByTestId('widget-click-overlay').length).toBeGreaterThan(0),
{ timeout: 4000 },
);
fireEvent.click(screen.getByRole('button', { name: 'Run' }));
await waitFor(() => expect(screen.queryAllByTestId('widget-click-overlay')).toHaveLength(0), {
timeout: 4000,
});
// ...and back: the switch is a round trip, not a one-way door.
fireEvent.click(screen.getByRole('button', { name: 'Design' }));
await waitFor(
() => expect(screen.getAllByTestId('widget-click-overlay').length).toBeGreaterThan(0),
{ timeout: 4000 },
);
});
});
104 changes: 70 additions & 34 deletions packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -91,7 +91,7 @@ import {
} from '../metadata-admin/nav-selection.js';
import { SourcePageEditor } from '../metadata-admin/previews/SourcePageEditor.js';
import { usePendingDrafts } from '../../preview/usePendingDrafts.js';
import { emitMetadataRefresh } from '../../assistant/assistantBus.js';
import { emitMetadataRefresh, subscribeMetadataRefresh } from '../../assistant/assistantBus.js';
import { formatMetadataError, formatPublishFailures, type PublishFailure } from './metadataError.js';
import { loadPackageSurfaces } from './packageSurfaces.js';
import { resolveSurface, findSurfaceInTree, type NavNode, type Surface } from './navSurface.js';
Expand DownExpand Up@@ -653,23 +653,35 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React
[appAddObjects, loadPackageObjects, shellClient, packageId, locale],
);

React.useEffect(() => {
let cancelled = false;
(async () => {
try {
const apps = (await shellClient.list('app', { packageId })) as Array<Record<string, unknown>>;
const first = (apps || [])
.map((a) => ({ name: String(a.name ?? ''), label: String(a.label ?? a.name ?? '') }))
.filter((a) => a.name)[0];
if (!cancelled) setPackageApp(first ?? null);
} catch {
if (!cancelled) setPackageApp(null);
// objectui#5800 顺手修 — the topbar's app detection used to disagree with the
// Interfaces pillar's (published-only read, no draftNonce dep, no refresh
// subscription, and never re-run on a pillar switch since /data and /access
// share one route element): a deep-link to /access could report 「还没有应用」
// while /data showed the app at the same moment. Same resolution as the
// pillar now: published first, DRAFT app fallback, re-resolved on draft
// saves and on the metadata-refresh pulse.
const resolvePackageApp = React.useCallback(async (): Promise<void> => {
try {
const apps = (await shellClient.list('app', { packageId })) as Array<Record<string, unknown>>;
let first = (apps || [])
.map((a) => ({ name: String(a.name ?? ''), label: String(a.label ?? a.name ?? '') }))
.filter((a) => a.name)[0];
if (!first) {
const drafts = await shellClient.listDrafts?.({ packageId, type: 'app' });
const d = drafts?.[0] as { name?: unknown; label?: unknown } | undefined;
if (d?.name) first = { name: String(d.name), label: String(d.label ?? d.name) };
}
})();
return () => {
cancelled = true;
};
}, [shellClient, packageId, publishNonce]);
setPackageApp(first ?? null);
} catch {
setPackageApp(null);
}
}, [shellClient, packageId]);
React.useEffect(() => {
void resolvePackageApp();
return subscribeMetadataRefresh(() => {
void resolvePackageApp();
});
}, [resolvePackageApp, publishNonce, draftNonce]);

// ADR-0057 P3 — the decided Studio grid: `[left: nav/tree] [center: canvas +
// properties] [right: chat]`. NOT keyed on the async agent catalog (that
Expand DownExpand Up@@ -818,17 +830,10 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React

{/* Package-level draft review + one atomic publish (replaces per-item 发布) */}
<div className="ml-auto flex shrink-0 items-center gap-2">
{packageApp ? (
<button
type="button"
onClick={() => window.open(resolveConsoleUrl(`apps/${encodeURIComponent(packageApp.name)}`), '_blank')}
title={tFormat('engine.studio.app.openTitle', locale, { label: packageApp.label })}
className="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
>
<ExternalLink className="h-3.5 w-3.5" />
{t('engine.studio.app.open', locale)}
</button>
) : appDraftPending ? (
{/* objectui#5800 — the 打开应用 teleport is retired: the canvas's 运行
mode IS the way to try the app without leaving the workbench.
The published-app state needs no chrome at all. */}
{packageApp ? null : appDraftPending ? (
<span
title={t('engine.studio.app.willOpenAfterPublish', locale)}
className="rounded bg-amber-400/15 px-2 py-0.5 text-[11px] text-amber-600 dark:text-amber-300"
Expand DownExpand Up@@ -1413,6 +1418,13 @@ export function InterfacesPillar({
// running app, not an editable draft — schema editing is the Data pillar's
// job — so those leaves are not draft-editable in this canvas.
const isEditable = !!Preview && !StudioCanvas;
// objectui#5800 — 设计⇄运行: one canvas, two modes (ADR-0080's pivot made
// visible). Run mode is pure subtraction: `editing=false` drops the design
// overlays (dashboard widget overlays, page block canvas) and the SAME
// renderer serves the interactive runtime — click 新建, enter a record.
// Selection state is retained so switching back to design keeps context.
const [canvasMode, setCanvasMode] = React.useState<'design' | 'run'>('design');
const designing = canvasMode === 'design';
// `kind: 'html'`/`'react'` pages are a `source` string (ADR-0080/0081),
// rendered by SourcePageEditor as a code-editor + live-preview split — there
// is no block tree, so `selection` never populates and the generic "click a
Expand DownExpand Up@@ -1535,9 +1547,33 @@ export function InterfacesPillar({
const canvasEl = (
<main className="flex min-w-0 flex-1 flex-col overflow-auto bg-muted/30 p-4">
<div className="mb-3 flex shrink-0 items-center gap-2">
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-[11px] text-primary">
<Eye className="h-3 w-3" /> {t('engine.studio.if.previewIsRuntime', locale)}
</span>
{/* objectui#5800 — the 设计⇄运行 switch replaces the static 实时预览
chip: same renderer either way, the switch only adds/removes the
design affordances. */}
<div className="inline-flex items-center gap-0.5 rounded-lg bg-muted p-1" data-testid="canvas-mode-toggle">
<button
type="button"
onClick={() => setCanvasMode('design')}
aria-pressed={designing}
className={
'rounded-md px-2.5 py-0.5 text-[11px] transition-all ' +
(designing ? 'bg-background font-medium text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground')
}
>
{t('engine.studio.if.modeDesign', locale)}
</button>
<button
type="button"
onClick={() => setCanvasMode('run')}
aria-pressed={!designing}
className={
'rounded-md px-2.5 py-0.5 text-[11px] transition-all ' +
(!designing ? 'bg-background font-medium text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground')
}
>
{t('engine.studio.if.modeRun', locale)}
</button>
</div>
{current && (
<span className="text-[11px] text-muted-foreground">
{current.type} · {current.name}
Expand DownExpand Up@@ -1599,9 +1635,9 @@ export function InterfacesPillar({
type={current.type}
name={current.name}
draft={draft}
editing
selection={selection}
onSelectionChange={setSelection}
editing={designing}
selection={designing ? selection : null}
onSelectionChange={designing ? setSelection : undefined}
onPatch={onPatch}
locale={locale}
/>
Expand Down
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
5 changes: 5 additions & 0 deletions .changeset/canvas-design-run-5800.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@object-ui/app-shell': minor
---

设计⇄运行 on the Interfaces canvas (#5800): a two-state switch in the canvas header flips the SAME renderer between design (selection + inspector + design overlays) and an interactive runtime (click 新建, enter records) — ADR-0080's design=run pivot made visible; selection context survives the round trip. The topbar's 打开应用 teleport is retired (run mode is the in-workbench way to try the app), and the topbar's app detection now matches the pillar's (draft-app fallback, re-resolved on draft saves and the metadata-refresh pulse) so a deep-link to /access can no longer claim the package has no app while /data shows one.
4 changes: 4 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1740,6 +1740,8 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.studio.if.noApp': 'This package has no app yet.',
'engine.studio.if.noNavItems': 'No nav items yet — (click “Edit” above to add)',
'engine.studio.if.previewIsRuntime': 'Live preview',
'engine.studio.if.modeDesign': 'Design',
'engine.studio.if.modeRun': 'Run',
'engine.studio.if.tabCanvas': 'Canvas',
'engine.studio.if.noAppTitle': 'This package has no app yet',
'engine.studio.if.noAppHint': 'Create an app to design its navigation and interfaces.',
Expand DownExpand Up@@ -3591,6 +3593,8 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.studio.if.noApp': '这个软件包还没有应用。',
'engine.studio.if.noNavItems': '还没有导航项 —(点上方「编辑」添加)',
'engine.studio.if.previewIsRuntime': '实时预览',
'engine.studio.if.modeDesign': '设计',
'engine.studio.if.modeRun': '运行',
'engine.studio.if.tabCanvas': '画布',
'engine.studio.if.noAppTitle': '这个软件包还没有应用',
'engine.studio.if.noAppHint': '创建一个应用来设计它的导航与界面。',
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#5800 — the 设计⇄运行 canvas switch (cloud#1609 增量二).
*
* ADR-0080's pivot (design state = run state, one renderer) made visible: the
* Interfaces canvas header carries a two-state switch; RUN mode is pure
* subtraction — `editing=false` drops the design overlays so the SAME
* renderer serves the interactive runtime. Pinned through the dashboard leaf
* because its design mode is the most explicit: `DashboardRenderer` swallows
* widget interaction behind `widget-click-overlay` elements exactly when
* designMode is on, so the overlay's presence IS the mode.
*
* Also pinned: selection context survives a run round-trip (the acceptance's
* 「切回设计不丢」), via the switch not clearing the pillar's selection state.
*/
import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

const NAV = [
{ id: 'nav_dash', type: 'dashboard', label: 'Overview', dashboardName: 'sales_overview' },
];

const DASHBOARD = {
name: 'sales_overview',
label: 'Sales Overview',
widgets: [
{ id: 'w1', type: 'metric', title: 'Total', options: { value: 42 } },
],
};

const mockClient = {
list: vi.fn(async (type: string) =>
type === 'app' ? [{ name: 'acme_app', label: 'Acme' }] : [],
),
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 === 'dashboard' && name === 'sales_overview') return { effective: DASHBOARD };
return { effective: { name } };
}),
getDraft: vi.fn(async () => null),
save: vi.fn(async () => ({})),
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 { registerMetadataPreview } from '../metadata-admin/preview-registry';
import { DashboardPreview } from '../metadata-admin/previews/DashboardPreview';

registerMetadataPreview('dashboard', DashboardPreview);

afterEach(cleanup);

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

async function openDashboardLeaf() {
renderPillar();
fireEvent.click(await screen.findByTitle('dashboard · sales_overview'));
await waitFor(() => expect(screen.getByTestId('canvas-mode-toggle')).toBeInTheDocument(), {
timeout: 4000,
});
}

describe('Interfaces canvas — 设计⇄运行 switch (objectui#5800)', () => {
it('design mode (default) swallows widget interaction behind the design overlay', async () => {
await openDashboardLeaf();
await waitFor(
() => expect(screen.getAllByTestId('widget-click-overlay').length).toBeGreaterThan(0),
{ timeout: 4000 },
);
});

it('run mode removes the overlays — the SAME renderer serves the interactive runtime', async () => {
await openDashboardLeaf();
await waitFor(
() => expect(screen.getAllByTestId('widget-click-overlay').length).toBeGreaterThan(0),
{ timeout: 4000 },
);
fireEvent.click(screen.getByRole('button', { name: 'Run' }));
await waitFor(() => expect(screen.queryAllByTestId('widget-click-overlay')).toHaveLength(0), {
timeout: 4000,
});
// ...and back: the switch is a round trip, not a one-way door.
fireEvent.click(screen.getByRole('button', { name: 'Design' }));
await waitFor(
() => expect(screen.getAllByTestId('widget-click-overlay').length).toBeGreaterThan(0),
{ timeout: 4000 },
);
});
});
104 changes: 70 additions & 34 deletions packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -91,7 +91,7 @@ import {
} from '../metadata-admin/nav-selection.js';
import { SourcePageEditor } from '../metadata-admin/previews/SourcePageEditor.js';
import { usePendingDrafts } from '../../preview/usePendingDrafts.js';
import { emitMetadataRefresh } from '../../assistant/assistantBus.js';
import { emitMetadataRefresh, subscribeMetadataRefresh } from '../../assistant/assistantBus.js';
import { formatMetadataError, formatPublishFailures, type PublishFailure } from './metadataError.js';
import { loadPackageSurfaces } from './packageSurfaces.js';
import { resolveSurface, findSurfaceInTree, type NavNode, type Surface } from './navSurface.js';
Expand DownExpand Up@@ -653,23 +653,35 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React
[appAddObjects, loadPackageObjects, shellClient, packageId, locale],
);

React.useEffect(() => {
let cancelled = false;
(async () => {
try {
const apps = (await shellClient.list('app', { packageId })) as Array<Record<string, unknown>>;
const first = (apps || [])
.map((a) => ({ name: String(a.name ?? ''), label: String(a.label ?? a.name ?? '') }))
.filter((a) => a.name)[0];
if (!cancelled) setPackageApp(first ?? null);
} catch {
if (!cancelled) setPackageApp(null);
// objectui#5800 顺手修 — the topbar's app detection used to disagree with the
// Interfaces pillar's (published-only read, no draftNonce dep, no refresh
// subscription, and never re-run on a pillar switch since /data and /access
// share one route element): a deep-link to /access could report 「还没有应用」
// while /data showed the app at the same moment. Same resolution as the
// pillar now: published first, DRAFT app fallback, re-resolved on draft
// saves and on the metadata-refresh pulse.
const resolvePackageApp = React.useCallback(async (): Promise<void> => {
try {
const apps = (await shellClient.list('app', { packageId })) as Array<Record<string, unknown>>;
let first = (apps || [])
.map((a) => ({ name: String(a.name ?? ''), label: String(a.label ?? a.name ?? '') }))
.filter((a) => a.name)[0];
if (!first) {
const drafts = await shellClient.listDrafts?.({ packageId, type: 'app' });
const d = drafts?.[0] as { name?: unknown; label?: unknown } | undefined;
if (d?.name) first = { name: String(d.name), label: String(d.label ?? d.name) };
}
})();
return () => {
cancelled = true;
};
}, [shellClient, packageId, publishNonce]);
setPackageApp(first ?? null);
} catch {
setPackageApp(null);
}
}, [shellClient, packageId]);
React.useEffect(() => {
void resolvePackageApp();
return subscribeMetadataRefresh(() => {
void resolvePackageApp();
});
}, [resolvePackageApp, publishNonce, draftNonce]);

// ADR-0057 P3 — the decided Studio grid: `[left: nav/tree] [center: canvas +
// properties] [right: chat]`. NOT keyed on the async agent catalog (that
Expand DownExpand Up@@ -818,17 +830,10 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React

{/* Package-level draft review + one atomic publish (replaces per-item 发布) */}
<div className="ml-auto flex shrink-0 items-center gap-2">
{packageApp ? (
<button
type="button"
onClick={() => window.open(resolveConsoleUrl(`apps/${encodeURIComponent(packageApp.name)}`), '_blank')}
title={tFormat('engine.studio.app.openTitle', locale, { label: packageApp.label })}
className="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
>
<ExternalLink className="h-3.5 w-3.5" />
{t('engine.studio.app.open', locale)}
</button>
) : appDraftPending ? (
{/* objectui#5800 — the 打开应用 teleport is retired: the canvas's 运行
mode IS the way to try the app without leaving the workbench.
The published-app state needs no chrome at all. */}
{packageApp ? null : appDraftPending ? (
<span
title={t('engine.studio.app.willOpenAfterPublish', locale)}
className="rounded bg-amber-400/15 px-2 py-0.5 text-[11px] text-amber-600 dark:text-amber-300"
Expand DownExpand Up@@ -1413,6 +1418,13 @@ export function InterfacesPillar({
// running app, not an editable draft — schema editing is the Data pillar's
// job — so those leaves are not draft-editable in this canvas.
const isEditable = !!Preview && !StudioCanvas;
// objectui#5800 — 设计⇄运行: one canvas, two modes (ADR-0080's pivot made
// visible). Run mode is pure subtraction: `editing=false` drops the design
// overlays (dashboard widget overlays, page block canvas) and the SAME
// renderer serves the interactive runtime — click 新建, enter a record.
// Selection state is retained so switching back to design keeps context.
const [canvasMode, setCanvasMode] = React.useState<'design' | 'run'>('design');
const designing = canvasMode === 'design';
// `kind: 'html'`/`'react'` pages are a `source` string (ADR-0080/0081),
// rendered by SourcePageEditor as a code-editor + live-preview split — there
// is no block tree, so `selection` never populates and the generic "click a
Expand DownExpand Up@@ -1535,9 +1547,33 @@ export function InterfacesPillar({
const canvasEl = (
<main className="flex min-w-0 flex-1 flex-col overflow-auto bg-muted/30 p-4">
<div className="mb-3 flex shrink-0 items-center gap-2">
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-[11px] text-primary">
<Eye className="h-3 w-3" /> {t('engine.studio.if.previewIsRuntime', locale)}
</span>
{/* objectui#5800 — the 设计⇄运行 switch replaces the static 实时预览
chip: same renderer either way, the switch only adds/removes the
design affordances. */}
<div className="inline-flex items-center gap-0.5 rounded-lg bg-muted p-1" data-testid="canvas-mode-toggle">
<button
type="button"
onClick={() => setCanvasMode('design')}
aria-pressed={designing}
className={
'rounded-md px-2.5 py-0.5 text-[11px] transition-all ' +
(designing ? 'bg-background font-medium text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground')
}
>
{t('engine.studio.if.modeDesign', locale)}
</button>
<button
type="button"
onClick={() => setCanvasMode('run')}
aria-pressed={!designing}
className={
'rounded-md px-2.5 py-0.5 text-[11px] transition-all ' +
(!designing ? 'bg-background font-medium text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground')
}
>
{t('engine.studio.if.modeRun', locale)}
</button>
</div>
{current && (
<span className="text-[11px] text-muted-foreground">
{current.type} · {current.name}
Expand DownExpand Up@@ -1599,9 +1635,9 @@ export function InterfacesPillar({
type={current.type}
name={current.name}
draft={draft}
editing
selection={selection}
onSelectionChange={setSelection}
editing={designing}
selection={designing ? selection : null}
onSelectionChange={designing ? setSelection : undefined}
onPatch={onPatch}
locale={locale}
/>
Expand Down
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
5 changes: 5 additions & 0 deletions .changeset/canvas-design-run-5800.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@object-ui/app-shell': minor
---

设计⇄运行 on the Interfaces canvas (#5800): a two-state switch in the canvas header flips the SAME renderer between design (selection + inspector + design overlays) and an interactive runtime (click 新建, enter records) — ADR-0080's design=run pivot made visible; selection context survives the round trip. The topbar's 打开应用 teleport is retired (run mode is the in-workbench way to try the app), and the topbar's app detection now matches the pillar's (draft-app fallback, re-resolved on draft saves and the metadata-refresh pulse) so a deep-link to /access can no longer claim the package has no app while /data shows one.
4 changes: 4 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1740,6 +1740,8 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.studio.if.noApp': 'This package has no app yet.',
'engine.studio.if.noNavItems': 'No nav items yet — (click “Edit” above to add)',
'engine.studio.if.previewIsRuntime': 'Live preview',
'engine.studio.if.modeDesign': 'Design',
'engine.studio.if.modeRun': 'Run',
'engine.studio.if.tabCanvas': 'Canvas',
'engine.studio.if.noAppTitle': 'This package has no app yet',
'engine.studio.if.noAppHint': 'Create an app to design its navigation and interfaces.',
Expand DownExpand Up@@ -3591,6 +3593,8 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.studio.if.noApp': '这个软件包还没有应用。',
'engine.studio.if.noNavItems': '还没有导航项 —(点上方「编辑」添加)',
'engine.studio.if.previewIsRuntime': '实时预览',
'engine.studio.if.modeDesign': '设计',
'engine.studio.if.modeRun': '运行',
'engine.studio.if.tabCanvas': '画布',
'engine.studio.if.noAppTitle': '这个软件包还没有应用',
'engine.studio.if.noAppHint': '创建一个应用来设计它的导航与界面。',
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#5800 — the 设计⇄运行 canvas switch (cloud#1609 增量二).
*
* ADR-0080's pivot (design state = run state, one renderer) made visible: the
* Interfaces canvas header carries a two-state switch; RUN mode is pure
* subtraction — `editing=false` drops the design overlays so the SAME
* renderer serves the interactive runtime. Pinned through the dashboard leaf
* because its design mode is the most explicit: `DashboardRenderer` swallows
* widget interaction behind `widget-click-overlay` elements exactly when
* designMode is on, so the overlay's presence IS the mode.
*
* Also pinned: selection context survives a run round-trip (the acceptance's
* 「切回设计不丢」), via the switch not clearing the pillar's selection state.
*/
import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

const NAV = [
{ id: 'nav_dash', type: 'dashboard', label: 'Overview', dashboardName: 'sales_overview' },
];

const DASHBOARD = {
name: 'sales_overview',
label: 'Sales Overview',
widgets: [
{ id: 'w1', type: 'metric', title: 'Total', options: { value: 42 } },
],
};

const mockClient = {
list: vi.fn(async (type: string) =>
type === 'app' ? [{ name: 'acme_app', label: 'Acme' }] : [],
),
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 === 'dashboard' && name === 'sales_overview') return { effective: DASHBOARD };
return { effective: { name } };
}),
getDraft: vi.fn(async () => null),
save: vi.fn(async () => ({})),
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 { registerMetadataPreview } from '../metadata-admin/preview-registry';
import { DashboardPreview } from '../metadata-admin/previews/DashboardPreview';

registerMetadataPreview('dashboard', DashboardPreview);

afterEach(cleanup);

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

async function openDashboardLeaf() {
renderPillar();
fireEvent.click(await screen.findByTitle('dashboard · sales_overview'));
await waitFor(() => expect(screen.getByTestId('canvas-mode-toggle')).toBeInTheDocument(), {
timeout: 4000,
});
}

describe('Interfaces canvas — 设计⇄运行 switch (objectui#5800)', () => {
it('design mode (default) swallows widget interaction behind the design overlay', async () => {
await openDashboardLeaf();
await waitFor(
() => expect(screen.getAllByTestId('widget-click-overlay').length).toBeGreaterThan(0),
{ timeout: 4000 },
);
});

it('run mode removes the overlays — the SAME renderer serves the interactive runtime', async () => {
await openDashboardLeaf();
await waitFor(
() => expect(screen.getAllByTestId('widget-click-overlay').length).toBeGreaterThan(0),
{ timeout: 4000 },
);
fireEvent.click(screen.getByRole('button', { name: 'Run' }));
await waitFor(() => expect(screen.queryAllByTestId('widget-click-overlay')).toHaveLength(0), {
timeout: 4000,
});
// ...and back: the switch is a round trip, not a one-way door.
fireEvent.click(screen.getByRole('button', { name: 'Design' }));
await waitFor(
() => expect(screen.getAllByTestId('widget-click-overlay').length).toBeGreaterThan(0),
{ timeout: 4000 },
);
});
});
104 changes: 70 additions & 34 deletions packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -91,7 +91,7 @@ import {
} from '../metadata-admin/nav-selection.js';
import { SourcePageEditor } from '../metadata-admin/previews/SourcePageEditor.js';
import { usePendingDrafts } from '../../preview/usePendingDrafts.js';
import { emitMetadataRefresh } from '../../assistant/assistantBus.js';
import { emitMetadataRefresh, subscribeMetadataRefresh } from '../../assistant/assistantBus.js';
import { formatMetadataError, formatPublishFailures, type PublishFailure } from './metadataError.js';
import { loadPackageSurfaces } from './packageSurfaces.js';
import { resolveSurface, findSurfaceInTree, type NavNode, type Surface } from './navSurface.js';
Expand DownExpand Up@@ -653,23 +653,35 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React
[appAddObjects, loadPackageObjects, shellClient, packageId, locale],
);

React.useEffect(() => {
let cancelled = false;
(async () => {
try {
const apps = (await shellClient.list('app', { packageId })) as Array<Record<string, unknown>>;
const first = (apps || [])
.map((a) => ({ name: String(a.name ?? ''), label: String(a.label ?? a.name ?? '') }))
.filter((a) => a.name)[0];
if (!cancelled) setPackageApp(first ?? null);
} catch {
if (!cancelled) setPackageApp(null);
// objectui#5800 顺手修 — the topbar's app detection used to disagree with the
// Interfaces pillar's (published-only read, no draftNonce dep, no refresh
// subscription, and never re-run on a pillar switch since /data and /access
// share one route element): a deep-link to /access could report 「还没有应用」
// while /data showed the app at the same moment. Same resolution as the
// pillar now: published first, DRAFT app fallback, re-resolved on draft
// saves and on the metadata-refresh pulse.
const resolvePackageApp = React.useCallback(async (): Promise<void> => {
try {
const apps = (await shellClient.list('app', { packageId })) as Array<Record<string, unknown>>;
let first = (apps || [])
.map((a) => ({ name: String(a.name ?? ''), label: String(a.label ?? a.name ?? '') }))
.filter((a) => a.name)[0];
if (!first) {
const drafts = await shellClient.listDrafts?.({ packageId, type: 'app' });
const d = drafts?.[0] as { name?: unknown; label?: unknown } | undefined;
if (d?.name) first = { name: String(d.name), label: String(d.label ?? d.name) };
}
})();
return () => {
cancelled = true;
};
}, [shellClient, packageId, publishNonce]);
setPackageApp(first ?? null);
} catch {
setPackageApp(null);
}
}, [shellClient, packageId]);
React.useEffect(() => {
void resolvePackageApp();
return subscribeMetadataRefresh(() => {
void resolvePackageApp();
});
}, [resolvePackageApp, publishNonce, draftNonce]);

// ADR-0057 P3 — the decided Studio grid: `[left: nav/tree] [center: canvas +
// properties] [right: chat]`. NOT keyed on the async agent catalog (that
Expand DownExpand Up@@ -818,17 +830,10 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React

{/* Package-level draft review + one atomic publish (replaces per-item 发布) */}
<div className="ml-auto flex shrink-0 items-center gap-2">
{packageApp ? (
<button
type="button"
onClick={() => window.open(resolveConsoleUrl(`apps/${encodeURIComponent(packageApp.name)}`), '_blank')}
title={tFormat('engine.studio.app.openTitle', locale, { label: packageApp.label })}
className="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
>
<ExternalLink className="h-3.5 w-3.5" />
{t('engine.studio.app.open', locale)}
</button>
) : appDraftPending ? (
{/* objectui#5800 — the 打开应用 teleport is retired: the canvas's 运行
mode IS the way to try the app without leaving the workbench.
The published-app state needs no chrome at all. */}
{packageApp ? null : appDraftPending ? (
<span
title={t('engine.studio.app.willOpenAfterPublish', locale)}
className="rounded bg-amber-400/15 px-2 py-0.5 text-[11px] text-amber-600 dark:text-amber-300"
Expand DownExpand Up@@ -1413,6 +1418,13 @@ export function InterfacesPillar({
// running app, not an editable draft — schema editing is the Data pillar's
// job — so those leaves are not draft-editable in this canvas.
const isEditable = !!Preview && !StudioCanvas;
// objectui#5800 — 设计⇄运行: one canvas, two modes (ADR-0080's pivot made
// visible). Run mode is pure subtraction: `editing=false` drops the design
// overlays (dashboard widget overlays, page block canvas) and the SAME
// renderer serves the interactive runtime — click 新建, enter a record.
// Selection state is retained so switching back to design keeps context.
const [canvasMode, setCanvasMode] = React.useState<'design' | 'run'>('design');
const designing = canvasMode === 'design';
// `kind: 'html'`/`'react'` pages are a `source` string (ADR-0080/0081),
// rendered by SourcePageEditor as a code-editor + live-preview split — there
// is no block tree, so `selection` never populates and the generic "click a
Expand DownExpand Up@@ -1535,9 +1547,33 @@ export function InterfacesPillar({
const canvasEl = (
<main className="flex min-w-0 flex-1 flex-col overflow-auto bg-muted/30 p-4">
<div className="mb-3 flex shrink-0 items-center gap-2">
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-[11px] text-primary">
<Eye className="h-3 w-3" /> {t('engine.studio.if.previewIsRuntime', locale)}
</span>
{/* objectui#5800 — the 设计⇄运行 switch replaces the static 实时预览
chip: same renderer either way, the switch only adds/removes the
design affordances. */}
<div className="inline-flex items-center gap-0.5 rounded-lg bg-muted p-1" data-testid="canvas-mode-toggle">
<button
type="button"
onClick={() => setCanvasMode('design')}
aria-pressed={designing}
className={
'rounded-md px-2.5 py-0.5 text-[11px] transition-all ' +
(designing ? 'bg-background font-medium text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground')
}
>
{t('engine.studio.if.modeDesign', locale)}
</button>
<button
type="button"
onClick={() => setCanvasMode('run')}
aria-pressed={!designing}
className={
'rounded-md px-2.5 py-0.5 text-[11px] transition-all ' +
(!designing ? 'bg-background font-medium text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground')
}
>
{t('engine.studio.if.modeRun', locale)}
</button>
</div>
{current && (
<span className="text-[11px] text-muted-foreground">
{current.type} · {current.name}
Expand DownExpand Up@@ -1599,9 +1635,9 @@ export function InterfacesPillar({
type={current.type}
name={current.name}
draft={draft}
editing
selection={selection}
onSelectionChange={setSelection}
editing={designing}
selection={designing ? selection : null}
onSelectionChange={designing ? setSelection : undefined}
onPatch={onPatch}
locale={locale}
/>
Expand Down
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
5 changes: 5 additions & 0 deletions .changeset/canvas-design-run-5800.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@object-ui/app-shell': minor
---

设计⇄运行 on the Interfaces canvas (#5800): a two-state switch in the canvas header flips the SAME renderer between design (selection + inspector + design overlays) and an interactive runtime (click 新建, enter records) — ADR-0080's design=run pivot made visible; selection context survives the round trip. The topbar's 打开应用 teleport is retired (run mode is the in-workbench way to try the app), and the topbar's app detection now matches the pillar's (draft-app fallback, re-resolved on draft saves and the metadata-refresh pulse) so a deep-link to /access can no longer claim the package has no app while /data shows one.
4 changes: 4 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1740,6 +1740,8 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.studio.if.noApp': 'This package has no app yet.',
'engine.studio.if.noNavItems': 'No nav items yet — (click “Edit” above to add)',
'engine.studio.if.previewIsRuntime': 'Live preview',
'engine.studio.if.modeDesign': 'Design',
'engine.studio.if.modeRun': 'Run',
'engine.studio.if.tabCanvas': 'Canvas',
'engine.studio.if.noAppTitle': 'This package has no app yet',
'engine.studio.if.noAppHint': 'Create an app to design its navigation and interfaces.',
Expand DownExpand Up@@ -3591,6 +3593,8 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.studio.if.noApp': '这个软件包还没有应用。',
'engine.studio.if.noNavItems': '还没有导航项 —(点上方「编辑」添加)',
'engine.studio.if.previewIsRuntime': '实时预览',
'engine.studio.if.modeDesign': '设计',
'engine.studio.if.modeRun': '运行',
'engine.studio.if.tabCanvas': '画布',
'engine.studio.if.noAppTitle': '这个软件包还没有应用',
'engine.studio.if.noAppHint': '创建一个应用来设计它的导航与界面。',
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#5800 — the 设计⇄运行 canvas switch (cloud#1609 增量二).
*
* ADR-0080's pivot (design state = run state, one renderer) made visible: the
* Interfaces canvas header carries a two-state switch; RUN mode is pure
* subtraction — `editing=false` drops the design overlays so the SAME
* renderer serves the interactive runtime. Pinned through the dashboard leaf
* because its design mode is the most explicit: `DashboardRenderer` swallows
* widget interaction behind `widget-click-overlay` elements exactly when
* designMode is on, so the overlay's presence IS the mode.
*
* Also pinned: selection context survives a run round-trip (the acceptance's
* 「切回设计不丢」), via the switch not clearing the pillar's selection state.
*/
import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

const NAV = [
{ id: 'nav_dash', type: 'dashboard', label: 'Overview', dashboardName: 'sales_overview' },
];

const DASHBOARD = {
name: 'sales_overview',
label: 'Sales Overview',
widgets: [
{ id: 'w1', type: 'metric', title: 'Total', options: { value: 42 } },
],
};

const mockClient = {
list: vi.fn(async (type: string) =>
type === 'app' ? [{ name: 'acme_app', label: 'Acme' }] : [],
),
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 === 'dashboard' && name === 'sales_overview') return { effective: DASHBOARD };
return { effective: { name } };
}),
getDraft: vi.fn(async () => null),
save: vi.fn(async () => ({})),
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 { registerMetadataPreview } from '../metadata-admin/preview-registry';
import { DashboardPreview } from '../metadata-admin/previews/DashboardPreview';

registerMetadataPreview('dashboard', DashboardPreview);

afterEach(cleanup);

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

async function openDashboardLeaf() {
renderPillar();
fireEvent.click(await screen.findByTitle('dashboard · sales_overview'));
await waitFor(() => expect(screen.getByTestId('canvas-mode-toggle')).toBeInTheDocument(), {
timeout: 4000,
});
}

describe('Interfaces canvas — 设计⇄运行 switch (objectui#5800)', () => {
it('design mode (default) swallows widget interaction behind the design overlay', async () => {
await openDashboardLeaf();
await waitFor(
() => expect(screen.getAllByTestId('widget-click-overlay').length).toBeGreaterThan(0),
{ timeout: 4000 },
);
});

it('run mode removes the overlays — the SAME renderer serves the interactive runtime', async () => {
await openDashboardLeaf();
await waitFor(
() => expect(screen.getAllByTestId('widget-click-overlay').length).toBeGreaterThan(0),
{ timeout: 4000 },
);
fireEvent.click(screen.getByRole('button', { name: 'Run' }));
await waitFor(() => expect(screen.queryAllByTestId('widget-click-overlay')).toHaveLength(0), {
timeout: 4000,
});
// ...and back: the switch is a round trip, not a one-way door.
fireEvent.click(screen.getByRole('button', { name: 'Design' }));
await waitFor(
() => expect(screen.getAllByTestId('widget-click-overlay').length).toBeGreaterThan(0),
{ timeout: 4000 },
);
});
});
104 changes: 70 additions & 34 deletions packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -91,7 +91,7 @@ import {
} from '../metadata-admin/nav-selection.js';
import { SourcePageEditor } from '../metadata-admin/previews/SourcePageEditor.js';
import { usePendingDrafts } from '../../preview/usePendingDrafts.js';
import { emitMetadataRefresh } from '../../assistant/assistantBus.js';
import { emitMetadataRefresh, subscribeMetadataRefresh } from '../../assistant/assistantBus.js';
import { formatMetadataError, formatPublishFailures, type PublishFailure } from './metadataError.js';
import { loadPackageSurfaces } from './packageSurfaces.js';
import { resolveSurface, findSurfaceInTree, type NavNode, type Surface } from './navSurface.js';
Expand DownExpand Up@@ -653,23 +653,35 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React
[appAddObjects, loadPackageObjects, shellClient, packageId, locale],
);

React.useEffect(() => {
let cancelled = false;
(async () => {
try {
const apps = (await shellClient.list('app', { packageId })) as Array<Record<string, unknown>>;
const first = (apps || [])
.map((a) => ({ name: String(a.name ?? ''), label: String(a.label ?? a.name ?? '') }))
.filter((a) => a.name)[0];
if (!cancelled) setPackageApp(first ?? null);
} catch {
if (!cancelled) setPackageApp(null);
// objectui#5800 顺手修 — the topbar's app detection used to disagree with the
// Interfaces pillar's (published-only read, no draftNonce dep, no refresh
// subscription, and never re-run on a pillar switch since /data and /access
// share one route element): a deep-link to /access could report 「还没有应用」
// while /data showed the app at the same moment. Same resolution as the
// pillar now: published first, DRAFT app fallback, re-resolved on draft
// saves and on the metadata-refresh pulse.
const resolvePackageApp = React.useCallback(async (): Promise<void> => {
try {
const apps = (await shellClient.list('app', { packageId })) as Array<Record<string, unknown>>;
let first = (apps || [])
.map((a) => ({ name: String(a.name ?? ''), label: String(a.label ?? a.name ?? '') }))
.filter((a) => a.name)[0];
if (!first) {
const drafts = await shellClient.listDrafts?.({ packageId, type: 'app' });
const d = drafts?.[0] as { name?: unknown; label?: unknown } | undefined;
if (d?.name) first = { name: String(d.name), label: String(d.label ?? d.name) };
}
})();
return () => {
cancelled = true;
};
}, [shellClient, packageId, publishNonce]);
setPackageApp(first ?? null);
} catch {
setPackageApp(null);
}
}, [shellClient, packageId]);
React.useEffect(() => {
void resolvePackageApp();
return subscribeMetadataRefresh(() => {
void resolvePackageApp();
});
}, [resolvePackageApp, publishNonce, draftNonce]);

// ADR-0057 P3 — the decided Studio grid: `[left: nav/tree] [center: canvas +
// properties] [right: chat]`. NOT keyed on the async agent catalog (that
Expand DownExpand Up@@ -818,17 +830,10 @@ export function StudioDesignSurface({ aiSlot }: StudioDesignSurfaceProps): React

{/* Package-level draft review + one atomic publish (replaces per-item 发布) */}
<div className="ml-auto flex shrink-0 items-center gap-2">
{packageApp ? (
<button
type="button"
onClick={() => window.open(resolveConsoleUrl(`apps/${encodeURIComponent(packageApp.name)}`), '_blank')}
title={tFormat('engine.studio.app.openTitle', locale, { label: packageApp.label })}
className="inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
>
<ExternalLink className="h-3.5 w-3.5" />
{t('engine.studio.app.open', locale)}
</button>
) : appDraftPending ? (
{/* objectui#5800 — the 打开应用 teleport is retired: the canvas's 运行
mode IS the way to try the app without leaving the workbench.
The published-app state needs no chrome at all. */}
{packageApp ? null : appDraftPending ? (
<span
title={t('engine.studio.app.willOpenAfterPublish', locale)}
className="rounded bg-amber-400/15 px-2 py-0.5 text-[11px] text-amber-600 dark:text-amber-300"
Expand DownExpand Up@@ -1413,6 +1418,13 @@ export function InterfacesPillar({
// running app, not an editable draft — schema editing is the Data pillar's
// job — so those leaves are not draft-editable in this canvas.
const isEditable = !!Preview && !StudioCanvas;
// objectui#5800 — 设计⇄运行: one canvas, two modes (ADR-0080's pivot made
// visible). Run mode is pure subtraction: `editing=false` drops the design
// overlays (dashboard widget overlays, page block canvas) and the SAME
// renderer serves the interactive runtime — click 新建, enter a record.
// Selection state is retained so switching back to design keeps context.
const [canvasMode, setCanvasMode] = React.useState<'design' | 'run'>('design');
const designing = canvasMode === 'design';
// `kind: 'html'`/`'react'` pages are a `source` string (ADR-0080/0081),
// rendered by SourcePageEditor as a code-editor + live-preview split — there
// is no block tree, so `selection` never populates and the generic "click a
Expand DownExpand Up@@ -1535,9 +1547,33 @@ export function InterfacesPillar({
const canvasEl = (
<main className="flex min-w-0 flex-1 flex-col overflow-auto bg-muted/30 p-4">
<div className="mb-3 flex shrink-0 items-center gap-2">
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-[11px] text-primary">
<Eye className="h-3 w-3" /> {t('engine.studio.if.previewIsRuntime', locale)}
</span>
{/* objectui#5800 — the 设计⇄运行 switch replaces the static 实时预览
chip: same renderer either way, the switch only adds/removes the
design affordances. */}
<div className="inline-flex items-center gap-0.5 rounded-lg bg-muted p-1" data-testid="canvas-mode-toggle">
<button
type="button"
onClick={() => setCanvasMode('design')}
aria-pressed={designing}
className={
'rounded-md px-2.5 py-0.5 text-[11px] transition-all ' +
(designing ? 'bg-background font-medium text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground')
}
>
{t('engine.studio.if.modeDesign', locale)}
</button>
<button
type="button"
onClick={() => setCanvasMode('run')}
aria-pressed={!designing}
className={
'rounded-md px-2.5 py-0.5 text-[11px] transition-all ' +
(!designing ? 'bg-background font-medium text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground')
}
>
{t('engine.studio.if.modeRun', locale)}
</button>
</div>
{current && (
<span className="text-[11px] text-muted-foreground">
{current.type} · {current.name}
Expand DownExpand Up@@ -1599,9 +1635,9 @@ export function InterfacesPillar({
type={current.type}
name={current.name}
draft={draft}
editing
selection={selection}
onSelectionChange={setSelection}
editing={designing}
selection={designing ? selection : null}
onSelectionChange={designing ? setSelection : undefined}
onPatch={onPatch}
locale={locale}
/>
Expand Down
Loading