open-design/apps/web/src/components/FileViewer.tsx
huyhoangnhh98 140a4e1ff6
Improve responsive preview and design handoff outputs (#1224)
* feat: improve responsive design handoff

* feat: refine cross-platform design outputs

Changelog:\n- Add auto-fit responsive preview behavior for tablet/mobile frames.\n- Add landing page and OS widgets metadata options with project header chips.\n- Strengthen prompt contracts for modern breakpoints, app-specific modules, CJX-ready UX, and final product surfaces.\n- Require cross-platform outputs to use separate platform files instead of tabbed demo selectors.\n- Add DESIGN-MANIFEST.json plus richer handoff guidance to daemon/client exports.\n- Update archive/export tests for manifest and responsive viewport matrix.

* feat: enforce screen-file design outputs

Changelog:\n- Enforce screen-file-first generation for landing pages, app screens, platform surfaces, and OS widgets.\n- Update design handoff and manifest exports so coding tools map each screen file to separate routes/surfaces.\n- Strengthen minimal-brief visual guidance to avoid monochrome or unstyled design outputs.

* fix: address responsive handoff review feedback

* fix: address handoff review blockers

* fix: preserve proxy auth and normalized export entry

* fix: narrow frame wrapper filter to directory paths only

* fix: make artifact save failure banner generic

---------

Co-authored-by: Huy Hoàng <macos@MacBook-Pro-Hoang.local>
2026-05-12 14:18:33 +08:00

6188 lines
228 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useId, useMemo, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { APP_CHROME_FILE_ACTIONS_ID } from './AppChromeHeader';
import { MarkdownRenderer, artifactRendererRegistry } from '../artifacts/renderer-registry';
import { renderMarkdownToSafeHtml } from '../artifacts/markdown';
import { useT, useI18n } from '../i18n';
import type { Dict, Locale } from '../i18n/types';
import {
fetchLiveArtifact,
fetchLiveArtifactCode,
fetchLiveArtifactRefreshes,
checkDeploymentLink,
CLOUDFLARE_PAGES_PROVIDER_ID,
DEFAULT_DEPLOY_PROVIDER_ID,
deployProjectFile,
fetchCloudflarePagesZones,
fetchDeployConfig,
fetchProjectDeployments,
fetchProjectFilePreview,
fetchProjectFileText,
liveArtifactPreviewUrl,
projectFileUrl,
projectRawUrl,
LiveArtifactRefreshError,
refreshLiveArtifact,
updateDeployConfig,
type WebDeployConfigResponse,
type WebCloudflarePagesDeploySelection,
type WebDeploymentInfo,
type WebDeployProjectFileResponse,
type WebDeployProviderId,
type WebUpdateDeployConfigRequest,
writeProjectTextFile,
} from '../providers/registry';
import type { ProjectFilePreview } from '../providers/registry';
import {
exportAsHtml,
exportAsJsx,
exportAsMd,
exportAsPdf,
exportProjectAsPdf,
exportProjectAsZip,
exportReactComponentAsHtml,
exportReactComponentAsZip,
openSandboxedPreviewInNewTab,
} from '../runtime/exports';
import { buildReactComponentSrcdoc } from '../runtime/react-component';
import { buildSrcdoc } from '../runtime/srcdoc';
import { parseForceInline, shouldUrlLoadHtmlPreview } from './file-viewer-render-mode';
import { saveTemplate } from '../state/projects';
import type {
LiveArtifactEventItem,
LiveArtifact,
LiveArtifactRefreshLogEntry,
LiveArtifactViewerTab,
LiveArtifactWorkspaceEntry,
ProjectFile,
} from '../types';
import { Icon } from './Icon';
import {
buildBoardCommentAttachments,
liveSnapshotForComment,
overlayBoundsFromSnapshot,
selectionKindLabel,
targetFromSnapshot,
type PreviewCommentSnapshot,
} from '../comments';
import type {
ChatCommentAttachment,
PreviewComment,
PreviewCommentMember,
PreviewCommentTarget,
} from '../types';
import { ManualEditPanel, emptyManualEditDraft, type ManualEditDraft } from './ManualEditPanel';
import {
applyManualEditPatch,
readManualEditAttributes,
readManualEditFields,
readManualEditOuterHtml,
readManualEditStyles,
} from '../edit-mode/source-patches';
import type { ManualEditBridgeMessage, ManualEditHistoryEntry, ManualEditPatch, ManualEditTarget } from '../edit-mode/types';
import { isRenderableSketchJson, SketchPreview } from './SketchPreview';
type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) => string;
type SlideState = { active: number; count: number };
type BoardTool = 'inspect' | 'pod';
type StrokePoint = { x: number; y: number };
type PreviewViewportId = 'desktop' | 'tablet' | 'mobile';
type PreviewCanvasSize = { width: number; height: number };
type PreviewViewportPreset = {
id: PreviewViewportId;
width: number | null;
height: number | null;
labelKey: keyof Dict;
titleKey: keyof Dict;
};
type DeployProviderOption = {
id: WebDeployProviderId;
labelKey: 'fileViewer.vercelProvider' | 'fileViewer.cloudflarePagesProvider';
tokenLink: string;
tokenLinkKey: 'fileViewer.vercelTokenGetLink' | 'fileViewer.cloudflareApiTokenGetLink';
tokenPlaceholderKey:
| 'fileViewer.vercelTokenPlaceholder'
| 'fileViewer.cloudflareApiTokenPlaceholder';
tokenReuseHintKey: 'fileViewer.vercelTokenReuseHint' | 'fileViewer.cloudflareApiTokenReuseHint';
tokenRequiredKey: 'fileViewer.vercelTokenRequired' | 'fileViewer.cloudflareApiTokenRequired';
previewHintKey: 'fileViewer.vercelPreviewOnly' | 'fileViewer.cloudflarePagesPreviewHint';
tokenLabelKey:
| 'fileViewer.vercelToken'
| 'fileViewer.cloudflareApiToken';
accountIdLabelKey?: 'fileViewer.cloudflareAccountId';
accountIdHintKey?: 'fileViewer.cloudflareAccountIdHint';
};
type CloudflarePagesZoneOption = {
id: string;
name: string;
status?: string;
type?: string;
};
type DeployResultCard = {
id: string;
label: string;
url: string;
status: string;
message?: string;
};
const MAX_BRIDGE_COORDINATE = 1_000_000;
const PREVIEW_VIEWPORT_PRESETS: PreviewViewportPreset[] = [
{
id: 'desktop',
width: null,
height: null,
labelKey: 'fileViewer.viewportDesktop',
titleKey: 'fileViewer.viewportDesktopTitle',
},
{
id: 'tablet',
width: 820,
height: 1180,
labelKey: 'fileViewer.viewportTablet',
titleKey: 'fileViewer.viewportTabletTitle',
},
{
id: 'mobile',
width: 390,
height: 844,
labelKey: 'fileViewer.viewportMobile',
titleKey: 'fileViewer.viewportMobileTitle',
},
];
// The five basic style facets the inspect panel exposes. Kept narrow on
// purpose — open-slide's design tokens panel only edits global tokens, so
// the per-element delta is small + obvious + cheap to read back from
// getComputedStyle on the iframe side.
type InspectStyleSnapshot = {
color?: string;
backgroundColor?: string;
fontSize?: string;
fontWeight?: string;
paddingTop?: string;
paddingRight?: string;
paddingBottom?: string;
paddingLeft?: string;
borderRadius?: string;
textAlign?: string;
fontFamily?: string;
lineHeight?: string;
};
type InspectTarget = {
elementId: string;
selector: string;
label: string;
text: string;
style: InspectStyleSnapshot;
};
const MAX_CACHED_SLIDE_STATES = 64;
const htmlPreviewSlideState = new Map<string, SlideState>();
const MARKDOWN_CODE_BLOCK_ATTR = 'data-markdown-code-block';
const MARKDOWN_COPY_BLOCK_ATTR = 'data-copy-code-block';
const MARKDOWN_COPY_BUTTON_CLASS = 'markdown-code-copy';
const MARKDOWN_COPY_TOAST_CLASS = 'markdown-code-toast';
const DEPLOY_PROVIDER_OPTIONS: DeployProviderOption[] = [
{
id: DEFAULT_DEPLOY_PROVIDER_ID,
labelKey: 'fileViewer.vercelProvider',
tokenLink: 'https://vercel.com/account/settings/tokens',
tokenLinkKey: 'fileViewer.vercelTokenGetLink',
tokenPlaceholderKey: 'fileViewer.vercelTokenPlaceholder',
tokenReuseHintKey: 'fileViewer.vercelTokenReuseHint',
tokenRequiredKey: 'fileViewer.vercelTokenRequired',
previewHintKey: 'fileViewer.vercelPreviewOnly',
tokenLabelKey: 'fileViewer.vercelToken',
},
{
id: CLOUDFLARE_PAGES_PROVIDER_ID,
labelKey: 'fileViewer.cloudflarePagesProvider',
tokenLink: 'https://dash.cloudflare.com/profile/api-tokens',
tokenLinkKey: 'fileViewer.cloudflareApiTokenGetLink',
tokenPlaceholderKey: 'fileViewer.cloudflareApiTokenPlaceholder',
tokenReuseHintKey: 'fileViewer.cloudflareApiTokenReuseHint',
tokenRequiredKey: 'fileViewer.cloudflareApiTokenRequired',
previewHintKey: 'fileViewer.cloudflarePagesPreviewHint',
tokenLabelKey: 'fileViewer.cloudflareApiToken',
accountIdLabelKey: 'fileViewer.cloudflareAccountId',
accountIdHintKey: 'fileViewer.cloudflareAccountIdHint',
},
];
function getDeployProviderOption(providerId: WebDeployProviderId): DeployProviderOption {
return DEPLOY_PROVIDER_OPTIONS.find((option) => option.id === providerId) ?? DEPLOY_PROVIDER_OPTIONS[0]!;
}
function normalizeCloudflareDomainPrefixInput(raw: string): string {
return raw.trim().toLowerCase();
}
function isValidCloudflareDomainPrefixInput(raw: string): boolean {
const prefix = normalizeCloudflareDomainPrefixInput(raw);
return /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(prefix);
}
function deployResultState(status?: string): 'ready' | 'delayed' | 'protected' | 'failed' {
if (status === 'protected') return 'protected';
if (status === 'failed' || status === 'conflict') return 'failed';
if (status === 'link-delayed' || status === 'pending') return 'delayed';
return 'ready';
}
async function copyTextToClipboard(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
const priorFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try {
return document.execCommand('copy');
} catch {
return false;
} finally {
document.body.removeChild(ta);
if (priorFocus?.isConnected) {
try {
priorFocus.focus({ preventScroll: true });
} catch {
priorFocus.focus();
}
}
}
}
}
function decorateMarkdownCodeBlocks(html: string): string {
let blockIndex = 0;
return html.replace(/<pre\b([^>]*)>([\s\S]*?)<\/pre>/g, (_match, attrs: string, content: string) => {
const blockId = String(blockIndex++);
return `<div class="markdown-code-block" ${MARKDOWN_CODE_BLOCK_ATTR}="${blockId}"><pre${attrs}>${content}</pre></div>`;
});
}
function setMarkdownCodeBlockCopiedState(block: HTMLElement, copied: boolean, t: TranslateFn) {
const button = block.querySelector<HTMLButtonElement>(`.${MARKDOWN_COPY_BUTTON_CLASS}`);
if (!button) return;
const label = copied ? t('fileViewer.copied') : t('fileViewer.copy');
button.textContent = label;
button.setAttribute('aria-label', label);
button.title = t('fileViewer.copyTitle');
const existingToast = block.querySelector(`.${MARKDOWN_COPY_TOAST_CLASS}`);
if (copied) {
if (existingToast instanceof HTMLElement) {
existingToast.textContent = t('fileViewer.copied');
return;
}
const toast = document.createElement('span');
toast.className = MARKDOWN_COPY_TOAST_CLASS;
toast.setAttribute('role', 'status');
toast.setAttribute('aria-live', 'polite');
toast.textContent = t('fileViewer.copied');
button.insertAdjacentElement('afterend', toast);
return;
}
existingToast?.remove();
}
function PreviewViewportControls({
viewport,
onViewport,
t,
tabIndex,
}: {
viewport: PreviewViewportId;
onViewport: (viewport: PreviewViewportId) => void;
t: TranslateFn;
tabIndex?: number;
}) {
return (
<div className="viewer-viewport-switcher" role="group" aria-label={t('fileViewer.viewportAria')}>
{PREVIEW_VIEWPORT_PRESETS.map((preset) => (
<button
key={preset.id}
type="button"
className={`viewer-action viewer-viewport-button${viewport === preset.id ? ' active' : ''}`}
aria-pressed={viewport === preset.id}
title={t(preset.titleKey)}
tabIndex={tabIndex}
onClick={() => onViewport(preset.id)}
>
{t(preset.labelKey)}
</button>
))}
</div>
);
}
function previewViewportStyle(
viewport: PreviewViewportId,
previewScale = 1,
canvasSize?: PreviewCanvasSize,
): CSSProperties & Record<string, string | number> {
const preset = PREVIEW_VIEWPORT_PRESETS.find((item) => item.id === viewport) ?? PREVIEW_VIEWPORT_PRESETS[0]!;
if (!preset.width) return {};
const effectiveScale = effectivePreviewScale(viewport, previewScale, canvasSize);
return {
'--preview-viewport-width': `${preset.width}px`,
'--preview-viewport-height': `${preset.height}px`,
'--preview-scale': effectiveScale,
'--preview-user-scale': previewScale,
};
}
export function effectivePreviewScale(
viewport: PreviewViewportId,
previewScale: number,
canvasSize?: PreviewCanvasSize,
) {
if (viewport === 'desktop') return previewScale;
const preset = PREVIEW_VIEWPORT_PRESETS.find((item) => item.id === viewport);
if (!preset?.width || !preset.height || !canvasSize?.width || !canvasSize.height) return previewScale;
const canvasPadding = 48;
const availableWidth = Math.max(1, canvasSize.width - canvasPadding);
const availableHeight = Math.max(1, canvasSize.height - canvasPadding);
const fitScale = Math.min(1, availableWidth / preset.width, availableHeight / preset.height);
return Math.min(previewScale, fitScale);
}
function previewScaleShellStyle(
viewport: PreviewViewportId,
previewScale: number,
): CSSProperties & Record<string, string | number> {
if (viewport === 'desktop') {
return {
width: `${100 / previewScale}%`,
height: `${100 / previewScale}%`,
transform: `scale(${previewScale})`,
transformOrigin: '0 0',
};
}
return {
width: 'var(--preview-viewport-width)',
height: 'var(--preview-viewport-height)',
transform: 'scale(var(--preview-scale, 1))',
transformOrigin: '0 0',
};
}
function usePreviewCanvasSize<T extends HTMLElement>() {
const ref = useRef<T | null>(null);
const [size, setSize] = useState<PreviewCanvasSize | undefined>(undefined);
useEffect(() => {
const el = ref.current;
if (!el) return;
const measure = () => {
const rect = el.getBoundingClientRect();
setSize({ width: rect.width, height: rect.height });
};
measure();
if (typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}, []);
return [ref, size] as const;
}
function ensureMarkdownCodeBlockControls(root: HTMLElement, t: TranslateFn) {
for (const block of root.querySelectorAll<HTMLElement>(`[${MARKDOWN_CODE_BLOCK_ATTR}]`)) {
let button = block.querySelector<HTMLButtonElement>(`.${MARKDOWN_COPY_BUTTON_CLASS}`);
if (!button) {
button = document.createElement('button');
button.type = 'button';
button.className = MARKDOWN_COPY_BUTTON_CLASS;
const blockId = block.getAttribute(MARKDOWN_CODE_BLOCK_ATTR) ?? '';
button.setAttribute(MARKDOWN_COPY_BLOCK_ATTR, blockId);
block.prepend(button);
}
setMarkdownCodeBlockCopiedState(block, false, t);
}
}
function setSlideStateCached(key: string, state: SlideState) {
htmlPreviewSlideState.set(key, state);
if (htmlPreviewSlideState.size > MAX_CACHED_SLIDE_STATES) {
const oldest = htmlPreviewSlideState.keys().next().value;
if (oldest != null) htmlPreviewSlideState.delete(oldest);
}
}
interface Props {
projectId: string;
file: ProjectFile;
liveHtml?: string;
isDeck?: boolean;
onExportAsPptx?: ((fileName: string) => void) | undefined;
streaming?: boolean;
previewComments?: PreviewComment[];
onSavePreviewComment?: (target: PreviewCommentTarget, note: string, attachAfterSave: boolean) => Promise<PreviewComment | null>;
onRemovePreviewComment?: (commentId: string) => Promise<void>;
onSendBoardCommentAttachments?: (attachments: ChatCommentAttachment[]) => Promise<void> | void;
onFileSaved?: () => Promise<void> | void;
}
export function FileViewer({
projectId,
file,
liveHtml,
isDeck,
onExportAsPptx,
streaming,
previewComments = [],
onSavePreviewComment,
onRemovePreviewComment,
onSendBoardCommentAttachments,
onFileSaved,
}: Props) {
const rendererMatch = artifactRendererRegistry.resolve({
file,
isDeckHint: Boolean(isDeck),
});
if (rendererMatch?.renderer.id === 'html' || rendererMatch?.renderer.id === 'deck-html') {
return (
<HtmlViewer
projectId={projectId}
file={file}
liveHtml={liveHtml}
isDeck={rendererMatch.renderer.id === 'deck-html'}
onExportAsPptx={onExportAsPptx}
streaming={Boolean(streaming)}
previewComments={previewComments}
onSavePreviewComment={onSavePreviewComment}
onRemovePreviewComment={onRemovePreviewComment}
onSendBoardCommentAttachments={onSendBoardCommentAttachments}
onFileSaved={onFileSaved}
/>
);
}
if (rendererMatch?.renderer.id === 'react-component') {
return <ReactComponentViewer projectId={projectId} file={file} />;
}
if (rendererMatch?.renderer.id === 'markdown') {
return <MarkdownViewer projectId={projectId} file={file} />;
}
if (rendererMatch?.renderer.id === 'svg') {
return <SvgViewer projectId={projectId} file={file} />;
}
if (file.kind === 'image') {
return <ImageViewer projectId={projectId} file={file} />;
}
if (file.kind === 'video') {
return <VideoViewer projectId={projectId} file={file} />;
}
if (file.kind === 'audio') {
return <AudioViewer projectId={projectId} file={file} />;
}
if (file.kind === 'sketch') {
if (isRenderableSketchJson(file)) {
return <SketchViewer projectId={projectId} file={file} />;
}
return <ImageViewer projectId={projectId} file={file} />;
}
if (file.kind === 'text' || file.kind === 'code') {
return <TextViewer projectId={projectId} file={file} />;
}
if (
file.kind === 'pdf' ||
file.kind === 'document' ||
file.kind === 'presentation' ||
file.kind === 'spreadsheet'
) {
return <DocumentPreviewViewer projectId={projectId} file={file} />;
}
return <BinaryViewer projectId={projectId} file={file} />;
}
export function LiveArtifactViewer({
projectId,
liveArtifact,
liveArtifactEvents = [],
onRefreshArtifacts,
}: {
projectId: string;
liveArtifact: LiveArtifactWorkspaceEntry;
liveArtifactEvents?: LiveArtifactEventItem[];
onRefreshArtifacts?: () => Promise<void> | void;
}) {
const t = useT();
const tabs = useMemo(() => liveArtifactViewerTabs(t), [t]);
const [mode, setMode] = useState<LiveArtifactViewerTab>('preview');
const [detail, setDetail] = useState<LiveArtifact | null>(null);
const [loading, setLoading] = useState(true);
const [reloadKey, setReloadKey] = useState(0);
const [zoom, setZoom] = useState(100);
const [previewViewport, setPreviewViewport] = useState<PreviewViewportId>('desktop');
const [previewBodyRef, previewBodySize] = usePreviewCanvasSize<HTMLDivElement>();
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [refreshError, setRefreshError] = useState<string | null>(null);
const [refreshSuccess, setRefreshSuccess] = useState<string | null>(null);
const [refreshEvents, setRefreshEvents] = useState<LiveArtifactRefreshEvent[]>([]);
const [refreshHistory, setRefreshHistory] = useState<LiveArtifactRefreshLogEntry[]>([]);
const [presentMenuOpen, setPresentMenuOpen] = useState(false);
const [inTabPresent, setInTabPresent] = useState(false);
const presentWrapRef = useRef<HTMLDivElement | null>(null);
const [chromeActionsHost, setChromeActionsHost] = useState<HTMLElement | null>(null);
useEffect(() => {
if (typeof document === 'undefined') return;
setChromeActionsHost(document.getElementById(APP_CHROME_FILE_ACTIONS_ID));
}, []);
useEffect(() => {
if (!presentMenuOpen) return;
const onPointer = (e: MouseEvent) => {
const target = e.target as HTMLElement | null;
if (!target) return;
if (target.closest('.present-wrap')) return;
setPresentMenuOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setPresentMenuOpen(false);
};
document.addEventListener('mousedown', onPointer);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onPointer);
document.removeEventListener('keydown', onKey);
};
}, [presentMenuOpen]);
useEffect(() => {
setRefreshError(null);
setRefreshSuccess(null);
setRefreshEvents([]);
}, [projectId, liveArtifact.artifactId]);
useEffect(() => {
if (!refreshSuccess) return;
const timeout = window.setTimeout(() => setRefreshSuccess(null), 6000);
return () => window.clearTimeout(timeout);
}, [refreshSuccess]);
const processedLiveArtifactEventIdRef = useRef(0);
useEffect(() => {
const pendingEvents = liveArtifactEvents.filter((item) => item.id > processedLiveArtifactEventIdRef.current);
if (pendingEvents.length === 0) return;
processedLiveArtifactEventIdRef.current = pendingEvents[pendingEvents.length - 1]?.id ?? processedLiveArtifactEventIdRef.current;
for (const { event: liveArtifactEvent } of pendingEvents) {
if (
(liveArtifactEvent.kind !== 'live_artifact' && liveArtifactEvent.kind !== 'live_artifact_refresh') ||
liveArtifactEvent.projectId !== projectId ||
liveArtifactEvent.artifactId !== liveArtifact.artifactId
) {
continue;
}
if (liveArtifactEvent.kind === 'live_artifact') {
setRefreshError(null);
if (liveArtifactEvent.action === 'deleted') {
setRefreshSuccess(`Live artifact deleted: ${liveArtifactEvent.title}`);
continue;
}
setRefreshSuccess(
liveArtifactEvent.action === 'created'
? `Live artifact created: ${liveArtifactEvent.title}`
: `Live artifact updated: ${liveArtifactEvent.title}`,
);
void fetchLiveArtifact(projectId, liveArtifact.artifactId).then((next) => {
if (next) setDetail(next);
});
void fetchLiveArtifactRefreshes(projectId, liveArtifact.artifactId).then(setRefreshHistory);
setReloadKey((n) => n + 1);
continue;
}
if (liveArtifactEvent.phase === 'started') {
setRefreshing(true);
setRefreshError(null);
setRefreshSuccess(null);
setRefreshEvents((prev) => appendRefreshEvent(prev, { phase: 'started' }));
continue;
}
if (liveArtifactEvent.phase === 'failed') {
setRefreshing(false);
setRefreshError(liveArtifactEvent.error ?? t('liveArtifact.refresh.genericFailure'));
setRefreshEvents((prev) =>
appendRefreshEvent(prev, {
phase: 'failed',
error: liveArtifactEvent.error ?? undefined,
}),
);
void fetchLiveArtifact(projectId, liveArtifact.artifactId).then((next) => {
if (next) setDetail(next);
});
void fetchLiveArtifactRefreshes(projectId, liveArtifact.artifactId).then(setRefreshHistory);
continue;
}
setRefreshing(false);
setRefreshError(null);
setRefreshEvents((prev) =>
appendRefreshEvent(prev, {
phase: 'succeeded',
refreshedSourceCount: liveArtifactEvent.refreshedSourceCount ?? 0,
}),
);
if ((liveArtifactEvent.refreshedSourceCount ?? 0) > 0) {
setRefreshSuccess(t('liveArtifact.refresh.successOne'));
} else {
setRefreshError(t('liveArtifact.refresh.noSourceTitle'));
}
void fetchLiveArtifact(projectId, liveArtifact.artifactId).then((next) => {
if (next) setDetail(next);
});
void fetchLiveArtifactRefreshes(projectId, liveArtifact.artifactId).then(setRefreshHistory);
setReloadKey((n) => n + 1);
}
}, [liveArtifactEvents, liveArtifact.artifactId, projectId, t]);
useEffect(() => {
let cancelled = false;
setLoading(true);
setDetail(null);
void fetchLiveArtifact(projectId, liveArtifact.artifactId).then((next) => {
if (cancelled) return;
setDetail(next);
setLoading(false);
});
void fetchLiveArtifactRefreshes(projectId, liveArtifact.artifactId).then((next) => {
if (!cancelled) setRefreshHistory(next);
});
return () => {
cancelled = true;
};
}, [projectId, liveArtifact.artifactId, liveArtifact.updatedAt]);
const previewUrl = useMemo(
() => `${liveArtifactPreviewUrl(projectId, liveArtifact.artifactId)}&v=${reloadKey}`,
[projectId, liveArtifact.artifactId, reloadKey],
);
const previewScale = zoom / 100;
function bumpZoom(delta: number) {
setZoom((z) => Math.max(25, Math.min(200, z + delta)));
}
async function handleRefresh() {
if (refreshing) return;
setRefreshing(true);
setRefreshError(null);
setRefreshSuccess(null);
setRefreshEvents((prev) => appendRefreshEvent(prev, { phase: 'started' }));
try {
const result = await refreshLiveArtifact(projectId, liveArtifact.artifactId);
setDetail(result.artifact);
void fetchLiveArtifactRefreshes(projectId, liveArtifact.artifactId).then(setRefreshHistory);
setReloadKey((n) => n + 1);
setRefreshEvents((prev) =>
appendRefreshEvent(prev, {
phase: 'succeeded',
refreshedSourceCount: result.refresh.refreshedSourceCount,
}),
);
if (result.refresh.refreshedSourceCount > 0) {
setRefreshSuccess(t('liveArtifact.refresh.successOne'));
} else {
setRefreshError(t('liveArtifact.refresh.noSourceTitle'));
}
await onRefreshArtifacts?.();
} catch (error) {
const message = refreshErrorMessage(error, t);
setRefreshError(message);
setRefreshEvents((prev) => appendRefreshEvent(prev, { phase: 'failed', error: message }));
} finally {
setRefreshing(false);
}
}
const dataPayload = detail?.document?.dataJson ?? null;
const currentRefreshStatus = detail?.refreshStatus ?? liveArtifact.refreshStatus;
const isRunning = refreshing || currentRefreshStatus === 'running';
const presentInThisTab = () => {
setPresentMenuOpen(false);
setMode('preview');
setInTabPresent(true);
};
const presentFullscreen = () => {
setPresentMenuOpen(false);
setMode('preview');
const target = previewBodyRef.current ?? iframeRef.current;
if (target?.requestFullscreen) {
void target.requestFullscreen().catch(() => {});
}
};
const presentNewTab = () => {
setPresentMenuOpen(false);
if (typeof window === 'undefined') return;
window.open(liveArtifactPreviewUrl(projectId, liveArtifact.artifactId), '_blank', 'noopener,noreferrer');
};
useEffect(() => {
if (!inTabPresent) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setInTabPresent(false);
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [inTabPresent]);
return (
<div className={`viewer html-viewer live-artifact-viewer${inTabPresent ? ' is-tab-present' : ''}`}>
{((node: ReactNode) => (
chromeActionsHost ? createPortal(node, chromeActionsHost) : node
))(
<div className="present-wrap chrome-present-wrap" ref={presentWrapRef}>
<button
className="chrome-action chrome-action-secondary present-trigger"
aria-haspopup="menu"
aria-expanded={presentMenuOpen}
onClick={() => setPresentMenuOpen((v) => !v)}
>
<Icon name="present" size={13} />
<span>{t('fileViewer.present')}</span>
<Icon name="chevron-down" size={11} />
</button>
{presentMenuOpen ? (
<div className="present-menu" role="menu">
<button role="menuitem" onClick={presentInThisTab}>
<span className="present-icon"><Icon name="eye" size={13} /></span>{' '}
{t('fileViewer.presentInTab')}
</button>
<button role="menuitem" onClick={presentFullscreen}>
<span className="present-icon"><Icon name="play" size={13} /></span>{' '}
{t('fileViewer.presentFullscreen')}
</button>
<button role="menuitem" onClick={presentNewTab}>
<span className="present-icon"><Icon name="share" size={13} /></span>{' '}
{t('fileViewer.presentNewTab')}
</button>
</div>
) : null}
</div>
)}
{inTabPresent ? (
<button
type="button"
className="present-exit-btn"
onClick={() => setInTabPresent(false)}
title={t('common.exitFullscreen')}
aria-label={t('common.exitFullscreen')}
>
<Icon name="close" size={14} />
</button>
) : null}
<div className="viewer-toolbar">
<div className="viewer-toolbar-left">
<button
type="button"
className="icon-only"
onClick={() => setReloadKey((n) => n + 1)}
title={t('fileViewer.reload')}
aria-label={t('fileViewer.reloadAria')}
>
<Icon name="reload" size={14} />
</button>
</div>
<div className="viewer-toolbar-actions">
<div className="viewer-tabs">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
className={`viewer-tab ${mode === tab.id ? 'active' : ''}`}
onClick={() => setMode(tab.id)}
>
{tab.label}
</button>
))}
</div>
<div
className="viewer-preview-controls"
data-active={mode === 'preview' ? 'true' : 'false'}
aria-hidden={mode === 'preview' ? undefined : true}
>
<span className="viewer-divider" aria-hidden />
<PreviewViewportControls
viewport={previewViewport}
onViewport={setPreviewViewport}
t={t}
tabIndex={mode === 'preview' ? 0 : -1}
/>
<span className="viewer-divider" aria-hidden />
<button
type="button"
className="icon-only"
onClick={() => bumpZoom(-25)}
title={t('fileViewer.zoomOut')}
aria-label={t('fileViewer.zoomOut')}
tabIndex={mode === 'preview' ? 0 : -1}
>
<Icon name="minus" size={14} />
</button>
<button
type="button"
className="viewer-action viewer-zoom-level"
onClick={() => setZoom(100)}
title={t('fileViewer.resetZoom')}
tabIndex={mode === 'preview' ? 0 : -1}
>
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{zoom}%</span>
</button>
<button
type="button"
className="icon-only"
onClick={() => bumpZoom(25)}
title={t('fileViewer.zoomIn')}
aria-label={t('fileViewer.zoomIn')}
tabIndex={mode === 'preview' ? 0 : -1}
>
<Icon name="plus" size={14} />
</button>
<span className="viewer-divider" aria-hidden />
<a
className="ghost-link"
href={liveArtifactPreviewUrl(projectId, liveArtifact.artifactId)}
target="_blank"
rel="noreferrer noopener"
tabIndex={mode === 'preview' ? 0 : -1}
>
{t('fileViewer.open')}
</a>
</div>
<span className="viewer-divider" aria-hidden />
<button
type="button"
className="viewer-action primary"
data-running={isRunning ? 'true' : 'false'}
onClick={() => void handleRefresh()}
disabled={isRunning}
aria-busy={isRunning}
aria-label={isRunning ? t('liveArtifact.refresh.running') : t('liveArtifact.refresh.button')}
title={
isRunning
? t('liveArtifact.refresh.running')
: t('liveArtifact.refresh.buttonTitle')
}
>
<Icon name={isRunning ? 'spinner' : 'reload'} size={13} />
<span>{isRunning ? t('liveArtifact.refresh.running') : t('liveArtifact.refresh.button')}</span>
</button>
</div>
</div>
<div className="viewer-body" ref={previewBodyRef}>
{refreshError ? (
<LiveArtifactRefreshNotice
tone="error"
message={refreshError}
action={t('liveArtifact.refresh.failureAction')}
/>
) : refreshSuccess ? (
<LiveArtifactRefreshNotice
tone="success"
message={refreshSuccess}
action={t('liveArtifact.refresh.successAction')}
onDismiss={() => setRefreshSuccess(null)}
dismissLabel={t('common.close')}
/>
) : isRunning ? (
<LiveArtifactRefreshNotice
tone="running"
message={t('liveArtifact.refresh.runningMessage')}
action={t('liveArtifact.refresh.runningAction')}
/>
) : currentRefreshStatus === 'failed' ? (
<LiveArtifactRefreshNotice
tone="error"
message={t('liveArtifact.refresh.previousFailure', { message: t('liveArtifact.refresh.genericFailure') })}
action={t('liveArtifact.refresh.failureAction')}
/>
) : null}
{mode === 'preview' ? (
<div
className={`live-artifact-preview-layer preview-viewport preview-viewport-${previewViewport}`}
style={previewViewportStyle(previewViewport, previewScale, previewBodySize)}
>
<div className="preview-frame-clip">
<div style={previewScaleShellStyle(previewViewport, previewScale)}>
<iframe
ref={iframeRef}
data-testid="live-artifact-preview-frame"
title={liveArtifact.title}
sandbox="allow-scripts allow-popups"
src={previewUrl}
/>
</div>
</div>
</div>
) : loading ? (
<div className="viewer-empty">{t('fileViewer.loading')}</div>
) : mode === 'code' ? (
<LiveArtifactCodePanel
projectId={projectId}
artifactId={liveArtifact.artifactId}
reloadKey={reloadKey}
/>
) : mode === 'data' ? (
<JsonPanel value={dataPayload} emptyLabel={t('liveArtifact.viewer.dataEmpty')} />
) : (
<LiveArtifactRefreshHistoryPanel
liveArtifact={detail}
fallbackRefreshStatus={liveArtifact.refreshStatus}
fallbackLastRefreshedAt={liveArtifact.lastRefreshedAt}
isRunning={isRunning}
sessionEvents={refreshEvents}
persistedEvents={refreshHistory}
/>
)}
</div>
</div>
);
}
function LiveArtifactRefreshNotice({
tone,
message,
action,
onDismiss,
dismissLabel,
}: {
tone: 'running' | 'success' | 'error';
message: string;
action: string;
onDismiss?: () => void;
dismissLabel?: string;
}) {
return (
<div
className={`live-artifact-refresh-notice ${tone}`}
role={tone === 'error' ? 'alert' : 'status'}
aria-label={`${message} ${action}`}
>
<span className="live-artifact-refresh-notice-copy">
<strong>{message}</strong>
<span>{action}</span>
</span>
{onDismiss ? (
<button type="button" className="icon-only" onClick={onDismiss} aria-label={dismissLabel}>
×
</button>
) : null}
</div>
);
}
function refreshErrorMessage(error: unknown, t: TranslateFn): string {
if (error instanceof LiveArtifactRefreshError && error.status === 0) {
return t('liveArtifact.refresh.networkFailure');
}
if (error instanceof LiveArtifactRefreshError && error.code === 'LIVE_ARTIFACT_REFRESH_UNAVAILABLE') {
return t('liveArtifact.refresh.noSourceTitle');
}
if (error instanceof Error && error.message.length > 0) return error.message;
return t('liveArtifact.refresh.genericFailure');
}
function liveArtifactViewerTabs(t: TranslateFn): Array<{ id: LiveArtifactViewerTab; label: string }> {
return [
{ id: 'preview', label: t('liveArtifact.viewer.tabPreview') },
{ id: 'code', label: t('liveArtifact.viewer.tabCode') },
{ id: 'data', label: t('liveArtifact.viewer.tabData') },
{ id: 'refresh-history', label: t('liveArtifact.viewer.tabRefreshHistory') },
];
}
type LiveArtifactCodeVariant = 'template' | 'rendered-source';
function LiveArtifactCodePanel({
projectId,
artifactId,
reloadKey,
}: {
projectId: string;
artifactId: string;
reloadKey: number;
}) {
const t = useT();
const [variant, setVariant] = useState<LiveArtifactCodeVariant>('template');
const [code, setCode] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false);
useEffect(() => {
let cancelled = false;
setLoading(true);
setFailed(false);
setCode(null);
void fetchLiveArtifactCode(projectId, artifactId, variant).then((next) => {
if (cancelled) return;
setCode(next);
setFailed(next == null);
setLoading(false);
});
return () => {
cancelled = true;
};
}, [artifactId, projectId, reloadKey, variant]);
return (
<div className="live-artifact-code-panel">
<div className="live-artifact-code-header">
<div className="live-artifact-code-copy">
<strong>
{variant === 'template'
? t('liveArtifact.viewer.code.templateHeading')
: t('liveArtifact.viewer.code.renderedHeading')}
</strong>
<span>
{variant === 'template'
? t('liveArtifact.viewer.code.templateHelp')
: t('liveArtifact.viewer.code.renderedHelp')}
</span>
</div>
<div
className="viewer-tabs live-artifact-code-tabs"
aria-label={t('liveArtifact.viewer.code.variantAria')}
>
<button
type="button"
className={`viewer-tab ${variant === 'template' ? 'active' : ''}`}
onClick={() => setVariant('template')}
>
{t('liveArtifact.viewer.code.variantTemplate')}
</button>
<button
type="button"
className={`viewer-tab ${variant === 'rendered-source' ? 'active' : ''}`}
onClick={() => setVariant('rendered-source')}
>
{t('liveArtifact.viewer.code.variantRendered')}
</button>
</div>
</div>
{loading ? (
<div className="viewer-empty">{t('liveArtifact.viewer.code.loading')}</div>
) : failed ? (
<div className="viewer-empty">{t('liveArtifact.viewer.code.unavailable')}</div>
) : code && code.trim().length > 0 ? (
<pre className="viewer-source">{code}</pre>
) : (
<div className="viewer-empty">{t('liveArtifact.viewer.code.empty')}</div>
)}
</div>
);
}
function JsonPanel({ value, emptyLabel }: { value: unknown; emptyLabel: string }) {
if (value == null) return <div className="viewer-empty">{emptyLabel}</div>;
return <pre className="viewer-source">{JSON.stringify(value, null, 2)}</pre>;
}
function liveArtifactMetadataPayload(liveArtifact: LiveArtifact): unknown {
return {
artifact: {
id: liveArtifact.id,
title: liveArtifact.title,
slug: liveArtifact.slug,
status: liveArtifact.status,
pinned: liveArtifact.pinned,
preview: liveArtifact.preview,
refreshStatus: liveArtifact.refreshStatus,
createdAt: liveArtifact.createdAt,
updatedAt: liveArtifact.updatedAt,
lastRefreshedAt: liveArtifact.lastRefreshedAt,
},
document: liveArtifact.document
? {
format: liveArtifact.document.format,
templatePath: liveArtifact.document.templatePath,
generatedPreviewPath: liveArtifact.document.generatedPreviewPath,
dataPath: liveArtifact.document.dataPath,
dataSchemaJson: liveArtifact.document.dataSchemaJson,
sourceJson: liveArtifact.document.sourceJson,
}
: null,
};
}
function liveArtifactProvenancePayload(liveArtifact: LiveArtifact): unknown {
return {
documentSource: liveArtifact.document?.sourceJson ?? null,
};
}
function liveArtifactRefreshPayload(liveArtifact: LiveArtifact): unknown {
return {
refreshStatus: liveArtifact.refreshStatus,
lastRefreshedAt: liveArtifact.lastRefreshedAt ?? null,
};
}
type LiveArtifactRefreshStatus = LiveArtifact['refreshStatus'];
interface LiveArtifactRefreshEvent {
id: number;
phase: 'started' | 'succeeded' | 'failed';
at: number;
durationMs?: number;
refreshedSourceCount?: number;
error?: string;
}
let refreshEventSequence = 0;
function appendRefreshEvent(
prev: LiveArtifactRefreshEvent[],
next: Omit<LiveArtifactRefreshEvent, 'id' | 'at' | 'durationMs'>,
): LiveArtifactRefreshEvent[] {
const at = Date.now();
refreshEventSequence += 1;
const event: LiveArtifactRefreshEvent = { ...next, id: refreshEventSequence, at };
if (next.phase !== 'started') {
// Pair with the most recent 'started' to compute duration.
for (let i = prev.length - 1; i >= 0; i -= 1) {
const candidate = prev[i];
if (candidate && candidate.phase === 'started') {
event.durationMs = Math.max(0, at - candidate.at);
break;
}
}
}
// Cap at 25 entries to keep the panel lightweight.
const MAX = 25;
const combined = [...prev, event];
return combined.length > MAX ? combined.slice(combined.length - MAX) : combined;
}
function formatAbsoluteDateTime(iso: string | number | undefined): string | null {
if (iso === undefined || iso === null) return null;
const date = typeof iso === 'number' ? new Date(iso) : new Date(iso);
if (Number.isNaN(date.getTime())) return null;
try {
return date.toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
} catch {
return date.toISOString();
}
}
function formatRelativeTime(
iso: string | number | undefined,
now = Date.now(),
locale: Locale = 'en',
t?: TranslateFn,
): string | null {
if (iso === undefined || iso === null) return null;
const ms = typeof iso === 'number' ? iso : new Date(iso).getTime();
if (Number.isNaN(ms)) return null;
const deltaSec = Math.round((ms - now) / 1000);
const abs = Math.abs(deltaSec);
if (abs < 5) {
// "just now" lives in the i18n dict because Intl.RelativeTimeFormat's
// "0 seconds ago" reads awkwardly in narrow style and we want a
// single canonical translation per locale. Fall back to the English
// literal only when called without t (background utilities, tests).
return t ? t('liveArtifact.refresh.justNow') : 'just now';
}
// Intl.RelativeTimeFormat handles tense (past / future), pluralisation,
// and word-order per locale so the panel matches the rest of the
// localised UI instead of mixing in English units like `5s ago`.
// `style: 'narrow'` keeps the English output close to the historical
// `5s ago` shape; `numeric: 'always'` forces numeric output so we
// don't get "yesterday" / "now" mixed in unexpectedly with the
// bucketing above.
let rtf: Intl.RelativeTimeFormat;
try {
rtf = new Intl.RelativeTimeFormat(locale, { style: 'narrow', numeric: 'always' });
} catch {
rtf = new Intl.RelativeTimeFormat('en', { style: 'narrow', numeric: 'always' });
}
const value = deltaSec; // negative = past, positive = future
if (abs < 60) return rtf.format(value, 'second');
if (abs < 3600) return rtf.format(Math.round(value / 60), 'minute');
if (abs < 86400) return rtf.format(Math.round(value / 3600), 'hour');
if (abs < 86400 * 30) return rtf.format(Math.round(value / 86400), 'day');
if (abs < 86400 * 365) return rtf.format(Math.round(value / (86400 * 30)), 'month');
return rtf.format(Math.round(value / (86400 * 365)), 'year');
}
function formatDurationMs(ms: number | undefined): string | null {
if (ms === undefined || ms === null || Number.isNaN(ms)) return null;
if (ms < 1000) return `${Math.max(0, Math.round(ms))}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s`;
const minutes = Math.floor(ms / 60_000);
const seconds = Math.round((ms % 60_000) / 1000);
return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`;
}
interface RefreshStatusDescriptor {
label: string;
tone: 'neutral' | 'running' | 'success' | 'warning' | 'error';
description: string;
}
function describeRefreshStatus(
status: LiveArtifactRefreshStatus,
t: TranslateFn,
): RefreshStatusDescriptor {
switch (status) {
case 'running':
return {
label: t('liveArtifact.refresh.statusRunning'),
tone: 'running',
description: t('liveArtifact.refresh.statusRunningDescription'),
};
case 'succeeded':
return {
label: t('liveArtifact.refresh.statusSucceeded'),
tone: 'success',
description: t('liveArtifact.refresh.statusSucceededDescription'),
};
case 'failed':
return {
label: t('liveArtifact.refresh.statusFailed'),
tone: 'error',
description: t('liveArtifact.refresh.statusFailedDescription'),
};
case 'idle':
return {
label: t('liveArtifact.refresh.statusReady'),
tone: 'neutral',
description: t('liveArtifact.refresh.statusReadyDescription'),
};
case 'never':
default:
return {
label: t('liveArtifact.refresh.statusNever'),
tone: 'warning',
description: t('liveArtifact.refresh.statusNeverDescription'),
};
}
}
function describeEventPhase(
event: LiveArtifactRefreshEvent,
t: TranslateFn,
): { label: string; tone: 'running' | 'success' | 'error' } {
if (event.phase === 'started')
return { label: t('liveArtifact.refresh.eventStarted'), tone: 'running' };
if (event.phase === 'succeeded')
return { label: t('liveArtifact.refresh.eventSucceeded'), tone: 'success' };
return { label: t('liveArtifact.refresh.eventFailed'), tone: 'error' };
}
function describePersistedStatus(
status: LiveArtifactRefreshLogEntry['status'],
t: TranslateFn,
): string {
switch (status) {
case 'succeeded':
return t('liveArtifact.refresh.persistedStatusSucceeded');
case 'running':
return t('liveArtifact.refresh.persistedStatusRunning');
case 'failed':
return t('liveArtifact.refresh.persistedStatusFailed');
case 'cancelled':
return t('liveArtifact.refresh.persistedStatusCancelled');
case 'skipped':
return t('liveArtifact.refresh.persistedStatusSkipped');
default: {
const exhaustive: never = status;
return exhaustive;
}
}
}
export function LiveArtifactRefreshHistoryPanel({
liveArtifact,
fallbackRefreshStatus,
fallbackLastRefreshedAt,
isRunning,
sessionEvents,
persistedEvents = [],
}: {
liveArtifact: LiveArtifact | null;
fallbackRefreshStatus: LiveArtifactRefreshStatus;
fallbackLastRefreshedAt?: string;
isRunning: boolean;
sessionEvents: LiveArtifactRefreshEvent[];
persistedEvents?: LiveArtifactRefreshLogEntry[];
}) {
const t = useT();
const { locale } = useI18n();
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
// Keep relative timestamps fresh; 30s cadence is enough for "x minutes ago" feel.
const id = window.setInterval(() => setNow(Date.now()), 30_000);
return () => window.clearInterval(id);
}, []);
const status: LiveArtifactRefreshStatus = isRunning
? 'running'
: liveArtifact?.refreshStatus ?? fallbackRefreshStatus;
const descriptor = describeRefreshStatus(status, t);
const lastRefreshedAt = liveArtifact?.lastRefreshedAt ?? fallbackLastRefreshedAt;
const createdAt = liveArtifact?.createdAt;
const updatedAt = liveArtifact?.updatedAt;
const documentSource = liveArtifact?.document?.sourceJson ?? null;
const reversedEvents = [...sessionEvents].reverse();
const reversedPersistedEvents = [...persistedEvents].reverse().slice(0, 25);
const rawDebugPayload = liveArtifact
? {
refresh: liveArtifactRefreshPayload(liveArtifact),
metadata: liveArtifactMetadataPayload(liveArtifact),
provenance: liveArtifactProvenancePayload(liveArtifact),
}
: null;
return (
<div className="live-artifact-refresh-panel">
<section className="live-artifact-refresh-hero">
<div className="live-artifact-refresh-hero-main">
<span
className={`live-artifact-badge refresh-status tone-${descriptor.tone}`}
data-testid="live-artifact-refresh-status-badge"
>
{descriptor.label}
</span>
<p className="live-artifact-refresh-hero-desc">{descriptor.description}</p>
</div>
<div className="live-artifact-refresh-hero-meta">
<div className="live-artifact-refresh-hero-metric">
<span className="live-artifact-refresh-label">
{t('liveArtifact.refresh.heroLastRefreshedLabel')}
</span>
{lastRefreshedAt ? (
<>
<span className="live-artifact-refresh-value">
{formatRelativeTime(lastRefreshedAt, now, locale, t) ?? '—'}
</span>
<span
className="live-artifact-refresh-sub"
title={formatAbsoluteDateTime(lastRefreshedAt) ?? undefined}
>
{formatAbsoluteDateTime(lastRefreshedAt) ?? ''}
</span>
</>
) : (
<span className="live-artifact-refresh-value muted">
{t('liveArtifact.refresh.heroLastRefreshedNever')}
</span>
)}
</div>
</div>
</section>
<section className="live-artifact-refresh-facts">
<LiveArtifactRefreshFact
label={t('liveArtifact.refresh.factCreated')}
iso={createdAt}
emptyLabel={t('liveArtifact.refresh.factUnknown')}
now={now}
locale={locale}
t={t}
/>
<LiveArtifactRefreshFact
label={t('liveArtifact.refresh.factLastUpdated')}
iso={updatedAt}
emptyLabel={t('liveArtifact.refresh.factUnknown')}
now={now}
locale={locale}
t={t}
/>
</section>
<section className="live-artifact-refresh-section">
<header className="live-artifact-refresh-section-header">
<h4>{t('liveArtifact.refresh.persistedTitle')}</h4>
<span className="live-artifact-refresh-hint">
{t('liveArtifact.refresh.persistedHint')}
</span>
</header>
{reversedPersistedEvents.length === 0 ? (
<div className="live-artifact-refresh-empty">
{t('liveArtifact.refresh.persistedEmpty')}
</div>
) : (
<ol className="live-artifact-refresh-timeline">
{reversedPersistedEvents.map((event) => {
const tone = event.status === 'succeeded'
? 'success'
: event.status === 'running'
? 'running'
: event.status === 'failed' || event.status === 'cancelled'
? 'error'
: 'running';
const duration = formatDurationMs(event.durationMs);
return (
<li key={`${event.refreshId}:${event.sequence}`} className={`live-artifact-refresh-event tone-${tone}`}>
<span className="live-artifact-refresh-event-dot" aria-hidden />
<div className="live-artifact-refresh-event-body">
<div className="live-artifact-refresh-event-row">
<span className={`live-artifact-badge refresh-status tone-${tone}`}>
{describePersistedStatus(event.status, t)}
</span>
<strong>{event.step}</strong>
<span className="live-artifact-refresh-event-time">
{formatRelativeTime(event.startedAt, now, locale, t)
?? t('liveArtifact.refresh.justNow')}
</span>
</div>
<div className="live-artifact-refresh-event-meta">
<span>{event.refreshId}</span>
{duration ? <span>{duration}</span> : null}
{event.error?.message ? <span>{event.error.message}</span> : null}
</div>
</div>
</li>
);
})}
</ol>
)}
</section>
<section className="live-artifact-refresh-section">
<header className="live-artifact-refresh-section-header">
<h4>{t('liveArtifact.refresh.sessionTitle')}</h4>
<span className="live-artifact-refresh-hint">
{t('liveArtifact.refresh.sessionHint')}
</span>
</header>
{reversedEvents.length === 0 ? (
<div className="live-artifact-refresh-empty">
{t('liveArtifact.refresh.timelineEmpty')}
</div>
) : (
<ol className="live-artifact-refresh-timeline">
{reversedEvents.map((event) => {
const phase = describeEventPhase(event, t);
const duration = formatDurationMs(event.durationMs);
const refreshedCount = event.refreshedSourceCount ?? 0;
return (
<li key={event.id} className={`live-artifact-refresh-event tone-${phase.tone}`}>
<span className="live-artifact-refresh-event-dot" aria-hidden />
<div className="live-artifact-refresh-event-body">
<div className="live-artifact-refresh-event-row">
<span
className={`live-artifact-badge refresh-status tone-${phase.tone}`}
>
{phase.label}
</span>
<span
className="live-artifact-refresh-event-time"
title={formatAbsoluteDateTime(event.at) ?? undefined}
>
{formatRelativeTime(event.at, now, locale, t) ?? ''}
</span>
</div>
<div className="live-artifact-refresh-event-detail">
{event.phase === 'succeeded' ? (
<span>
{t(
refreshedCount === 1
? 'liveArtifact.refresh.sourcesUpdatedOne'
: 'liveArtifact.refresh.sourcesUpdatedMany',
{ n: refreshedCount },
)}
{duration ? ` · ${duration}` : ''}
</span>
) : event.phase === 'failed' ? (
<span>
{event.error ?? t('liveArtifact.refresh.genericFailure')}
{duration ? ` · ${duration}` : ''}
</span>
) : (
<span>{t('liveArtifact.refresh.eventStartedDetail')}</span>
)}
</div>
</div>
</li>
);
})}
</ol>
)}
</section>
{documentSource ? (
<section className="live-artifact-refresh-section">
<header className="live-artifact-refresh-section-header">
<h4>{t('liveArtifact.refresh.docSourceTitle')}</h4>
<span className="live-artifact-refresh-hint">
{t('liveArtifact.refresh.docSourceHint')}
</span>
</header>
<dl className="live-artifact-refresh-kv">
<div>
<dt>{t('liveArtifact.refresh.docSourceType')}</dt>
<dd>{documentSource.type}</dd>
</div>
{documentSource.toolName ? (
<div>
<dt>{t('liveArtifact.refresh.docSourceTool')}</dt>
<dd>
<code>{documentSource.toolName}</code>
</dd>
</div>
) : null}
{documentSource.connector ? (
<div>
<dt>{t('liveArtifact.refresh.docSourceConnector')}</dt>
<dd>
{documentSource.connector.accountLabel ??
documentSource.connector.connectorId}
</dd>
</div>
) : null}
</dl>
</section>
) : null}
{rawDebugPayload != null ? (
<details className="live-artifact-refresh-raw">
<summary>{t('liveArtifact.refresh.debugSummary')}</summary>
<p className="live-artifact-refresh-raw-note">
{t('liveArtifact.refresh.debugNote')}
</p>
<pre className="viewer-source">{JSON.stringify(rawDebugPayload, null, 2)}</pre>
</details>
) : null}
</div>
);
}
function LiveArtifactRefreshFact({
label,
iso,
value,
helper,
emptyLabel,
now,
locale,
t,
}: {
label: string;
iso?: string;
value?: string;
helper?: string;
emptyLabel?: string;
now?: number;
locale?: Locale;
t?: TranslateFn;
}) {
const relative = iso !== undefined ? formatRelativeTime(iso, now, locale, t) : null;
const absolute = iso !== undefined ? formatAbsoluteDateTime(iso) : null;
const resolved = value ?? relative ?? emptyLabel ?? '—';
const sub = helper ?? (iso !== undefined ? absolute ?? '' : '');
return (
<div className="live-artifact-refresh-fact">
<span className="live-artifact-refresh-label">{label}</span>
<span className="live-artifact-refresh-value" title={absolute ?? undefined}>
{resolved}
</span>
{sub ? <span className="live-artifact-refresh-sub">{sub}</span> : null}
</div>
);
}
function FileActions({
projectId,
file,
}: {
projectId: string;
file: ProjectFile;
}) {
const t = useT();
return (
<div className="viewer-toolbar-actions">
<a
className="ghost-link"
href={projectFileUrl(projectId, file.name)}
download={file.name}
>
{t('fileViewer.download')}
</a>
<a
className="ghost-link"
href={projectFileUrl(projectId, file.name)}
target="_blank"
rel="noreferrer noopener"
>
{t('fileViewer.open')}
</a>
</div>
);
}
function BoardComposerPopover({
target,
existing,
draft,
notes,
onDraft,
onAddDraft,
onRemoveQueuedNote,
onClose,
onSaveComment,
onSendBatch,
onRemove,
sending,
t,
}: {
target: PreviewCommentSnapshot;
existing: PreviewComment | null;
draft: string;
notes: string[];
onDraft: (value: string) => void;
onAddDraft: () => void;
onRemoveQueuedNote: (index: number) => void;
onClose: () => void;
onSaveComment: () => void | Promise<void>;
onSendBatch: () => void | Promise<void>;
onRemove: (commentId: string) => void | Promise<void>;
sending: boolean;
t: TranslateFn;
}) {
const pendingCount = notes.length + (draft.trim() ? 1 : 0);
const podMembers = target.podMembers ?? [];
const titleId = useId();
return (
<div
className="comment-popover"
data-testid="comment-popover"
role="dialog"
aria-modal="false"
aria-labelledby={titleId}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
onClose();
}
}}
>
<div className="comment-popover-head">
<div title={target.elementId}>
<strong id={titleId}>{target.elementId}</strong>
<span>{target.label}</span>
<span>{selectionKindLabel(target.selectionKind, target.memberCount)}</span>
</div>
<button type="button" className="ghost" onClick={onClose} title={t('common.close')}>
{t('common.close')}
</button>
</div>
{podMembers.length > 0 ? (
<div className="board-pod-summary">
<strong>{target.memberCount || podMembers.length} captured items</strong>
<div className="board-pod-members">
{podMembers.slice(0, 6).map((member) => (
<span key={member.elementId} className="board-pod-chip">
{summarizeMember(member)}
</span>
))}
</div>
</div>
) : null}
{notes.length > 0 ? (
<div className="board-note-list">
{notes.map((note, index) => (
<div key={`${target.elementId}-${index}`} className="board-note-item">
<span>{note}</span>
<button type="button" className="ghost" onClick={() => onRemoveQueuedNote(index)}>
Remove
</button>
</div>
))}
</div>
) : null}
<textarea
data-testid="comment-popover-input"
value={draft}
autoFocus
aria-label={t('chat.comments.placeholder')}
placeholder={t('chat.comments.placeholder')}
onChange={(event) => onDraft(event.target.value)}
/>
<div className="comment-popover-actions">
{existing ? (
<button type="button" className="comment-popover-remove" onClick={() => onRemove(existing.id)}>
{t('chat.comments.remove')}
</button>
) : <span />}
<button
type="button"
className="ghost"
disabled={!draft.trim()}
onClick={onAddDraft}
>
Add note
</button>
{target.selectionKind === 'pod' ? null : (
<button
type="button"
className="ghost"
disabled={!draft.trim()}
onClick={() => void onSaveComment()}
>
Save comment
</button>
)}
<button
type="button"
className="primary"
data-testid="comment-add-send"
disabled={pendingCount === 0 || sending}
onClick={() => void onSendBatch()}
>
{sending ? 'Sending...' : 'Send to chat'}
</button>
</div>
</div>
);
}
// Maps a CSS computed value (e.g. "rgb(40, 50, 60)" or "16px") to a form
// input value. Browsers return colors as rgb()/rgba(); HTML <input type=color>
// only accepts "#rrggbb". Lengths come back as "12px" or "0px"; we strip
// units for slider binding and re-append on emit.
//
// Note: <input type=color> has no alpha channel, so an rgba() with alpha < 1
// is collapsed to its opaque RGB equivalent here. Most agent-generated HTML
// uses opaque colors, so this is a known cosmetic limitation — a
// semi-transparent source value will display in the panel as fully opaque.
function rgbToHex(value: string | undefined): string {
if (!value) return '#000000';
const v = value.trim();
if (v.startsWith('#') && (v.length === 7 || v.length === 4)) {
if (v.length === 4) {
return '#' + [1, 2, 3].map((i) => {
const c = v.charAt(i);
return c + c;
}).join('');
}
return v;
}
const m = v.match(/rgba?\(\s*([0-9.]+)[ ,]+([0-9.]+)[ ,]+([0-9.]+)/i);
if (!m) return '#000000';
const toHex = (n: string) => {
const x = Math.max(0, Math.min(255, Math.round(Number(n))));
return x.toString(16).padStart(2, '0');
};
return '#' + toHex(m[1] ?? '0') + toHex(m[2] ?? '0') + toHex(m[3] ?? '0');
}
// Parse a CSS length to a number. Inspect's current sliders all clamp to a
// non-negative range (padding, font-size, border-radius), so we reject
// negatives at parse time too — otherwise a `-12px` source value would be
// silently floored to 0 by the slider clamp without the regex agreeing.
// If a future control needs negative values (e.g. margin), thread an
// explicit `allowNegative` flag rather than reintroducing `-?` here.
function pxToNumber(value: string | undefined): number {
if (!value) return 0;
const m = value.trim().match(/^(\d+(?:\.\d+)?)/);
return m ? Number(m[1]) : 0;
}
function clamp(n: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, n));
}
function InspectPanel({
target,
onApply,
onResetElement,
onSaveToSource,
onClose,
saving,
savedAt,
error,
}: {
target: InspectTarget;
onApply: (prop: string, value: string) => void;
onResetElement: (elementId: string) => void;
onSaveToSource: () => void;
onClose: () => void;
saving: boolean;
savedAt: number | null;
error: string | null;
}) {
// Local "draft" mirror of the most recent value the user picked, so
// sliders/colors keep responding even before the iframe echoes back the
// computed result. Reset whenever the selected element changes.
const [draft, setDraft] = useState<Record<string, string>>({});
useEffect(() => {
setDraft({});
}, [target.elementId]);
const value = (prop: string, fallback: string): string =>
draft[prop] ?? fallback;
function setVal(prop: string, raw: string) {
setDraft((d) => ({ ...d, [prop]: raw }));
onApply(prop, raw);
}
// Padding is exposed as a single shared slider that emits the `padding`
// shorthand; the browser fans the value out to all four sides internally.
// When per-side control becomes useful, switch to emitting explicit
// padding-top / padding-right / padding-bottom / padding-left props
// (the bridge already allow-lists those long-hand names).
const initialPadding = pxToNumber(target.style.paddingTop);
const initialFontSize = pxToNumber(target.style.fontSize);
const initialRadius = pxToNumber(target.style.borderRadius);
// Color / length controls all read through `draft` first so the input
// tracks the most recent user pick even before getComputedStyle catches
// up. Without this the picker would snap back to the initial computed
// snapshot on every change and feel non-editable.
const colorHex = value('color', rgbToHex(target.style.color));
const bgHex = value('background-color', rgbToHex(target.style.backgroundColor));
const padding = value('padding', String(initialPadding));
const fontSize = value('font-size', String(initialFontSize));
const radius = value('border-radius', String(initialRadius));
const textAlign = value('text-align', target.style.textAlign || 'left');
const fontWeight = value('font-weight', target.style.fontWeight || '400');
// Parse once: `pxToNumber(...) || initial...` would treat a legitimate
// `0px` draft as missing and snap the slider back to the original
// computed value, making it impossible to remove padding/radius from an
// element whose initial value is nonzero. `pxToNumber` already returns
// 0 for unparseable input, so its result is safe to consume directly
// and zero is preserved.
const paddingNum = pxToNumber(padding);
const fontSizeNum = pxToNumber(fontSize);
const radiusNum = pxToNumber(radius);
const justSaved = savedAt && Date.now() - savedAt < 4000;
return (
<aside className="inspect-panel" data-testid="inspect-panel">
<header className="inspect-panel-head">
<div className="inspect-panel-title">
<strong title={target.label || target.elementId}>{target.label || target.elementId}</strong>
<code title={target.selector}>{target.elementId}</code>
</div>
<button type="button" className="ghost" onClick={onClose} aria-label="Close inspect">
×
</button>
</header>
<section className="inspect-section">
<div className="inspect-section-label">Colors</div>
<div className="inspect-row">
<label htmlFor="ip-color">Text</label>
<input
id="ip-color"
data-testid="inspect-color"
type="color"
value={colorHex}
onChange={(e) => setVal('color', e.target.value)}
/>
<input
type="text"
value={colorHex}
onChange={(e) => setVal('color', e.target.value)}
spellCheck={false}
/>
</div>
<div className="inspect-row">
<label htmlFor="ip-bg">Background</label>
<input
id="ip-bg"
data-testid="inspect-bg"
type="color"
value={bgHex}
onChange={(e) => setVal('background-color', e.target.value)}
/>
<input
type="text"
value={bgHex}
onChange={(e) => setVal('background-color', e.target.value)}
spellCheck={false}
/>
</div>
</section>
<section className="inspect-section">
<div className="inspect-section-label">Typography</div>
<div className="inspect-row">
<label htmlFor="ip-fs">Size</label>
<input
id="ip-fs"
data-testid="inspect-font-size"
type="range"
min={8}
max={160}
step={1}
value={clamp(fontSizeNum, 8, 160)}
onChange={(e) => setVal('font-size', `${e.target.value}px`)}
/>
<span className="inspect-row-value">{Math.round(fontSizeNum)}px</span>
</div>
<div className="inspect-row">
<label htmlFor="ip-fw">Weight</label>
<select
id="ip-fw"
value={fontWeight}
onChange={(e) => setVal('font-weight', e.target.value)}
>
{['100', '300', '400', '500', '600', '700', '800', '900'].map((w) => (
<option key={w} value={w}>{w}</option>
))}
</select>
</div>
<div className="inspect-row">
<label htmlFor="ip-ta">Align</label>
<select
id="ip-ta"
value={textAlign}
onChange={(e) => setVal('text-align', e.target.value)}
>
{['left', 'center', 'right', 'justify'].map((a) => (
<option key={a} value={a}>{a}</option>
))}
</select>
</div>
</section>
<section className="inspect-section">
<div className="inspect-section-label">Spacing &amp; Shape</div>
<div className="inspect-row">
<label htmlFor="ip-pad">Padding</label>
<input
id="ip-pad"
data-testid="inspect-padding"
type="range"
min={0}
max={120}
step={1}
value={clamp(paddingNum, 0, 120)}
onChange={(e) => setVal('padding', `${e.target.value}px`)}
/>
<span className="inspect-row-value">{Math.round(paddingNum)}px</span>
</div>
<div className="inspect-row">
<label htmlFor="ip-rad">Radius</label>
<input
id="ip-rad"
data-testid="inspect-radius"
type="range"
min={0}
max={120}
step={1}
value={clamp(radiusNum, 0, 120)}
onChange={(e) => setVal('border-radius', `${e.target.value}px`)}
/>
<span className="inspect-row-value">{Math.round(radiusNum)}px</span>
</div>
</section>
<footer className="inspect-panel-footer">
<button
type="button"
className="ghost"
onClick={() => {
setDraft({});
onResetElement(target.elementId);
}}
>
Reset element
</button>
<button
type="button"
className="primary"
data-testid="inspect-save"
disabled={saving}
onClick={onSaveToSource}
>
{saving ? 'Saving…' : justSaved ? 'Saved ✓' : 'Save to source'}
</button>
</footer>
{error ? <div className="inspect-panel-error">{error}</div> : null}
</aside>
);
}
// Inspect-mode override entry as held in the host's authoritative map and as
// it travels in od:inspect-overrides messages. The host's persisted map is
// owned and mutated only by host-driven onApply / reset actions plus the
// initial parse of the source's <style data-od-inspect-overrides> block;
// inbound iframe messages are treated as preview acknowledgements, never as
// save input. Artifact code rendered with scripts enabled can call
// window.parent.postMessage with a forged payload — ev.source still points
// at iframe.contentWindow — so any field arriving from the iframe is
// untrusted. Even the structured `overrides` field could be tampered with
// to flip allow-listed properties on elements the user never edited, which
// is why we no longer ingest it on save.
type InspectOverridePayload = {
selector?: unknown;
props?: unknown;
};
// Authoritative host-side override map: elementId → { selector, props }.
// Mirrors the in-iframe shape so serializeInspectOverrides can consume it.
export type InspectOverrideEntry = {
selector: string;
props: Record<string, string>;
};
export type InspectOverrideMap = Record<string, InspectOverrideEntry>;
// Allow-list of CSS properties the host will persist on Save. Mirrors the
// in-iframe ALLOWED_PROPS list so the host doesn't accept properties that
// the bridge itself would reject.
const HOST_ALLOWED_INSPECT_PROPS = new Set([
'color',
'background-color',
'font-size',
'font-weight',
'font-family',
'line-height',
'text-align',
'padding',
'padding-top',
'padding-right',
'padding-bottom',
'padding-left',
'border-radius',
]);
// Reject values that could break out of `prop: value` and into the
// surrounding <style> block — semicolons, braces, angle brackets, and
// newlines. Mirrors the bridge's UNSAFE_VALUE regex.
const HOST_UNSAFE_INSPECT_VALUE = /[;{}<>\n\r]/;
// Reject elementIds whose characters could break out of `[attr="..."]`
// inside a <style> block. Forbidden:
// - `"` and `\` would close the attribute string or smuggle CSS
// escapes the host didn't pre-process;
// - `<` and `>` would close the surrounding <style> tag;
// - C0/C1 controls (newline, etc.) end the CSS rule under string
// tokenization — kept in as defense-in-depth against parser quirks.
// Everything else — including ASCII whitespace and leading digits — is
// allowed, so deck labels like `01 Cover` survive instead of being
// dropped on the way to the persisted overrides block.
const HOST_UNSAFE_INSPECT_ID = /["\\<>\u0000-\u001f\u007f]/;
// Build the inspect overrides CSS body the host will persist, from the
// structured `overrides` field of an od:inspect-overrides message. The host
// MUST NOT trust the sibling `css` string — it is attacker-controlled when
// artifact JS forges the message. The selector is re-derived from each
// elementId; only allow-listed properties with safe values survive.
//
// Exported so unit tests can exercise the validator with hostile payloads.
export function serializeInspectOverrides(overrides: unknown): string {
if (!overrides || typeof overrides !== 'object') return '';
const map = overrides as Record<string, unknown>;
const lines: string[] = [];
for (const elementId of Object.keys(map)) {
if (!elementId || HOST_UNSAFE_INSPECT_ID.test(elementId)) continue;
const entry = map[elementId] as InspectOverridePayload | null | undefined;
if (!entry || typeof entry !== 'object') continue;
const props = entry.props;
if (!props || typeof props !== 'object') continue;
// Trust only the *kind* of selector the bridge built, not the value
// it carried. The bridge runs CSS.escape over the elementId, so a raw
// equality check against `[data-screen-label="${elementId}"]` would
// miss legitimate deck labels like `01 Cover` (whitespace, leading
// digit) and silently downgrade them to `[data-od-id="..."]`. The
// elementId itself was sanitized above, so embedding it verbatim into
// the re-derived selector is safe inside an attribute value string.
const inboundSelector = typeof entry.selector === 'string' ? entry.selector : '';
const attr = inboundSelector.startsWith('[data-screen-label="')
? 'data-screen-label'
: 'data-od-id';
const safeSelector = `[${attr}="${elementId}"]`;
const decls: string[] = [];
for (const [rawName, rawValue] of Object.entries(props as Record<string, unknown>)) {
if (typeof rawName !== 'string' || typeof rawValue !== 'string') continue;
const name = rawName.toLowerCase();
if (!HOST_ALLOWED_INSPECT_PROPS.has(name)) continue;
const value = rawValue.trim();
if (!value || HOST_UNSAFE_INSPECT_VALUE.test(value)) continue;
decls.push(`${name}: ${value} !important`);
}
if (!decls.length) continue;
lines.push(`${safeSelector} { ${decls.join('; ')} }`);
}
return lines.join('\n');
}
// Apply a single host-driven prop change to the authoritative override map.
// Returns a new map (or the same reference if no-op so React skips renders).
// Empty value clears the prop; clearing the last prop drops the elementId.
// Mirrors the iframe bridge's applyOverride sanitization so the host map and
// the live preview stay in lock-step under the same rules.
export function updateInspectOverride(
map: InspectOverrideMap,
elementId: string,
selector: string,
prop: string,
value: string,
): InspectOverrideMap {
if (!elementId || HOST_UNSAFE_INSPECT_ID.test(elementId)) return map;
const propName = String(prop || '').toLowerCase();
if (!HOST_ALLOWED_INSPECT_PROPS.has(propName)) return map;
const trimmed = String(value ?? '').trim();
if (trimmed && HOST_UNSAFE_INSPECT_VALUE.test(trimmed)) return map;
const existing = map[elementId];
const nextProps: Record<string, string> = { ...(existing?.props ?? {}) };
if (!trimmed) {
if (!(propName in nextProps)) return map;
delete nextProps[propName];
} else if (nextProps[propName] === trimmed && existing?.selector === selector) {
return map;
} else {
nextProps[propName] = trimmed;
}
const nextMap: InspectOverrideMap = { ...map };
if (Object.keys(nextProps).length === 0) {
delete nextMap[elementId];
} else {
nextMap[elementId] = { selector: selector || existing?.selector || '', props: nextProps };
}
return nextMap;
}
// Parse any persisted <style data-od-inspect-overrides> blocks in the
// artifact source into the host's authoritative override map. The host owns
// this map and only mutates it from onApply / reset actions plus this
// initial hydration step — inbound iframe od:inspect-overrides messages are
// not ingested. Without this step, opening a file that already carries an
// override block would leave the host map empty, so a Save-to-source after
// any subsequent edit could splice a CSS body that drops every previously
// saved rule for elements the user did not touch in this session.
//
// Mirrors the iframe bridge's hydrateOverridesFromDom: same allow-list,
// same value sanitizer, same selector kinds, so what the iframe applies and
// what the host persists stay in lock-step. Pure string transform; no DOM.
//
// HTML-aware: enumerates `<style data-od-inspect-overrides>` elements via
// the same walker used by the splicer, so a `<style data-od-inspect-overrides>`
// literal living inside a `<script>`, `<style>` (e.g. CSS comment), `<textarea>`,
// `<title>`, or HTML comment is not mistaken for a real override block. Without
// that exclusion, useEffect would seed the host map from forged/quoted text and
// a later Save-to-source would persist phantom CSS the user never created.
export function parseInspectOverridesFromSource(source: string): InspectOverrideMap {
const map: InspectOverrideMap = {};
if (!source) return map;
for (const body of stripInspectOverridesAndIndex(source).bodies) {
const ruleRe = /(\[data-(?:od-id|screen-label)="([^"]*)"\])\s*\{\s*([^}]*)\}/g;
let ruleMatch: RegExpExecArray | null;
while ((ruleMatch = ruleRe.exec(body)) !== null) {
const selector = ruleMatch[1] ?? '';
const elementId = ruleMatch[2] ?? '';
const declBody = ruleMatch[3] ?? '';
if (!selector || !elementId || HOST_UNSAFE_INSPECT_ID.test(elementId)) continue;
const props: Record<string, string> = {};
for (const raw of declBody.split(';')) {
if (!raw) continue;
const colon = raw.indexOf(':');
if (colon <= 0) continue;
const name = raw.slice(0, colon).trim().toLowerCase();
if (!HOST_ALLOWED_INSPECT_PROPS.has(name)) continue;
const value = raw.slice(colon + 1).replace(/!important/gi, '').trim();
if (!value || HOST_UNSAFE_INSPECT_VALUE.test(value)) continue;
props[name] = value;
}
if (Object.keys(props).length) {
map[elementId] = { selector, props };
}
}
}
return map;
}
// HTML5 raw-text and escapable-raw-text elements: the parser does not
// interpret markup inside their contents, so a literal `</head>` or
// `<style data-od-inspect-overrides>` written as text inside one of them
// must NOT be treated as a real tag. Without this exclusion, a regex-only
// splicer can match `</head>` inside an inline <script> string literal or
// a CSS comment and inject the override block into the middle of
// JavaScript/CSS instead of the actual document head, corrupting the
// artifact on Save to source.
const RAW_TEXT_INSPECT_ELEMENTS = new Set(['script', 'style', 'textarea', 'title']);
// Decide whether a `<style ...>` opening tag actually carries a real
// `data-od-inspect-overrides` attribute, as opposed to merely mentioning
// the marker text inside another attribute name or value. The naive
// `\bdata-od-inspect-overrides\b` test against the whole tag text is
// over-broad in two cases:
//
// 1. A longer attribute name that has the marker as a prefix, e.g.
// `<style data-od-inspect-overrides-note="docs">`. The `-` after
// `overrides` is a non-word character, so `\b` matches and the tag
// gets mis-stripped on save / mis-parsed on hydration.
// 2. The marker spelled inside an attribute value, e.g.
// `<style title="data-od-inspect-overrides">`. The whole tag text
// contains the literal, so the regex matches even though the actual
// attribute names are `title` only.
//
// Both shapes occur in real artifacts (notes, documentation, fixtures)
// and would either silently drop the user's CSS on save or seed phantom
// overrides into the host map even though the artifact has no real
// override block. So we walk attributes proper, lower-casing each name
// and skipping any quoted value, and report a hit only when one of those
// names is exactly `data-od-inspect-overrides` (boolean attribute or
// assigned value, both legal HTML for our marker).
function styleTagIsInspectOverrideBlock(tagText: string): boolean {
const start = /^<style/i.exec(tagText);
if (!start) return false;
let i = start[0].length;
const end = tagText.length;
while (i < end) {
const ch = tagText.charAt(i);
if (ch === '>') return false;
if (ch === '/' || /\s/.test(ch)) {
i++;
continue;
}
const nameStart = i;
while (i < end) {
const c = tagText.charAt(i);
if (c === '=' || c === '/' || c === '>' || /\s/.test(c)) break;
i++;
}
const name = tagText.slice(nameStart, i).toLowerCase();
while (i < end && /\s/.test(tagText.charAt(i))) i++;
if (i < end && tagText.charAt(i) === '=') {
i++;
while (i < end && /\s/.test(tagText.charAt(i))) i++;
const quote = tagText.charAt(i);
if (quote === '"' || quote === "'") {
i++;
const close = tagText.indexOf(quote, i);
i = close < 0 ? end : close + 1;
} else {
while (i < end) {
const c = tagText.charAt(i);
if (c === '>' || /\s/.test(c)) break;
i++;
}
}
}
if (name === 'data-od-inspect-overrides') return true;
}
return false;
}
// Find the start (`<` position) of the matching close tag for a raw-text
// element, scanning case-insensitively. The close tag must be followed by
// a tag-name boundary (whitespace, `/`, or `>`) so a longer name like
// `</scripted>` doesn't accidentally close a `<script>`.
function findInspectRawTextEnd(source: string, start: number, name: string): number {
const lower = source.toLowerCase();
const needle = '</' + name.toLowerCase();
let p = start;
while (p < source.length) {
const idx = lower.indexOf(needle, p);
if (idx < 0) return -1;
const after = source.charAt(idx + needle.length);
if (after === '' || after === '>' || after === '/' || /\s/.test(after)) return idx;
p = idx + needle.length;
}
return -1;
}
type InspectSpliceScan = {
out: string;
// Position in `out` immediately after the first top-level `<head ...>`
// open tag, or -1 if no head was found outside raw-text content.
headOpenEnd: number;
// Position in `out` at the first top-level `</head>` close tag, or -1.
headCloseStart: number;
// Raw inner-text of every real `<style data-od-inspect-overrides>` element
// discovered during the walk, in source order. Excludes occurrences inside
// raw-text element contents and HTML comments. Hydration parses these
// bodies for the host map; the splicer ignores them.
bodies: string[];
};
// Walk `source` and produce a copy with every existing
// `<style data-od-inspect-overrides>...</style>` block removed, while
// remembering where the real (non-raw-text) `<head>` boundaries land in
// the output. The walker honours HTML comment, doctype/processing
// instruction, and raw-text element boundaries so the splicer can ignore
// tag-shaped literals inside scripts/styles/textareas/titles. Pure string
// transform — no DOM dependency, safe to run during SSR/tests.
function stripInspectOverridesAndIndex(source: string): InspectSpliceScan {
const parts: string[] = [];
const bodies: string[] = [];
let outLen = 0;
let headOpenEnd = -1;
let headCloseStart = -1;
let i = 0;
function emit(text: string): void {
if (!text) return;
parts.push(text);
outLen += text.length;
}
while (i < source.length) {
const lt = source.indexOf('<', i);
if (lt < 0) {
emit(source.slice(i));
break;
}
if (lt > i) emit(source.slice(i, lt));
i = lt;
if (source.startsWith('<!--', i)) {
const end = source.indexOf('-->', i + 4);
const stop = end < 0 ? source.length : end + 3;
emit(source.slice(i, stop));
i = stop;
continue;
}
if (source.startsWith('<!', i) || source.startsWith('<?', i)) {
const end = source.indexOf('>', i + 2);
const stop = end < 0 ? source.length : end + 1;
emit(source.slice(i, stop));
i = stop;
continue;
}
const tagEnd = source.indexOf('>', i + 1);
if (tagEnd < 0) {
emit(source.slice(i));
break;
}
const tagText = source.slice(i, tagEnd + 1);
const closeMatch = /^<\/([a-zA-Z][a-zA-Z0-9-]*)/.exec(tagText);
if (closeMatch) {
const name = closeMatch[1]!.toLowerCase();
if (name === 'head' && headCloseStart < 0) headCloseStart = outLen;
emit(tagText);
i = tagEnd + 1;
continue;
}
const openMatch = /^<([a-zA-Z][a-zA-Z0-9-]*)/.exec(tagText);
if (!openMatch) {
emit(tagText);
i = tagEnd + 1;
continue;
}
const name = openMatch[1]!.toLowerCase();
const isSelfClose = /\/\s*>$/.test(tagText);
if (name === 'head' && headOpenEnd < 0) headOpenEnd = outLen + tagText.length;
if (name === 'style' && styleTagIsInspectOverrideBlock(tagText)) {
// Strip the entire override block. A self-closing <style /> is a
// degenerate authoring case; treat it as nothing to skip past.
if (isSelfClose) {
i = tagEnd + 1;
continue;
}
const closeStart = findInspectRawTextEnd(source, tagEnd + 1, 'style');
if (closeStart < 0) {
// Unterminated override block — drop the rest of the document
// rather than silently reflowing later content into a dangling
// <style>. Matches the "stop" behaviour of the previous regex.
i = source.length;
continue;
}
bodies.push(source.slice(tagEnd + 1, closeStart));
const closeEnd = source.indexOf('>', closeStart);
let stop = closeEnd < 0 ? source.length : closeEnd + 1;
while (stop < source.length && /\s/.test(source.charAt(stop))) stop++;
i = stop;
continue;
}
if (!isSelfClose && RAW_TEXT_INSPECT_ELEMENTS.has(name)) {
const closeStart = findInspectRawTextEnd(source, tagEnd + 1, name);
if (closeStart < 0) {
emit(source.slice(i));
i = source.length;
continue;
}
const closeEnd = source.indexOf('>', closeStart);
const stop = closeEnd < 0 ? source.length : closeEnd + 1;
// Copy the entire raw-text element (open tag, body, close tag) to
// the output verbatim so its contents pass through unmodified.
emit(source.slice(i, stop));
i = stop;
continue;
}
emit(tagText);
i = tagEnd + 1;
}
return { out: parts.join(''), headOpenEnd, headCloseStart, bodies };
}
// Splice (or remove) the inspect overrides <style> block in an HTML
// document. Idempotent: calling with the same css produces the same
// document. Empty css strips the block entirely.
//
// HTML-aware: the underlying scan ignores comments and raw-text element
// contents (script / style / textarea / title), so a literal `</head>` or
// `<style data-od-inspect-overrides>` written inside an inline script or
// style block does not trick the splicer into stripping user code or
// inserting the override block in the middle of JavaScript/CSS.
//
// Exported (via the module) so a unit test can drive it without a live
// browser. Pure string transform — no DOM, no parser dependency.
export function applyInspectOverridesToSource(source: string, css: string): string {
const trimmed = css.trim();
const { out, headOpenEnd, headCloseStart } = stripInspectOverridesAndIndex(source);
if (!trimmed) return out;
const block = `<style data-od-inspect-overrides>\n${trimmed}\n</style>\n`;
if (headCloseStart >= 0) {
return out.slice(0, headCloseStart) + block + out.slice(headCloseStart);
}
if (headOpenEnd >= 0) {
return out.slice(0, headOpenEnd) + block + out.slice(headOpenEnd);
}
return block + out;
}
function summarizeMember(member: PreviewCommentMember): string {
const text = String(member.text || '').trim();
if (text) {
const trimmed = text.length > 24 ? `${text.slice(0, 21)}...` : text;
return `${member.label || member.elementId} · ${trimmed}`;
}
return member.label || member.elementId;
}
function CommentPreviewOverlays({
comments,
liveTargets,
hoveredTarget,
activeTarget,
boardTool,
scale,
strokePoints,
onOpenComment,
}: {
comments: PreviewComment[];
liveTargets: Map<string, PreviewCommentSnapshot>;
hoveredTarget: PreviewCommentSnapshot | null;
activeTarget: PreviewCommentSnapshot | null;
boardTool: BoardTool;
scale: number;
strokePoints: StrokePoint[];
onOpenComment: (comment: PreviewComment, snapshot: PreviewCommentSnapshot) => void;
}) {
const visibleComments = comments
.map((comment, index) => ({
comment,
index,
snapshot: liveSnapshotForComment(comment, liveTargets),
}))
.filter((item): item is { comment: PreviewComment; index: number; snapshot: PreviewCommentSnapshot } =>
Boolean(item.snapshot),
);
const targetOverlay = activeTarget ?? hoveredTarget;
return (
<div className="comment-overlay-layer" aria-hidden={false}>
{visibleComments.map(({ comment, index, snapshot }) => {
const bounds = overlayBoundsFromSnapshot(snapshot, scale);
return (
<div
key={comment.id}
className="comment-saved-marker"
style={{
left: bounds.left,
top: bounds.top,
width: bounds.width,
height: bounds.height,
}}
data-testid={`comment-saved-marker-${comment.elementId}`}
>
<div className="comment-saved-outline" />
<button
type="button"
className="comment-saved-pin"
onClick={() => onOpenComment(comment, snapshot)}
title={`${comment.elementId}: ${comment.note}`}
aria-label={`Open comment for ${comment.elementId}`}
>
{index + 1}
</button>
</div>
);
})}
{targetOverlay ? (
<CommentTargetOverlay
snapshot={targetOverlay}
scale={scale}
selected={Boolean(activeTarget)}
/>
) : null}
{boardTool === 'pod' && strokePoints.length > 1 ? (
<svg className="board-pod-stroke">
<polyline
points={strokePoints.map((point) => `${point.x * scale},${point.y * scale}`).join(' ')}
/>
</svg>
) : null}
</div>
);
}
function CommentTargetOverlay({
snapshot,
scale,
selected,
}: {
snapshot: PreviewCommentSnapshot;
scale: number;
selected: boolean;
}) {
const displayMembers = podDisplayMembers(snapshot);
if (displayMembers.length > 0) {
const overlayWeights = podOverlayWeights(displayMembers);
return (
<>
{displayMembers.map((member, index) => {
const bounds = overlayBoundsFromSnapshot(member, scale);
const width = Math.round(member.position.width);
const height = Math.round(member.position.height);
const overlayWeight = overlayWeights[index] ?? {
backgroundOpacity: 0.24,
outlineOpacity: 0.72,
ringOpacity: 0.18,
};
const overlayStyle: CSSProperties & Record<string, string | number> = {
left: bounds.left,
top: bounds.top,
width: bounds.width,
height: bounds.height,
'--comment-overlay-bg': `rgba(22, 119, 255, ${overlayWeight.backgroundOpacity})`,
'--comment-overlay-ring': `rgba(22, 119, 255, ${overlayWeight.ringOpacity})`,
'--comment-overlay-border': `rgba(22, 119, 255, ${overlayWeight.outlineOpacity})`,
};
return (
<div
key={`${member.elementId}-${index}`}
className={`comment-target-overlay comment-target-overlay--member${selected ? ' selected' : ''}`}
style={overlayStyle}
data-testid="comment-target-overlay"
>
<span className="comment-target-overlay-label">{snapshot.elementId}</span>
</div>
);
})}
</>
);
}
const bounds = overlayBoundsFromSnapshot(snapshot, scale);
return (
<div
className={`comment-target-overlay${selected ? ' selected' : ''}`}
style={{
left: bounds.left,
top: bounds.top,
width: bounds.width,
height: bounds.height,
}}
data-testid="comment-target-overlay"
>
<span className="comment-target-overlay-label">{snapshot.elementId}</span>
</div>
);
}
function podDisplayMembers(snapshot: PreviewCommentSnapshot): PreviewCommentSnapshot[] {
if (snapshot.selectionKind !== 'pod' || !Array.isArray(snapshot.podMembers)) return [];
const memberSnapshots = snapshot.podMembers.map((member) => ({
filePath: snapshot.filePath,
elementId: member.elementId,
selector: member.selector,
label: member.label,
text: member.text,
position: member.position,
htmlHint: member.htmlHint,
selectionKind: 'element' as const,
}));
const refined = pruneContainerSelections(memberSnapshots);
return refined.length > 0 ? refined : memberSnapshots;
}
function podOverlayWeights(
members: PreviewCommentSnapshot[],
): Array<{ backgroundOpacity: number; outlineOpacity: number; ringOpacity: number }> {
const areas = members.map((member) =>
Math.max(1, member.position.width * member.position.height),
);
const maxArea = Math.max(...areas);
const minArea = Math.min(...areas);
return areas.map((area) => {
const normalized =
maxArea === minArea ? 1 : 1 - (area - minArea) / (maxArea - minArea);
const emphasis = Math.pow(normalized, 0.9);
return {
backgroundOpacity: roundOverlayOpacity(0.1 + emphasis * 0.6),
outlineOpacity: roundOverlayOpacity(0.34 + emphasis * 0.36),
ringOpacity: roundOverlayOpacity(0.08 + emphasis * 0.18),
};
});
}
function roundOverlayOpacity(value: number): number {
return Math.round(value * 100) / 100;
}
function buildPodSnapshot(input: {
filePath: string;
strokePoints: StrokePoint[];
liveTargets: Map<string, PreviewCommentSnapshot>;
}): PreviewCommentSnapshot | null {
if (input.strokePoints.length < 2) return null;
const closedLoop = isClosedLoop(input.strokePoints);
const intersected = Array.from(input.liveTargets.values()).filter((snapshot) =>
selectionHitsSnapshot({
points: input.strokePoints,
snapshot,
closedLoop,
}),
);
const refined = pruneContainerSelections(intersected);
const selected = refined.length > 0 ? refined : intersected;
if (selected.length === 0) return null;
const bounds = selected.reduce(
(acc, snapshot) => {
const rect = snapshot.position;
return {
left: Math.min(acc.left, rect.x),
top: Math.min(acc.top, rect.y),
right: Math.max(acc.right, rect.x + rect.width),
bottom: Math.max(acc.bottom, rect.y + rect.height),
};
},
{
left: Number.POSITIVE_INFINITY,
top: Number.POSITIVE_INFINITY,
right: Number.NEGATIVE_INFINITY,
bottom: Number.NEGATIVE_INFINITY,
},
);
const podMembers: PreviewCommentMember[] = selected.map((snapshot) => ({
elementId: snapshot.elementId,
selector: snapshot.selector,
label: snapshot.label,
text: snapshot.text,
position: snapshot.position,
htmlHint: snapshot.htmlHint,
}));
const summary = selected
.slice(0, 3)
.map((snapshot) => summarizeSnapshot(snapshot))
.join(' · ');
const htmlHint = selected
.slice(0, 4)
.map((snapshot) => snapshot.htmlHint)
.filter(Boolean)
.join(' ');
const combinedSelector = selected
.slice(0, 8)
.map((snapshot) => snapshot.selector)
.filter(Boolean)
.join(', ');
return {
filePath: input.filePath,
elementId: `pod-${Date.now()}`,
selector: combinedSelector || 'body *',
label: summary || `Pod of ${intersected.length} items`,
text: intersected
.slice(0, 4)
.map((snapshot) => snapshot.text)
.filter(Boolean)
.join(' · '),
position: {
x: Math.round(bounds.left),
y: Math.round(bounds.top),
width: Math.max(1, Math.round(bounds.right - bounds.left)),
height: Math.max(1, Math.round(bounds.bottom - bounds.top)),
},
htmlHint: htmlHint.slice(0, 180),
selectionKind: 'pod',
memberCount: selected.length,
podMembers,
};
}
function pruneContainerSelections(
snapshots: PreviewCommentSnapshot[],
): PreviewCommentSnapshot[] {
if (snapshots.length < 2) return snapshots;
return snapshots.filter((candidate) => {
const candidateArea = Math.max(1, candidate.position.width * candidate.position.height);
const contained = snapshots.filter(
(other) =>
other.elementId !== candidate.elementId &&
rectContains(candidate.position, other.position),
);
if (contained.length === 0) return true;
const union = contained.reduce(
(acc, other) => ({
left: Math.min(acc.left, other.position.x),
top: Math.min(acc.top, other.position.y),
right: Math.max(acc.right, other.position.x + other.position.width),
bottom: Math.max(acc.bottom, other.position.y + other.position.height),
}),
{
left: Number.POSITIVE_INFINITY,
top: Number.POSITIVE_INFINITY,
right: Number.NEGATIVE_INFINITY,
bottom: Number.NEGATIVE_INFINITY,
},
);
const unionArea = Math.max(1, (union.right - union.left) * (union.bottom - union.top));
return !(contained.length >= 2 && candidateArea > unionArea * 2.4);
});
}
function summarizeSnapshot(snapshot: PreviewCommentSnapshot): string {
const text = snapshot.text.trim();
if (text) {
const trimmed = text.length > 28 ? `${text.slice(0, 25)}...` : text;
return `${snapshot.label || snapshot.elementId} · ${trimmed}`;
}
return snapshot.label || snapshot.elementId;
}
function selectionHitsSnapshot(input: {
points: StrokePoint[];
snapshot: PreviewCommentSnapshot;
closedLoop: boolean;
}): boolean {
const bounds = {
left: input.snapshot.position.x,
top: input.snapshot.position.y,
width: input.snapshot.position.width,
height: input.snapshot.position.height,
};
if (pathIntersectsRect(input.points, bounds)) return true;
if (!input.closedLoop) return false;
const center = {
x: bounds.left + bounds.width / 2,
y: bounds.top + bounds.height / 2,
};
if (pointInPolygon(center, input.points)) return true;
const corners = [
{ x: bounds.left, y: bounds.top },
{ x: bounds.left + bounds.width, y: bounds.top },
{ x: bounds.left + bounds.width, y: bounds.top + bounds.height },
{ x: bounds.left, y: bounds.top + bounds.height },
];
return corners.some((corner) => pointInPolygon(corner, input.points));
}
function isClosedLoop(points: StrokePoint[]): boolean {
if (points.length < 4) return false;
const first = points[0]!;
const last = points[points.length - 1]!;
return Math.hypot(first.x - last.x, first.y - last.y) <= 28;
}
function rectContains(
outer: { x: number; y: number; width: number; height: number },
inner: { x: number; y: number; width: number; height: number },
): boolean {
return (
outer.x <= inner.x &&
outer.y <= inner.y &&
outer.x + outer.width >= inner.x + inner.width &&
outer.y + outer.height >= inner.y + inner.height
);
}
function pathIntersectsRect(
points: StrokePoint[],
rect: { left: number; top: number; width: number; height: number },
): boolean {
if (points.length === 0) return false;
const x1 = rect.left;
const y1 = rect.top;
const x2 = rect.left + rect.width;
const y2 = rect.top + rect.height;
for (let index = 0; index < points.length; index += 1) {
const point = points[index]!;
if (point.x >= x1 && point.x <= x2 && point.y >= y1 && point.y <= y2) {
return true;
}
const next = points[index + 1];
if (!next) continue;
if (
lineIntersectsLine(point, next, { x: x1, y: y1 }, { x: x2, y: y1 }) ||
lineIntersectsLine(point, next, { x: x2, y: y1 }, { x: x2, y: y2 }) ||
lineIntersectsLine(point, next, { x: x2, y: y2 }, { x: x1, y: y2 }) ||
lineIntersectsLine(point, next, { x: x1, y: y2 }, { x: x1, y: y1 })
) {
return true;
}
}
return false;
}
function pointInPolygon(point: StrokePoint, polygon: StrokePoint[]): boolean {
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const pi = polygon[i]!;
const pj = polygon[j]!;
const intersects =
pi.y > point.y !== pj.y > point.y &&
point.x <
((pj.x - pi.x) * (point.y - pi.y)) / ((pj.y - pi.y) || Number.EPSILON) + pi.x;
if (intersects) inside = !inside;
}
return inside;
}
function lineIntersectsLine(a1: StrokePoint, a2: StrokePoint, b1: StrokePoint, b2: StrokePoint): boolean {
const denominator =
(a2.x - a1.x) * (b2.y - b1.y) - (a2.y - a1.y) * (b2.x - b1.x);
if (denominator === 0) return false;
const ua =
((b2.x - b1.x) * (a1.y - b1.y) - (b2.y - b1.y) * (a1.x - b1.x)) / denominator;
const ub =
((a2.x - a1.x) * (a1.y - b1.y) - (a2.y - a1.y) * (a1.x - b1.x)) / denominator;
return ua >= 0 && ua <= 1 && ub >= 0 && ub <= 1;
}
function finiteBridgeInteger(value: unknown): number | undefined {
if (!Number.isFinite(value)) return undefined;
return clampBridgeCoordinate(value);
}
function clampBridgeCoordinate(value: unknown): number {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return 0;
return Math.max(-MAX_BRIDGE_COORDINATE, Math.min(MAX_BRIDGE_COORDINATE, Math.round(numeric)));
}
function ReactComponentViewer({
projectId,
file,
}: {
projectId: string;
file: ProjectFile;
}) {
const t = useT();
const [mode, setMode] = useState<'preview' | 'source'>('preview');
const [source, setSource] = useState<string | null>(null);
const [srcDoc, setSrcDoc] = useState('');
const [reloadKey, setReloadKey] = useState(0);
const [shareMenuOpen, setShareMenuOpen] = useState(false);
const shareRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
setSource(null);
let cancelled = false;
void fetchProjectFileText(projectId, file.name).then((text) => {
if (!cancelled) setSource(text ?? '');
});
return () => {
cancelled = true;
};
}, [projectId, file.name, file.mtime, reloadKey]);
useEffect(() => {
if (!shareMenuOpen) return;
const onDocClick = (e: MouseEvent) => {
if (!shareRef.current) return;
if (!shareRef.current.contains(e.target as Node)) setShareMenuOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setShareMenuOpen(false);
};
document.addEventListener('mousedown', onDocClick);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDocClick);
document.removeEventListener('keydown', onKey);
};
}, [shareMenuOpen]);
const exportTitle = file.name.replace(/\.(jsx|tsx)$/i, '') || file.name;
const sourceExtension = file.name.toLowerCase().endsWith('.tsx') ? '.tsx' : '.jsx';
useEffect(() => {
if (source === null) {
setSrcDoc('');
return;
}
let cancelled = false;
const buildSrcDoc = () => {
const nextSrcDoc = buildReactComponentSrcdoc(source, { title: exportTitle });
if (!cancelled) setSrcDoc(nextSrcDoc);
};
if (source.length > 100_000) {
setSrcDoc('');
const timeout = window.setTimeout(buildSrcDoc, 0);
return () => {
cancelled = true;
window.clearTimeout(timeout);
};
}
buildSrcDoc();
return () => {
cancelled = true;
};
}, [source, exportTitle]);
return (
<div className="viewer react-component-viewer">
<div className="viewer-toolbar">
<div className="viewer-toolbar-left">
<button
type="button"
className="icon-only"
onClick={() => setReloadKey((n) => n + 1)}
title={t('fileViewer.reload')}
aria-label={t('fileViewer.reloadAria')}
>
<Icon name="reload" size={14} />
</button>
<span className="viewer-meta">
{t('fileViewer.reactMeta', { size: humanSize(file.size) })}
</span>
</div>
<div className="viewer-toolbar-actions">
<div className="viewer-tabs">
<button
type="button"
className={`viewer-tab ${mode === 'preview' ? 'active' : ''}`}
onClick={() => setMode('preview')}
>
{t('fileViewer.preview')}
</button>
<button
type="button"
className={`viewer-tab ${mode === 'source' ? 'active' : ''}`}
onClick={() => setMode('source')}
>
{t('fileViewer.source')}
</button>
</div>
{source !== null ? (
<>
<span className="viewer-divider" aria-hidden />
<div className="share-menu" ref={shareRef}>
<button
type="button"
className="viewer-action primary"
aria-haspopup="menu"
aria-expanded={shareMenuOpen}
onClick={() => setShareMenuOpen((v) => !v)}
>
<span>{t('fileViewer.shareLabel')}</span>
<Icon name="chevron-down" size={11} />
</button>
{shareMenuOpen ? (
<div className="share-menu-popover" role="menu">
<button
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => {
setShareMenuOpen(false);
exportAsJsx(source, exportTitle, sourceExtension);
}}
>
<span className="share-menu-icon"><Icon name="file-code" size={14} /></span>
<span>{t('fileViewer.exportJsx')}</span>
</button>
<button
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => {
setShareMenuOpen(false);
exportReactComponentAsHtml(source, exportTitle);
}}
>
<span className="share-menu-icon"><Icon name="file" size={14} /></span>
<span>{t('fileViewer.exportReactHtml')}</span>
</button>
<div className="share-menu-divider" />
<button
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => {
setShareMenuOpen(false);
exportReactComponentAsZip(source, exportTitle, sourceExtension);
}}
>
<span className="share-menu-icon"><Icon name="download" size={14} /></span>
<span>{t('fileViewer.exportZip')}</span>
</button>
</div>
) : null}
</div>
</>
) : null}
</div>
</div>
<div className="viewer-body">
{source === null || (mode === 'preview' && !srcDoc) ? (
<div className="viewer-empty">{t('fileViewer.loading')}</div>
) : mode === 'preview' ? (
<iframe
data-testid="react-component-preview-frame"
title={file.name}
sandbox="allow-scripts"
srcDoc={srcDoc}
/>
) : (
<CodeWithLines text={source} />
)}
</div>
</div>
);
}
function BinaryViewer({
projectId,
file,
}: {
projectId: string;
file: ProjectFile;
}) {
const t = useT();
return (
<div className="viewer binary-viewer">
<div className="viewer-toolbar">
<div className="viewer-toolbar-left">
<span className="viewer-meta">
{t('fileViewer.binaryMeta', { size: humanSize(file.size) })}
</span>
</div>
<FileActions projectId={projectId} file={file} />
</div>
<div className="viewer-body">
<div className="viewer-empty">
{t('fileViewer.binaryNote', { size: file.size })}
</div>
</div>
</div>
);
}
function DocumentPreviewViewer({
projectId,
file,
}: {
projectId: string;
file: ProjectFile;
}) {
const t = useT();
const [preview, setPreview] = useState<ProjectFilePreview | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
setPreview(null);
void fetchProjectFilePreview(projectId, file.name).then((next) => {
if (!cancelled) {
setPreview(next);
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [projectId, file.name, file.mtime]);
return (
<div className="viewer document-viewer">
<div className="viewer-toolbar">
<div className="viewer-toolbar-left">
<span className="viewer-meta">
{documentMetaLabel(file, t)} · {humanSize(file.size)}
</span>
</div>
<FileActions projectId={projectId} file={file} />
</div>
<div className="viewer-body">
{loading ? (
<div className="viewer-empty">{t('fileViewer.loading')}</div>
) : preview ? (
<div className="document-preview">
<h2>{preview.title}</h2>
{preview.sections.map((section, idx) => (
<section key={`${section.title}-${idx}`}>
<h3>{section.title}</h3>
{section.lines.map((line, lineIdx) => (
<p key={`${lineIdx}-${line}`}>{line}</p>
))}
</section>
))}
</div>
) : (
<div className="viewer-empty">{t('fileViewer.previewUnavailable')}</div>
)}
</div>
</div>
);
}
function HtmlViewer({
projectId,
file,
liveHtml,
isDeck,
onExportAsPptx,
streaming,
previewComments = [],
onSavePreviewComment,
onRemovePreviewComment,
onSendBoardCommentAttachments,
onFileSaved,
}: {
projectId: string;
file: ProjectFile;
liveHtml?: string;
isDeck: boolean;
onExportAsPptx?: ((fileName: string) => void) | undefined;
streaming: boolean;
previewComments?: PreviewComment[];
onSavePreviewComment?: (target: PreviewCommentTarget, note: string, attachAfterSave: boolean) => Promise<PreviewComment | null>;
onRemovePreviewComment?: (commentId: string) => Promise<void>;
onSendBoardCommentAttachments?: (attachments: ChatCommentAttachment[]) => Promise<void> | void;
onFileSaved?: () => Promise<void> | void;
}) {
const t = useT();
const [mode, setMode] = useState<'preview' | 'source'>('preview');
const [source, setSource] = useState<string | null>(liveHtml ?? null);
const [inlinedSource, setInlinedSource] = useState<string | null>(null);
const [zoom, setZoom] = useState(100);
const [previewViewport, setPreviewViewport] = useState<PreviewViewportId>('desktop');
const [zoomMenuOpen, setZoomMenuOpen] = useState(false);
const zoomMenuRef = useRef<HTMLDivElement | null>(null);
const [presentMenuOpen, setPresentMenuOpen] = useState(false);
const [shareMenuOpen, setShareMenuOpen] = useState(false);
// Template save UX. We surface a transient "Saved" pill in the share
// menu so the user gets feedback without a noisy toast layer.
const [savingTemplate, setSavingTemplate] = useState(false);
const [templateNote, setTemplateNote] = useState<string | null>(null);
const [templateModalOpen, setTemplateModalOpen] = useState(false);
const [templateName, setTemplateName] = useState('');
const [templateDescription, setTemplateDescription] = useState('');
const [templateSaveError, setTemplateSaveError] = useState<string | null>(null);
const [deployment, setDeployment] = useState<WebDeploymentInfo | null>(null);
const [deploymentsByProvider, setDeploymentsByProvider] = useState<Partial<Record<WebDeployProviderId, WebDeploymentInfo>>>({});
const [deployModalOpen, setDeployModalOpen] = useState(false);
const [deployConfig, setDeployConfig] = useState<WebDeployConfigResponse | null>(null);
const [deploying, setDeploying] = useState(false);
const [deployPhase, setDeployPhase] = useState<'idle' | 'deploying' | 'preparing-link'>('idle');
const [savingDeployConfig, setSavingDeployConfig] = useState(false);
const [deployError, setDeployError] = useState<string | null>(null);
const [deployResult, setDeployResult] = useState<WebDeployProjectFileResponse | null>(null);
const [copiedDeployLink, setCopiedDeployLink] = useState<string | null>(null);
const [deployProviderId, setDeployProviderId] = useState<WebDeployProviderId>(DEFAULT_DEPLOY_PROVIDER_ID);
const [deployToken, setDeployToken] = useState('');
const [teamId, setTeamId] = useState('');
const [teamSlug, setTeamSlug] = useState('');
const [cloudflareAccountId, setCloudflareAccountId] = useState('');
const [cloudflareZones, setCloudflareZones] = useState<CloudflarePagesZoneOption[]>([]);
const [cloudflareZonesLoading, setCloudflareZonesLoading] = useState(false);
const [cloudflareZonesError, setCloudflareZonesError] = useState<string | null>(null);
const [cloudflareZoneId, setCloudflareZoneId] = useState('');
const [cloudflareDomainPrefix, setCloudflareDomainPrefix] = useState('');
const deployProviderLoadSeqRef = useRef(0);
const [inTabPresent, setInTabPresent] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
const [boardMode, setBoardMode] = useState(false);
const [boardTool, setBoardTool] = useState<BoardTool>('inspect');
const [inspectMode, setInspectMode] = useState(false);
// for hint managing hint box state
const [openHintBox, setOpenHintBox] = useState(true);
const [manualEditMode, setManualEditMode] = useState(false);
const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([]);
const [selectedManualEditTarget, setSelectedManualEditTarget] = useState<ManualEditTarget | null>(null);
const [manualEditDraft, setManualEditDraft] = useState<ManualEditDraft>(() => emptyManualEditDraft());
const [manualEditHistory, setManualEditHistory] = useState<ManualEditHistoryEntry[]>([]);
const [manualEditUndone, setManualEditUndone] = useState<ManualEditHistoryEntry[]>([]);
const [manualEditError, setManualEditError] = useState<string | null>(null);
const [manualEditSaving, setManualEditSaving] = useState(false);
const manualEditSavingRef = useRef(false);
const templateNameId = useId();
const templateDescriptionId = useId();
// Opt back into the legacy inline-asset srcDoc path via `?forceInline=1`
// on the host page. Lets users escape-hatch around the URL-load default
// for non-deck HTML that depends on the in-iframe localStorage shim.
const forceInline = useMemo(
() => (typeof window === 'undefined' ? false : parseForceInline(window.location.search)),
[],
);
const [activeCommentTarget, setActiveCommentTarget] = useState<PreviewCommentSnapshot | null>(null);
const [hoveredCommentTarget, setHoveredCommentTarget] = useState<PreviewCommentSnapshot | null>(null);
const [liveCommentTargets, setLiveCommentTargets] = useState<Map<string, PreviewCommentSnapshot>>(() => new Map());
const liveCommentTargetsRef = useRef(liveCommentTargets);
const [commentDraft, setCommentDraft] = useState('');
// Inspect mode shares the iframe selection bridge with comment mode but
// routes the picked element to a side panel that mutates per-element CSS
// overrides via postMessage. The host owns the authoritative override map:
// it is hydrated from the artifact's persisted <style> block on load and
// mutated only by host-driven onApply / reset actions. Save-to-source
// serializes that host map directly — iframe od:inspect-overrides messages
// are preview acknowledgements and never feed save input, so artifact JS
// forging a postMessage cannot tamper with what gets persisted.
const [activeInspectTarget, setActiveInspectTarget] = useState<InspectTarget | null>(null);
const [inspectOverrides, setInspectOverrides] = useState<InspectOverrideMap>(() =>
typeof source === 'string' ? parseInspectOverridesFromSource(source) : {},
);
// Track which `source` value the host map was last hydrated from so the
// setState-during-render hydration below only fires when the artifact
// text actually changes (file switch, save round-trip, live edits). The
// ref is initialised to `source` so the matching useState initialiser
// above counts as the first hydration.
const inspectHydratedSourceRef = useRef<string | null | undefined>(source);
const [savingInspect, setSavingInspect] = useState(false);
const [inspectSavedAt, setInspectSavedAt] = useState<number | null>(null);
const [inspectError, setInspectError] = useState<string | null>(null);
const [queuedBoardNotes, setQueuedBoardNotes] = useState<string[]>([]);
const [sendingBoardBatch, setSendingBoardBatch] = useState(false);
const [strokePoints, setStrokePoints] = useState<StrokePoint[]>([]);
const previewStateKey = `${projectId}:${file.name}`;
const previewScale = zoom / 100;
function deploymentMapForCurrentFile(items: WebDeploymentInfo[]) {
const next: Partial<Record<WebDeployProviderId, WebDeploymentInfo>> = {};
for (const option of DEPLOY_PROVIDER_OPTIONS) {
const deploymentForProvider = items.find(
(item) => item.fileName === file.name && item.providerId === option.id && item.url?.trim(),
);
if (deploymentForProvider) next[option.id] = deploymentForProvider;
}
return next;
}
function syncDeployFormFromConfig(
providerId: WebDeployProviderId,
config: WebDeployConfigResponse | null,
) {
const matchingConfig = config?.providerId === providerId ? config : null;
setDeployProviderId(providerId);
setDeployConfig(matchingConfig);
setDeployToken(matchingConfig?.tokenMask || '');
setTeamId(matchingConfig?.teamId || '');
setTeamSlug(matchingConfig?.teamSlug || '');
setCloudflareAccountId(matchingConfig?.accountId || '');
setCloudflareZoneId(matchingConfig?.cloudflarePages?.lastZoneId || '');
setCloudflareDomainPrefix(matchingConfig?.cloudflarePages?.lastDomainPrefix || '');
}
function cloudflareConfigHintsFromForm() {
const zone = cloudflareZones.find((item) => item.id === cloudflareZoneId);
const hints = {
...(cloudflareZoneId.trim() ? { lastZoneId: cloudflareZoneId.trim() } : {}),
...((zone?.name || deployConfig?.cloudflarePages?.lastZoneName)
? { lastZoneName: zone?.name || deployConfig?.cloudflarePages?.lastZoneName }
: {}),
...(cloudflareDomainPrefix.trim()
? { lastDomainPrefix: normalizeCloudflareDomainPrefixInput(cloudflareDomainPrefix) }
: {}),
};
return Object.keys(hints).length > 0 ? hints : undefined;
}
function buildDeployConfigRequest(providerId: WebDeployProviderId): WebUpdateDeployConfigRequest {
const token = deployToken.trim();
if (providerId === CLOUDFLARE_PAGES_PROVIDER_ID) {
return {
providerId,
token,
accountId: cloudflareAccountId.trim(),
cloudflarePages: cloudflareConfigHintsFromForm(),
};
}
return {
providerId,
token,
teamId: teamId.trim(),
teamSlug: teamSlug.trim(),
};
}
async function loadDeployProvider(
providerId: WebDeployProviderId,
options?: { fallbackToExisting?: boolean },
) {
const requestSeq = ++deployProviderLoadSeqRef.current;
setDeployProviderId(providerId);
const deployments = await fetchProjectDeployments(projectId);
const nextDeploymentsByProvider = deploymentMapForCurrentFile(deployments);
const exactDeployment = nextDeploymentsByProvider[providerId] ?? null;
const fallbackDeployment = options?.fallbackToExisting
? Object.values(nextDeploymentsByProvider)[0] ?? null
: null;
const currentDeployment = exactDeployment ?? fallbackDeployment;
// Use the explicit providerId for config/form so a fallback deployment from
// another provider only fills the existing-URL display, never the form/credentials.
const config = await fetchDeployConfig(providerId);
if (requestSeq !== deployProviderLoadSeqRef.current) {
return { config: null, currentDeployment: null };
}
syncDeployFormFromConfig(providerId, config);
setDeploymentsByProvider(nextDeploymentsByProvider);
setDeployment(currentDeployment ?? null);
setDeployResult(currentDeployment ?? null);
if (providerId === CLOUDFLARE_PAGES_PROVIDER_ID && config?.configured) {
void loadCloudflareZones(config, { requestSeq });
}
return { config, currentDeployment };
}
async function loadCloudflareZones(
config: WebDeployConfigResponse | null = deployConfig,
options?: { requestSeq?: number },
) {
if (!config?.configured || config.providerId !== CLOUDFLARE_PAGES_PROVIDER_ID) return;
const requestSeq = options?.requestSeq ?? deployProviderLoadSeqRef.current;
setCloudflareZonesLoading(true);
setCloudflareZonesError(null);
try {
const response = await fetchCloudflarePagesZones();
if (requestSeq !== deployProviderLoadSeqRef.current) return;
const zones = response?.zones ?? [];
setCloudflareZones(zones);
const hintedZoneId = response?.cloudflarePages?.lastZoneId || config.cloudflarePages?.lastZoneId || '';
const nextZoneId = hintedZoneId && zones.some((zone) => zone.id === hintedZoneId)
? hintedZoneId
: zones[0]?.id || '';
setCloudflareZoneId(nextZoneId);
const hintedPrefix = response?.cloudflarePages?.lastDomainPrefix || config.cloudflarePages?.lastDomainPrefix || '';
if (hintedPrefix) setCloudflareDomainPrefix(hintedPrefix);
} catch (err) {
if (requestSeq !== deployProviderLoadSeqRef.current) return;
setCloudflareZones([]);
setCloudflareZonesError(err instanceof Error ? err.message : t('fileViewer.cloudflareZonesLoadFailed'));
} finally {
if (requestSeq === deployProviderLoadSeqRef.current) setCloudflareZonesLoading(false);
}
}
// Slide deck nav state: the iframe posts the active index + total count
// back to the host every time a slide settles. Host renders prev/next
// controls in the toolbar and reflects the count beside them.
const [slideState, setSlideState] = useState<SlideState | null>(
() => htmlPreviewSlideState.get(previewStateKey) ?? null,
);
const [previewBodyRef, previewBodySize] = usePreviewCanvasSize<HTMLDivElement>();
const overlayPreviewScale = effectivePreviewScale(previewViewport, previewScale, previewBodySize);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const shareRef = useRef<HTMLDivElement | null>(null);
const [chromeActionsHost, setChromeActionsHost] = useState<HTMLElement | null>(null);
useEffect(() => {
if (typeof document === 'undefined') return;
setChromeActionsHost(document.getElementById(APP_CHROME_FILE_ACTIONS_ID));
}, []);
useEffect(() => {
liveCommentTargetsRef.current = liveCommentTargets;
}, [liveCommentTargets]);
useEffect(() => {
if (liveHtml !== undefined) {
setSource(liveHtml);
return;
}
setSource(null);
let cancelled = false;
void fetchProjectFileText(projectId, file.name).then((text) => {
if (!cancelled) setSource(text);
});
return () => {
cancelled = true;
};
}, [projectId, file.name, file.mtime, liveHtml, reloadKey]);
useEffect(() => {
let cancelled = false;
setDeployResult(null);
setDeployError(null);
setCopiedDeployLink(null);
setDeployPhase('idle');
void fetchProjectDeployments(projectId).then((items) => {
if (cancelled) return;
const nextDeploymentsByProvider = deploymentMapForCurrentFile(items);
const current = nextDeploymentsByProvider[deployProviderId] ?? null;
setDeploymentsByProvider(nextDeploymentsByProvider);
setDeployment(current ?? null);
setDeployResult(current ?? null);
});
return () => {
cancelled = true;
};
}, [projectId, file.name, deployProviderId]);
// Detect deck-shaped HTML even when the project's skill didn't declare
// `mode: deck`. Freeform projects often produce a deck because the user
// asked for one in plain prose; without this, prev/next and Present
// never surface and the deck becomes a static, unnavigable preview.
const looksLikeDeck = useMemo(() => {
if (!source) return false;
return /class\s*=\s*['"][^'"]*\bslide\b/i.test(source);
}, [source]);
const effectiveDeck = isDeck || looksLikeDeck;
const previewSource = inlinedSource ?? source;
// When we URL-load the iframe directly, skip every in-host inlining /
// srcDoc-rebuilding step. The browser does the asset resolution itself,
// which is the whole point of the URL-load path.
const useUrlLoadPreview = shouldUrlLoadHtmlPreview({
mode,
isDeck: effectiveDeck,
commentMode: boardMode || manualEditMode,
inspectMode,
forceInline,
});
const previewSrcUrl = useMemo(
() => `${projectRawUrl(projectId, file.name)}?v=${Math.round(file.mtime)}&r=${reloadKey}`,
[projectId, file.name, file.mtime, reloadKey],
);
useEffect(() => {
setInlinedSource(null);
if (useUrlLoadPreview) return;
if (!source || effectiveDeck || !hasRelativeAssetRefs(source)) return;
let cancelled = false;
void inlineRelativeAssets(source, projectId, file.name).then((next) => {
if (!cancelled) setInlinedSource(next);
});
return () => {
cancelled = true;
};
}, [source, effectiveDeck, projectId, file.name, useUrlLoadPreview]);
const srcDoc = useMemo(
() => (previewSource ? buildSrcdoc(previewSource, {
deck: effectiveDeck,
baseHref: projectRawUrl(projectId, baseDirFor(file.name)),
initialSlideIndex: htmlPreviewSlideState.get(previewStateKey)?.active ?? 0,
commentBridge: boardMode && !manualEditMode,
inspectBridge: inspectMode,
editBridge: manualEditMode,
}) : ''),
[previewSource, effectiveDeck, projectId, file.name, previewStateKey, boardMode, manualEditMode, inspectMode],
);
useEffect(() => {
if (!effectiveDeck) {
setSlideState(null);
return;
}
setSlideState(htmlPreviewSlideState.get(previewStateKey) ?? null);
function onMessage(ev: MessageEvent) {
if (ev.source !== iframeRef.current?.contentWindow) return;
const data = ev?.data as
| { type?: string; active?: number; count?: number }
| null;
if (!data || data.type !== 'od:slide-state') return;
if (typeof data.active !== 'number' || typeof data.count !== 'number') return;
const next = { active: data.active, count: data.count };
setSlideStateCached(previewStateKey, next);
setSlideState(next);
}
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [effectiveDeck, previewStateKey]);
useEffect(() => {
const win = iframeRef.current?.contentWindow;
if (!win) return;
win.postMessage({ type: 'od:comment-mode', enabled: boardMode, mode: boardTool }, '*');
}, [boardMode, boardTool, srcDoc]);
useEffect(() => {
const win = iframeRef.current?.contentWindow;
if (!win) return;
win.postMessage({ type: 'od-edit-mode', enabled: manualEditMode }, '*');
}, [manualEditMode, srcDoc]);
function syncBridgeModes() {
const win = iframeRef.current?.contentWindow;
if (!win) return;
win.postMessage({ type: 'od:comment-mode', enabled: boardMode, mode: boardTool }, '*');
win.postMessage({ type: 'od-edit-mode', enabled: manualEditMode }, '*');
}
useEffect(() => {
const win = iframeRef.current?.contentWindow;
if (!win) return;
win.postMessage({ type: 'od:inspect-mode', enabled: inspectMode }, '*');
}, [inspectMode, srcDoc]);
// Mirror the bridge's `od:comment-targets` broadcast into
// `liveCommentTargets` whenever EITHER Inspect or Comments mode is
// active. The boardMode-only useEffect below still handles its
// own comment-specific events (hover / click target / pod), but
// the targets list itself is mode-agnostic — it's just "which
// elements on the page carry data-od-id / data-screen-label".
// Without this listener Inspect mode never learns the artifact's
// annotation count, and the empty-state hint added for #890 would
// misfire (always firing in Inspect mode, even on annotated
// artifacts) because the comment-mode listener short-circuits on
// `!boardMode`. Issue #890.
useEffect(() => {
if (!inspectMode && !boardMode) {
setLiveCommentTargets((current) => (current.size > 0 ? new Map() : current));
return;
}
function onMessage(ev: MessageEvent) {
if (ev.source !== iframeRef.current?.contentWindow) return;
const data = ev.data as
| {
type?: string;
targets?: Array<Partial<PreviewCommentSnapshot>>;
}
| null;
if (data?.type !== 'od:comment-targets' || !Array.isArray(data.targets)) return;
const next = new Map<string, PreviewCommentSnapshot>();
data.targets.forEach((item) => {
const elementId = String(item?.elementId || '');
if (!elementId) return;
next.set(elementId, {
filePath: file.name,
elementId,
selector: String(item?.selector || ''),
label: String(item?.label || ''),
text: String(item?.text || ''),
position: {
x: clampBridgeCoordinate(item?.position?.x),
y: clampBridgeCoordinate(item?.position?.y),
width: clampBridgeCoordinate(item?.position?.width),
height: clampBridgeCoordinate(item?.position?.height),
},
htmlHint: String(item?.htmlHint || ''),
selectionKind: 'element',
memberCount: undefined,
});
});
setLiveCommentTargets(next);
}
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [inspectMode, boardMode, file.name]);
useEffect(() => {
setActiveCommentTarget(null);
setHoveredCommentTarget(null);
setLiveCommentTargets(new Map());
setCommentDraft('');
setActiveInspectTarget(null);
setInspectOverrides({});
setInspectSavedAt(null);
setInspectError(null);
setQueuedBoardNotes([]);
setStrokePoints([]);
setManualEditTargets([]);
setSelectedManualEditTarget(null);
setManualEditDraft(emptyManualEditDraft());
setManualEditHistory([]);
setManualEditUndone([]);
setManualEditError(null);
}, [file.name]);
// Selecting a new file or turning inspect off resets the panel target.
useEffect(() => {
if (!inspectMode) {
setActiveInspectTarget(null);
setInspectError(null);
}
}, [inspectMode]);
// Hydrate the host-authoritative override map from the artifact source
// synchronously, *before* React commits a render that carries a new
// `srcDoc` to the iframe. A `useEffect([source])` would commit the new
// source first and only re-render with the parsed map afterwards — if
// the iframe finishes loading the new srcDoc in that window, its
// `onLoad` handler captures the previous file's empty/stale map in its
// closure and posts that map back over the bridge's freshly DOM-hydrated
// overrides, leaving the preview without saved inspect styles until the
// next reload or mode toggle. Setting state during render is React's
// documented escape hatch for "store a value derived from props"
// (https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes):
// the in-flight render is discarded and React re-renders with the
// updated state before commit, so the new `srcDoc` and the new
// `inspectOverrides` always commit together. After hydration the map
// only mutates from host-driven onApply / reset callbacks below, so
// artifact JS forging an od:inspect-overrides message cannot tamper
// with what saveInspectToSource will persist.
if (inspectHydratedSourceRef.current !== source) {
inspectHydratedSourceRef.current = source;
setInspectOverrides(typeof source === 'string' ? parseInspectOverridesFromSource(source) : {});
}
useEffect(() => {
if (source == null) return;
setManualEditDraft((current) => (
current.fullSource === source ? current : { ...current, fullSource: source }
));
}, [source]);
useEffect(() => {
if (!boardMode) {
setActiveCommentTarget((current) => (current ? null : current));
setHoveredCommentTarget((current) => (current ? null : current));
setLiveCommentTargets((current) => (current.size > 0 ? new Map() : current));
setQueuedBoardNotes((current) => (current.length > 0 ? [] : current));
setStrokePoints((current) => (current.length > 0 ? [] : current));
return;
}
const snapshotFromData = (data: Partial<PreviewCommentSnapshot>): PreviewCommentSnapshot => ({
filePath: file.name,
elementId: String(data.elementId || ''),
selector: String(data.selector || ''),
label: String(data.label || ''),
text: String(data.text || ''),
position: {
x: clampBridgeCoordinate(data.position?.x),
y: clampBridgeCoordinate(data.position?.y),
width: clampBridgeCoordinate(data.position?.width),
height: clampBridgeCoordinate(data.position?.height),
},
htmlHint: String(data.htmlHint || ''),
selectionKind: data.selectionKind === 'pod' ? 'pod' : 'element',
memberCount: finiteBridgeInteger(data.memberCount),
podMembers: Array.isArray(data.podMembers) ? data.podMembers : undefined,
});
function onMessage(ev: MessageEvent) {
if (ev.source !== iframeRef.current?.contentWindow) return;
const data = ev.data as (Partial<PreviewCommentSnapshot> & {
type?: string;
targets?: Array<Partial<PreviewCommentSnapshot>>;
points?: StrokePoint[];
}) | null;
if (!data?.type) return;
if (data.type === 'od:comment-targets' && Array.isArray(data.targets)) {
const next = new Map<string, PreviewCommentSnapshot>();
data.targets.forEach((item) => {
const snapshot = snapshotFromData(item);
if (snapshot.elementId) next.set(snapshot.elementId, snapshot);
});
setLiveCommentTargets(next);
setActiveCommentTarget((current) => (
current
? current.selectionKind === 'pod'
? current
: next.get(current.elementId) ?? null
: null
));
setHoveredCommentTarget((current) => (
current
? current.selectionKind === 'pod'
? current
: next.get(current.elementId) ?? null
: null
));
return;
}
if (data.type === 'od:comment-leave') {
setHoveredCommentTarget(null);
return;
}
if (data.type === 'od:comment-hover') {
const snapshot = snapshotFromData(data);
if (!snapshot.elementId) return;
setHoveredCommentTarget(snapshot);
setLiveCommentTargets((current) => new Map(current).set(snapshot.elementId, snapshot));
return;
}
if (data.type === 'od:comment-target') {
const snapshot = snapshotFromData(data);
if (!snapshot.elementId) return;
const existing = previewComments.find((comment) => comment.elementId === snapshot.elementId);
setActiveCommentTarget(snapshot);
setHoveredCommentTarget(snapshot);
setLiveCommentTargets((current) => new Map(current).set(snapshot.elementId, snapshot));
setCommentDraft(existing?.note ?? '');
setQueuedBoardNotes([]);
return;
}
if (data.type === 'od:pod-clear') {
setStrokePoints([]);
return;
}
if (data.type === 'od:pod-stroke' && Array.isArray(data.points)) {
setStrokePoints(
data.points.map((point) => ({
x: clampBridgeCoordinate(point.x),
y: clampBridgeCoordinate(point.y),
})),
);
return;
}
if (data.type === 'od:pod-select' && Array.isArray(data.points)) {
const points = data.points.map((point) => ({
x: clampBridgeCoordinate(point.x),
y: clampBridgeCoordinate(point.y),
}));
setStrokePoints(points);
const nextTarget = buildPodSnapshot({
filePath: file.name,
strokePoints: points,
liveTargets: liveCommentTargetsRef.current,
});
if (!nextTarget) {
setStrokePoints([]);
return;
}
setActiveCommentTarget(nextTarget);
setHoveredCommentTarget(nextTarget);
setQueuedBoardNotes([]);
setCommentDraft('');
setStrokePoints([]);
}
}
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [boardMode, file.name, previewComments]);
useEffect(() => {
if (!manualEditMode) {
setManualEditTargets([]);
setSelectedManualEditTarget(null);
setManualEditError(null);
return;
}
function onMessage(ev: MessageEvent) {
if (ev.source !== iframeRef.current?.contentWindow) return;
const data = ev.data as ManualEditBridgeMessage | null;
if (!data?.type) return;
if (data.type === 'od-edit-targets' && Array.isArray(data.targets)) {
setManualEditTargets(data.targets);
setSelectedManualEditTarget((current) =>
current ? data.targets.find((target) => target.id === current.id) ?? null : current,
);
return;
}
if (data.type === 'od-edit-select') {
selectManualEditTarget(data.target);
}
}
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [manualEditMode, source]);
function selectManualEditTarget(target: ManualEditTarget) {
const base = source ?? '';
const fields = readManualEditFields(base, target.id);
setSelectedManualEditTarget(target);
setManualEditDraft({
text: fields.text ?? target.fields.text ?? target.text,
href: fields.href ?? target.fields.href ?? '',
src: fields.src ?? target.fields.src ?? '',
alt: fields.alt ?? target.fields.alt ?? '',
styles: readManualEditStyles(base, target.id),
attributesText: JSON.stringify(readManualEditAttributes(base, target.id), null, 2),
outerHtml: readManualEditOuterHtml(base, target.id) || target.outerHtml,
fullSource: base,
});
setManualEditError(null);
}
async function applyManualEdit(patch: ManualEditPatch, label: string) {
if (manualEditSavingRef.current) return;
if (source == null) return;
manualEditSavingRef.current = true;
setManualEditSaving(true);
setManualEditError(null);
try {
const baseSource = source;
const result = applyManualEditPatch(baseSource, patch);
if (!result.ok) {
setManualEditError(result.error ?? 'Could not apply edit.');
return;
}
if (!(await confirmManualEditHistorySource(
baseSource,
'The file changed outside manual edit mode. Refreshing before applying manual edits.',
))) return;
const saved = await writeProjectTextFile(projectId, file.name, result.source, {
artifactManifest: file.artifactManifest,
});
if (!saved) {
setManualEditError('Could not save the edited file.');
return;
}
const entry: ManualEditHistoryEntry = {
id: `${Date.now()}-${manualEditHistory.length}`,
label,
patch,
beforeSource: baseSource,
afterSource: result.source,
createdAt: Date.now(),
};
setSource(result.source);
setInlinedSource(null);
setManualEditHistory((current) => [entry, ...current]);
setManualEditUndone([]);
setManualEditDraft((current) => ({ ...current, fullSource: result.source }));
await onFileSaved?.();
} finally {
manualEditSavingRef.current = false;
setManualEditSaving(false);
}
}
async function confirmManualEditHistorySource(expectedSource: string, message: string): Promise<boolean> {
const persisted = await fetchProjectFileText(projectId, file.name, {
cache: 'no-store',
cacheBustKey: Date.now(),
});
if (persisted == null || persisted === expectedSource) return true;
setSource(persisted);
setInlinedSource(null);
setManualEditHistory([]);
setManualEditUndone([]);
setManualEditDraft((current) => ({ ...current, fullSource: persisted }));
setManualEditError(message);
return false;
}
async function undoManualEdit() {
if (manualEditSavingRef.current) return;
const [latest, ...rest] = manualEditHistory;
if (!latest) return;
manualEditSavingRef.current = true;
setManualEditSaving(true);
try {
if (!(await confirmManualEditHistorySource(
latest.afterSource,
'The file changed outside manual edit mode. History was cleared to avoid overwriting newer content.',
))) return;
const saved = await writeProjectTextFile(projectId, file.name, latest.beforeSource, {
artifactManifest: file.artifactManifest,
});
if (!saved) {
setManualEditError('Could not save the undo result.');
return;
}
setSource(latest.beforeSource);
setInlinedSource(null);
setManualEditHistory(rest);
setManualEditUndone((current) => [latest, ...current]);
setManualEditDraft((current) => ({ ...current, fullSource: latest.beforeSource }));
await onFileSaved?.();
} finally {
manualEditSavingRef.current = false;
setManualEditSaving(false);
}
}
async function redoManualEdit() {
if (manualEditSavingRef.current) return;
const [latest, ...rest] = manualEditUndone;
if (!latest) return;
manualEditSavingRef.current = true;
setManualEditSaving(true);
try {
if (!(await confirmManualEditHistorySource(
latest.beforeSource,
'The file changed outside manual edit mode. History was cleared to avoid overwriting newer content.',
))) return;
const saved = await writeProjectTextFile(projectId, file.name, latest.afterSource, {
artifactManifest: file.artifactManifest,
});
if (!saved) {
setManualEditError('Could not save the redo result.');
return;
}
setSource(latest.afterSource);
setInlinedSource(null);
setManualEditUndone(rest);
setManualEditHistory((current) => [latest, ...current]);
setManualEditDraft((current) => ({ ...current, fullSource: latest.afterSource }));
await onFileSaved?.();
} finally {
manualEditSavingRef.current = false;
setManualEditSaving(false);
}
}
// Inspect-mode picker: same `od:comment-target` payload, different sink.
// The bridge tags the message with a computed-style snapshot so the panel
// can show real starting values for color / typography / spacing / radius.
useEffect(() => {
if (!inspectMode) return;
function onMessage(ev: MessageEvent) {
if (ev.source !== iframeRef.current?.contentWindow) return;
const data = ev.data as
| { type?: string; elementId?: string; selector?: string; label?: string; text?: string; style?: InspectStyleSnapshot }
| null;
if (!data || data.type !== 'od:comment-target') return;
if (!data.elementId || !data.selector) return;
setActiveInspectTarget({
elementId: String(data.elementId),
selector: String(data.selector),
label: String(data.label || ''),
text: String(data.text || ''),
style: data.style && typeof data.style === 'object' ? data.style : {},
});
setInspectError(null);
setInspectSavedAt(null);
}
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [inspectMode]);
function postSlide(action: 'next' | 'prev' | 'first' | 'last') {
const win = iframeRef.current?.contentWindow;
if (!win) return;
win.postMessage({ type: 'od:slide', action }, '*');
}
function postInspectSet(elementId: string, selector: string, prop: string, value: string) {
const win = iframeRef.current?.contentWindow;
if (!win) return;
win.postMessage(
{ type: 'od:inspect-set', elementId, selector, prop, value },
'*',
);
}
function postInspectReset(elementId?: string) {
const win = iframeRef.current?.contentWindow;
if (!win) return;
win.postMessage({ type: 'od:inspect-reset', elementId }, '*');
}
// Replay the host's authoritative override map into the freshly loaded
// iframe. The bridge inside the iframe only sees rules persisted in the
// artifact source via its own hydrateOverridesFromDom() — any unsaved
// edit lives on the host side until Save-to-source. Without this replay,
// toggling Inspect off/on, switching to Comment mode, or any other
// srcdoc rebuild reloads the iframe from previewSource without the
// unsaved style block, so the preview drops the live edits while
// saveInspectToSource() can still persist them later from the stale
// host map. The bridge re-validates each entry under its own allow-list,
// so a parent that posted a hostile replay can only land overrides the
// bridge would also have accepted via od:inspect-set.
//
// The render-time hydration above keeps `inspectOverrides` aligned with
// the current `source` whenever React commits, but the iframe `onLoad`
// callback fires from a separate event-loop turn after the new srcDoc
// is parsed; if it ever races a stale closure (e.g. an interleaved
// remount), reading React state would post the previous file's map over
// the bridge's DOM-hydrated one and silently strip the persisted styles
// from preview. Re-derive synchronously from `source` whenever the
// hydration ref disagrees so onLoad never sends a stale snapshot.
function replayInspectOverridesToIframe() {
const win = iframeRef.current?.contentWindow;
if (!win) return;
const overrides = inspectHydratedSourceRef.current === source
? inspectOverrides
: (typeof source === 'string' ? parseInspectOverridesFromSource(source) : {});
win.postMessage({ type: 'od:inspect-replay', overrides }, '*');
}
// Persist accumulated inspect overrides into the artifact source: replace
// (or insert) a single <style data-od-inspect-overrides> block in <head>.
// The CSS body is serialized from the host's own override map, hydrated
// from source on load and updated only by host-driven onApply / reset
// callbacks. We deliberately do NOT round-trip through the iframe at save
// time: artifact JS rendered inside the preview shares the same
// contentWindow as the bridge and could forge an od:inspect-overrides
// reply that flips allow-listed properties on elements the user never
// touched. POSTing to /api/projects/:id/files upserts the file via
// writeProjectFile (multipart-or-JSON; we use JSON).
async function saveInspectToSource() {
if (!source) return;
setSavingInspect(true);
setInspectError(null);
try {
const css = serializeInspectOverrides(inspectOverrides).trim();
const next = applyInspectOverridesToSource(source, css);
const resp = await fetch(`/api/projects/${encodeURIComponent(projectId)}/files`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: file.name, content: next }),
});
if (!resp.ok) {
const payload = await resp.json().catch(() => null) as { error?: string; message?: string } | null;
throw new Error(payload?.error || payload?.message || `Save failed (${resp.status})`);
}
setSource(next);
setInspectSavedAt(Date.now());
setReloadKey((k) => k + 1);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Save failed';
setInspectError(msg);
// The error banner inside the inspect panel is easy to miss when the
// user is focused on the iframe preview — surface failures in the
// console as well so quota/network errors aren't silently lost.
console.error('[inspect] saveToSource failed:', err);
} finally {
setSavingInspect(false);
}
}
// Keyboard nav on the host, so the user can press ←/→ even when focus
// is on the chat composer or any other host control.
useEffect(() => {
if (!effectiveDeck || mode !== 'preview') return;
function onKey(e: KeyboardEvent) {
const target = e.target as HTMLElement | null;
if (target) {
const tag = target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable) return;
}
if (e.key === 'ArrowRight' || e.key === 'PageDown') {
e.preventDefault();
postSlide('next');
} else if (e.key === 'ArrowLeft' || e.key === 'PageUp') {
e.preventDefault();
postSlide('prev');
} else if (e.key === 'Home') {
e.preventDefault();
postSlide('first');
} else if (e.key === 'End') {
e.preventDefault();
postSlide('last');
}
}
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [effectiveDeck, mode]);
useEffect(() => {
if (!presentMenuOpen) return;
const onPointer = (e: MouseEvent) => {
const target = e.target as HTMLElement | null;
if (!target) return;
if (target.closest('.present-wrap')) return;
setPresentMenuOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setPresentMenuOpen(false);
};
document.addEventListener('mousedown', onPointer);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onPointer);
document.removeEventListener('keydown', onKey);
};
}, [presentMenuOpen]);
useEffect(() => {
if (!zoomMenuOpen) return;
const onDocClick = (e: MouseEvent) => {
if (!zoomMenuRef.current) return;
if (!zoomMenuRef.current.contains(e.target as Node)) setZoomMenuOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setZoomMenuOpen(false);
};
document.addEventListener('mousedown', onDocClick);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDocClick);
document.removeEventListener('keydown', onKey);
};
}, [zoomMenuOpen]);
useEffect(() => {
if (!shareMenuOpen) return;
const onDocClick = (e: MouseEvent) => {
if (!shareRef.current) return;
if (!shareRef.current.contains(e.target as Node)) setShareMenuOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setShareMenuOpen(false);
};
document.addEventListener('mousedown', onDocClick);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDocClick);
document.removeEventListener('keydown', onKey);
};
}, [shareMenuOpen]);
useEffect(() => {
if (!inTabPresent) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setInTabPresent(false);
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [inTabPresent]);
function openInNewTab() {
if (!source) return;
openSandboxedPreviewInNewTab(source, exportTitle, {
deck: effectiveDeck,
baseHref: projectRawUrl(projectId, baseDirFor(file.name)),
initialSlideIndex: htmlPreviewSlideState.get(previewStateKey)?.active ?? 0,
});
}
// Snapshot this project as a reusable template. The daemon snapshots
// EVERY html/text/code file in the project (not just the file open in
// the viewer), so the template captures the whole design, not a single
// page. Surfaced here in the Share menu because that's where the user's
// share / export mental model already lives.
function openSaveAsTemplateModal() {
setShareMenuOpen(false);
const defaultName =
file.name.replace(/\.html?$/i, '') || t('fileViewer.templateNameDefault');
setTemplateName(defaultName);
setTemplateDescription('');
setTemplateSaveError(null);
setTemplateModalOpen(true);
}
async function handleSaveAsTemplate() {
const name = templateName.trim();
if (!name) return;
setSavingTemplate(true);
setTemplateNote(null);
setTemplateSaveError(null);
let savedName: string | null = null;
try {
const tpl = await saveTemplate({
name,
description: templateDescription.trim() || undefined,
sourceProjectId: projectId,
});
if (!tpl) {
setTemplateSaveError(t('fileViewer.savedTemplateFail'));
return;
}
savedName = tpl.name;
setTemplateModalOpen(false);
setTemplateName('');
setTemplateDescription('');
setTemplateNote(t('fileViewer.savedTemplate', { name: tpl.name }));
} finally {
setSavingTemplate(false);
if (savedName) {
// Auto-clear the note so the menu doesn't keep stale state next open.
setTimeout(() => setTemplateNote(null), 4000);
}
}
}
async function openDeployModal(nextProviderId: WebDeployProviderId = deployProviderId) {
setShareMenuOpen(false);
setDeployModalOpen(true);
setDeployError(null);
setCopiedDeployLink(null);
setDeployPhase('idle');
await loadDeployProvider(nextProviderId, { fallbackToExisting: true });
}
async function changeDeployProvider(nextProviderId: WebDeployProviderId) {
if (nextProviderId === deployProviderId) return;
setDeployError(null);
setDeployPhase('idle');
await loadDeployProvider(nextProviderId);
}
async function saveDeployConfig() {
setSavingDeployConfig(true);
setDeployError(null);
try {
if (deployProviderId === CLOUDFLARE_PAGES_PROVIDER_ID) {
if (!deployToken.trim()) {
throw new Error(t('fileViewer.cloudflareApiTokenRequired'));
}
if (!cloudflareAccountId.trim()) {
throw new Error(t('fileViewer.cloudflareAccountIdRequired'));
}
}
const config = await updateDeployConfig(buildDeployConfigRequest(deployProviderId));
if (!config || config.providerId !== deployProviderId) {
throw new Error(t('fileViewer.deployProviderConfigSaveFailed', { provider: deployProviderLabel }));
}
syncDeployFormFromConfig(deployProviderId, config);
if (deployProviderId === CLOUDFLARE_PAGES_PROVIDER_ID) {
await loadCloudflareZones(config);
}
return config;
} catch (err) {
setDeployError(err instanceof Error ? err.message : t('fileViewer.deployProviderConfigSaveFailed', { provider: deployProviderLabel }));
return null;
} finally {
setSavingDeployConfig(false);
}
}
function buildCloudflarePagesDeploySelection(): WebCloudflarePagesDeploySelection | undefined {
if (deployProviderId !== CLOUDFLARE_PAGES_PROVIDER_ID) return undefined;
const prefix = normalizeCloudflareDomainPrefixInput(cloudflareDomainPrefix);
if (!prefix) return undefined;
if (!isValidCloudflareDomainPrefixInput(prefix)) {
throw new Error(t('fileViewer.cloudflareDomainPrefixInvalid'));
}
const zone = cloudflareZones.find((item) => item.id === cloudflareZoneId);
if (!zone) {
throw new Error(t('fileViewer.cloudflareZoneRequired'));
}
return {
zoneId: zone.id,
zoneName: zone.name,
domainPrefix: prefix,
};
}
async function deployToSelectedProvider() {
setDeploying(true);
setDeployPhase('deploying');
setDeployError(null);
setCopiedDeployLink(null);
try {
const cloudflarePagesSelection = buildCloudflarePagesDeploySelection();
const typedToken = deployToken.trim();
const hasNewToken = typedToken && typedToken !== deployConfig?.tokenMask;
const cloudflareHints = cloudflareConfigHintsFromForm();
const cloudflareHintsChanged = deployProviderId === CLOUDFLARE_PAGES_PROVIDER_ID && Boolean(
cloudflareHints?.lastZoneId !== deployConfig?.cloudflarePages?.lastZoneId ||
cloudflareHints?.lastZoneName !== deployConfig?.cloudflarePages?.lastZoneName ||
cloudflareHints?.lastDomainPrefix !== deployConfig?.cloudflarePages?.lastDomainPrefix,
);
const needsConfigSave =
hasNewToken ||
teamId.trim() !== (deployConfig?.teamId || '') ||
teamSlug.trim() !== (deployConfig?.teamSlug || '') ||
cloudflareAccountId.trim() !== (deployConfig?.accountId || '') ||
cloudflareHintsChanged ||
!deployConfig?.configured;
if (needsConfigSave) {
const nextConfig = await saveDeployConfig();
if (!nextConfig) return;
if (!nextConfig?.configured) {
const option = getDeployProviderOption(deployProviderId);
throw new Error(t(option.tokenRequiredKey, { provider: t(option.labelKey) }));
}
}
setDeployPhase('preparing-link');
const next = await deployProjectFile(projectId, file.name, deployProviderId, cloudflarePagesSelection);
setDeploymentsByProvider((current) => ({
...current,
[next.providerId]: next,
}));
setDeployment(next);
setDeployResult(next);
} catch (err) {
const option = getDeployProviderOption(deployProviderId);
setDeployError(
err instanceof Error ? err.message : t('fileViewer.deployProviderFailed', { provider: t(option.labelKey) }),
);
} finally {
setDeploying(false);
setDeployPhase('idle');
}
}
async function retryDeploymentLink() {
const current = deployResult || deployment;
if (!current?.id) return;
setDeployError(null);
setDeployPhase('preparing-link');
try {
const next = await checkDeploymentLink(projectId, current.id);
setDeploymentsByProvider((items) => ({
...items,
[next.providerId]: next,
}));
setDeployment(next);
setDeployResult(next);
} catch (err) {
setDeployError(err instanceof Error ? err.message : t('fileViewer.deployFailed'));
} finally {
setDeployPhase('idle');
}
}
async function copyDeployLink(url: string) {
const safeUrl = url.trim();
if (!safeUrl) return;
try {
await navigator.clipboard.writeText(safeUrl);
} catch {
const textarea = document.createElement('textarea');
textarea.value = safeUrl;
textarea.setAttribute('readonly', 'true');
textarea.style.position = 'fixed';
textarea.style.top = '-1000px';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
setCopiedDeployLink(safeUrl);
window.setTimeout(() => {
setCopiedDeployLink((current) => (current === safeUrl ? null : current));
}, 1800);
}
function presentInThisTab() {
setPresentMenuOpen(false);
setInTabPresent(true);
}
function presentFullscreen() {
setPresentMenuOpen(false);
const el = previewBodyRef.current;
if (el && typeof el.requestFullscreen === 'function') {
el.requestFullscreen().catch(() => setInTabPresent(true));
} else {
setInTabPresent(true);
}
}
function presentNewTab() {
setPresentMenuOpen(false);
openInNewTab();
}
function bumpZoom(delta: number) {
setZoom((z) => Math.max(25, Math.min(200, z + delta)));
}
function clearBoardComposer() {
setActiveCommentTarget(null);
setHoveredCommentTarget(null);
setCommentDraft('');
setQueuedBoardNotes([]);
setStrokePoints([]);
}
function activateBoard(nextTool?: BoardTool) {
setMode('preview');
setBoardMode(true);
if (nextTool) {
setBoardTool(nextTool);
}
}
function queueCurrentDraft() {
const note = commentDraft.trim();
if (!note) return;
setQueuedBoardNotes((current) => [...current, note]);
setCommentDraft('');
}
async function sendBoardBatch() {
if (!activeCommentTarget || !onSendBoardCommentAttachments) return;
const nextNotes = [...queuedBoardNotes];
if (commentDraft.trim()) nextNotes.push(commentDraft.trim());
if (nextNotes.length === 0) return;
setSendingBoardBatch(true);
try {
await onSendBoardCommentAttachments(
buildBoardCommentAttachments({
target: targetFromSnapshot(activeCommentTarget),
notes: nextNotes,
}),
);
clearBoardComposer();
} finally {
setSendingBoardBatch(false);
}
}
async function savePersistentComment() {
if (!activeCommentTarget || !commentDraft.trim() || !onSavePreviewComment) return;
const saved = await onSavePreviewComment(
targetFromSnapshot(activeCommentTarget),
commentDraft.trim(),
false,
);
if (saved) {
setCommentDraft('');
}
}
const showPresent = source !== null;
const canShare = source !== null;
const exportTitle = file.name.replace(/\.html?$/i, '') || file.name;
const canPptx = canShare && Boolean(onExportAsPptx) && !streaming;
const boardAvailable = source !== null;
const activeDeployment = deployResult || deployment;
const activeDeployedUrl = activeDeployment?.url?.trim() || '';
const activeDeploymentDelayed = activeDeployment?.status === 'link-delayed';
const activeDeploymentProtected = activeDeployment?.status === 'protected';
const activeCloudflarePages = activeDeployment?.providerId === CLOUDFLARE_PAGES_PROVIDER_ID
? activeDeployment.cloudflarePages
: undefined;
const activeCloudflareCustomDomain = activeCloudflarePages?.customDomain;
const deployProvider = getDeployProviderOption(deployProviderId);
const deployProviderLabel = t(deployProvider.labelKey);
const selectedCloudflareZone = cloudflareZones.find((zone) => zone.id === cloudflareZoneId) ?? null;
const normalizedCloudflarePrefix = normalizeCloudflareDomainPrefixInput(cloudflareDomainPrefix);
const cloudflareHostnamePreview =
selectedCloudflareZone && normalizedCloudflarePrefix
? `${normalizedCloudflarePrefix}.${selectedCloudflareZone.name}`
: '';
const deployResultCards: DeployResultCard[] = activeCloudflarePages
? (() => {
const cards: DeployResultCard[] = [];
const pagesDevUrl = activeCloudflarePages.pagesDev?.url || activeDeployedUrl;
if (pagesDevUrl) {
cards.push({
id: 'pages-dev',
label: t('fileViewer.cloudflarePagesDevLinkLabel'),
url: pagesDevUrl,
status: activeCloudflarePages.pagesDev?.status || activeDeployment?.status || 'link-delayed',
message: activeCloudflarePages.pagesDev?.statusMessage,
});
}
if (activeCloudflareCustomDomain?.url) {
cards.push({
id: 'custom-domain',
label: t('fileViewer.cloudflareCustomDomainLinkLabel'),
url: activeCloudflareCustomDomain.url,
status: activeCloudflareCustomDomain.status,
message:
activeCloudflareCustomDomain.errorMessage ||
activeCloudflareCustomDomain.statusMessage,
});
}
return cards;
})()
: activeDeployedUrl
? [{
id: 'default',
label: activeDeploymentProtected
? t('fileViewer.deployLinkProtectedLabel')
: activeDeploymentDelayed
? t('fileViewer.deployLinkPreparingLabel')
: t('fileViewer.deployResultLabel'),
url: activeDeployedUrl,
status: activeDeployment?.status || 'ready',
message: activeDeploymentProtected
? t('fileViewer.deployLinkProtected')
: activeDeploymentDelayed
? t('fileViewer.deployLinkDelayed')
: activeDeployment?.statusMessage,
}]
: [];
const deployActionLabelFor = (providerId: WebDeployProviderId) => {
const option = getDeployProviderOption(providerId);
const label = t(option.labelKey);
const hasActiveDeploymentForProvider = Boolean(deploymentsByProvider[providerId]?.url?.trim());
return hasActiveDeploymentForProvider
? t('fileViewer.redeployToProvider', { provider: label })
: t('fileViewer.deployToProvider', { provider: label });
};
const deployCopyLinks = DEPLOY_PROVIDER_OPTIONS.map((option) => ({
providerId: option.id,
providerLabel: t(option.labelKey),
url: deploymentsByProvider[option.id]?.url?.trim() || '',
})).filter((item) => item.url);
const deployButtonLabel =
deployPhase === 'deploying'
? t('fileViewer.deployingToProvider', { provider: deployProviderLabel })
: deployPhase === 'preparing-link'
? t('fileViewer.preparingPublicLink')
: t('fileViewer.deployToProvider', { provider: deployProviderLabel });
const copyDeployLabel = (url: string) =>
copiedDeployLink === url.trim()
? t('fileViewer.copied')
: t('fileViewer.copyDeployLink');
const copyDeployMenuLabel = (providerLabel: string, url: string) =>
copiedDeployLink === url.trim()
? t('fileViewer.copied')
: `${t('fileViewer.copyDeployLink')} · ${providerLabel}`;
const statusLabelFor = (state: ReturnType<typeof deployResultState>) => {
if (state === 'ready') return t('fileViewer.deployLinkReady');
if (state === 'protected') return t('fileViewer.deployLinkProtectedLabel');
if (state === 'failed') return t('fileViewer.deployLinkFailed');
return t('fileViewer.deployLinkPreparingLabel');
};
return (
<div className="viewer html-viewer">
<div className="viewer-toolbar">
<div className="viewer-toolbar-left">
<button
type="button"
className="icon-only"
onClick={() => setReloadKey((n) => n + 1)}
title={t('fileViewer.reload')}
aria-label={t('fileViewer.reloadAria')}
>
<Icon name="reload" size={14} />
</button>
<div className="viewer-tabs">
<button
className={`viewer-tab ${mode === 'preview' ? 'active' : ''}`}
onClick={() => setMode('preview')}
>
{t('fileViewer.preview')}
</button>
<button
className={`viewer-tab ${mode === 'source' ? 'active' : ''}`}
onClick={() => setMode('source')}
>
{t('fileViewer.source')}
</button>
</div>
{effectiveDeck ? (
<span
className="deck-nav"
role="group"
aria-label={t('fileViewer.slideNavAria')}
>
<button
type="button"
className="icon-only"
onClick={() => postSlide('prev')}
title={t('fileViewer.previousSlide')}
aria-label={t('fileViewer.previousSlide')}
disabled={slideState !== null && slideState.active <= 0}
>
<Icon name="chevron-right" size={14} style={{ transform: 'rotate(180deg)' }} />
</button>
<span className="deck-nav-counter">
{slideState
? `${slideState.active + 1} / ${slideState.count}`
: '— / —'}
</span>
<button
type="button"
className="icon-only"
onClick={() => postSlide('next')}
title={t('fileViewer.nextSlide')}
aria-label={t('fileViewer.nextSlide')}
disabled={
slideState !== null &&
slideState.active >= slideState.count - 1
}
>
<Icon name="chevron-right" size={14} />
</button>
</span>
) : null}
</div>
<div className="viewer-toolbar-actions">
<button
type="button"
className={`viewer-toggle${boardMode ? ' active' : ''}`}
data-testid="board-mode-toggle"
title={t('fileViewer.tweaks')}
aria-pressed={boardMode}
disabled={!boardAvailable}
onClick={() => {
if (boardMode) {
setBoardMode(false);
clearBoardComposer();
return;
}
setManualEditMode(false);
activateBoard(boardTool);
}}
>
<Icon name="tweaks" size={13} />
<span>{t('fileViewer.tweaks')}</span>
<span className="switch" aria-hidden />
</button>
{boardMode ? (
<>
<button
className={`viewer-action${boardTool === 'inspect' ? ' active' : ''}`}
type="button"
data-testid="comment-mode-toggle"
disabled={!boardAvailable}
title="Pick one element"
aria-label="Picker"
aria-pressed={boardTool === 'inspect'}
onClick={() => activateBoard('inspect')}
>
<Icon name="edit" size={13} />
<span>Picker</span>
</button>
<button
className={`viewer-action${boardTool === 'pod' ? ' active' : ''}`}
type="button"
disabled={!boardAvailable}
title="Draw a pod selection"
aria-label="Pods"
aria-pressed={boardTool === 'pod'}
onClick={() => activateBoard('pod')}
>
<Icon name="draw" size={13} />
<span>Pods</span>
</button>
</>
) : null}
<button
className={`viewer-action${inspectMode ? ' active' : ''}`}
type="button"
data-testid="inspect-mode-toggle"
title="Inspect"
aria-pressed={inspectMode}
onClick={() => {
setInspectMode((v) => {
const next = !v;
if (next) {
setBoardMode(false);
clearBoardComposer();
setManualEditMode(false);
setOpenHintBox(true);
}
return next;
});
}}
>
<Icon name="tweaks" size={13} />
<span>Inspect</span>
</button>
<button
className={`viewer-action${manualEditMode ? ' active' : ''}`}
type="button"
data-testid="manual-edit-mode-toggle"
title={t('fileViewer.edit')}
aria-pressed={manualEditMode}
onClick={() => {
if (!manualEditMode) {
setBoardMode(false);
clearBoardComposer();
setInspectMode(false);
setMode('preview');
}
setManualEditMode((value) => !value);
}}
>
<Icon name="edit" size={13} />
<span>{t('fileViewer.edit')}</span>
</button>
<span className="viewer-divider" aria-hidden />
<PreviewViewportControls
viewport={previewViewport}
onViewport={setPreviewViewport}
t={t}
/>
<span className="viewer-divider" aria-hidden />
<button
type="button"
className="icon-only"
onClick={() => bumpZoom(-25)}
title={t('fileViewer.zoomOut')}
aria-label={t('fileViewer.zoomOut')}
>
<Icon name="minus" size={14} />
</button>
<div className="zoom-menu" ref={zoomMenuRef}>
<button
type="button"
className="viewer-action zoom-trigger"
aria-haspopup="menu"
aria-expanded={zoomMenuOpen}
onClick={() => setZoomMenuOpen((v) => !v)}
style={{ minWidth: 64 }}
>
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{zoom}%</span>
<Icon name="chevron-down" size={11} />
</button>
{zoomMenuOpen ? (
<div className="zoom-menu-popover" role="menu">
{[50, 75, 100, 125, 150, 200].map((level) => (
<button
key={level}
type="button"
className={`zoom-menu-item${zoom === level ? ' active' : ''}`}
role="menuitem"
onClick={() => {
setZoom(level);
setZoomMenuOpen(false);
}}
>
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{level}%</span>
{zoom === level ? (
<Icon name="check" size={13} />
) : null}
</button>
))}
</div>
) : null}
</div>
<button
type="button"
className="icon-only"
onClick={() => bumpZoom(25)}
title={t('fileViewer.zoomIn')}
aria-label={t('fileViewer.zoomIn')}
>
<Icon name="plus" size={14} />
</button>
</div>
</div>
{((filePrimaryActions: ReactNode) => (
chromeActionsHost ? createPortal(filePrimaryActions, chromeActionsHost) : filePrimaryActions
))(<>
{showPresent ? (
<div className="present-wrap chrome-present-wrap">
<button
className="chrome-action chrome-action-secondary present-trigger"
aria-haspopup="menu"
aria-expanded={presentMenuOpen}
onClick={() => setPresentMenuOpen((v) => !v)}
>
<Icon name="present" size={13} />
<span>{t('fileViewer.present')}</span>
<Icon name="chevron-down" size={11} />
</button>
{presentMenuOpen ? (
<div className="present-menu" role="menu">
<button role="menuitem" onClick={presentInThisTab}>
<span className="present-icon"><Icon name="eye" size={13} /></span>{' '}
{t('fileViewer.presentInTab')}
</button>
<button role="menuitem" onClick={presentFullscreen}>
<span className="present-icon"><Icon name="play" size={13} /></span>{' '}
{t('fileViewer.presentFullscreen')}
</button>
<button role="menuitem" onClick={presentNewTab}>
<span className="present-icon"><Icon name="share" size={13} /></span>{' '}
{t('fileViewer.presentNewTab')}
</button>
</div>
) : null}
</div>
) : null}
{canShare ? (
<div className="share-menu chrome-share-menu" ref={shareRef}>
<button
className="chrome-action chrome-action-primary"
aria-haspopup="menu"
aria-expanded={shareMenuOpen}
onClick={() => setShareMenuOpen((v) => !v)}
>
<Icon name="share" size={13} />
<span>{t('fileViewer.shareLabel')}</span>
<Icon name="chevron-down" size={11} />
</button>
{shareMenuOpen ? (
<div className="share-menu-popover" role="menu">
<button
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => {
setShareMenuOpen(false);
void exportProjectAsPdf({
deck: effectiveDeck,
fallbackPdf: () => exportAsPdf(source ?? '', exportTitle, { deck: effectiveDeck }),
filePath: file.name,
projectId,
title: exportTitle,
});
}}
>
<span className="share-menu-icon"><Icon name="file" size={14} /></span>
<span>
{effectiveDeck
? t('fileViewer.exportPdfAllSlides')
: t('fileViewer.exportPdf')}
</span>
</button>
<button
type="button"
className="share-menu-item"
role="menuitem"
disabled={!canPptx}
title={
onExportAsPptx
? streaming
? t('fileViewer.exportPptxBusy')
: t('fileViewer.exportPptxHint')
: t('fileViewer.exportPptxNa')
}
onClick={() => {
setShareMenuOpen(false);
if (onExportAsPptx) onExportAsPptx(file.name);
}}
>
<span className="share-menu-icon"><Icon name="present" size={14} /></span>
<span>{t('fileViewer.exportPptx') + '…'}</span>
</button>
<div className="share-menu-divider" />
<button
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => {
setShareMenuOpen(false);
void exportProjectAsZip({
projectId,
filePath: file.name,
fallbackHtml: source ?? '',
fallbackTitle: exportTitle,
});
}}
>
<span className="share-menu-icon"><Icon name="download" size={14} /></span>
<span>{t('fileViewer.exportZip')}</span>
</button>
<button
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => {
setShareMenuOpen(false);
exportAsHtml(source ?? '', exportTitle);
}}
>
<span className="share-menu-icon"><Icon name="file-code" size={14} /></span>
<span>{t('fileViewer.exportHtml')}</span>
</button>
{/* Export as Markdown — pass-through download of the
artifact source with a `.md` extension. No conversion
runs; the file body is identical to the Source view.
Useful for piping the artifact into markdown-aware
tooling (LLM context windows, vault apps). See
issue #279. */}
<button
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => {
setShareMenuOpen(false);
exportAsMd(source ?? '', exportTitle);
}}
>
<span className="share-menu-icon"><Icon name="file" size={14} /></span>
<span>{t('fileViewer.exportMd')}</span>
</button>
<div className="share-menu-divider" />
<button
type="button"
className="share-menu-item"
role="menuitem"
disabled={savingTemplate}
onClick={() => {
openSaveAsTemplateModal();
}}
>
<span className="share-menu-icon"><Icon name="copy" size={14} /></span>
<span>
{savingTemplate
? t('fileViewer.savingTemplate')
: templateNote
? templateNote
: t('fileViewer.saveAsTemplate')}
</span>
</button>
<div className="share-menu-divider" />
{DEPLOY_PROVIDER_OPTIONS.map((option) => (
<button
key={option.id}
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => {
void openDeployModal(option.id);
}}
>
<span className="share-menu-icon"><Icon name="upload" size={14} /></span>
<span>{deployActionLabelFor(option.id)}</span>
</button>
))}
{deployCopyLinks.length > 0 ? (
<div className="share-menu-divider" />
) : null}
{deployCopyLinks.map((item) => (
<button
key={`copy-${item.providerId}`}
type="button"
className="share-menu-item"
role="menuitem"
onClick={() => {
setShareMenuOpen(false);
void copyDeployLink(item.url);
}}
>
<span className="share-menu-icon"><Icon name="copy" size={14} /></span>
<span>{copyDeployMenuLabel(item.providerLabel, item.url)}</span>
</button>
))}
</div>
) : null}
</div>
) : null}
</>)}
<div className="viewer-body" ref={previewBodyRef}>
{source === null ? (
<div className="viewer-empty">{t('fileViewer.loading')}</div>
) : mode === 'preview' ? (
<div
className={`${manualEditMode ? 'manual-edit-workspace' : 'comment-preview-layer'} preview-viewport preview-viewport-${previewViewport}`}
style={previewViewportStyle(previewViewport, previewScale, previewBodySize)}
>
{manualEditMode ? (
<ManualEditPanel
targets={manualEditTargets}
selectedTarget={selectedManualEditTarget}
draft={manualEditDraft}
history={manualEditHistory}
error={manualEditError}
canUndo={manualEditHistory.length > 0}
canRedo={manualEditUndone.length > 0}
busy={manualEditSaving}
onSelectTarget={selectManualEditTarget}
onDraftChange={setManualEditDraft}
onApplyPatch={(patch, label) => {
void applyManualEdit(patch, label);
}}
onError={setManualEditError}
onCancelDraft={() => {
if (selectedManualEditTarget) selectManualEditTarget(selectedManualEditTarget);
}}
onUndo={() => {
void undoManualEdit();
}}
onRedo={() => {
void redoManualEdit();
}}
/>
) : null}
<div className={manualEditMode ? 'manual-edit-canvas' : 'comment-frame-clip'}>
<div
style={previewScaleShellStyle(previewViewport, previewScale)}
>
{useUrlLoadPreview ? (
<iframe
ref={iframeRef}
data-testid="artifact-preview-frame"
data-od-render-mode="url-load"
title={file.name}
sandbox="allow-scripts"
src={previewSrcUrl}
onLoad={syncBridgeModes}
/>
) : (
<iframe
ref={iframeRef}
data-testid="artifact-preview-frame"
data-od-render-mode="srcdoc"
title={file.name}
sandbox="allow-scripts"
srcDoc={srcDoc}
// Re-seeds the iframe-side bridge with the host's
// authoritative inspect override map after each srcdoc
// rebuild, then syncs comment/edit bridge modes.
// URL-loaded iframes have no inspect bridge, so the
// replay handler is intentionally only on the srcDoc
// branch.
onLoad={() => {
replayInspectOverridesToIframe();
syncBridgeModes();
}}
/>
)}
</div>
</div>
{boardMode ? (
<CommentPreviewOverlays
comments={previewComments}
liveTargets={liveCommentTargets}
hoveredTarget={hoveredCommentTarget}
activeTarget={activeCommentTarget}
boardTool={boardTool}
scale={overlayPreviewScale}
strokePoints={strokePoints}
onOpenComment={(comment, snapshot) => {
setActiveCommentTarget(snapshot);
setHoveredCommentTarget(snapshot);
setCommentDraft(comment.note);
setQueuedBoardNotes([]);
}}
/>
) : null}
{boardMode && activeCommentTarget ? (
<BoardComposerPopover
target={activeCommentTarget}
existing={previewComments.find((comment) => comment.elementId === activeCommentTarget.elementId) ?? null}
draft={commentDraft}
notes={queuedBoardNotes}
onDraft={setCommentDraft}
onAddDraft={queueCurrentDraft}
onRemoveQueuedNote={(index) =>
setQueuedBoardNotes((current) => current.filter((_, currentIndex) => currentIndex !== index))
}
onClose={clearBoardComposer}
onSaveComment={savePersistentComment}
onSendBatch={sendBoardBatch}
onRemove={async (commentId) => {
if (!onRemovePreviewComment) return;
await onRemovePreviewComment(commentId);
clearBoardComposer();
}}
sending={sendingBoardBatch || streaming}
t={t}
/>
) : null}
{inspectMode && activeInspectTarget ? (
<InspectPanel
target={activeInspectTarget}
onApply={(prop, value) => {
const target = activeInspectTarget;
setInspectOverrides((current) =>
updateInspectOverride(current, target.elementId, target.selector, prop, value),
);
postInspectSet(target.elementId, target.selector, prop, value);
}}
onResetElement={(elementId) => {
setInspectOverrides((current) => {
if (!(elementId in current)) return current;
const next = { ...current };
delete next[elementId];
return next;
});
postInspectReset(elementId);
setActiveInspectTarget((current) => current && current.elementId === elementId
? current
: current);
}}
onSaveToSource={() => {
void saveInspectToSource();
}}
onClose={() => setActiveInspectTarget(null)}
saving={savingInspect}
savedAt={inspectSavedAt}
error={inspectError}
/>
) : null}
{/*
Hint banner for Inspect / Picker modes. The bridge in
`apps/web/src/runtime/srcdoc.ts` posts `od:comment-targets`
with every element annotated with `data-od-id` /
`data-screen-label`, so `liveCommentTargets.size` is the
authoritative annotation count for the current artifact.
Two states:
- "has targets": the existing copy ("Click any element with
`data-od-id` to tune its style.") for users who just don't
see the crosshair cursor.
- "no targets" (issue #890): a freeform-generated artifact
(e.g. PRD → HTML through a Claude-Code-compatible CLI
without a skill) ships zero `data-od-id` annotations. The
bridge's click handler walks up to <html>, finds nothing,
and bails — clicks no-op silently. The static copy made
this look broken; the empty-state copy explains what's
missing and how to fix it. Mirrored across Inspect and
Picker because the failure surface is identical.
*/}
{(inspectMode || (boardMode && boardTool === 'inspect'))
&& openHintBox
&& !activeInspectTarget
&& !activeCommentTarget ? (
<div className="inspect-empty-hint-container">
{liveCommentTargets.size === 0 ? (
<div
className="inspect-empty-hint"
data-testid="inspect-empty-hint-no-targets"
>
This artifact has no <code>data-od-id</code>{' '}
annotations yet ask the agent to add them to the
sections you want to{' '}
{inspectMode ? 'inspect' : 'comment on'}.
</div>
) : (
<div
className="inspect-empty-hint"
data-testid="inspect-empty-hint"
>
Click any element with <code>data-od-id</code> to{' '}
{inspectMode ? 'tune its style' : 'leave a comment'}.
</div>
)}
<button
type="button"
title="Close Inspect Hint"
aria-label="Close Inspect Hint"
onClick={() => setOpenHintBox(false)}
className="orbit-artifact-ghost"
>
<Icon className="" name="close" size={12} />
</button>
</div>
) : null}
</div>
) : (
<pre className="viewer-source">{source}</pre>
)}
</div>
{inTabPresent && source ? (
<div
className="present-overlay"
role="dialog"
aria-label={t('fileViewer.exitPresentation')}
>
<button
className="present-exit"
onClick={() => setInTabPresent(false)}
aria-label={t('fileViewer.exitPresentation')}
>
<Icon name="close" size={13} /> {t('fileViewer.exitPresentation')}
</button>
{useUrlLoadPreview ? (
<iframe
title="present"
sandbox="allow-scripts"
data-od-render-mode="url-load"
src={previewSrcUrl}
/>
) : (
<iframe
title="present"
sandbox="allow-scripts"
data-od-render-mode="srcdoc"
srcDoc={srcDoc}
/>
)}
</div>
) : null}
{templateModalOpen ? (
<div className="modal-backdrop" role="presentation">
<div className="modal deploy-modal" role="dialog" aria-modal="true">
<div className="modal-head">
<div className="kicker">TEMPLATE</div>
<h2>{t('fileViewer.saveAsTemplate')}</h2>
<p className="subtitle">{t('fileViewer.templateDescPrompt')}</p>
</div>
<div className="deploy-form">
<label className="field" htmlFor={templateNameId}>
<span className="field-label">{t('fileViewer.templateNamePrompt')}</span>
<input
id={templateNameId}
type="text"
value={templateName}
placeholder={t('fileViewer.templateNameDefault')}
autoFocus
onChange={(e) => setTemplateName(e.target.value)}
/>
</label>
<label className="field" htmlFor={templateDescriptionId}>
<span className="field-label">{t('fileViewer.templateDescPrompt')}</span>
<textarea
id={templateDescriptionId}
rows={3}
value={templateDescription}
placeholder={t('fileViewer.optional')}
onChange={(e) => setTemplateDescription(e.target.value)}
/>
</label>
{templateSaveError ? <p className="deploy-error">{templateSaveError}</p> : null}
</div>
<div className="modal-foot">
<button
type="button"
className="ghost-link button-like"
disabled={savingTemplate}
onClick={() => {
setTemplateModalOpen(false);
setTemplateSaveError(null);
}}
>
{t('common.cancel')}
</button>
<button
type="button"
className="viewer-action primary"
disabled={savingTemplate || !templateName.trim()}
onClick={() => {
void handleSaveAsTemplate();
}}
>
{savingTemplate ? t('fileViewer.savingTemplate') : t('common.save')}
</button>
</div>
</div>
</div>
) : null}
{deployModalOpen ? (
<div className="modal-backdrop" role="presentation">
<div className="modal deploy-modal deploy-flow-modal" role="dialog" aria-modal="true">
<div className="modal-head">
<div className="kicker">{deployProviderLabel}</div>
<h2>{t('fileViewer.deployToProvider', { provider: deployProviderLabel })}</h2>
<p className="subtitle">{t('fileViewer.deployModalSubtitle')}</p>
</div>
<div className="deploy-form">
<label className="deploy-provider-field">
<span>{t('fileViewer.deployProviderLabel')}</span>
<select
value={deployProviderId}
onChange={(e) => {
void changeDeployProvider(e.target.value as WebDeployProviderId);
}}
>
{DEPLOY_PROVIDER_OPTIONS.map((option) => (
<option key={option.id} value={option.id}>
{t(option.labelKey)}
</option>
))}
</select>
</label>
<div className="field-label-row">
<label htmlFor="deploy-token">{t(deployProvider.tokenLabelKey)}</label>
<div className="field-label-note">
{deployConfig?.configured ? (
<p className="hint">{t(deployProvider.tokenReuseHintKey, { provider: deployProviderLabel })}</p>
) : null}
{deployProviderId === CLOUDFLARE_PAGES_PROVIDER_ID ? (
<p className="hint">{t('fileViewer.cloudflareApiTokenScopeHint')}</p>
) : null}
<a
href={deployProvider.tokenLink}
target="_blank"
rel="noreferrer noopener"
>
{t(deployProvider.tokenLinkKey)}
</a>
</div>
</div>
<input
id="deploy-token"
type="password"
value={deployToken}
placeholder={t(deployProvider.tokenPlaceholderKey, { provider: deployProviderLabel })}
onChange={(e) => setDeployToken(e.target.value)}
/>
<div className="deploy-config-actions">
<button
type="button"
className="ghost-link button-like"
disabled={savingDeployConfig}
onClick={() => {
void saveDeployConfig();
}}
>
{savingDeployConfig ? t('fileViewer.savingConfig') : t('fileViewer.save')}
</button>
</div>
{deployProviderId === CLOUDFLARE_PAGES_PROVIDER_ID ? (
<>
<div className="deploy-field-grid single-field">
<label>
<span>{t('fileViewer.cloudflareAccountId')}</span>
<input
value={cloudflareAccountId}
onChange={(e) => setCloudflareAccountId(e.target.value)}
/>
<span className="field-hint">{t('fileViewer.cloudflareAccountIdHint')}</span>
</label>
</div>
<div className="deploy-field-grid cloudflare-domain-grid">
<label>
<span>{t('fileViewer.cloudflareDomainPrefixLabel')}</span>
<input
value={cloudflareDomainPrefix}
placeholder={t('fileViewer.cloudflareDomainPrefixPlaceholder')}
onChange={(e) => setCloudflareDomainPrefix(e.target.value)}
/>
</label>
<label>
<span>{t('fileViewer.cloudflareZoneLabel')}</span>
<select
value={cloudflareZoneId}
disabled={cloudflareZonesLoading || (!deployConfig?.configured && !cloudflareZones.length)}
onChange={(e) => setCloudflareZoneId(e.target.value)}
>
{cloudflareZones.length === 0 ? (
<option value="">{t('fileViewer.cloudflareZonePlaceholder')}</option>
) : null}
{cloudflareZones.map((zone) => (
<option key={zone.id} value={zone.id}>
{zone.name}
</option>
))}
</select>
</label>
</div>
<div className="deploy-config-actions secondary">
<button
type="button"
className="ghost-link button-like"
disabled={cloudflareZonesLoading || !deployConfig?.configured}
onClick={() => {
void loadCloudflareZones();
}}
>
{cloudflareZonesLoading ? t('fileViewer.cloudflareZonesLoading') : t('fileViewer.cloudflareZonesRefresh')}
</button>
</div>
{cloudflareZonesError ? (
<p className="deploy-error">{cloudflareZonesError}</p>
) : cloudflareZonesLoading ? (
<p className="hint">{t('fileViewer.cloudflareZonesLoading')}</p>
) : deployConfig?.configured && cloudflareZones.length === 0 ? (
<p className="hint">{t('fileViewer.cloudflareZonesEmpty')}</p>
) : (
<p className="hint">{t('fileViewer.cloudflareCustomDomainHint')}</p>
)}
{cloudflareDomainPrefix.trim() && !isValidCloudflareDomainPrefixInput(cloudflareDomainPrefix) ? (
<p className="deploy-error">{t('fileViewer.cloudflareDomainPrefixInvalid')}</p>
) : cloudflareHostnamePreview ? (
<p className="hint">
{t('fileViewer.cloudflareHostnamePreview', { hostname: cloudflareHostnamePreview })}
</p>
) : null}
</>
) : (
<div className="deploy-field-grid">
<label>
<span>{t('fileViewer.vercelTeamId')}</span>
<input
value={teamId}
placeholder={t('fileViewer.optional')}
onChange={(e) => setTeamId(e.target.value)}
/>
</label>
<label>
<span>{t('fileViewer.vercelTeamSlug')}</span>
<input
value={teamSlug}
placeholder={t('fileViewer.optional')}
onChange={(e) => setTeamSlug(e.target.value)}
/>
</label>
</div>
)}
<p className="hint">{t(deployProvider.previewHintKey)}</p>
{deployError ? <p className="deploy-error">{deployError}</p> : null}
{deployResultCards.length > 0 ? (
<div className={`deploy-result-block ${deployResultState(activeDeployment?.status)}`}>
<div className="deploy-result-summary">
<div className="deploy-result-summary-head">
<div className="deploy-result-label">{t('fileViewer.deployResultLabel')}</div>
<div className={`deploy-result-badge ${deployResultState(activeDeployment?.status)}`}>
{statusLabelFor(deployResultState(activeDeployment?.status))}
</div>
</div>
{activeDeployment?.statusMessage ? (
<p className="deploy-result-message">{activeDeployment.statusMessage}</p>
) : null}
<div className="deploy-result-links">
{deployResultCards.map((card) => {
const state = deployResultState(card.status);
const canRetry = state === 'delayed' || state === 'protected';
const isDisabled = state === 'protected' || state === 'failed';
return (
<div key={card.id} className={`deploy-result-link ${state}`}>
<div className="deploy-result-link-main">
<div className="deploy-result-link-head">
<span className="deploy-result-link-label">{card.label}</span>
<span className={`deploy-result-link-state ${state}`}>{statusLabelFor(state)}</span>
</div>
{card.message ? (
<p className="deploy-result-link-message">{card.message}</p>
) : null}
<a
className="deploy-result-url"
href={card.url}
target="_blank"
rel="noreferrer noopener"
>
{card.url}
</a>
</div>
<div className="deploy-result-actions">
{canRetry ? (
<button
type="button"
className="viewer-action"
disabled={deployPhase === 'preparing-link'}
onClick={() => {
void retryDeploymentLink();
}}
>
{deployPhase === 'preparing-link'
? t('fileViewer.preparingPublicLink')
: t('fileViewer.retryLink')}
</button>
) : null}
<button
type="button"
className="viewer-action"
onClick={() => {
void copyDeployLink(card.url);
}}
>
<Icon name="copy" size={14} />
<span>{copyDeployLabel(card.url)}</span>
</button>
<a
className={`ghost-link ${isDisabled ? 'disabled' : ''}`}
href={isDisabled ? undefined : card.url}
target="_blank"
rel="noreferrer noopener"
aria-disabled={isDisabled}
>
<Icon name="upload" size={14} />
{t('fileViewer.open')}
</a>
</div>
</div>
);
})}
</div>
</div>
</div>
) : null}
</div>
<div className="modal-foot">
<button
type="button"
className="ghost-link button-like"
onClick={() => setDeployModalOpen(false)}
>
{t('common.cancel')}
</button>
<button
type="button"
className="viewer-action primary"
disabled={deploying || savingDeployConfig || deployPhase !== 'idle'}
onClick={() => {
void deployToSelectedProvider();
}}
>
{deployButtonLabel}
</button>
</div>
</div>
</div>
) : null}
</div>
);
}
function baseDirFor(fileName: string): string {
const idx = fileName.lastIndexOf('/');
return idx >= 0 ? fileName.slice(0, idx + 1) : '';
}
function hasRelativeAssetRefs(html: string): boolean {
const attr = /\s(?:src|href)\s*=\s*["']([^"']+)["']/gi;
let match: RegExpExecArray | null;
while ((match = attr.exec(html)) !== null) {
const value = match[1]?.trim();
if (!value) continue;
if (/^(?:https?:|data:|blob:|mailto:|tel:|#|\/)/i.test(value)) continue;
return true;
}
return false;
}
async function inlineRelativeAssets(
html: string,
projectId: string,
fileName: string,
): Promise<string> {
const replacements: Array<Promise<{ from: string; to: string } | null>> = [];
const links = html.match(/<link\b[^>]*>/gi) ?? [];
for (const tag of links) {
const rel = readHtmlAttr(tag, 'rel');
const href = readHtmlAttr(tag, 'href');
if (!rel || !/\bstylesheet\b/i.test(rel) || !href) continue;
replacements.push(
fetchProjectRelativeText(projectId, fileName, href).then((css) =>
css == null
? null
: {
from: tag,
to:
`<style data-od-inline-asset="${escapeHtmlAttr(href)}">\n` +
`${css.replace(/<\/style/gi, '<\\/style')}\n</style>`,
},
),
);
}
const scripts = html.match(/<script\b[^>]*\bsrc\s*=\s*["'][^"']+["'][^>]*>\s*<\/script>/gi) ?? [];
for (const tag of scripts) {
const src = readHtmlAttr(tag, 'src');
if (!src) continue;
replacements.push(
fetchProjectRelativeText(projectId, fileName, src).then((js) => {
if (js == null) return null;
const open = tag.match(/^<script\b[^>]*>/i)?.[0] ?? '<script>';
const attrs = open
.replace(/^<script/i, '')
.replace(/>$/i, '')
.replace(/\ssrc\s*=\s*(['"])[\s\S]*?\1/i, '');
return {
from: tag,
to: `<script${attrs}>\n${js.replace(/<\/script/gi, '<\\/script')}\n</script>`,
};
}),
);
}
const resolved = (await Promise.all(replacements)).filter(
(item): item is { from: string; to: string } => item !== null,
);
return resolved.reduce((next, { from, to }) => next.replace(from, () => to), html);
}
async function fetchProjectRelativeText(
projectId: string,
ownerFileName: string,
assetRef: string,
): Promise<string | null> {
const filePath = resolveProjectRelativePath(ownerFileName, assetRef);
if (!filePath) return null;
try {
const resp = await fetch(projectRawUrl(projectId, filePath));
if (!resp.ok) return null;
return await resp.text();
} catch {
return null;
}
}
function resolveProjectRelativePath(ownerFileName: string, assetRef: string): string | null {
if (/^(?:https?:|data:|blob:|mailto:|tel:|#|\/)/i.test(assetRef)) return null;
try {
const url = new URL(assetRef, `https://od.local/${baseDirFor(ownerFileName)}`);
if (url.origin !== 'https://od.local') return null;
return decodeURIComponent(url.pathname.replace(/^\/+/, ''));
} catch {
return null;
}
}
function readHtmlAttr(tag: string, name: string): string | null {
const match = tag.match(new RegExp(`\\s${name}\\s*=\\s*(['"])([\\s\\S]*?)\\1`, 'i'));
return match?.[2] ?? null;
}
function escapeHtmlAttr(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function ImageViewer({
projectId,
file,
}: {
projectId: string;
file: ProjectFile;
}) {
const t = useT();
const url = `${projectFileUrl(projectId, file.name)}?v=${Math.round(file.mtime)}`;
return (
<div className="viewer image-viewer">
<div className="viewer-toolbar">
<div className="viewer-toolbar-left">
<span className="viewer-meta">
{file.kind === 'sketch'
? t('fileViewer.sketchMeta', { size: humanSize(file.size) })
: t('fileViewer.imageMeta', { size: humanSize(file.size) })}
</span>
</div>
<div className="viewer-toolbar-actions">
<a
className="ghost-link"
href={projectFileUrl(projectId, file.name)}
download={file.name}
>
{t('fileViewer.download')}
</a>
<a
className="ghost-link"
href={projectFileUrl(projectId, file.name)}
target="_blank"
rel="noreferrer noopener"
>
{t('fileViewer.open')}
</a>
</div>
</div>
<div className="viewer-body image-body">
<img alt={file.name} src={url} />
</div>
</div>
);
}
function SketchViewer({
projectId,
file,
}: {
projectId: string;
file: ProjectFile;
}) {
const t = useT();
return (
<div className="viewer image-viewer sketch-viewer">
<div className="viewer-toolbar">
<div className="viewer-toolbar-left">
<span className="viewer-meta">
{t('fileViewer.sketchMeta', { size: humanSize(file.size) })}
</span>
</div>
<FileActions projectId={projectId} file={file} />
</div>
<div className="viewer-body image-body">
<SketchPreview projectId={projectId} file={file} className="viewer-sketch-preview" />
</div>
</div>
);
}
function VideoViewer({
projectId,
file,
}: {
projectId: string;
file: ProjectFile;
}) {
const t = useT();
const url = `${projectFileUrl(projectId, file.name)}?v=${Math.round(file.mtime)}`;
return (
<div className="viewer video-viewer">
<div className="viewer-toolbar">
<div className="viewer-toolbar-left">
<span className="viewer-meta">
{t('fileViewer.videoMeta', { size: humanSize(file.size) })}
</span>
</div>
<FileActions projectId={projectId} file={file} />
</div>
<div className="viewer-body video-body">
<video src={url} controls playsInline preload="metadata" />
</div>
</div>
);
}
function AudioViewer({
projectId,
file,
}: {
projectId: string;
file: ProjectFile;
}) {
const t = useT();
const url = `${projectFileUrl(projectId, file.name)}?v=${Math.round(file.mtime)}`;
return (
<div className="viewer audio-viewer">
<div className="viewer-toolbar">
<div className="viewer-toolbar-left">
<span className="viewer-meta">
{t('fileViewer.audioMeta', { size: humanSize(file.size) })}
</span>
</div>
<FileActions projectId={projectId} file={file} />
</div>
<div className="viewer-body audio-body">
<div className="audio-card">
<Icon name="mic" size={28} />
<div className="audio-card-name">{file.name}</div>
<audio src={url} controls preload="metadata" />
</div>
</div>
</div>
);
}
type SvgViewerMode = 'preview' | 'source';
interface SvgViewerProps {
projectId: string;
file: ProjectFile;
initialMode?: SvgViewerMode;
initialSource?: string | null | undefined;
}
export function SvgViewer({
projectId,
file,
initialMode = 'preview',
initialSource,
}: SvgViewerProps) {
const t = useT();
const [mode, setMode] = useState<SvgViewerMode>(initialMode);
const [source, setSource] = useState<string | null>(initialSource ?? null);
const [loadingSource, setLoadingSource] = useState(false);
const [sourceError, setSourceError] = useState(false);
const [reloadKey, setReloadKey] = useState(0);
const url = `${projectFileUrl(projectId, file.name)}?v=${Math.round(file.mtime)}&r=${reloadKey}`;
useEffect(() => {
if (mode !== 'source') return;
if (initialSource !== undefined && reloadKey === 0) return;
let cancelled = false;
setLoadingSource(true);
setSourceError(false);
void fetchProjectFileText(projectId, file.name, {
cache: 'no-store',
cacheBustKey: `${Math.round(file.mtime)}-${reloadKey}`,
}).then((next) => {
if (cancelled) return;
if (next === null) {
setSource('');
setSourceError(true);
} else {
setSource(next);
}
setLoadingSource(false);
});
return () => {
cancelled = true;
};
}, [projectId, file.name, file.mtime, initialSource, mode, reloadKey]);
return (
<div className="viewer svg-viewer">
<div className="viewer-toolbar">
<div className="viewer-toolbar-left">
<span className="viewer-meta">
{t('fileViewer.imageMeta', { size: humanSize(file.size) })}
</span>
</div>
<div className="viewer-toolbar-actions">
<div className="viewer-tabs">
<button
type="button"
className={`viewer-tab ${mode === 'preview' ? 'active' : ''}`}
aria-pressed={mode === 'preview'}
onClick={() => setMode('preview')}
>
{t('fileViewer.preview')}
</button>
<button
type="button"
className={`viewer-tab ${mode === 'source' ? 'active' : ''}`}
aria-pressed={mode === 'source'}
onClick={() => setMode('source')}
>
{t('fileViewer.source')}
</button>
</div>
<span className="viewer-divider" aria-hidden />
<button
type="button"
className="viewer-action"
onClick={() => setReloadKey((n) => n + 1)}
title={t('fileViewer.reloadDisk')}
>
<Icon name="reload" size={13} />
<span>{t('fileViewer.reload')}</span>
</button>
<a
className="ghost-link"
href={projectFileUrl(projectId, file.name)}
download={file.name}
>
{t('fileViewer.download')}
</a>
<a
className="ghost-link"
href={projectFileUrl(projectId, file.name)}
target="_blank"
rel="noreferrer noopener"
>
{t('fileViewer.open')}
</a>
</div>
</div>
<div className={`viewer-body ${mode === 'preview' ? 'image-body' : ''}`}>
{mode === 'preview' ? (
<img alt={file.name} src={url} />
) : loadingSource ? (
<div className="viewer-empty">{t('fileViewer.loading')}</div>
) : sourceError ? (
<div className="viewer-empty">{t('fileViewer.previewUnavailable')}</div>
) : (
<pre className="viewer-source">{source ?? ''}</pre>
)}
</div>
</div>
);
}
function TextViewer({
projectId,
file,
}: {
projectId: string;
file: ProjectFile;
}) {
const t = useT();
const [text, setText] = useState<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);
const [copied, setCopied] = useState(false);
useEffect(() => {
setText(null);
let cancelled = false;
void fetchProjectFileText(projectId, file.name).then((t) => {
if (!cancelled) setText(t ?? '');
});
return () => {
cancelled = true;
};
}, [projectId, file.name, file.mtime, reloadKey]);
async function copy() {
if (text == null) return;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
// best-effort fallback
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
try {
document.execCommand('copy');
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} finally {
document.body.removeChild(ta);
}
}
}
const displayText = useMemo(
() => (text == null ? null : formatJsonFileTextForDisplay(file, text)),
[file.name, file.mime, text],
);
const lineCount = displayText ? displayText.split('\n').length : 0;
return (
<div className="viewer text-viewer">
<div className="viewer-toolbar">
<div className="viewer-toolbar-left" />
<div className="viewer-toolbar-actions">
<button
type="button"
className="viewer-action"
onClick={() => setReloadKey((n) => n + 1)}
title={t('fileViewer.reloadDisk')}
>
<Icon name="reload" size={13} />
<span>{t('fileViewer.reload')}</span>
</button>
<button
type="button"
className="viewer-action"
disabled
title={t('fileViewer.saveDisabled')}
>
<Icon name="check" size={13} />
<span>{t('fileViewer.save')}</span>
</button>
<button
type="button"
className="viewer-action"
onClick={() => void copy()}
title={t('fileViewer.copyTitle')}
>
<Icon name={copied ? 'check' : 'copy'} size={13} />
<span>{copied ? t('fileViewer.copied') : t('fileViewer.copy')}</span>
</button>
</div>
</div>
<div className="viewer-body">
{text === null ? (
<div className="viewer-empty">{t('fileViewer.loading')}</div>
) : displayText !== null && lineCount > 0 ? (
<CodeWithLines text={displayText} />
) : (
<pre className="viewer-source">{displayText}</pre>
)}
</div>
</div>
);
}
function formatJsonFileTextForDisplay(file: ProjectFile, text: string): string {
if (!isJsonFile(file)) return text;
try {
if (hasPrecisionSensitiveJsonNumberText(text)) return text;
const parsed = JSON.parse(text) as unknown;
if (hasUnsafeJsonNumber(parsed)) return text;
return JSON.stringify(parsed, null, 2);
} catch {
return text;
}
}
function hasPrecisionSensitiveJsonNumberText(text: string): boolean {
let inString = false;
let escaped = false;
const numberTokenPattern = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
for (let i = 0; i < text.length;) {
const char = text[i];
if (inString) {
if (escaped) {
escaped = false;
} else if (char === '\\') {
escaped = true;
} else if (char === '"') {
inString = false;
}
i += 1;
continue;
}
if (char === '"') {
inString = true;
i += 1;
continue;
}
numberTokenPattern.lastIndex = i;
const match = numberTokenPattern.exec(text);
if (!match) {
i += 1;
continue;
}
const token = match[0];
if (isSignedNegativeZeroJsonNumberToken(token)) return true;
if (/[.eE]/.test(token) && isPrecisionSensitiveJsonNumberToken(token)) return true;
i = numberTokenPattern.lastIndex;
}
return false;
}
function isSignedNegativeZeroJsonNumberToken(token: string): boolean {
return /^-0(?:\.0+)?(?:[eE][+-]?\d+)?$/.test(token);
}
function isPrecisionSensitiveJsonNumberToken(token: string): boolean {
const parsed = Number(token);
if (!Number.isFinite(parsed)) return true;
const rendered = JSON.stringify(parsed);
if (!rendered) return true;
const originalValue = parseJsonNumberTokenAsDecimal(token);
const renderedValue = parseJsonNumberTokenAsDecimal(rendered);
return (
!originalValue ||
!renderedValue ||
originalValue.coefficient !== renderedValue.coefficient ||
originalValue.exponent !== renderedValue.exponent
);
}
function parseJsonNumberTokenAsDecimal(token: string): { coefficient: bigint; exponent: number } | null {
const match = /^(-)?(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/.exec(token);
if (!match) return null;
const [, sign, integerPart, fractionPart = '', exponentPart = '0'] = match;
const coefficient = BigInt(`${sign ?? ''}${integerPart}${fractionPart}`);
const exponent = Number(exponentPart) - fractionPart.length;
return normalizeDecimalParts(coefficient, exponent);
}
function normalizeDecimalParts(coefficient: bigint, exponent: number): { coefficient: bigint; exponent: number } {
if (coefficient === 0n) return { coefficient: 0n, exponent: 0 };
let normalizedCoefficient = coefficient;
let normalizedExponent = exponent;
while (normalizedCoefficient % 10n === 0n) {
normalizedCoefficient /= 10n;
normalizedExponent += 1;
}
return { coefficient: normalizedCoefficient, exponent: normalizedExponent };
}
function hasUnsafeJsonNumber(value: unknown): boolean {
if (typeof value === 'number') {
return !Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value));
}
if (Array.isArray(value)) return value.some(hasUnsafeJsonNumber);
if (value && typeof value === 'object') return Object.values(value).some(hasUnsafeJsonNumber);
return false;
}
function isJsonFile(file: ProjectFile): boolean {
return file.name.toLowerCase().endsWith('.json') || file.mime.toLowerCase().startsWith('application/json');
}
function MarkdownViewer({
projectId,
file,
}: {
projectId: string;
file: ProjectFile;
}) {
const t = useT();
const [text, setText] = useState<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);
const [copied, setCopied] = useState(false);
const markdownArticleRef = useRef<HTMLElement | null>(null);
const copyBlockTimerRef = useRef<number | null>(null);
const copiedMarkdownBlockRef = useRef<HTMLElement | null>(null);
const status = file.artifactManifest?.status ?? 'complete';
const isStreaming = status === 'streaming';
const isError = status === 'error';
useEffect(() => {
setText(null);
copiedMarkdownBlockRef.current = null;
if (copyBlockTimerRef.current) {
window.clearTimeout(copyBlockTimerRef.current);
copyBlockTimerRef.current = null;
}
let cancelled = false;
void fetchProjectFileText(projectId, file.name).then((next) => {
if (!cancelled) setText(next ?? '');
});
return () => {
cancelled = true;
};
}, [projectId, file.name, file.mtime, reloadKey]);
useEffect(() => {
return () => {
copiedMarkdownBlockRef.current = null;
if (copyBlockTimerRef.current) {
window.clearTimeout(copyBlockTimerRef.current);
}
};
}, []);
async function copy() {
if (text == null) return;
const didCopy = await copyTextToClipboard(text);
if (didCopy) {
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
}
}
const html = useMemo(() => {
if (text === null) return null;
const renderPartial = MarkdownRenderer.renderPartial ?? renderMarkdownToSafeHtml;
return decorateMarkdownCodeBlocks(renderPartial(text));
}, [text]);
useEffect(() => {
const article = markdownArticleRef.current;
if (!article) return;
ensureMarkdownCodeBlockControls(article, t);
if (copiedMarkdownBlockRef.current?.isConnected) {
setMarkdownCodeBlockCopiedState(copiedMarkdownBlockRef.current, true, t);
}
}, [html, t]);
async function handleMarkdownBodyClick(event: ReactMouseEvent<HTMLElement>) {
const target = event.target;
if (!(target instanceof Element)) return;
const button = target.closest<HTMLButtonElement>(`button[${MARKDOWN_COPY_BLOCK_ATTR}]`);
if (!button) return;
const block = button.closest('.markdown-code-block');
if (!(block instanceof HTMLElement)) return;
const pre = block.querySelector('pre');
if (!pre) return;
const didCopy = await copyTextToClipboard(pre.textContent ?? '');
if (!didCopy) return;
if (copiedMarkdownBlockRef.current && copiedMarkdownBlockRef.current !== block) {
setMarkdownCodeBlockCopiedState(copiedMarkdownBlockRef.current, false, t);
}
copiedMarkdownBlockRef.current = block;
setMarkdownCodeBlockCopiedState(block, true, t);
if (copyBlockTimerRef.current) {
window.clearTimeout(copyBlockTimerRef.current);
}
copyBlockTimerRef.current = window.setTimeout(() => {
if (copiedMarkdownBlockRef.current) {
setMarkdownCodeBlockCopiedState(copiedMarkdownBlockRef.current, false, t);
}
copiedMarkdownBlockRef.current = null;
copyBlockTimerRef.current = null;
}, 1800);
}
return (
<div className="viewer text-viewer">
<div className="viewer-toolbar">
<div className="viewer-toolbar-left">
{isStreaming ? <span className="viewer-meta">{t('fileViewer.markdownStreamingMeta')}</span> : null}
{isError ? <span className="viewer-meta">{t('fileViewer.markdownErrorMeta')}</span> : null}
</div>
<div className="viewer-toolbar-actions">
<button
type="button"
className="viewer-action"
onClick={() => setReloadKey((n) => n + 1)}
title={t('fileViewer.reloadDisk')}
>
<Icon name="reload" size={13} />
<span>{t('fileViewer.reload')}</span>
</button>
<button
type="button"
className="viewer-action"
onClick={() => void copy()}
title={t('fileViewer.copyTitle')}
>
<Icon name={copied ? 'check' : 'copy'} size={13} />
<span>{copied ? t('fileViewer.copied') : t('fileViewer.copy')}</span>
</button>
</div>
</div>
<div className="viewer-body">
{html === null ? (
<div className="viewer-empty">{t('fileViewer.loading')}</div>
) : (
<>
{isStreaming ? <div className="markdown-status">{t('fileViewer.markdownStreamingStatus')}</div> : null}
{isError ? <div className="markdown-status markdown-status-error">{t('fileViewer.markdownErrorStatus')}</div> : null}
{/* Safe by contract: renderMarkdownToSafeHtml escapes raw HTML and rejects unsafe link protocols. */}
<article
ref={markdownArticleRef}
className="markdown-rendered"
onClick={(event) => void handleMarkdownBodyClick(event)}
dangerouslySetInnerHTML={{ __html: html }}
/>
</>
)}
</div>
</div>
);
}
function CodeWithLines({ text }: { text: string }) {
const lines = text.split('\n');
// Trailing newline produces a phantom empty line — keep gutter aligned.
const gutter = lines.map((_, i) => `${i + 1}`).join('\n');
return (
<pre className="code-viewer">
<code className="gutter" aria-hidden>
{gutter}
</code>
<code className="lines">{text}</code>
</pre>
);
}
function humanSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function documentMetaLabel(file: ProjectFile, t: TranslateFn): string {
if (file.kind === 'pdf') return t('fileViewer.pdfMeta');
if (file.kind === 'document') return t('fileViewer.documentMeta');
if (file.kind === 'presentation') return t('fileViewer.presentationMeta');
if (file.kind === 'spreadsheet') return t('fileViewer.spreadsheetMeta');
return t('fileViewer.binaryMeta', { size: humanSize(file.size) });
}