diff --git a/docs/assets/pr/site-explorer-hover-preview.png b/docs/assets/pr/site-explorer-hover-preview.png
new file mode 100644
index 00000000..78d2a0b4
Binary files /dev/null and b/docs/assets/pr/site-explorer-hover-preview.png differ
diff --git a/src/__tests__/publisher/previewOverlay.test.tsx b/src/__tests__/publisher/previewOverlay.test.tsx
index 4949aca8..e54e9a04 100644
--- a/src/__tests__/publisher/previewOverlay.test.tsx
+++ b/src/__tests__/publisher/previewOverlay.test.tsx
@@ -305,7 +305,11 @@ describe('PreviewOverlay — source enforcement', () => {
})
it('uses the CMS runtime preview boundary instead of bypassing server prefetch', () => {
- expect(overlaySrc).toContain('buildCmsRuntimePreview(')
+ const previewHookSrc = readFileSync(
+ new URL('../../admin/pages/site/preview/useRuntimePreviewDocument.ts', import.meta.url),
+ 'utf-8',
+ )
+ expect(previewHookSrc).toContain('buildCmsRuntimePreview(')
expect(overlaySrc).not.toContain('publishPage(')
})
})
diff --git a/src/__tests__/site-explorer/siteExplorerPanel.test.tsx b/src/__tests__/site-explorer/siteExplorerPanel.test.tsx
index d6a5fd88..5a25d89f 100644
--- a/src/__tests__/site-explorer/siteExplorerPanel.test.tsx
+++ b/src/__tests__/site-explorer/siteExplorerPanel.test.tsx
@@ -195,6 +195,39 @@ describe('SiteExplorerPanel', () => {
expect(rows[0].getAttribute('draggable')).not.toBe('true')
})
+ it('shows a server-built desktop preview when a page row is hovered', async () => {
+ const originalFetch = globalThis.fetch
+ const previewRequests: Array<{ input: RequestInfo | URL; init?: RequestInit }> = []
+ globalThis.fetch = async (input, init) => {
+ previewRequests.push({ input, init })
+ return new Response(JSON.stringify({
+ html: '
Hover preview',
+ assets: [],
+ runtimeAssets: { scripts: [] },
+ diagnostics: [],
+ }), { status: 200 })
+ }
+
+ try {
+ loadSite()
+ render()
+
+ fireEvent.mouseEnter(rowForButton(/open page home/i))
+ const preview = await screen.findByTestId('site-explorer-hover-preview')
+ const iframe = await screen.findByTestId('site-explorer-hover-preview-iframe')
+
+ expect(preview.textContent).not.toContain('Desktop · 1440 px')
+ expect(iframe.getAttribute('srcdoc')).toContain('Hover preview')
+ expect(previewRequests).toHaveLength(1)
+ expect(previewRequests[0]?.input).toBe('/admin/api/cms/runtime/preview')
+ expect(JSON.parse(String(previewRequests[0]?.init?.body))).toMatchObject({
+ pageId: 'page-home',
+ })
+ } finally {
+ globalThis.fetch = originalFetch
+ }
+ })
+
it('renders nested page and script paths as recursive folders', () => {
loadSite()
useEditorStore.setState((state) => {
diff --git a/src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerHoverPreview.tsx b/src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerHoverPreview.tsx
new file mode 100644
index 00000000..94b24858
--- /dev/null
+++ b/src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerHoverPreview.tsx
@@ -0,0 +1,176 @@
+import {
+ useEffect,
+ useRef,
+ type CSSProperties,
+ type MouseEventHandler,
+ type WheelEvent,
+} from 'react'
+import { createPortal } from 'react-dom'
+import { Button } from '@ui/components/Button'
+import { computeFloatingPosition } from '@ui/lib/floatingPosition'
+import { useTemplatePreviewContext } from '@site/hooks/useTemplatePreviewContext'
+import { useRuntimePreviewDocument } from '@site/preview/useRuntimePreviewDocument'
+import { useEditorStore } from '@site/store/store'
+import type { TemplateRenderDataContext } from '@core/templates/dynamicBindings'
+import type { SiteDocument } from '@core/page-tree'
+import type { SiteExplorerItemPreview } from './siteExplorerModel'
+import styles from './SiteExplorerPanel.module.css'
+
+const DESKTOP_VIEWPORT_WIDTH = 1440
+const DESKTOP_VIEWPORT_HEIGHT = 900
+const PREVIEW_WIDTH = 320
+const PREVIEW_SHELL_PADDING = 4
+const PREVIEW_SCALE = PREVIEW_WIDTH / DESKTOP_VIEWPORT_WIDTH
+const PREVIEW_HEIGHT = DESKTOP_VIEWPORT_HEIGHT * PREVIEW_SCALE
+const PREVIEW_CARD_WIDTH = PREVIEW_WIDTH + PREVIEW_SHELL_PADDING * 2
+const PREVIEW_CARD_HEIGHT = PREVIEW_HEIGHT + PREVIEW_SHELL_PADDING * 2
+
+interface SiteExplorerHoverPreviewProps {
+ anchor: HTMLElement
+ preview: SiteExplorerItemPreview
+ onMouseEnter: MouseEventHandler
+ onMouseLeave: MouseEventHandler
+ onRequestClose: () => void
+}
+
+export function SiteExplorerHoverPreview({
+ anchor,
+ preview,
+ onMouseEnter,
+ onMouseLeave,
+ onRequestClose,
+}: SiteExplorerHoverPreviewProps) {
+ const previewRef = useRef(null)
+ const site = useEditorStore((state) => state.site)
+ const { context: templatePreviewContext, loading: templateContextLoading } =
+ useTemplatePreviewContext(preview.page)
+
+ useEffect(() => {
+ const closeOnScroll = () => {
+ // Wheel scrolling inside the scaled iframe is part of the feature. The
+ // parent receives that scroll during capture, so keep the card open while
+ // the pointer is anywhere over it; scrolling the explorer itself still
+ // dismisses the now-stale anchor position.
+ if (previewRef.current?.matches(':hover')) return
+ onRequestClose()
+ }
+ const closeOnEscape = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') onRequestClose()
+ }
+
+ window.addEventListener('scroll', closeOnScroll, { capture: true, passive: true })
+ window.addEventListener('keydown', closeOnEscape)
+ return () => {
+ window.removeEventListener('scroll', closeOnScroll, { capture: true })
+ window.removeEventListener('keydown', closeOnEscape)
+ }
+ }, [onRequestClose])
+
+ if (!site) return null
+
+ const position = computeFloatingPosition(anchor.getBoundingClientRect(), {
+ floatingWidth: PREVIEW_CARD_WIDTH,
+ floatingHeight: PREVIEW_CARD_HEIGHT,
+ side: 'right',
+ align: 'center',
+ offset: 10,
+ viewportMargin: 10,
+ })
+ const previewStyle = {
+ '--site-preview-x': `${position.x}px`,
+ '--site-preview-y': `${position.y}px`,
+ '--site-preview-scale': PREVIEW_SCALE,
+ } as CSSProperties
+
+ return createPortal(
+ ,
+ document.body,
+ )
+}
+
+interface HoverPreviewDocumentProps {
+ site: SiteDocument
+ preview: SiteExplorerItemPreview
+ templatePreviewContext: TemplateRenderDataContext | undefined
+}
+
+function HoverPreviewDocument({
+ site,
+ preview,
+ templatePreviewContext,
+}: HoverPreviewDocumentProps) {
+ const iframeRef = useRef(null)
+ const { html, loading, error, refresh } = useRuntimePreviewDocument(
+ site,
+ preview.page,
+ templatePreviewContext,
+ )
+
+ if (error) {
+ return (
+
+ Preview unavailable
+
+
+ )
+ }
+
+ if (loading || !html) return
+
+ function handlePreviewWheel(event: WheelEvent) {
+ const previewWindow = iframeRef.current?.contentWindow
+ if (!previewWindow) return
+ event.preventDefault()
+ previewWindow.scrollBy({
+ top: event.deltaY,
+ left: event.deltaX,
+ behavior: 'auto',
+ })
+ }
+
+ return (
+
+
+
+ )
+}
+
+function HoverPreviewStatus({ label }: { label: string }) {
+ return (
+
+
+ {label}
+
+ )
+}
diff --git a/src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerPanel.module.css b/src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerPanel.module.css
index f8db69e1..e3aba1cf 100644
--- a/src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerPanel.module.css
+++ b/src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerPanel.module.css
@@ -96,6 +96,89 @@
min-width: 0;
}
+.hoverPreview {
+ --site-preview-x: 0px;
+ --site-preview-y: 0px;
+ --site-preview-scale: 1;
+ --site-preview-shell-padding: var(--space-3xs);
+
+ position: fixed;
+ left: 0;
+ top: 0;
+ z-index: var(--spotlight-z-index);
+ box-sizing: content-box;
+ width: 320px;
+ overflow: hidden;
+ padding: var(--site-preview-shell-padding);
+ border-radius: var(--panel-radius);
+ background: var(--scrim);
+ box-shadow: var(--shadow-panel);
+ color: var(--text);
+ transform: translate3d(var(--site-preview-x), var(--site-preview-y), 0);
+ animation: hoverPreviewIn 160ms cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+.hoverPreviewViewportFrame {
+ position: relative;
+ width: 320px;
+ height: 200px;
+ overflow: hidden;
+ border-radius: calc(var(--panel-radius) - var(--site-preview-shell-padding));
+ background: var(--bg-surface-3);
+}
+
+.hoverPreviewIframe {
+ display: block;
+ width: 1440px;
+ height: 900px;
+ border: 0;
+ background: var(--overlay);
+ transform: scale(var(--site-preview-scale));
+ transform-origin: left top;
+ pointer-events: none;
+}
+
+.hoverPreviewScrollSurface {
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+}
+
+.hoverPreviewStatus {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: var(--space-xs);
+ color: var(--text-subtle);
+ font-size: var(--text-xs);
+}
+
+.hoverPreviewPulse {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--text-disabled);
+ animation: hoverPreviewPulse 900ms ease-in-out infinite alternate;
+}
+
+@keyframes hoverPreviewIn {
+ from {
+ opacity: 0;
+ filter: blur(3px);
+ transform: translate3d(var(--site-preview-x), var(--site-preview-y), 0)
+ scale(0.985);
+ }
+}
+
+@keyframes hoverPreviewPulse {
+ to {
+ opacity: 0.35;
+ transform: scale(0.72);
+ }
+}
+
.dragOverlayRow {
width: min(240px, 70vw);
border-radius: var(--panel-radius);
diff --git a/src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerPanel.tsx b/src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerPanel.tsx
index 1b224c73..dccdf947 100644
--- a/src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerPanel.tsx
+++ b/src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerPanel.tsx
@@ -4,6 +4,7 @@ import type { SiteFile } from '@core/files/schemas'
import type { ExplorerPathChangePlan, Page, SiteExplorerSectionId, StructuralSiteExplorerSectionId } from '@core/page-tree'
import { createUniquePageSlug, pagePublicPath, isHomePage } from '@core/page-tree'
import { templateTargetLabel } from '@core/templates'
+import { flattenVCToVirtualPage } from '@core/visualComponents'
import { SkeletonBlock } from '@ui/components/Skeleton'
import { FileTextSolidIcon } from 'pixel-art-icons/icons/file-text-solid'
import { FolderGlyphIcon } from 'pixel-art-icons/icons/folder-glyph'
@@ -536,6 +537,7 @@ export function SiteExplorerPanel({
pinned: isHomePage(page),
ariaLabel: `Open page ${page.title}`,
target: { kind: 'page', id: page.id, title: page.title, slug: page.slug },
+ preview: { page, kindLabel: 'Page' },
})),
)
: null
@@ -552,6 +554,7 @@ export function SiteExplorerPanel({
active: page.id === activePageId && activeDocument?.kind !== 'visualComponent',
ariaLabel: `Open template ${page.title}`,
target: { kind: 'page', id: page.id, title: page.title, slug: page.slug },
+ preview: { page, kindLabel: 'Template' },
})),
)
: null
@@ -568,6 +571,7 @@ export function SiteExplorerPanel({
active: activeDocument?.kind === 'visualComponent' && activeDocument.vcId === component.id,
ariaLabel: `Open component ${component.name}`,
target: { kind: 'component', id: component.id, name: component.name },
+ preview: { page: flattenVCToVirtualPage(component), kindLabel: 'Component' },
})),
)
: null
diff --git a/src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerTreeRows.tsx b/src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerTreeRows.tsx
index 0242d4ac..00a66388 100644
--- a/src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerTreeRows.tsx
+++ b/src/admin/pages/site/panels/SiteExplorerPanel/SiteExplorerTreeRows.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef, type KeyboardEvent, type MouseEvent } from 'react'
+import { useEffect, useRef, useState, type KeyboardEvent, type MouseEvent } from 'react'
import { useDraggable, useDroppable } from '@dnd-kit/core'
import { FolderGlyphIcon } from 'pixel-art-icons/icons/folder-glyph'
import { Button } from '@ui/components/Button'
@@ -16,8 +16,12 @@ import {
import type { SiteExplorerSectionId } from '@core/page-tree'
import type { SiteExplorerTreeFolder, SiteExplorerTreeItem } from './siteExplorerModel'
import type { SiteExplorerDragData, SiteExplorerDropData, SiteExplorerDropPosition } from './useSiteExplorerDnd'
+import { SiteExplorerHoverPreview } from './SiteExplorerHoverPreview'
import styles from './SiteExplorerPanel.module.css'
+const HOVER_PREVIEW_OPEN_DELAY_MS = 360
+const HOVER_PREVIEW_CLOSE_DELAY_MS = 180
+
interface ExplorerFolderRowProps {
folder: SiteExplorerTreeFolder
sectionId: SiteExplorerSectionId
@@ -183,6 +187,9 @@ export function ExplorerItemRow({
onKeyDown,
dropPosition,
}: ExplorerItemRowProps) {
+ const [previewAnchor, setPreviewAnchor] = useState(null)
+ const previewOpenTimerRef = useRef(null)
+ const previewCloseTimerRef = useRef(null)
const draggable = useDraggable({
id: `site-explorer-drag-item:${sectionId}:${item.id}`,
disabled: item.pinned === true || renameActive,
@@ -212,71 +219,131 @@ export function ExplorerItemRow({
droppable.setNodeRef(node)
}
+ function clearPreviewOpenTimer() {
+ if (previewOpenTimerRef.current === null) return
+ window.clearTimeout(previewOpenTimerRef.current)
+ previewOpenTimerRef.current = null
+ }
+
+ function clearPreviewCloseTimer() {
+ if (previewCloseTimerRef.current === null) return
+ window.clearTimeout(previewCloseTimerRef.current)
+ previewCloseTimerRef.current = null
+ }
+
+ function closePreview() {
+ clearPreviewOpenTimer()
+ clearPreviewCloseTimer()
+ setPreviewAnchor(null)
+ }
+
+ function schedulePreviewOpen(event: MouseEvent) {
+ if (!item.preview || renameActive || draggable.isDragging) return
+ clearPreviewOpenTimer()
+ clearPreviewCloseTimer()
+ const anchor = event.currentTarget
+ previewOpenTimerRef.current = window.setTimeout(() => {
+ setPreviewAnchor(anchor)
+ previewOpenTimerRef.current = null
+ }, HOVER_PREVIEW_OPEN_DELAY_MS)
+ }
+
+ function schedulePreviewClose() {
+ clearPreviewOpenTimer()
+ clearPreviewCloseTimer()
+ previewCloseTimerRef.current = window.setTimeout(() => {
+ setPreviewAnchor(null)
+ previewCloseTimerRef.current = null
+ }, HOVER_PREVIEW_CLOSE_DELAY_MS)
+ }
+
+ useEffect(() => () => {
+ if (previewOpenTimerRef.current !== null) {
+ window.clearTimeout(previewOpenTimerRef.current)
+ }
+ if (previewCloseTimerRef.current !== null) {
+ window.clearTimeout(previewCloseTimerRef.current)
+ }
+ }, [])
+
return (
-
- {renameActive ? (
- <>
-
-
-
- >
- ) : (
-
+ )}
+
+ {previewAnchor && item.preview && (
+
)}
-
+ >
)
}
diff --git a/src/admin/pages/site/panels/SiteExplorerPanel/siteExplorerModel.ts b/src/admin/pages/site/panels/SiteExplorerPanel/siteExplorerModel.ts
index e827995f..205f9bb2 100644
--- a/src/admin/pages/site/panels/SiteExplorerPanel/siteExplorerModel.ts
+++ b/src/admin/pages/site/panels/SiteExplorerPanel/siteExplorerModel.ts
@@ -1,4 +1,5 @@
import type { IconComponent } from 'pixel-art-icons/types'
+import type { Page } from '@core/page-tree'
import type {
StructuralExplorerRowOrder,
StructuralExplorerSection,
@@ -17,6 +18,12 @@ export interface SiteExplorerTreeItem {
pinned?: boolean
ariaLabel: string
target: TTarget
+ preview?: SiteExplorerItemPreview
+}
+
+export interface SiteExplorerItemPreview {
+ page: Page
+ kindLabel: 'Page' | 'Template' | 'Component'
}
export interface SiteExplorerTreeFolder {
diff --git a/src/admin/pages/site/preview/PreviewOverlay.tsx b/src/admin/pages/site/preview/PreviewOverlay.tsx
index 37d54a5b..81d752eb 100644
--- a/src/admin/pages/site/preview/PreviewOverlay.tsx
+++ b/src/admin/pages/site/preview/PreviewOverlay.tsx
@@ -20,10 +20,7 @@
import { useEffect, useRef } from 'react'
import type { Page, SiteDocument } from '@core/page-tree'
-import { isAbortError } from '@core/http'
-import { buildCmsRuntimePreview } from '@core/persistence'
import type { TemplateRenderDataContext } from '@core/templates/dynamicBindings'
-import { useAsyncResource } from '@admin/lib/useAsyncResource'
import { useEditorStore, selectActivePage } from '@site/store/store'
import { useTemplatePreviewContext } from '@site/hooks/useTemplatePreviewContext'
import { EyeSolidIcon } from 'pixel-art-icons/icons/eye-solid'
@@ -31,6 +28,7 @@ import { CloseIcon } from 'pixel-art-icons/icons/close'
import { Button } from '@ui/components/Button'
import { EmptyState } from '@ui/components/EmptyState'
import { pushToast } from '@ui/components/Toast'
+import { useRuntimePreviewDocument } from './useRuntimePreviewDocument'
import styles from './PreviewOverlay.module.css'
interface PreviewDocumentProps {
@@ -39,42 +37,12 @@ interface PreviewDocumentProps {
templatePreviewContext: TemplateRenderDataContext | undefined
}
-interface LoadedPreviewDocument {
- site: SiteDocument
- pageId: string
- contextKey: string
- html: string
-}
-
function PreviewDocument({ site, page, templatePreviewContext }: PreviewDocumentProps) {
- const contextKey = JSON.stringify(templatePreviewContext ?? null)
const reportedErrorRef = useRef(null)
- const { data, loading, error, refresh } = useAsyncResource(
- async (signal) => {
- try {
- const preview = await buildCmsRuntimePreview(
- {
- site,
- pageId: page.id,
- templateContext: templatePreviewContext,
- },
- { signal },
- )
- return {
- site,
- pageId: page.id,
- contextKey,
- html: preview.html,
- }
- } catch (err) {
- if (!signal.aborted && !isAbortError(err)) {
- console.error('[PreviewOverlay] Failed to build preview:', err)
- }
- throw err
- }
- },
- [site, page.id, contextKey],
- { fallbackError: 'Preview build failed' },
+ const { html, loading, error, refresh } = useRuntimePreviewDocument(
+ site,
+ page,
+ templatePreviewContext,
)
useEffect(() => {
@@ -92,11 +60,6 @@ function PreviewDocument({ site, page, templatePreviewContext }: PreviewDocument
})
}, [error])
- const currentHtml =
- data?.site === site && data.pageId === page.id && data.contextKey === contextKey
- ? data.html
- : null
-
if (error) {
return (
void
+}
+
+/**
+ * Build a server-resolved preview document for an in-memory draft page.
+ *
+ * Full-page Preview and lightweight explorer hover previews share this hook so
+ * both render through the same authenticated runtime boundary, including
+ * template data, loops, media, and unsaved site changes.
+ */
+export function useRuntimePreviewDocument(
+ site: SiteDocument,
+ page: Page,
+ templatePreviewContext: TemplateRenderDataContext | undefined,
+): RuntimePreviewDocumentState {
+ const contextKey = JSON.stringify(templatePreviewContext ?? null)
+ const { data, loading, error, refresh } = useAsyncResource(
+ async (signal) => {
+ try {
+ const preview = await buildCmsRuntimePreview(
+ {
+ site,
+ pageId: page.id,
+ templateContext: templatePreviewContext,
+ },
+ { signal },
+ )
+ return {
+ site,
+ pageId: page.id,
+ contextKey,
+ html: preview.html,
+ }
+ } catch (err) {
+ if (!signal.aborted && !isAbortError(err)) {
+ console.error('[RuntimePreview] Failed to build preview:', err)
+ }
+ throw err
+ }
+ },
+ [site, page.id, contextKey],
+ { fallbackError: 'Preview build failed' },
+ )
+
+ const html =
+ data?.site === site && data.pageId === page.id && data.contextKey === contextKey
+ ? data.html
+ : null
+
+ return { html, loading, error, refresh }
+}