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
6 changes: 4 additions & 2 deletions src/components/Dialogs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,9 @@ export function Dialogs({
</div>
<div className="preview-stage">
<PreviewFrame
autoFocus
frameKey={`${selectedRun.id}-${selectedPhase}`}
onEscape={onClosePreview}
scrolling="no"
src={selectedPreviewPath}
title={`${getRunTitle(selectedRun)} Phase ${selectedPhase} output`}
Expand All @@ -102,7 +104,7 @@ export function Dialogs({
</div>
</div>
<div className="preview-stage">
<PreviewFrame scrolling="no" src="/previews/showcase/index.html" title="Showcase Nord Stage 4" />
<PreviewFrame autoFocus onEscape={onCloseShowcase} scrolling="no" src="/previews/showcase/index.html" title="Showcase Nord Stage 4" />
</div>
</div>
)}
Expand Down Expand Up @@ -174,7 +176,7 @@ export function Dialogs({
<button type="button" onClick={onCloseReport}>Close</button>
</div>
</div>
<PreviewFrame className="report-frame" src={selectedReport.reportPath} title={`${getRunTitle(selectedReport)} evaluation report`} />
<PreviewFrame className="report-frame" onEscape={onCloseReport} src={selectedReport.reportPath} title={`${getRunTitle(selectedReport)} evaluation report`} />
</div>
)}
</>
Expand Down
58 changes: 54 additions & 4 deletions src/components/PreviewFrame.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// The published-build iframe used by every preview and report overlay, with a
// loading state and a load-failure fallback.
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import type { SyntheticEvent } from 'react'

// Backstop for the truly-opaque case (a cross-origin frame, or a server that
// rejects HEAD): only fires while still loading. Generous enough that a
Expand All @@ -14,14 +15,65 @@ export function PreviewFrame({
src,
title,
frameKey,
autoFocus,
onEscape,
}: {
className?: string
scrolling?: 'no'
src: string
title: string
frameKey?: string
/**
* Focus the iframe once its build loads, so previews that listen for
* computer-keyboard input on their own window (the showcase, candidate
* builds) are playable immediately — without a click inside the frame
* first. Real focus also means the keystrokes are trusted user activation
* inside the frame, which is what lets its AudioContext start.
*/
autoFocus?: boolean
/**
* Called when Escape is pressed *inside* the frame. Once focus is in the
* iframe the parent window never sees the keydown, so same-origin previews
* get a listener attached to their content window; without this, Escape
* would silently stop closing the overlay.
*/
onEscape?: () => void
}) {
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading')
// Latest-callback ref: the content-window listener is attached once per
// frame load, but Dialogs passes inline close handlers that change identity
// every render.
const onEscapeRef = useRef(onEscape)
onEscapeRef.current = onEscape

const handleFrameLoad = (event: SyntheticEvent<HTMLIFrameElement>) => {
// A 404/5xx body still fires onLoad, so never let a late load event
// overwrite the probe's error verdict.
setState((current) => (current === 'error' ? current : 'ready'))

const frame = event.currentTarget
try {
// Same-origin only (all previews are served under /previews and
// /reports); a cross-origin frame throws and gets skipped. Each
// navigation creates a fresh content window, so re-attach on every
// load — the old window (and its listener) is gone.
frame.contentWindow?.addEventListener('keydown', (keyEvent) => {
if (keyEvent.key !== 'Escape') return
// A preview that consumes Escape itself (the showcase's section-zoom
// dialog preventDefaults it to close just the zoom) keeps the outer
// overlay open — Escape peels one layer at a time. This listener runs
// before the preview's own (it registers at load, theirs later), so
// defer the verdict past the whole dispatch; setTimeout, not a
// microtask, since microtask checkpoints run between listeners.
setTimeout(() => {
if (!keyEvent.defaultPrevented) onEscapeRef.current?.()
}, 0)
})
} catch {
// Cross-origin frame: Escape inside it can't be observed.
}
if (autoFocus) frame.focus({ preventScroll: true })
}

// Iframe error signaling is unreliable across origins, so a slow build must
// not be mistaken for a broken one. Previews are served same-origin under
Expand Down Expand Up @@ -83,9 +135,7 @@ export function PreviewFrame({
className={className}
key={frameKey}
onError={() => setState('error')}
// A 404/5xx body still fires onLoad, so never let a late load
// event overwrite the probe's error verdict.
onLoad={() => setState((current) => (current === 'error' ? current : 'ready'))}
onLoad={handleFrameLoad}
scrolling={scrolling}
src={src}
title={title}
Expand Down
Loading