diff --git a/.changeset/preview-screen-links.md b/.changeset/preview-screen-links.md
new file mode 100644
index 00000000..28048828
--- /dev/null
+++ b/.changeset/preview-screen-links.md
@@ -0,0 +1,6 @@
+---
+"@open-codesign/runtime": minor
+"@open-codesign/core": patch
+---
+
+Switch multi-page prototypes inside one preview document. Matching `id` and `data-oc-screen` values identify each screen, `#id` links select one screen, and shared chrome stays visible. Inactive screens use the `hidden` attribute so their own layout remains intact.
diff --git a/apps/desktop/src/renderer/src/preview/screens.browser.test.ts b/apps/desktop/src/renderer/src/preview/screens.browser.test.ts
new file mode 100644
index 00000000..c317e44b
--- /dev/null
+++ b/apps/desktop/src/renderer/src/preview/screens.browser.test.ts
@@ -0,0 +1,91 @@
+import { findSystemChrome } from '@open-codesign/exporters';
+import {
+ buildInteractivePreviewDocument,
+ INTERACTIVE_PREVIEW_SANDBOX,
+} from '@open-codesign/runtime';
+import puppeteer, { type Browser } from 'puppeteer-core';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+
+const chrome = await findSystemChrome().catch(() => null);
+
+describe.skipIf(!chrome)('interactive preview screens in system Chrome', () => {
+ let browser: Browser;
+
+ beforeAll(async () => {
+ if (!chrome) throw new Error('System Chrome unavailable');
+ browser = await puppeteer.launch({ executablePath: chrome, headless: true });
+ }, 30_000);
+
+ afterAll(async () => {
+ await browser?.close();
+ }, 30_000);
+
+ it('hides grid/flex screens, preserves their layout and state, and handles later screens', async () => {
+ const page = await browser.newPage();
+ try {
+ await page.setContent('');
+ await page.$eval(
+ 'iframe',
+ (node, options) => {
+ node.sandbox.value = options.sandbox;
+ node.srcdoc = options.document;
+ },
+ {
+ sandbox: INTERACTIVE_PREVIEW_SANDBOX,
+ document: buildInteractivePreviewDocument(
+ '
',
+ { path: 'index.html' },
+ ),
+ },
+ );
+ const frame = await (await page.$('iframe'))?.contentFrame();
+ if (!frame) throw new Error('Missing screen preview frame');
+ await frame.waitForSelector('#home', { visible: true });
+ await frame.waitForSelector('#pricing', { hidden: true });
+ expect(
+ await frame.evaluate(() => document.documentElement.hasAttribute('data-oc-screen')),
+ ).toBe(false);
+ await frame.type('#home input', ' kept');
+ await frame.click('a[href="#pricing"]');
+ await frame.waitForSelector('#home', { hidden: true });
+ expect(await frame.evaluate(() => document.activeElement?.getAttribute('href'))).toBe(
+ '#pricing',
+ );
+ expect(await frame.$eval('#pricing', (node) => getComputedStyle(node).display)).toBe('flex');
+ expect(
+ await frame.$eval('nav', (node) => node.getBoundingClientRect().height),
+ ).toBeGreaterThan(0);
+ await frame.focus('#back');
+ await page.keyboard.press('Enter');
+ await frame.waitForSelector('#pricing', { hidden: true });
+ expect(await frame.evaluate(() => document.activeElement?.id)).toBe('home');
+ expect(await frame.$eval('#home', (node) => node.getAttribute('tabindex'))).toBe('-1');
+ await frame.focus('nav a[href="#home"]');
+ expect(await frame.$eval('#home', (node) => node.hasAttribute('tabindex'))).toBe(false);
+ expect(await frame.$eval('#home', (node) => getComputedStyle(node).display)).toBe('grid');
+ expect(
+ await frame.$eval('#home input', (node) => (node as HTMLInputElement).value),
+ ).toContain('kept');
+ await frame.$eval('#home', (node) => node.setAttribute('tabindex', '0'));
+ await frame.click('nav a[href="#pricing"]');
+ await frame.focus('#back');
+ await page.keyboard.press('Enter');
+ expect(await frame.evaluate(() => document.activeElement?.id)).toBe('home');
+ await frame.focus('nav a[href="#home"]');
+ expect(await frame.$eval('#home', (node) => node.getAttribute('tabindex'))).toBe('0');
+ await frame.evaluate(() => {
+ const later = document.createElement('section');
+ later.id = 'later';
+ later.setAttribute('data-oc-screen', 'later');
+ later.textContent = 'Later screen';
+ document.body.appendChild(later);
+ });
+ await frame.waitForSelector('#later', { hidden: true });
+ await frame.$eval('#home', (node) => node.remove());
+ await frame.waitForSelector('#pricing', { visible: true });
+ expect(await frame.evaluate(() => location.hash)).toBe('');
+ } finally {
+ await page.close();
+ }
+ }, 30_000);
+});
diff --git a/packages/core/src/prompts/sections/output-rules.md b/packages/core/src/prompts/sections/output-rules.md
index f9928a2f..455cbcfd 100644
--- a/packages/core/src/prompts/sections/output-rules.md
+++ b/packages/core/src/prompts/sections/output-rules.md
@@ -7,6 +7,7 @@
- `App.jsx` is JSX for the host runtime, not standalone HTML. Define `App` and end with `ReactDOM.createRoot(document.getElementById('root')).render();`.
- The host supplies the document shell and libraries. Do not put imports, CDN loaders, ``, ``, ``, ``, a root div, or a global `render()` helper in `App.jsx`.
- Keep connected screens in one source unless multiple files are needed. Use named components, readable multiline JSX and CSS, and component-sized edits. Checkpoints must be syntactically complete with defined dependencies, not half-components or unclosed tags/braces.
+- Multiple pages: one source, matching `id` and `data-oc-screen`, ``, shared chrome outside.
## Content and interaction
diff --git a/packages/runtime/src/overlay.test.ts b/packages/runtime/src/overlay.test.ts
index 9600143a..0a6781a8 100644
--- a/packages/runtime/src/overlay.test.ts
+++ b/packages/runtime/src/overlay.test.ts
@@ -90,6 +90,7 @@ describe('OVERLAY_SCRIPT reattach loop warning throttle', () => {
interface ListenerHarness {
body: object;
+ documentElement: { getAttribute: (name: string) => string | null };
selectorMatches: Map;
elementIds: Map;
documentListeners: Map void>;
@@ -99,7 +100,7 @@ interface ListenerHarness {
setHitTarget: (target: unknown) => void;
runTick: () => void;
queueMutations: (records: object[]) => void;
- hitLayer: { isConnected: boolean; style: Record };
+ hitLayer: { isConnected: boolean; style: Record; textContent?: string };
}
function runOverlayWithHarness(script = OVERLAY_SCRIPT): ListenerHarness {
@@ -128,6 +129,13 @@ function runOverlayWithHarness(script = OVERLAY_SCRIPT): ListenerHarness {
querySelector: (selector: string) => selectorMatches.get(selector)?.[0] ?? null,
createElement: () => hitLayer,
documentElement: {
+ attrs: {} as Record,
+ getAttribute(name: string) {
+ return this.attrs[name] ?? null;
+ },
+ setAttribute(name: string, value: string) {
+ this.attrs[name] = value;
+ },
appendChild: () => {
hitLayer.isConnected = true;
},
@@ -170,6 +178,7 @@ function runOverlayWithHarness(script = OVERLAY_SCRIPT): ListenerHarness {
sandbox(fakeWindow, fakeDocument, { warn: () => {} }, fakeSetInterval);
return {
body,
+ documentElement: fakeDocument.documentElement,
selectorMatches,
elementIds,
documentListeners,
@@ -231,6 +240,53 @@ describe('OVERLAY_SCRIPT fragment navigation', () => {
expect(preventDefault).toHaveBeenCalledOnce();
expect(stopPropagation).toHaveBeenCalledOnce();
});
+
+ function screenDouble(id: string) {
+ const attrs: Record = { 'data-oc-screen': id };
+ return {
+ id,
+ scrollIntoView: vi.fn(),
+ getAttribute: (name: string) => attrs[name] ?? null,
+ setAttribute: (name: string, value: string) => {
+ attrs[name] = value;
+ },
+ removeAttribute: (name: string) => {
+ delete attrs[name];
+ },
+ };
+ }
+
+ it('shows the targeted data-oc-screen without scrolling or leaving the document', () => {
+ const h = runOverlayWithHarness();
+ const home = screenDouble('home');
+ const pricing = screenDouble('pricing');
+ h.elementIds.set('pricing', pricing);
+ h.selectorMatches.set('[data-oc-screen]', [h.documentElement, home, pricing]);
+ const preventDefault = vi.fn();
+ h.documentListeners.get('click')?.({
+ target: { tagName: 'A', href: '#pricing', getAttribute: () => '#pricing' },
+ preventDefault,
+ stopPropagation: vi.fn(),
+ });
+ expect(h.documentElement.getAttribute('data-oc-active-screen')).toBe('pricing');
+ expect(h.documentElement.getAttribute('data-oc-screen')).toBeNull();
+ expect(pricing.getAttribute('hidden')).toBeNull();
+ expect(home.getAttribute('hidden')).toBe('');
+ expect(h.documentElement.getAttribute('hidden')).toBeNull();
+ expect(preventDefault).toHaveBeenCalledOnce();
+ expect(pricing.scrollIntoView).not.toHaveBeenCalled();
+ });
+
+ it('selects the first screen when the document renders one later', () => {
+ const h = runOverlayWithHarness();
+ const home = screenDouble('home');
+ const pricing = screenDouble('pricing');
+ h.selectorMatches.set('[data-oc-screen]', [home, pricing]);
+ h.runTick();
+ expect(h.documentElement.getAttribute('data-oc-active-screen')).toBe('home');
+ expect(home.getAttribute('hidden')).toBeNull();
+ expect(pricing.getAttribute('hidden')).toBe('');
+ });
});
describe('OVERLAY_SCRIPT fullscreen Escape forwarding', () => {
diff --git a/packages/runtime/src/overlay.ts b/packages/runtime/src/overlay.ts
index 89045f5c..dc9eff0d 100644
--- a/packages/runtime/src/overlay.ts
+++ b/packages/runtime/src/overlay.ts
@@ -470,10 +470,9 @@ export function buildOverlayScript(sourceEdit?: SourceEditOverlayContext): strin
} catch (err) { console.warn('[overlay] postMessage ELEMENT_SELECTED failed:', err); }
return;
}
- // Default mode: block ALL navigating links — the sandbox iframe has no
- // routing and any real navigation (including hash jumps to non-existent
- // ids) would blank the preview. Agent should use React view-state for
- // multi-page designs; see agent.ts AGENTIC_TOOL_GUIDANCE.
+ // Default mode: block document navigation. The sandbox iframe has no
+ // router, so a real location change blanks the preview. Same-document
+ // screens use data-oc-screen; ordinary hash links only scroll.
var anchor = e.target;
while (anchor && anchor.tagName !== 'A') anchor = anchor.parentElement;
if (anchor && (anchor.href || anchor.getAttribute('href'))) {
@@ -487,6 +486,10 @@ export function buildOverlayScript(sourceEdit?: SourceEditOverlayContext): strin
if (target) {
// A workspace base URL turns even #fragment links into document navigation.
e.preventDefault();
+ if (target.getAttribute && target.getAttribute('data-oc-screen') != null) {
+ showScreen(target.id || target.getAttribute('data-oc-screen'));
+ return;
+ }
target.scrollIntoView();
return;
}
@@ -594,6 +597,62 @@ export function buildOverlayScript(sourceEdit?: SourceEditOverlayContext): strin
{ evt: 'submit', fn: function(e) { e.preventDefault(); } }
];
if (sourceEditContext) installs.push({ evt: 'pointerdown', fn: onEditPointer }, { evt: 'pointermove', fn: onEditPointer });
+ var screenStyle = null;
+ function showScreen(id) {
+ var root = document.documentElement;
+ if (!id || !root || !root.setAttribute || !document.querySelectorAll) return;
+ root.setAttribute('data-oc-active-screen', id);
+ var screens = document.querySelectorAll('[data-oc-screen]');
+ var active = document.activeElement;
+ var destination = null;
+ var moveFocus = false;
+ for (var i = 0; i < screens.length; i++) {
+ var screen = screens[i];
+ if (screen === root) continue;
+ var name = screen.id || screen.getAttribute('data-oc-screen');
+ if (name === id) {
+ destination = screen;
+ screen.removeAttribute('hidden');
+ } else {
+ if (active && screen.contains(active)) moveFocus = true;
+ screen.setAttribute('hidden', '');
+ }
+ }
+ if (moveFocus && destination) {
+ if (!destination.hasAttribute('tabindex')) {
+ destination.setAttribute('tabindex', '-1');
+ // Keep the target focusable until blur; immediate removal can reset focus.
+ destination.addEventListener('blur', function(e) {
+ e.currentTarget.removeAttribute('tabindex');
+ }, { once: true });
+ }
+ destination.focus({ preventScroll: true });
+ }
+ }
+ function ensureScreens() {
+ var root = document.documentElement;
+ if (!root || !root.getAttribute || !document.querySelectorAll) return;
+ var screens = document.querySelectorAll('[data-oc-screen]');
+ if (!screens || !screens.length) return;
+ if (!screenStyle) {
+ screenStyle = document.createElement('style');
+ // Authored grid/flex display rules otherwise override the native hidden style.
+ screenStyle.textContent = ':root[data-oc-active-screen] [data-oc-screen][hidden]{display:none!important;}';
+ root.appendChild(screenStyle);
+ }
+ var current = root.getAttribute('data-oc-active-screen');
+ var first = null;
+ for (var i = 0; i < screens.length; i++) {
+ if (screens[i] === root) continue;
+ if (!first) first = screens[i];
+ var name = screens[i].id || screens[i].getAttribute('data-oc-screen');
+ if (current && name === current) {
+ showScreen(current);
+ return;
+ }
+ }
+ if (first) showScreen(first.id || first.getAttribute('data-oc-screen'));
+ }
function reattach() {
if (currentMode === 'source-edit') {
syncEditHitLayer();
@@ -607,6 +666,7 @@ export function buildOverlayScript(sourceEdit?: SourceEditOverlayContext): strin
try { document.removeEventListener(spec.evt, spec.fn, true); } catch (err) { warnOnce('removeEventListener failed for ' + spec.evt, err); }
try { document.addEventListener(spec.evt, spec.fn, true); } catch (err) { warnOnce('addEventListener failed for ' + spec.evt, err); }
}
+ ensureScreens();
if (!window.__cs_err) {
try { window.addEventListener('error', onError, true); window.__cs_err = true; } catch (err) { warnOnce('attach window error listener failed', err); }
}