FN-6939: add narrow dock preview modal

Adds an accessible preview modal path for constrained Dev Server right-dock layouts.

- Detect narrow direct right-dock hosts while preserving inline previews for full-page, mobile, and expanded modal hosts.
- Replace the crowded inline preview with a compact Open preview launcher and accessible modal controls in narrow docks.
- Add focused preview/mobile coverage, documentation, styling, and a published package changeset.

Files changed:
 .../fn-6939-dev-server-narrow-preview-modal.md     |   5 +
 docs/dashboard-guide.md                            |   3 +
 .../dashboard/app/components/DevServerView.css     | 100 ++++-
 .../dashboard/app/components/DevServerView.tsx     | 449 +++++++++++++++------
 .../__tests__/DevServerView.mobile.test.tsx        |  17 +-
 .../__tests__/DevServerView.preview.test.tsx       | 148 +++++++
 6 files changed, 603 insertions(+), 119 deletions(-)

Fusion-Task-Id: FN-6939

Fusion-Task-Lineage: 745ea56d-16bf-4246-bfe7-0461754466d9
This commit is contained in:
gsxdsm
2026-06-23 14:01:19 -07:00
parent 7b60539006
commit e9a6955b49
6 changed files with 602 additions and 118 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix narrow right-sidebar Dev Server preview overlap by replacing the inline preview with an accessible modal launcher when the dock is very narrow, while keeping inline preview for full-page, mobile viewport, and expanded pop-out hosts.

View File

@@ -866,6 +866,9 @@ Features:
- Start, stop, and restart the current server session - Start, stop, and restart the current server session
- Manage preview URLs with embedded preview and **Open in new tab** fallback - Manage preview URLs with embedded preview and **Open in new tab** fallback
- Tail live logs, load older history, and refresh session status - Tail live logs, load older history, and refresh session status
- When Dev Server is hosted in a very narrow right sidebar, open the preview from the compact **Open preview** launcher; the modal keeps preview actions available while configuration and logs stay usable in the sidebar.
<!-- FNXC:DevServerDocs 2026-06-23-00:00: The narrow right-sidebar Dev Server host must describe the preview modal launcher so users do not expect the preview iframe to remain inline when the dock is too constrained for logs and preview together. -->
For module-level behavior and API surfaces, see [Dev Server modules](./dev-server-modules.md). For module-level behavior and API surfaces, see [Dev Server modules](./dev-server-modules.md).

View File

@@ -582,6 +582,65 @@ exactly when the surrounding chrome is gone.
margin: 0; margin: 0;
} }
.devserver-preview-modal-launcher {
align-items: stretch;
}
.devserver-preview-modal-launcher__copy {
display: flex;
align-items: center;
gap: var(--space-sm);
min-width: 0;
}
.devserver-preview-modal-launcher__copy .devserver-preview-url-badge {
max-width: none;
}
.devserver-preview-modal-launcher__description {
margin: 0;
color: var(--text-muted);
line-height: 1.5;
}
.devserver-preview-modal-overlay {
align-items: center;
padding: var(--space-xl);
}
.devserver-preview-modal {
width: min(calc(var(--space-2xl) * 28), calc(100vw - var(--space-xl) * 2));
max-height: calc(100vh - var(--space-xl) * 2);
}
.devserver-preview-modal__titlebar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
padding: var(--space-md);
border-bottom: 1px solid var(--border);
}
.devserver-preview-modal__titlebar h2 {
margin: 0;
font-size: 1rem;
}
.devserver-preview-modal__body {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.devserver-preview-modal__body .devserver-preview-container {
flex: 1;
min-height: min(60vh, calc(var(--space-2xl) * 14));
max-height: none;
}
/* Legacy selector compatibility for static CSS tests */ /* Legacy selector compatibility for static CSS tests */
.dev-server-preview-fallback { .dev-server-preview-fallback {
border: 1px solid color-mix(in srgb, var(--color-warning) 40%, transparent); border: 1px solid color-mix(in srgb, var(--color-warning) 40%, transparent);
@@ -655,7 +714,8 @@ exactly when the surrounding chrome is gone.
max-width: none; max-width: none;
} }
.devserver-preview-header { .devserver-preview-header,
.devserver-preview-modal-launcher__copy {
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -703,6 +763,20 @@ exactly when the surrounding chrome is gone.
max-height: calc(var(--space-2xl) * 3); max-height: calc(var(--space-2xl) * 3);
} }
.devserver-preview-modal-overlay {
align-items: stretch;
padding: var(--space-md);
}
.devserver-preview-modal {
width: 100%;
max-height: calc(100vh - var(--space-md) * 2);
}
.devserver-preview-modal__body .devserver-preview-container {
min-height: calc(var(--space-2xl) * 7);
}
.dev-server-config { .dev-server-config {
max-height: min(48vh, calc(var(--space-2xl) * 13)); max-height: min(48vh, calc(var(--space-2xl) * 13));
} }
@@ -852,7 +926,8 @@ vertically (.dev-server-view overflow-y:auto), so each panel just needs to be fu
flex-direction: column; flex-direction: column;
} }
.devserver-preview-panel { .devserver-preview-panel,
.devserver-preview-modal-launcher {
grid-column: auto; grid-column: auto;
grid-row: auto; grid-row: auto;
} }
@@ -862,10 +937,25 @@ vertically (.dev-server-view overflow-y:auto), so each panel just needs to be fu
max-width: none; max-width: none;
} }
.devserver-preview-header { .devserver-preview-header,
.devserver-preview-modal-launcher__copy {
flex-wrap: wrap; flex-wrap: wrap;
} }
.devserver-preview-modal-overlay {
align-items: stretch;
padding: var(--space-md);
}
.devserver-preview-modal {
width: min(calc(var(--space-2xl) * 20), calc(100vw - var(--space-md) * 2));
max-height: calc(100vh - var(--space-md) * 2);
}
.devserver-preview-modal__body .devserver-preview-container {
min-height: calc(var(--space-2xl) * 7);
}
.devserver-preview-url-badge { .devserver-preview-url-badge {
order: 2; order: 2;
flex: 1 1 100%; flex: 1 1 100%;
@@ -915,8 +1005,8 @@ vertically (.dev-server-view overflow-y:auto), so each panel just needs to be fu
} }
.dev-server-logs, .dev-server-logs,
.devserver-preview-container, .devserver-preview-panel .devserver-preview-container,
.devserver-preview-iframe { .devserver-preview-panel .devserver-preview-iframe {
min-height: calc(var(--space-2xl) * 4 + var(--space-md)); min-height: calc(var(--space-2xl) * 4 + var(--space-md));
max-height: none; max-height: none;
} }

View File

@@ -1,13 +1,15 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { RefObject } from "react";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { AlertTriangle, ExternalLink, Eye, Loader2, Monitor, Play, RefreshCw, RotateCw, ShieldAlert, Square } from "lucide-react"; import { AlertTriangle, ExternalLink, Eye, Loader2, Monitor, Play, RefreshCw, RotateCw, ShieldAlert, Square, X } from "lucide-react";
import type { Task, TaskDetail } from "@fusion/core"; import type { Task, TaskDetail } from "@fusion/core";
import "./DevServerView.css"; import "./DevServerView.css";
import type { DetectedDevServerCommand } from "../api"; import type { DetectedDevServerCommand } from "../api";
import { useDevServer } from "../hooks/useDevServer"; import { useDevServer } from "../hooks/useDevServer";
import { useDevServerLogs } from "../hooks/useDevServerLogs"; import { useDevServerLogs } from "../hooks/useDevServerLogs";
import { usePreviewEmbed } from "../hooks/usePreviewEmbed"; import { usePreviewEmbed } from "../hooks/usePreviewEmbed";
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
import type { ToastType } from "../hooks/useToast"; import type { ToastType } from "../hooks/useToast";
import { DevServerLogViewer } from "./DevServerLogViewer"; import { DevServerLogViewer } from "./DevServerLogViewer";
import { PreviewIframe } from "./PreviewIframe"; import { PreviewIframe } from "./PreviewIframe";
@@ -37,6 +39,85 @@ function getStatusBadgeConfig(t: TFunction<"app">): Record<"stopped" | "starting
}; };
} }
const NARROW_RIGHT_DOCK_PREVIEW_THRESHOLD = 480;
function isTrueMobileViewport(): boolean {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
return false;
}
return window.matchMedia("(max-width: 768px)").matches;
}
function getDirectRightDockBodyHost(element: HTMLElement): HTMLElement | null {
if (element.closest(".right-dock-expand-modal__body")) {
return null;
}
const parent = element.parentElement;
if (!parent?.classList.contains("right-dock__body")) {
return null;
}
return parent;
}
function readHostInlineSize(host: HTMLElement): number {
if (host.clientWidth > 0) {
return host.clientWidth;
}
const rect = host.getBoundingClientRect();
return rect.width;
}
function shouldUseNarrowRightDockPreviewMode(root: HTMLElement | null): boolean {
if (!root || isTrueMobileViewport()) {
return false;
}
const host = getDirectRightDockBodyHost(root);
if (!host) {
return false;
}
return readHostInlineSize(host) <= NARROW_RIGHT_DOCK_PREVIEW_THRESHOLD;
}
function useNarrowRightDockPreviewMode(rootRef: RefObject<HTMLDivElement | null>): boolean {
const [isNarrowRightDockPreviewMode, setIsNarrowRightDockPreviewMode] = useState(false);
useEffect(() => {
const root = rootRef.current;
if (!root) {
setIsNarrowRightDockPreviewMode(false);
return;
}
const host = getDirectRightDockBodyHost(root);
const updateMode = () => setIsNarrowRightDockPreviewMode(shouldUseNarrowRightDockPreviewMode(root));
updateMode();
if (!host || typeof ResizeObserver === "undefined") {
window.addEventListener("resize", updateMode);
return () => window.removeEventListener("resize", updateMode);
}
const observer = new ResizeObserver(updateMode);
observer.observe(host);
window.addEventListener("resize", updateMode);
return () => {
observer.disconnect();
window.removeEventListener("resize", updateMode);
};
}, [rootRef]);
return isNarrowRightDockPreviewMode;
}
let devServerViewWasPreviouslyInactive = false; let devServerViewWasPreviouslyInactive = false;
function normalizeError(error: unknown): string { function normalizeError(error: unknown): string {
@@ -142,6 +223,14 @@ export function DevServerView({ addToast, projectId, tasks }: DevServerViewProps
const effectivePreviewUrl = previewUrl; const effectivePreviewUrl = previewUrl;
const selectedSource = session?.config?.cwd ?? null; const selectedSource = session?.config?.cwd ?? null;
const rootRef = useRef<HTMLDivElement>(null);
const isNarrowRightDockPreviewMode = useNarrowRightDockPreviewMode(rootRef);
/*
FNXC:DevServer 2026-06-23-00:00:
The Dev Server preview must escape into a modal when the direct right-dock host is very narrow so preview chrome does not crowd logs and configuration in the same dock column.
The 480px threshold catches the dock's compact range before preview chrome becomes unusable while preserving full-page, true mobile viewport, and expanded pop-out inline previews.
*/
const [showCandidates, setShowCandidates] = useState(true); const [showCandidates, setShowCandidates] = useState(true);
const [commandInput, setCommandInput] = useState(""); const [commandInput, setCommandInput] = useState("");
const [previewInput, setPreviewInput] = useState(""); const [previewInput, setPreviewInput] = useState("");
@@ -170,6 +259,9 @@ export function DevServerView({ addToast, projectId, tasks }: DevServerViewProps
}, [executingTasks, selectedTaskId]); }, [executingTasks, selectedTaskId]);
const [previewMode, setPreviewMode] = useState<PreviewMode>("embedded"); const [previewMode, setPreviewMode] = useState<PreviewMode>("embedded");
const [isPreviewModalOpen, setIsPreviewModalOpen] = useState(false);
const previewModalLauncherRef = useRef<HTMLButtonElement>(null);
const previewModalRef = useRef<HTMLDivElement>(null);
const previewEmbedUrl = previewMode === "embedded" ? effectivePreviewUrl : null; const previewEmbedUrl = previewMode === "embedded" ? effectivePreviewUrl : null;
const { const {
@@ -271,6 +363,60 @@ export function DevServerView({ addToast, projectId, tasks }: DevServerViewProps
setPreviewInput(effectivePreviewUrl ?? ""); setPreviewInput(effectivePreviewUrl ?? "");
}, [effectivePreviewUrl]); }, [effectivePreviewUrl]);
const closePreviewModal = useCallback(() => {
setIsPreviewModalOpen(false);
window.requestAnimationFrame(() => previewModalLauncherRef.current?.focus());
}, []);
const previewModalOverlayDismissProps = useOverlayDismiss(closePreviewModal);
useEffect(() => {
if (!isPreviewModalOpen) {
return;
}
previewModalRef.current?.focus();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closePreviewModal();
return;
}
if (event.key !== "Tab") {
return;
}
const focusableElements = Array.from(
previewModalRef.current?.querySelectorAll<HTMLElement>(
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
) ?? [],
).filter((element) => !element.hasAttribute("disabled") && element.getAttribute("aria-hidden") !== "true");
const firstElement = focusableElements[0];
const lastElement = focusableElements.at(-1);
if (!firstElement || !lastElement) {
return;
}
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
} else if (!event.shiftKey && document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [closePreviewModal, isPreviewModalOpen]);
useEffect(() => {
if (!isNarrowRightDockPreviewMode && isPreviewModalOpen) {
setIsPreviewModalOpen(false);
}
}, [isNarrowRightDockPreviewMode, isPreviewModalOpen]);
const handleOpenInNewTab = useCallback(() => { const handleOpenInNewTab = useCallback(() => {
if (!effectivePreviewUrl) { if (!effectivePreviewUrl) {
return; return;
@@ -399,8 +545,136 @@ export function DevServerView({ addToast, projectId, tasks }: DevServerViewProps
const stopDisabled = status === "stopped" || actionInFlight !== null; const stopDisabled = status === "stopped" || actionInFlight !== null;
const restartDisabled = status === "stopped" || status === "starting" || actionInFlight !== null; const restartDisabled = status === "stopped" || status === "starting" || actionInFlight !== null;
const renderPreviewContent = () => (
<>
<div className="devserver-preview-header">
<div className="devserver-preview-title">
<Eye size={14} />
<span>{t("devserver.preview", "Preview")}</span>
</div>
<span
className={`devserver-preview-url-badge ${isManualPreviewOverride ? "devserver-preview-url-badge--manual" : "devserver-preview-url-badge--auto"}`}
title={effectivePreviewUrl ?? t("devserver.noPreviewUrl", "No preview URL")}
data-testid="devserver-preview-url-badge"
>
{isManualPreviewOverride ? t("devserver.manual", "Manual") : t("devserver.auto", "Auto")}
{effectivePreviewUrl ? ` · ${effectivePreviewUrl}` : t("devserver.notAvailable", " · Not available")}
</span>
<div className="devserver-preview-actions">
<button
type="button"
className="btn btn-sm"
onClick={() => setPreviewMode((current) => (current === "embedded" ? "external" : "embedded"))}
data-testid="devserver-preview-mode-toggle"
>
{previewMode === "embedded" ? t("devserver.externalOnly", "External only") : t("devserver.embedded", "Embedded")}
</button>
<button
type="button"
className="btn btn-sm btn-icon"
title={t("devserver.openInNewTab", "Open in new tab")}
onClick={handleOpenInNewTab}
disabled={!effectivePreviewUrl}
data-testid="devserver-preview-open-tab"
>
<ExternalLink />
</button>
<button
type="button"
className="btn btn-sm btn-icon"
title={t("devserver.refreshPreview", "Refresh preview")}
onClick={handleRefreshPreview}
disabled={!effectivePreviewUrl}
data-testid="devserver-preview-refresh"
>
<RefreshCw />
</button>
</div>
</div>
<div className="devserver-preview-container" data-embed-status={embedStatus} data-embedded={isEmbedded ? "true" : "false"}>
{!effectivePreviewUrl && !isRunning && (
<p className="devserver-preview-empty">{t("devserver.startDevServer", "Start a dev server to see a live preview here.")}</p>
)}
{!effectivePreviewUrl && isRunning && (
<p className="devserver-preview-empty">{t("devserver.noPreviewDetected", "No preview URL detected. Start the dev server or set a manual URL to preview your app.")}</p>
)}
{effectivePreviewUrl && previewMode === "external" && (
<div className="devserver-preview-external-only" data-testid="devserver-preview-external-only">
<p>{t("devserver.embeddedPreviewDisabled", "Embedded preview is disabled. Open your app in a separate browser tab.")}</p>
<button
type="button"
className="btn btn-primary btn-sm touch-target"
onClick={handleOpenInNewTab}
data-testid="devserver-preview-external-open-tab"
>
{t("devserver.openInNewTab", "Open in new tab")}
</button>
</div>
)}
{effectivePreviewUrl && previewMode === "embedded" && showFallback && isBlocked && (
<div
className={embedStatus === "error" ? "devserver-preview-error-panel" : "devserver-preview-blocked-panel"}
data-testid="devserver-preview-fallback"
role="alert"
>
{embedStatus === "error"
? <AlertTriangle className="devserver-preview-blocked-icon" aria-hidden="true" />
: <ShieldAlert className="devserver-preview-blocked-icon" aria-hidden="true" />}
<div>
<p className="devserver-preview-blocked-title">
{embedStatus === "error" ? t("devserver.previewFailed", "Preview failed") : t("devserver.previewBlocked", "Preview blocked")}
</p>
{blockReason && <p className="devserver-preview-blocked-context">{blockReason}</p>}
</div>
<p className="devserver-preview-blocked-description">
{t("devserver.openPreviewOrRetry", "Open the preview in a new tab, or retry embedded mode after checking your server settings.")}
</p>
<div className="devserver-preview-blocked-actions">
<button
type="button"
className="btn btn-primary"
onClick={handleOpenInNewTab}
data-testid="devserver-preview-fallback-open-tab"
>
{t("devserver.openPreviewInNewTab", "Open preview in new tab")}
</button>
<button
type="button"
className="btn btn-sm"
onClick={handleRetryEmbeddedPreview}
data-testid="devserver-preview-fallback-retry"
>
{t("devserver.retryEmbeddedPreview", "Retry embedded preview")}
</button>
</div>
</div>
)}
{effectivePreviewUrl && previewMode === "embedded" && !showFallback && (
<PreviewIframe
url={effectivePreviewUrl}
embedStatus={embedStatus}
onEmbedStatusChange={setEmbedStatus}
iframeRef={iframeRef}
blockReason={blockReason}
onRetry={handleRetryEmbeddedPreview}
/>
)}
</div>
</>
);
return ( return (
<div className="dev-server-view" data-testid="dev-server-view"> <div
ref={rootRef}
className="dev-server-view"
data-testid="dev-server-view"
data-narrow-right-dock-preview={isNarrowRightDockPreviewMode ? "true" : "false"}
>
{/* {/*
FNXC:DevServer 2026-06-22-01:00: FNXC:DevServer 2026-06-22-01:00:
Migrated to the shared ViewHeader for cross-view consistency. The status badge sits next to the title inside the actions slot (wrapped in .dev-server-header-title so the existing mobile flex-wrap rule still applies), and the Start/Stop/Restart controls follow in .dev-server-header-actions. ViewHeader supplies the standard view padding; the view body must not repeat the top padding. Migrated to the shared ViewHeader for cross-view consistency. The status badge sits next to the title inside the actions slot (wrapped in .dev-server-header-title so the existing mobile flex-wrap rule still applies), and the Start/Stop/Restart controls follow in .dev-server-header-actions. ViewHeader supplies the standard view padding; the view body must not repeat the top padding.
@@ -641,126 +915,75 @@ export function DevServerView({ addToast, projectId, tasks }: DevServerViewProps
</section> </section>
</div> </div>
<section className="dev-server-panel devserver-preview-panel" data-testid="devserver-preview-panel" aria-label={t("devserver.previewLabel", "Dev server preview")}> {isNarrowRightDockPreviewMode ? (
<div className="devserver-preview-header"> <section
<div className="devserver-preview-title"> className="dev-server-panel devserver-preview-modal-launcher"
<Eye size={14} /> data-testid="devserver-preview-modal-launcher"
<span>{t("devserver.preview", "Preview")}</span> aria-label={t("devserver.previewLabel", "Dev server preview")}
>
<div className="devserver-preview-modal-launcher__copy">
<div className="devserver-preview-title">
<Eye size={14} />
<span>{t("devserver.preview", "Preview")}</span>
</div>
<span
className={`devserver-preview-url-badge ${isManualPreviewOverride ? "devserver-preview-url-badge--manual" : "devserver-preview-url-badge--auto"}`}
title={effectivePreviewUrl ?? t("devserver.noPreviewUrl", "No preview URL")}
data-testid="devserver-preview-url-badge"
>
{effectivePreviewUrl ? effectivePreviewUrl : t("devserver.notAvailable", "Not available")}
</span>
</div> </div>
<span <p className="devserver-preview-modal-launcher__description">
className={`devserver-preview-url-badge ${isManualPreviewOverride ? "devserver-preview-url-badge--manual" : "devserver-preview-url-badge--auto"}`} {effectivePreviewUrl
title={effectivePreviewUrl ?? t("devserver.noPreviewUrl", "No preview URL")} ? t("devserver.previewModalLauncherDescription", "Open the live preview in a modal so logs and configuration stay usable in this narrow dock.")
data-testid="devserver-preview-url-badge" : t("devserver.previewModalLauncherUnavailable", "Start the dev server or set a preview URL to open the preview modal.")}
</p>
<button
type="button"
className="btn btn-primary btn-sm"
ref={previewModalLauncherRef}
onClick={() => setIsPreviewModalOpen(true)}
data-testid="devserver-preview-modal-open"
> >
{isManualPreviewOverride ? t("devserver.manual", "Manual") : t("devserver.auto", "Auto")} {t("devserver.openPreview", "Open preview")}
{effectivePreviewUrl ? ` · ${effectivePreviewUrl}` : t("devserver.notAvailable", " · Not available")} </button>
</span> </section>
<div className="devserver-preview-actions"> ) : (
<button <section className="dev-server-panel devserver-preview-panel" data-testid="devserver-preview-panel" aria-label={t("devserver.previewLabel", "Dev server preview")}>
type="button" {renderPreviewContent()}
className="btn btn-sm" </section>
onClick={() => setPreviewMode((current) => (current === "embedded" ? "external" : "embedded"))} )}
data-testid="devserver-preview-mode-toggle"
>
{previewMode === "embedded" ? t("devserver.externalOnly", "External only") : t("devserver.embedded", "Embedded")}
</button>
<button
type="button"
className="btn btn-sm btn-icon"
title={t("devserver.openInNewTab", "Open in new tab")}
onClick={handleOpenInNewTab}
disabled={!effectivePreviewUrl}
data-testid="devserver-preview-open-tab"
>
<ExternalLink />
</button>
<button
type="button"
className="btn btn-sm btn-icon"
title={t("devserver.refreshPreview", "Refresh preview")}
onClick={handleRefreshPreview}
disabled={!effectivePreviewUrl}
data-testid="devserver-preview-refresh"
>
<RefreshCw />
</button>
</div>
</div>
<div className="devserver-preview-container" data-embed-status={embedStatus} data-embedded={isEmbedded ? "true" : "false"}> {isNarrowRightDockPreviewMode && isPreviewModalOpen && (
{!effectivePreviewUrl && !isRunning && ( <div className="modal-overlay open devserver-preview-modal-overlay" {...previewModalOverlayDismissProps}>
<p className="devserver-preview-empty">{t("devserver.startDevServer", "Start a dev server to see a live preview here.")}</p> <div
)} className="modal devserver-preview-modal"
role="dialog"
{!effectivePreviewUrl && isRunning && ( aria-modal="true"
<p className="devserver-preview-empty">{t("devserver.noPreviewDetected", "No preview URL detected. Start the dev server or set a manual URL to preview your app.")}</p> aria-labelledby="devserver-preview-modal-title"
)} tabIndex={-1}
ref={previewModalRef}
{effectivePreviewUrl && previewMode === "external" && ( data-testid="devserver-preview-modal"
<div className="devserver-preview-external-only" data-testid="devserver-preview-external-only"> >
<p>{t("devserver.embeddedPreviewDisabled", "Embedded preview is disabled. Open your app in a separate browser tab.")}</p> <div className="devserver-preview-modal__titlebar">
<h2 id="devserver-preview-modal-title">{t("devserver.preview", "Preview")}</h2>
<button <button
type="button" type="button"
className="btn btn-primary btn-sm touch-target" className="btn btn-sm btn-icon"
onClick={handleOpenInNewTab} onClick={closePreviewModal}
data-testid="devserver-preview-external-open-tab" aria-label={t("devserver.closePreviewModal", "Close preview modal")}
data-testid="devserver-preview-modal-close"
> >
{t("devserver.openInNewTab", "Open in new tab")} <X />
</button> </button>
</div> </div>
)} <div className="devserver-preview-modal__body">
{renderPreviewContent()}
{effectivePreviewUrl && previewMode === "embedded" && showFallback && isBlocked && (
<div
className={embedStatus === "error" ? "devserver-preview-error-panel" : "devserver-preview-blocked-panel"}
data-testid="devserver-preview-fallback"
role="alert"
>
{embedStatus === "error"
? <AlertTriangle className="devserver-preview-blocked-icon" aria-hidden="true" />
: <ShieldAlert className="devserver-preview-blocked-icon" aria-hidden="true" />}
<div>
<p className="devserver-preview-blocked-title">
{embedStatus === "error" ? t("devserver.previewFailed", "Preview failed") : t("devserver.previewBlocked", "Preview blocked")}
</p>
{blockReason && <p className="devserver-preview-blocked-context">{blockReason}</p>}
</div>
<p className="devserver-preview-blocked-description">
{t("devserver.openPreviewOrRetry", "Open the preview in a new tab, or retry embedded mode after checking your server settings.")}
</p>
<div className="devserver-preview-blocked-actions">
<button
type="button"
className="btn btn-primary"
onClick={handleOpenInNewTab}
data-testid="devserver-preview-fallback-open-tab"
>
{t("devserver.openPreviewInNewTab", "Open preview in new tab")}
</button>
<button
type="button"
className="btn btn-sm"
onClick={handleRetryEmbeddedPreview}
data-testid="devserver-preview-fallback-retry"
>
{t("devserver.retryEmbeddedPreview", "Retry embedded preview")}
</button>
</div>
</div> </div>
)} </div>
{effectivePreviewUrl && previewMode === "embedded" && !showFallback && (
<PreviewIframe
url={effectivePreviewUrl}
embedStatus={embedStatus}
onEmbedStatusChange={setEmbedStatus}
iframeRef={iframeRef}
blockReason={blockReason}
onRetry={handleRetryEmbeddedPreview}
/>
)}
</div> </div>
</section> )}
</div> </div>
); );
} }

View File

@@ -51,12 +51,27 @@ describe("DevServerView mobile CSS/structure", () => {
const mobileBlockMatch = css.match(/@media[^{]*\(max-width: 768px\)[^{]*\{([\s\S]*?)\n\}/g) ?? []; const mobileBlockMatch = css.match(/@media[^{]*\(max-width: 768px\)[^{]*\{([\s\S]*?)\n\}/g) ?? [];
const mobileCss = mobileBlockMatch.join("\n"); const mobileCss = mobileBlockMatch.join("\n");
const headerRuleCount = (mobileCss.match(/\.devserver-preview-header\s*\{/g) ?? []).length; const headerRuleCount = (mobileCss.match(/\.devserver-preview-header,\s*\.devserver-preview-modal-launcher__copy\s*\{/g) ?? []).length;
expect(headerRuleCount).toBe(1); expect(headerRuleCount).toBe(1);
expect(mobileCss).toMatch(/\.devserver-preview-url-badge\s*\{[\s\S]*max-width:\s*100%/); expect(mobileCss).toMatch(/\.devserver-preview-url-badge\s*\{[\s\S]*max-width:\s*100%/);
expect(mobileCss).toMatch(/\.dev-server-header-title\s*\{[\s\S]*flex-wrap:\s*wrap/); expect(mobileCss).toMatch(/\.dev-server-header-title\s*\{[\s\S]*flex-wrap:\s*wrap/);
}); });
it("defines narrow right-dock launcher and modal rules without duplicating mobile media rules", () => {
const css = loadAllAppCss();
const containerStart = css.indexOf("@container right-dock-body (max-width: 768px)");
expect(containerStart).toBeGreaterThan(-1);
const containerCss = css.slice(containerStart);
expect(containerCss).toMatch(/\.devserver-preview-panel,\s*\.devserver-preview-modal-launcher\s*\{[\s\S]*grid-column:\s*auto/);
expect(containerCss).toMatch(/\.devserver-preview-modal\s*\{[\s\S]*width:\s*min\(calc\(var\(--space-2xl\) \* 20\), calc\(100vw - var\(--space-md\) \* 2\)\)/);
expect(containerCss).toMatch(/\.devserver-preview-panel \.devserver-preview-container/);
expect(containerCss).not.toMatch(/\.dev-server-logs,\s*\.devserver-preview-container,\s*\.devserver-preview-iframe/);
expect(css).toMatch(/@media[^{]*\(max-width: 768px\)/);
expect(css).toMatch(/@container right-dock-body \(max-width: 768px\)/);
});
it("renders preview header elements and keeps URL badge outside preview actions", () => { it("renders preview header elements and keeps URL badge outside preview actions", () => {
mockUseDevServer.mockReturnValue(createDevServerHookState()); mockUseDevServer.mockReturnValue(createDevServerHookState());
mockUseDevServerLogs.mockReturnValue({ mockUseDevServerLogs.mockReturnValue({

View File

@@ -44,6 +44,7 @@ vi.mock("lucide-react", () => ({
Search: () => <span data-testid="icon-search" />, Search: () => <span data-testid="icon-search" />,
ShieldAlert: () => <span data-testid="icon-shield-alert" />, ShieldAlert: () => <span data-testid="icon-shield-alert" />,
Square: () => <span data-testid="icon-square" />, Square: () => <span data-testid="icon-square" />,
X: () => <span data-testid="icon-x" />,
})); }));
function createState(overrides: Partial<DevServerState> = {}): DevServerState { function createState(overrides: Partial<DevServerState> = {}): DevServerState {
@@ -201,6 +202,153 @@ describe("DevServerView preview panel", () => {
afterEach(() => { afterEach(() => {
window.open = originalWindowOpen; window.open = originalWindowOpen;
vi.unstubAllGlobals();
});
function renderInRightDock(width: number) {
const host = document.createElement("div");
host.className = "right-dock__body";
Object.defineProperty(host, "clientWidth", { configurable: true, value: width });
document.body.appendChild(host);
return render(<DevServerView addToast={addToast} projectId="project-a" />, { container: host });
}
it("activates narrow right-dock preview mode only below the dock threshold", async () => {
mockUseDevServer.mockReturnValue(
createDevServerHookState({ serverState: createState({ status: "running", previewUrl: "http://localhost:3000" }) }),
);
const narrow = renderInRightDock(420);
await waitFor(() => {
expect(screen.getByTestId("dev-server-view")).toHaveAttribute("data-narrow-right-dock-preview", "true");
});
narrow.unmount();
document.body.innerHTML = "";
renderInRightDock(640);
await waitFor(() => {
expect(screen.getByTestId("dev-server-view")).toHaveAttribute("data-narrow-right-dock-preview", "false");
});
expect(screen.queryByTestId("devserver-preview-modal-launcher")).not.toBeInTheDocument();
expect(screen.getByTestId("devserver-preview-panel")).toBeInTheDocument();
});
it("replaces the narrow right-dock inline preview with an accessible modal launcher", async () => {
mockUseDevServer.mockReturnValue(
createDevServerHookState({ serverState: createState({ status: "running", previewUrl: "http://localhost:3000" }) }),
);
mockUseDevServerLogs.mockReturnValue(createDevServerLogsHookState({
entries: [{ id: "log-1", timestamp: "2026-06-23T00:00:00.000Z", stream: "stdout", text: "ready" }],
total: 1,
}));
previewEmbedState = createPreviewEmbedState({ embedStatus: "embedded", isEmbedded: true });
renderInRightDock(420);
await waitFor(() => {
expect(screen.getByTestId("dev-server-view")).toHaveAttribute("data-narrow-right-dock-preview", "true");
});
expect(screen.getByTestId("dev-server-logs-panel")).toBeInTheDocument();
expect(screen.queryByTestId("devserver-preview-panel")).not.toBeInTheDocument();
expect(screen.queryByTitle("Dev server preview")).not.toBeInTheDocument();
expect(screen.getByTestId("devserver-preview-modal-launcher")).toHaveTextContent("http://localhost:3000");
expect(screen.getByTestId("devserver-preview-url-badge")).toHaveTextContent("http://localhost:3000");
fireEvent.click(screen.getByTestId("devserver-preview-modal-open"));
const modal = await screen.findByTestId("devserver-preview-modal");
expect(modal).toHaveAttribute("role", "dialog");
expect(modal).toHaveAttribute("aria-modal", "true");
expect(screen.getByTitle("Dev server preview")).toBeInTheDocument();
expect(screen.getByTestId("devserver-preview-open-tab")).toBeInTheDocument();
expect(screen.getByTestId("devserver-preview-refresh")).toBeInTheDocument();
fireEvent.keyDown(document, { key: "Escape" });
await waitFor(() => {
expect(screen.queryByTestId("devserver-preview-modal")).not.toBeInTheDocument();
});
});
it("keeps preview modes and fallback actions inside the narrow dock modal", async () => {
const retry = vi.fn();
mockUseDevServer.mockReturnValue(
createDevServerHookState({ serverState: createState({ status: "running", previewUrl: "http://localhost:3000" }) }),
);
previewEmbedState = createPreviewEmbedState({ embedStatus: "embedded", isEmbedded: true });
const { rerender } = renderInRightDock(420);
await waitFor(() => {
expect(screen.getByTestId("dev-server-view")).toHaveAttribute("data-narrow-right-dock-preview", "true");
});
fireEvent.click(screen.getByTestId("devserver-preview-modal-open"));
previewEmbedState = createPreviewEmbedState({
embedStatus: "blocked",
isBlocked: true,
embedContext: "The server may block iframe embedding...",
retry,
});
rerender(<DevServerView addToast={addToast} projectId="project-a" />);
await waitFor(() => {
expect(screen.getByTestId("devserver-preview-fallback")).toBeInTheDocument();
});
expect(screen.getByText("Preview blocked")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("devserver-preview-fallback-retry"));
expect(retry).toHaveBeenCalledTimes(1);
previewEmbedState = createPreviewEmbedState({ embedStatus: "embedded", isEmbedded: true });
rerender(<DevServerView addToast={addToast} projectId="project-a" />);
fireEvent.click(screen.getByTestId("devserver-preview-mode-toggle"));
expect(screen.getByTestId("devserver-preview-external-only")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("devserver-preview-external-open-tab"));
expect(window.open).toHaveBeenCalledWith("http://localhost:3000", "_blank", "noopener,noreferrer");
});
it("keeps inline preview mode for true mobile viewport and expanded right-dock hosts", async () => {
vi.stubGlobal("matchMedia", vi.fn().mockImplementation((query: string) => ({
matches: query === "(max-width: 768px)",
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})));
mockUseDevServer.mockReturnValue(
createDevServerHookState({ serverState: createState({ status: "running", previewUrl: "http://localhost:3000" }) }),
);
const mobile = renderInRightDock(420);
await waitFor(() => {
expect(screen.getByTestId("dev-server-view")).toHaveAttribute("data-narrow-right-dock-preview", "false");
});
mobile.unmount();
document.body.innerHTML = "";
vi.unstubAllGlobals();
const expandedHost = document.createElement("div");
expandedHost.className = "right-dock-expand-modal__body";
Object.defineProperty(expandedHost, "clientWidth", { configurable: true, value: 420 });
document.body.appendChild(expandedHost);
render(<DevServerView addToast={addToast} projectId="project-a" />, { container: expandedHost });
await waitFor(() => {
expect(screen.getByTestId("dev-server-view")).toHaveAttribute("data-narrow-right-dock-preview", "false");
});
}); });
it("shows start-empty state when server is not configured", () => { it("shows start-empty state when server is not configured", () => {