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( + '
PricingBack to home
', + { 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); } }