Skip to content
6 changes: 6 additions & 0 deletions .changeset/preview-screen-links.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -394,11 +394,11 @@ describe('full FilesTab source edit browser + real IPC handlers over HTTP', () =
const element = input.asElement();
if (!element) throw new Error('Missing edit textarea');
await element.evaluate((node) => {
if (node instanceof HTMLTextAreaElement) node.focus();
if (node instanceof HTMLTextAreaElement) {
node.focus();
node.select();
}
});
await page.keyboard.down('Control');
await page.keyboard.press('A');
await page.keyboard.up('Control');
await page.keyboard.type(value);
await page.click(selector, { delay: clickDelay });
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,9 +216,9 @@ describe.skipIf(!chrome)('tweak keyboard persistence in system Chrome', () => {
const input = await page.$(selector);
if (!input) throw new Error('Missing input');
await input.focus();
await page.keyboard.down('Control');
await page.keyboard.press('A');
await page.keyboard.up('Control');
await input.evaluate((node) => {
if (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) node.select();
});
await page.keyboard.type(value, { delay: 20 });
expect(
await input.evaluate((node) => node.isConnected && document.activeElement === node),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,9 +188,9 @@ describe.skipIf(!chrome)('active composer fake gate in system Chrome', () => {
),
).toBe(true);
await page.focus('textarea');
await page.keyboard.down('Control');
await page.keyboard.press('A');
await page.keyboard.up('Control');
await page.$eval('textarea', (node) => {
if (node instanceof HTMLTextAreaElement) node.select();
});
await page.keyboard.press('Backspace');
await page.waitForSelector('.codesign-active-message-actions', { hidden: true });
expect(await page.$('.codesign-active-message-help')).toBeNull();
Expand Down
91 changes: 91 additions & 0 deletions apps/desktop/src/renderer/src/preview/screens.browser.test.ts
Original file line number Diff line number Diff line change
@@ -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('<iframe title="screens"></iframe>');
await page.$eval(
'iframe',
(node, options) => {
node.sandbox.value = options.sandbox;
node.srcdoc = options.document;
},
{
sandbox: INTERACTIVE_PREVIEW_SANDBOX,
document: buildInteractivePreviewDocument(
'<!doctype html><html><head><style>[data-oc-screen]{display:grid;gap:12px;}</style></head><body><nav><a href="#home">Home</a><a href="#pricing">Pricing</a></nav><section id="home" data-oc-screen="home"><input value="draft"></section><section id="pricing" data-oc-screen="pricing" style="display:flex;gap:8px;">Pricing<a id="back" href="#home">Back to home</a></section></body></html>',
{ 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);
});
4 changes: 2 additions & 2 deletions apps/desktop/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
// Concurrent Chromium and filesystem suites otherwise exhaust Windows hosts.
maxWorkers: process.platform === 'win32' ? 2 : undefined,
// Concurrent Chromium and filesystem suites exhaust Windows and macOS hosts.
maxWorkers: process.platform === 'linux' ? undefined : 2,
},
});
1 change: 1 addition & 0 deletions packages/core/src/prompts/sections/output-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(<App />);`.
- The host supplies the document shell and libraries. Do not put imports, CDN loaders, `<!doctype>`, `<html>`, `<head>`, `<body>`, a root div, or a global `render(<App />)` 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`, `<a href="#id">`, shared chrome outside.

## Content and interaction

Expand Down
58 changes: 57 additions & 1 deletion packages/runtime/src/overlay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ describe('OVERLAY_SCRIPT reattach loop warning throttle', () => {

interface ListenerHarness {
body: object;
documentElement: { getAttribute: (name: string) => string | null };
selectorMatches: Map<string, unknown[]>;
elementIds: Map<string, unknown>;
documentListeners: Map<string, (e: unknown) => void>;
Expand All @@ -99,7 +100,7 @@ interface ListenerHarness {
setHitTarget: (target: unknown) => void;
runTick: () => void;
queueMutations: (records: object[]) => void;
hitLayer: { isConnected: boolean; style: Record<string, string> };
hitLayer: { isConnected: boolean; style: Record<string, string>; textContent?: string };
}

function runOverlayWithHarness(script = OVERLAY_SCRIPT): ListenerHarness {
Expand Down Expand Up @@ -128,6 +129,13 @@ function runOverlayWithHarness(script = OVERLAY_SCRIPT): ListenerHarness {
querySelector: (selector: string) => selectorMatches.get(selector)?.[0] ?? null,
createElement: () => hitLayer,
documentElement: {
attrs: {} as Record<string, string>,
getAttribute(name: string) {
return this.attrs[name] ?? null;
},
setAttribute(name: string, value: string) {
this.attrs[name] = value;
},
appendChild: () => {
hitLayer.isConnected = true;
},
Expand Down Expand Up @@ -170,6 +178,7 @@ function runOverlayWithHarness(script = OVERLAY_SCRIPT): ListenerHarness {
sandbox(fakeWindow, fakeDocument, { warn: () => {} }, fakeSetInterval);
return {
body,
documentElement: fakeDocument.documentElement,
selectorMatches,
elementIds,
documentListeners,
Expand Down Expand Up @@ -231,6 +240,53 @@ describe('OVERLAY_SCRIPT fragment navigation', () => {
expect(preventDefault).toHaveBeenCalledOnce();
expect(stopPropagation).toHaveBeenCalledOnce();
});

function screenDouble(id: string) {
const attrs: Record<string, string> = { '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', () => {
Expand Down
68 changes: 64 additions & 4 deletions packages/runtime/src/overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'))) {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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();
Expand All @@ -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); }
}
Expand Down
Loading