diff --git a/src/components/Dialogs.tsx b/src/components/Dialogs.tsx
index 1eb9f11..d85f9a0 100644
--- a/src/components/Dialogs.tsx
+++ b/src/components/Dialogs.tsx
@@ -82,7 +82,9 @@ export function Dialogs({
-
+
)}
@@ -174,7 +176,7 @@ export function Dialogs({
-
+
)}
>
diff --git a/src/components/PreviewFrame.tsx b/src/components/PreviewFrame.tsx
index dd2faf5..e99312c 100644
--- a/src/components/PreviewFrame.tsx
+++ b/src/components/PreviewFrame.tsx
@@ -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
@@ -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) => {
+ // 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
@@ -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}