diff --git a/src/client/HtmlPreviewFrame.tsx b/src/client/HtmlPreviewFrame.tsx
new file mode 100644
index 0000000..fe08a65
--- /dev/null
+++ b/src/client/HtmlPreviewFrame.tsx
@@ -0,0 +1,78 @@
+/**
+ * The one HTML-preview frame both preview surfaces render.
+ *
+ * Why this is not a plain `src` to the `/sidebar/html` route: on DSH Desktop
+ * the host answers 403 `forbidden` to every request that does not carry the
+ * Electron renderer capability (`x-dsh-desktop-renderer`), and the shell
+ * attaches that header only to MAIN-frame and SAME-ORIGIN-subframe traffic
+ * (`requestComesFromRendererOrigin`). A frame sandboxed WITHOUT
+ * allow-same-origin is an opaque origin at request time, so its document
+ * request never carries the capability and the preview shows nothing but the
+ * bare refusal.
+ *
+ * So the document is fetched by the MAIN frame — which does carry it — and
+ * handed to the frame through `srcdoc`. The `sandbox` attribute stays exactly
+ * as before: the preview still runs in an opaque origin, and the route's own
+ * workspace fence still bounds what can be fetched.
+ *
+ * Two consequences worth knowing:
+ * - sandboxed: an opaque origin cannot make network requests either, so a
+ * self-contained document previews completely while a multi-file one shows
+ * its markup with the relative assets missing (inlining them is a separate
+ * job, not this component's);
+ * - unsandboxed (the warned per-surface unlock, or the setting): the srcdoc
+ * frame inherits the GUI's origin — which is precisely what that escape
+ * hatch already meant — and there its relative references resolve and load
+ * like any other same-origin request.
+ *
+ * A fetch that fails hands the frame the route URL instead, so the route's
+ * own answer (its JSON error, or the refusal) stays visible rather than being
+ * replaced by a blank pane.
+ */
+import { useEffect, useState, type ReactNode } from 'react'
+import { HTML_IFRAME_SANDBOX, withPreviewBase } from './html-preview.ts'
+
+/** One preview frame. */
+export function HtmlPreviewFrame(props: {
+ /** The `/sidebar/html//` route URL of the SAVED file. */
+ url: string
+ /** Whether the opaque-origin sandbox applies (the default for both surfaces). */
+ sandboxed: boolean
+ className?: string
+ title: string
+}): ReactNode {
+ const { url, sandboxed, className, title } = props
+ const [doc, setDoc] = useState(null)
+ const [failed, setFailed] = useState(false)
+ useEffect(() => {
+ const controller = new AbortController()
+ setDoc(null)
+ setFailed(false)
+ void fetch(url, { signal: controller.signal })
+ .then(async (response) => {
+ if (!response.ok) throw new Error(String(response.status))
+ return response.text()
+ })
+ .then((text) => { setDoc(text) })
+ .catch((error: unknown) => {
+ // An abort is this effect's own cleanup, not a failure.
+ if ((error as { name?: string }).name !== 'AbortError') setFailed(true)
+ })
+ return () => { controller.abort() }
+ }, [url])
+ // The delivery path depends only on what we have: the fetched markup, the
+ // route's own answer after a failed fetch, or an empty frame while loading.
+ const delivery = doc !== null
+ ? { srcDoc: withPreviewBase(doc, url) }
+ : failed ? { src: url } : { srcDoc: '' }
+ return (
+
+ )
+}
diff --git a/src/client/TextEditor.tsx b/src/client/TextEditor.tsx
index 8617274..284c391 100644
--- a/src/client/TextEditor.tsx
+++ b/src/client/TextEditor.tsx
@@ -36,7 +36,7 @@ import { LazyMermaidMarkdown, MarkdownDocument, type MarkdownHtmlMedia } from '.
import { MdToc } from './md-toc.tsx'
import { splitMermaidBlocks } from './mermaid-blocks.ts'
import { t } from './locales.ts'
-import { HTML_IFRAME_SANDBOX } from './html-preview.ts'
+import { HtmlPreviewFrame } from './HtmlPreviewFrame.tsx'
import type { EditorToolbarState, FileViewerProps } from './service.ts'
import css from './sidebar.module.css'
@@ -545,16 +545,14 @@ export function TextEditor(props: FileViewerProps) {
onUnlock={() => { setLocalUnlock(true) }}
onRestore={() => { setLocalUnlock(false) }}
/>
- {/* Route-src (never srcdoc — a srcdoc frame inherits the parent
- origin when unsandboxed; the route URL keeps the frame
- cross-origin by construction). The preview shows the SAVED
- file; the draft is only visible in edit mode. */}
-