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:
5
.changeset/fn-6939-dev-server-narrow-preview-modal.md
Normal file
5
.changeset/fn-6939-dev-server-narrow-preview-modal.md
Normal 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.
|
||||
@@ -866,6 +866,9 @@ Features:
|
||||
- Start, stop, and restart the current server session
|
||||
- Manage preview URLs with embedded preview and **Open in new tab** fallback
|
||||
- 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).
|
||||
|
||||
|
||||
@@ -582,6 +582,65 @@ exactly when the surrounding chrome is gone.
|
||||
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 */
|
||||
.dev-server-preview-fallback {
|
||||
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;
|
||||
}
|
||||
|
||||
.devserver-preview-header {
|
||||
.devserver-preview-header,
|
||||
.devserver-preview-modal-launcher__copy {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -703,6 +763,20 @@ exactly when the surrounding chrome is gone.
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
.devserver-preview-panel {
|
||||
.devserver-preview-panel,
|
||||
.devserver-preview-modal-launcher {
|
||||
grid-column: 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;
|
||||
}
|
||||
|
||||
.devserver-preview-header {
|
||||
.devserver-preview-header,
|
||||
.devserver-preview-modal-launcher__copy {
|
||||
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 {
|
||||
order: 2;
|
||||
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,
|
||||
.devserver-preview-container,
|
||||
.devserver-preview-iframe {
|
||||
.devserver-preview-panel .devserver-preview-container,
|
||||
.devserver-preview-panel .devserver-preview-iframe {
|
||||
min-height: calc(var(--space-2xl) * 4 + var(--space-md));
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import type { TFunction } from "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 "./DevServerView.css";
|
||||
import type { DetectedDevServerCommand } from "../api";
|
||||
import { useDevServer } from "../hooks/useDevServer";
|
||||
import { useDevServerLogs } from "../hooks/useDevServerLogs";
|
||||
import { usePreviewEmbed } from "../hooks/usePreviewEmbed";
|
||||
import { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { DevServerLogViewer } from "./DevServerLogViewer";
|
||||
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;
|
||||
|
||||
function normalizeError(error: unknown): string {
|
||||
@@ -142,6 +223,14 @@ export function DevServerView({ addToast, projectId, tasks }: DevServerViewProps
|
||||
const effectivePreviewUrl = previewUrl;
|
||||
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 [commandInput, setCommandInput] = useState("");
|
||||
const [previewInput, setPreviewInput] = useState("");
|
||||
@@ -170,6 +259,9 @@ export function DevServerView({ addToast, projectId, tasks }: DevServerViewProps
|
||||
}, [executingTasks, selectedTaskId]);
|
||||
|
||||
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 {
|
||||
@@ -271,6 +363,60 @@ export function DevServerView({ addToast, projectId, tasks }: DevServerViewProps
|
||||
setPreviewInput(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(() => {
|
||||
if (!effectivePreviewUrl) {
|
||||
return;
|
||||
@@ -399,8 +545,136 @@ export function DevServerView({ addToast, projectId, tasks }: DevServerViewProps
|
||||
const stopDisabled = status === "stopped" || 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 (
|
||||
<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:
|
||||
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>
|
||||
</div>
|
||||
|
||||
<section className="dev-server-panel devserver-preview-panel" data-testid="devserver-preview-panel" aria-label={t("devserver.previewLabel", "Dev server preview")}>
|
||||
<div className="devserver-preview-header">
|
||||
<div className="devserver-preview-title">
|
||||
<Eye size={14} />
|
||||
<span>{t("devserver.preview", "Preview")}</span>
|
||||
{isNarrowRightDockPreviewMode ? (
|
||||
<section
|
||||
className="dev-server-panel devserver-preview-modal-launcher"
|
||||
data-testid="devserver-preview-modal-launcher"
|
||||
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>
|
||||
<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"
|
||||
<p className="devserver-preview-modal-launcher__description">
|
||||
{effectivePreviewUrl
|
||||
? t("devserver.previewModalLauncherDescription", "Open the live preview in a modal so logs and configuration stay usable in this narrow dock.")
|
||||
: 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")}
|
||||
{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>
|
||||
{t("devserver.openPreview", "Open preview")}
|
||||
</button>
|
||||
</section>
|
||||
) : (
|
||||
<section className="dev-server-panel devserver-preview-panel" data-testid="devserver-preview-panel" aria-label={t("devserver.previewLabel", "Dev server preview")}>
|
||||
{renderPreviewContent()}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<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>
|
||||
{isNarrowRightDockPreviewMode && isPreviewModalOpen && (
|
||||
<div className="modal-overlay open devserver-preview-modal-overlay" {...previewModalOverlayDismissProps}>
|
||||
<div
|
||||
className="modal devserver-preview-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="devserver-preview-modal-title"
|
||||
tabIndex={-1}
|
||||
ref={previewModalRef}
|
||||
data-testid="devserver-preview-modal"
|
||||
>
|
||||
<div className="devserver-preview-modal__titlebar">
|
||||
<h2 id="devserver-preview-modal-title">{t("devserver.preview", "Preview")}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm touch-target"
|
||||
onClick={handleOpenInNewTab}
|
||||
data-testid="devserver-preview-external-open-tab"
|
||||
className="btn btn-sm btn-icon"
|
||||
onClick={closePreviewModal}
|
||||
aria-label={t("devserver.closePreviewModal", "Close preview modal")}
|
||||
data-testid="devserver-preview-modal-close"
|
||||
>
|
||||
{t("devserver.openInNewTab", "Open in new tab")}
|
||||
<X />
|
||||
</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 className="devserver-preview-modal__body">
|
||||
{renderPreviewContent()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{effectivePreviewUrl && previewMode === "embedded" && !showFallback && (
|
||||
<PreviewIframe
|
||||
url={effectivePreviewUrl}
|
||||
embedStatus={embedStatus}
|
||||
onEmbedStatusChange={setEmbedStatus}
|
||||
iframeRef={iframeRef}
|
||||
blockReason={blockReason}
|
||||
onRetry={handleRetryEmbeddedPreview}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,12 +51,27 @@ describe("DevServerView mobile CSS/structure", () => {
|
||||
const mobileBlockMatch = css.match(/@media[^{]*\(max-width: 768px\)[^{]*\{([\s\S]*?)\n\}/g) ?? [];
|
||||
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(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/);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
mockUseDevServer.mockReturnValue(createDevServerHookState());
|
||||
mockUseDevServerLogs.mockReturnValue({
|
||||
|
||||
@@ -44,6 +44,7 @@ vi.mock("lucide-react", () => ({
|
||||
Search: () => <span data-testid="icon-search" />,
|
||||
ShieldAlert: () => <span data-testid="icon-shield-alert" />,
|
||||
Square: () => <span data-testid="icon-square" />,
|
||||
X: () => <span data-testid="icon-x" />,
|
||||
}));
|
||||
|
||||
function createState(overrides: Partial<DevServerState> = {}): DevServerState {
|
||||
@@ -201,6 +202,153 @@ describe("DevServerView preview panel", () => {
|
||||
|
||||
afterEach(() => {
|
||||
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", () => {
|
||||
|
||||
Reference in New Issue
Block a user