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
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
57 changes: 56 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,52 @@ 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-screen')).toBe('pricing');
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-screen')).toBe('home');
expect(home.getAttribute('hidden')).toBeNull();
expect(pricing.getAttribute('hidden')).toBe('');
});
});

describe('OVERLAY_SCRIPT fullscreen Escape forwarding', () => {
Expand Down
38 changes: 34 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,32 @@ 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 });
function showScreen(id) {
var root = document.documentElement;
if (!id || !root || !root.setAttribute || !document.querySelectorAll) return;
root.setAttribute('data-oc-screen', id);
var screens = document.querySelectorAll('[data-oc-screen]');
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) screen.removeAttribute('hidden');
else screen.setAttribute('hidden', '');
}
}
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;
var current = root.getAttribute('data-oc-screen');
for (var i = 0; i < screens.length; i++) {
var name = screens[i].id || screens[i].getAttribute('data-oc-screen');
if (current && name === current) return;
}
var first = screens[0];
showScreen(first.id || first.getAttribute('data-oc-screen'));
}
function reattach() {
if (currentMode === 'source-edit') {
syncEditHitLayer();
Expand All @@ -607,6 +636,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