mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
feat(web): redesign top bar — lift Share/Present, zoom dropdown, focus toggle (#1048)
* feat(web): redesign top bar — lift Share/Present, add zoom dropdown, move focus toggle - AppChromeHeader: add #app-chrome-file-actions portal anchor so file viewers can render their primary actions (Present/Share) up in the project chrome instead of cramming a second toolbar row. - HtmlFileViewer / LiveArtifactViewer: portal Present + Share into the top bar via createPortal; Share gets a real chrome-action-primary button. - HtmlFileViewer: replace the 100% reset button with a zoom dropdown (50/75/100/125/150/200) with click-outside + Esc handling. - HtmlFileViewer: move Preview/Source tabs next to Reload (left side, view modes); move Tweaks to the right cluster next to Inspect/Edit. - HtmlFileViewer: showPresent no longer requires deck — any HTML artifact with loaded source can be presented (prototype/slide/regular HTML). - LiveArtifactViewer: add Present (in-tab/fullscreen/new-tab) with iframe ref + previewBodyRef wrapper; in-tab present hides chrome and overlays an exit button (Esc also exits). - ChatPane: add chevron-left collapse icon in chat header (onCollapse prop) so users can hide the chat from where it lives. - FileWorkspace: focus toggle is now icon-only and only renders when chat is collapsed, sitting on the LEFT of the workspace tabs row as a chevron-right expand button — direction matches where chat re-emerges from. - index.css: add chrome-action-primary/secondary, zoom-menu, present-exit-btn, app-chrome-file-actions styling, plus a narrow-width media query that collapses secondary action labels. * fix(web): tests — fall back to inline render when chrome portal anchor missing The Share/Present primary actions render via createPortal into #app-chrome-file-actions, which only exists when AppChromeHeader has mounted. Vitest renders FileViewer / LiveArtifactViewer in isolation, so the portal anchor was absent and the buttons disappeared from the test DOM, breaking 7 share-menu tests. - HtmlFileViewer / LiveArtifactViewer: when chromeActionsHost is null, render the present/share JSX inline instead of returning null. UX is identical in production (host is always present); tests now find the buttons without needing a portal-aware harness. - FileWorkspace tests: rewrite the "focus toggle in tab bar" assertions to reflect the new design — the toggle lives in ChatPane while the chat is open, and FileWorkspace only renders an icon-only expand button on the LEFT of the tab bar once chat is collapsed.
This commit is contained in:
parent
c16e1179fe
commit
1bf7836471
7 changed files with 381 additions and 73 deletions
|
|
@ -9,6 +9,8 @@ interface Props {
|
|||
backLabel?: string;
|
||||
}
|
||||
|
||||
export const APP_CHROME_FILE_ACTIONS_ID = 'app-chrome-file-actions';
|
||||
|
||||
export function AppChromeHeader({ actions, children, onBack, backLabel }: Props) {
|
||||
const t = useT();
|
||||
const resolvedBackLabel = backLabel ?? t('project.backToProjects');
|
||||
|
|
@ -18,7 +20,6 @@ export function AppChromeHeader({ actions, children, onBack, backLabel }: Props)
|
|||
<div className="app-chrome-traffic-space" aria-hidden />
|
||||
<div className="app-chrome-brand" aria-label={t('app.brand')}>
|
||||
<span className="app-chrome-mark" aria-hidden>
|
||||
{/* decorative, parent has aria-label */}
|
||||
<img src="/app-icon.svg" alt="" className="brand-mark-img" draggable={false} />
|
||||
</span>
|
||||
<span className="app-chrome-name">{t('app.brand')}</span>
|
||||
|
|
@ -36,6 +37,7 @@ export function AppChromeHeader({ actions, children, onBack, backLabel }: Props)
|
|||
) : null}
|
||||
{children ? <div className="app-chrome-content">{children}</div> : null}
|
||||
<div className="app-chrome-drag" aria-hidden />
|
||||
<div id={APP_CHROME_FILE_ACTIONS_ID} className="app-chrome-file-actions" />
|
||||
{actions ? <div className="app-chrome-actions">{actions}</div> : null}
|
||||
</header>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ interface Props {
|
|||
projectMetadata?: ProjectMetadata;
|
||||
onProjectMetadataChange?: (metadata: ProjectMetadata) => void;
|
||||
researchAvailable?: boolean;
|
||||
onCollapse?: () => void;
|
||||
}
|
||||
|
||||
type Tab = 'chat' | 'comments';
|
||||
|
|
@ -136,6 +137,7 @@ export function ChatPane({
|
|||
projectMetadata,
|
||||
onProjectMetadataChange,
|
||||
researchAvailable,
|
||||
onCollapse,
|
||||
}: Props) {
|
||||
const t = useT();
|
||||
const logRef = useRef<HTMLDivElement | null>(null);
|
||||
|
|
@ -415,6 +417,18 @@ export function ChatPane({
|
|||
>
|
||||
<Icon name="plus" size={16} />
|
||||
</button>
|
||||
{onCollapse ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-only"
|
||||
data-testid="chat-collapse"
|
||||
title={t('workspace.focusMode')}
|
||||
aria-label={t('workspace.focusMode')}
|
||||
onClick={onCollapse}
|
||||
>
|
||||
<Icon name="chevron-left" size={15} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{tab === 'chat' ? (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { useEffect, useId, useMemo, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent } from 'react';
|
||||
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 } from '../i18n';
|
||||
|
|
@ -389,6 +391,34 @@ export function LiveArtifactViewer({
|
|||
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 previewBodyRef = useRef<HTMLDivElement | null>(null);
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
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);
|
||||
|
|
@ -545,8 +575,78 @@ export function LiveArtifactViewer({
|
|||
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">
|
||||
<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
|
||||
|
|
@ -667,20 +767,23 @@ export function LiveArtifactViewer({
|
|||
/>
|
||||
) : null}
|
||||
{mode === 'preview' ? (
|
||||
<div
|
||||
style={{
|
||||
width: `${100 / previewScale}%`,
|
||||
height: `${100 / previewScale}%`,
|
||||
transform: `scale(${previewScale})`,
|
||||
transformOrigin: '0 0',
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
data-testid="live-artifact-preview-frame"
|
||||
title={liveArtifact.title}
|
||||
sandbox="allow-scripts allow-popups"
|
||||
src={previewUrl}
|
||||
/>
|
||||
<div ref={previewBodyRef} className="live-artifact-preview-frame-host">
|
||||
<div
|
||||
style={{
|
||||
width: `${100 / previewScale}%`,
|
||||
height: `${100 / previewScale}%`,
|
||||
transform: `scale(${previewScale})`,
|
||||
transformOrigin: '0 0',
|
||||
}}
|
||||
>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
data-testid="live-artifact-preview-frame"
|
||||
title={liveArtifact.title}
|
||||
sandbox="allow-scripts allow-popups"
|
||||
src={previewUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="viewer-empty">{t('fileViewer.loading')}</div>
|
||||
|
|
@ -2849,6 +2952,8 @@ function HtmlViewer({
|
|||
const [source, setSource] = useState<string | null>(liveHtml ?? null);
|
||||
const [inlinedSource, setInlinedSource] = useState<string | null>(null);
|
||||
const [zoom, setZoom] = useState(100);
|
||||
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
|
||||
|
|
@ -3062,6 +3167,11 @@ function HtmlViewer({
|
|||
const previewBodyRef = useRef<HTMLDivElement | null>(null);
|
||||
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;
|
||||
|
|
@ -3749,6 +3859,23 @@ function HtmlViewer({
|
|||
};
|
||||
}, [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) => {
|
||||
|
|
@ -4062,7 +4189,7 @@ function HtmlViewer({
|
|||
}
|
||||
}
|
||||
|
||||
const showPresent = effectiveDeck && source !== null;
|
||||
const showPresent = source !== null;
|
||||
const canShare = source !== null;
|
||||
const exportTitle = file.name.replace(/\.html?$/i, '') || file.name;
|
||||
const canPptx = canShare && Boolean(onExportAsPptx) && !streaming;
|
||||
|
|
@ -4173,6 +4300,20 @@ function HtmlViewer({
|
|||
>
|
||||
<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"
|
||||
|
|
@ -4209,6 +4350,8 @@ function HtmlViewer({
|
|||
</button>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="viewer-toolbar-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={`viewer-toggle${boardMode ? ' active' : ''}`}
|
||||
|
|
@ -4230,23 +4373,6 @@ function HtmlViewer({
|
|||
<span>{t('fileViewer.tweaks')}</span>
|
||||
<span className="switch" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
<div className="viewer-toolbar-actions">
|
||||
<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>
|
||||
<span className="viewer-divider" aria-hidden />
|
||||
{boardMode ? (
|
||||
<>
|
||||
<button
|
||||
|
|
@ -4327,15 +4453,40 @@ function HtmlViewer({
|
|||
>
|
||||
<Icon name="minus" size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="viewer-action"
|
||||
onClick={() => setZoom(100)}
|
||||
title={t('fileViewer.resetZoom')}
|
||||
style={{ minWidth: 60 }}
|
||||
>
|
||||
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{zoom}%</span>
|
||||
</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"
|
||||
|
|
@ -4345,11 +4496,15 @@ function HtmlViewer({
|
|||
>
|
||||
<Icon name="plus" size={14} />
|
||||
</button>
|
||||
<span className="viewer-divider" aria-hidden />
|
||||
</div>
|
||||
</div>
|
||||
{((filePrimaryActions: ReactNode) => (
|
||||
chromeActionsHost ? createPortal(filePrimaryActions, chromeActionsHost) : filePrimaryActions
|
||||
))(<>
|
||||
{showPresent ? (
|
||||
<div className="present-wrap">
|
||||
<div className="present-wrap chrome-present-wrap">
|
||||
<button
|
||||
className="viewer-action present-trigger"
|
||||
className="chrome-action chrome-action-secondary present-trigger"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={presentMenuOpen}
|
||||
onClick={() => setPresentMenuOpen((v) => !v)}
|
||||
|
|
@ -4377,13 +4532,14 @@ function HtmlViewer({
|
|||
</div>
|
||||
) : null}
|
||||
{canShare ? (
|
||||
<div className="share-menu" ref={shareRef}>
|
||||
<div className="share-menu chrome-share-menu" ref={shareRef}>
|
||||
<button
|
||||
className="viewer-action primary"
|
||||
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>
|
||||
|
|
@ -4535,8 +4691,7 @@ function HtmlViewer({
|
|||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</>)}
|
||||
<div className="viewer-body" ref={previewBodyRef}>
|
||||
{source === null ? (
|
||||
<div className="viewer-empty">{t('fileViewer.loading')}</div>
|
||||
|
|
|
|||
|
|
@ -514,6 +514,19 @@ export function FileWorkspace({
|
|||
return (
|
||||
<div className="workspace" data-testid="file-workspace">
|
||||
<div className="ws-tabs-shell">
|
||||
{onFocusModeChange && focusMode ? (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-only ws-focus-expand"
|
||||
data-testid="workspace-focus-toggle"
|
||||
aria-pressed={focusMode}
|
||||
title={t('workspace.showChat')}
|
||||
aria-label={t('workspace.showChat')}
|
||||
onClick={() => onFocusModeChange(false)}
|
||||
>
|
||||
<Icon name="chevron-right" size={15} />
|
||||
</button>
|
||||
) : null}
|
||||
<div
|
||||
ref={tabsBarRef}
|
||||
className="ws-tabs-bar"
|
||||
|
|
@ -604,21 +617,6 @@ export function FileWorkspace({
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
{onFocusModeChange ? (
|
||||
<div className="ws-tabs-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="ws-focus-toggle"
|
||||
data-testid="workspace-focus-toggle"
|
||||
aria-pressed={focusMode}
|
||||
title={focusMode ? t('workspace.showChat') : t('workspace.focusMode')}
|
||||
onClick={() => onFocusModeChange(!focusMode)}
|
||||
>
|
||||
<Icon name={focusMode ? 'comment' : 'zoom-in'} size={13} />
|
||||
<span>{focusMode ? t('workspace.showChat') : t('workspace.focusMode')}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="ws-body">
|
||||
{/* Keep the failure banner visible across tab switches so the
|
||||
|
|
|
|||
|
|
@ -1869,6 +1869,7 @@ export function ProjectView({
|
|||
onProjectMetadataChange={(metadata) => {
|
||||
onProjectChange({ ...project, metadata });
|
||||
}}
|
||||
onCollapse={() => setWorkspaceFocused(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="pane" data-testid="chat-pane-loading">
|
||||
|
|
|
|||
|
|
@ -355,6 +355,141 @@ code {
|
|||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.app-chrome-file-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.app-chrome-file-actions:not(:empty) + .app-chrome-actions {
|
||||
margin-left: 4px;
|
||||
padding-left: 8px;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.chrome-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
border-radius: 7px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
.chrome-action:hover:not(:disabled) {
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
.chrome-action-secondary {
|
||||
border-color: var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
.chrome-action-secondary:hover:not(:disabled) {
|
||||
background: var(--bg-subtle);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
.chrome-action-primary {
|
||||
background: var(--text-strong);
|
||||
border-color: var(--text-strong);
|
||||
color: var(--bg);
|
||||
}
|
||||
.chrome-action-primary:hover:not(:disabled) {
|
||||
background: var(--text);
|
||||
border-color: var(--text);
|
||||
color: var(--bg);
|
||||
}
|
||||
.chrome-share-menu .share-menu-popover,
|
||||
.chrome-present-wrap .present-menu {
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
}
|
||||
.zoom-menu { position: relative; display: inline-block; }
|
||||
.zoom-menu .zoom-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.zoom-menu-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: 4px;
|
||||
min-width: 110px;
|
||||
z-index: 40;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
.zoom-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.zoom-menu-item:hover { background: var(--bg-subtle); }
|
||||
.zoom-menu-item.active { color: var(--accent-strong); font-weight: 600; }
|
||||
@media (max-width: 880px) {
|
||||
.chrome-action-secondary span { display: none; }
|
||||
.chrome-action-secondary { padding: 0 10px; }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.app-chrome-content { display: none; }
|
||||
}
|
||||
.viewer.is-tab-present .viewer-toolbar,
|
||||
.viewer.is-tab-present .live-artifact-refresh-notice {
|
||||
display: none;
|
||||
}
|
||||
.viewer.is-tab-present .viewer-body {
|
||||
inset: 0;
|
||||
}
|
||||
.viewer .present-exit-btn {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 50;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow-sm);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.viewer .present-exit-btn:hover {
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text);
|
||||
}
|
||||
.live-artifact-preview-frame-host {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
}
|
||||
.live-artifact-preview-frame-host:fullscreen {
|
||||
background: var(--bg);
|
||||
}
|
||||
.app-project-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ describe('FileWorkspace upload input', () => {
|
|||
expect(markup).not.toContain('accept=');
|
||||
});
|
||||
|
||||
it('keeps focus mode controls in the workspace tab bar', () => {
|
||||
it('hides the workspace focus control while the chat pane is open', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<FileWorkspace
|
||||
projectId="project-1"
|
||||
|
|
@ -128,11 +128,12 @@ describe('FileWorkspace upload input', () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('data-testid="workspace-focus-toggle"');
|
||||
expect(markup).toContain('Focus workspace');
|
||||
// While chat is visible the collapse trigger lives in ChatPane.
|
||||
// FileWorkspace only renders an expand control once chat is hidden.
|
||||
expect(markup).not.toContain('data-testid="workspace-focus-toggle"');
|
||||
});
|
||||
|
||||
it('keeps the focus mode action outside the horizontally scrollable tablist', () => {
|
||||
it('renders the expand control on the LEFT of the tab bar while focused', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<FileWorkspace
|
||||
projectId="project-1"
|
||||
|
|
@ -142,15 +143,17 @@ describe('FileWorkspace upload input', () => {
|
|||
isDeck={false}
|
||||
tabsState={{ tabs: [], active: null }}
|
||||
onTabsStateChange={vi.fn()}
|
||||
focusMode={false}
|
||||
focusMode
|
||||
onFocusModeChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('class="ws-tabs-shell"');
|
||||
expect(markup).toContain('class="ws-tabs-actions"');
|
||||
expect(markup).toContain('data-testid="workspace-focus-toggle"');
|
||||
// The expand control sits before the tabs bar (left side) so its
|
||||
// direction matches where the chat pane re-emerges from.
|
||||
expect(markup).toMatch(
|
||||
/<div class="ws-tabs-bar" role="tablist"[^>]*>[\s\S]*?<\/div><div class="ws-tabs-actions">/,
|
||||
/<div class="ws-tabs-shell">\s*<button[^>]*data-testid="workspace-focus-toggle"[\s\S]*?<\/button>\s*<div class="ws-tabs-bar"/,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue