From 4d6c4e9698ee5ae4a63bc1abdd9f06821ec88a6f Mon Sep 17 00:00:00 2001 From: Neil Renicker Date: Mon, 3 Aug 2026 10:45:26 -0500 Subject: [PATCH 1/3] studio: make the file viewer's two chrome rows read as one band The title row and the document toolbar now share a height and an inset, with no rule between them, so they read as one band rather than a header with a strip bolted underneath. The toolbar's controls hug the row above and leave their slack as the gap before the document. A hairline closes the band instead: every chrome row carries it, and a row followed by a toolbar drops it, so the stroke lands under whichever row is last whether or not the format has a toolbar. Leading controls that paint their own box or are bare text carry the glyph inset themselves, keeping one optical column down the left edge. The zoom readout also drops its caret, which crowded the percentage at toolbar size. --- .../components/document-viewers/csv-viewer.tsx | 2 +- .../document-viewers/viewer-toolbar.tsx | 15 ++++++++++++--- .../document-viewers/xlsx-viewer.tsx | 1 + .../src/client/components/file-viewer.tsx | 6 ++++-- .../src/client/components/zoom-controls.tsx | 9 +++++++-- apps/studio/src/client/styles/globals.css | 18 ++++++++++++++++++ 6 files changed, 43 insertions(+), 8 deletions(-) diff --git a/apps/studio/src/client/components/document-viewers/csv-viewer.tsx b/apps/studio/src/client/components/document-viewers/csv-viewer.tsx index 4e6a2e024..1a6412af7 100644 --- a/apps/studio/src/client/components/document-viewers/csv-viewer.tsx +++ b/apps/studio/src/client/components/document-viewers/csv-viewer.tsx @@ -155,7 +155,7 @@ function CsvGrid({ filename, text }: { filename: string; text: string }) { already scales, so a second scale factor inside it would only be a way to disagree with the rest of the app. */} - + {rows.length.toLocaleString()} {rows.length === 1 ? "row" : "rows"} diff --git a/apps/studio/src/client/components/document-viewers/viewer-toolbar.tsx b/apps/studio/src/client/components/document-viewers/viewer-toolbar.tsx index 1194f056a..1212412dd 100644 --- a/apps/studio/src/client/components/document-viewers/viewer-toolbar.tsx +++ b/apps/studio/src/client/components/document-viewers/viewer-toolbar.tsx @@ -231,8 +231,9 @@ export function ViewerRailToggle({ } /** - * The row of document controls beneath the file viewer's own header. Sized in - * the same idiom as that header so the two read as one piece of chrome. + * The row of document controls beneath the file viewer's own header. Same + * height as that header (`h-10` in both) and no rule between them, so the two + * read as one band of chrome. * * Groups are spaced apart rather than ruled apart: the zoom stepper is a single * bounded control and the rest are ghost buttons, so gaps alone carry the @@ -244,7 +245,11 @@ export function ViewerRailToggle({ */ export function ViewerToolbar({ children }: { children: ReactNode }) { return ( -
+ // `data-viewer-toolbar` is the hook `viewer-chrome-stroke` selects on. +
{children}
); @@ -287,6 +292,9 @@ export function ViewerZoomControl({ min} + // Leading the row, the stepper's own box edge is what has to land on the + // filename's column, not a glyph inset like the ghost buttons have. + className="first:ml-1.5" onZoomIn={() => { onZoomChange(steppedZoom({ direction: "in", factor: zoom, max, min })); }} @@ -301,6 +309,7 @@ export function ViewerZoomControl({ min={min} onFit={onFit} onSelect={onZoomChange} + showCaret={false} zoom={zoom} /> } diff --git a/apps/studio/src/client/components/document-viewers/xlsx-viewer.tsx b/apps/studio/src/client/components/document-viewers/xlsx-viewer.tsx index 1f93b2a0f..3cf8f06f2 100644 --- a/apps/studio/src/client/components/document-viewers/xlsx-viewer.tsx +++ b/apps/studio/src/client/components/document-viewers/xlsx-viewer.tsx @@ -129,6 +129,7 @@ export function XlsxViewer({ allowResizeInReadOnly className="absolute inset-0" controller={controller} + rounded={false} showDefaultToolbar={false} /> )} diff --git a/apps/studio/src/client/components/file-viewer.tsx b/apps/studio/src/client/components/file-viewer.tsx index d5bccbc75..ae27639af 100644 --- a/apps/studio/src/client/components/file-viewer.tsx +++ b/apps/studio/src/client/components/file-viewer.tsx @@ -492,11 +492,13 @@ export function FileViewer({ return (
-
+ {/* `h-10 px-2` matches `ViewerToolbar`, which some viewers render right + below this, so the two rows read as one band. */} +
{/* The trigger is the filename, not the space it sits in: as a flex item it shrinks to the text it holds, so the tooltip is anchored under the name rather than under the middle of a header-wide box. */} -
+
diff --git a/apps/studio/src/client/components/zoom-controls.tsx b/apps/studio/src/client/components/zoom-controls.tsx index a7634c92d..1fa4af3c6 100644 --- a/apps/studio/src/client/components/zoom-controls.tsx +++ b/apps/studio/src/client/components/zoom-controls.tsx @@ -48,6 +48,7 @@ export function ZoomLevelMenu({ nested = false, onFit, onSelect, + showCaret = true, zoom, }: { compact?: boolean; @@ -57,6 +58,7 @@ export function ZoomLevelMenu({ nested?: boolean; onFit?: () => void; onSelect: (zoom: number) => void; + showCaret?: boolean; zoom: number; }) { const levels = ( @@ -110,14 +112,14 @@ export function ZoomLevelMenu({ {Math.round(zoom * 100)}% - + {showCaret && } {levels} @@ -184,6 +186,7 @@ export function ZoomStepper() { export function ZoomStepperControl({ canZoomIn = true, canZoomOut = true, + className, onReset, onZoomIn, onZoomOut, @@ -193,6 +196,7 @@ export function ZoomStepperControl({ }: { canZoomIn?: boolean; canZoomOut?: boolean; + className?: string; onReset?: () => void; onZoomIn: () => void; onZoomOut: () => void; @@ -207,6 +211,7 @@ export function ZoomStepperControl({ className={cn( "flex items-stretch divide-x divide-border overflow-hidden bg-card button-sheen text-card-foreground shadow-sm dark:bg-gray-700 dark:text-foreground dark:shadow-sm", compact ? "h-7 rounded-md" : "h-9 rounded-lg", + className, )} > - - Reload - - )} - {fileActions.showCopy && !imageLoadError && ( - - )} - {showOverflowMenu && ( - - - - - - {onExpand && ( - - - Expand - - )} - {fileActions.showDownload && ( - void handleDownload()}> - - Save as… - - )} - {fileActions.showReveal && ( - - - {getRevealInFolderLabel()} - - )} - {hasHeaderMenuActions && hasPreview && ( - + /> + {fileType === "html" && viewMode === "preview" && ( + + + + + Reload + + )} + {fileActions.showCopy && !imageLoadError && ( + + )} + {showOverflowMenu && ( + + + + + + {onExpand && ( + + + Expand + + )} + {fileActions.showDownload && ( + void handleDownload()}> + + Save as… + + )} + {fileActions.showReveal && ( + + + {getRevealInFolderLabel()} + + )} + {hasHeaderMenuActions && hasPreview && ( + + )} + {hasPreview && ( + + + {viewMode === "preview" ? ( - Preview - - + ) : ( - Code - - - - - )} - - - )} - -
-
+ )} + View mode + + + + + + Preview + + + + Code + + + + + )} + + + )} + + } + filename={filename} + filePath={filePath} + mimeType={mimeType} + onClose={onClose} + /> {mediaLoadError ? (
@@ -703,3 +707,73 @@ export function FileViewer({
); } + +/** + * The file viewer's title row: the name on the left, the file's actions and the + * close button on the right. + * + * Shared with the artifact panel's placeholder frame, which wears it while a + * file is still being looked up. That is not a nicety: the two are on screen + * back to back every time a file is opened, so any difference between them + * reads as the name jumping the moment the viewer takes over. + * + * Where the format's viewer opens a toolbar, that row closes the band of chrome + * and this one carries no stroke. The registry is asked rather than the tree, + * so the answer holds from the first frame -- including in the placeholder, + * which knows the path and nothing else yet. + */ +export function FileViewerHeader({ + actions, + filename, + filePath, + mimeType, + onClose, +}: { + actions?: ReactNode; + filename: string; + filePath: string; + mimeType?: string; + onClose: () => void; +}) { + return ( + // `h-10 px-2` matches `ViewerToolbar`, which some viewers render right + // below this, so the two rows read as one band. +
+ {/* The trigger is the filename, not the space it sits in: as a flex + item it shrinks to the text it holds, so the tooltip is anchored + under the name rather than under the middle of a header-wide box. */} +
+ + + + {filename} + + + + {filePath} + + +
+
+ {actions} + +
+
+ ); +} diff --git a/apps/studio/src/client/components/task/view.tsx b/apps/studio/src/client/components/task/view.tsx index b586d606a..cc84912ff 100644 --- a/apps/studio/src/client/components/task/view.tsx +++ b/apps/studio/src/client/components/task/view.tsx @@ -2,7 +2,11 @@ import { openFileViewerAtom, type TaskFileViewerFile, } from "@/client/atoms/task-file-viewer"; -import { FileViewer } from "@/client/components/file-viewer"; +import { + FileViewer, + fileViewerClassName, + FileViewerHeader, +} from "@/client/components/file-viewer"; import { ResizableHandle, ResizablePanel, @@ -19,14 +23,12 @@ import { type StoreId, type Task, } from "@instrument-org/workspace/client"; -import { XIcon } from "@phosphor-icons/react"; import { skipToken, useMutation, useQuery } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { useSetAtom } from "jotai"; import { type ReactNode, useState } from "react"; import { FileLoading } from "../file-loading"; -import { Button } from "../ui/button"; import { TaskBrowserPanel } from "./browser-panel"; import { TaskSidebar, type TaskSidebarMode } from "./sidebar"; @@ -286,8 +288,9 @@ export function TaskView({ /** * The artifact panel's frame for a file that has no viewer mounted in it, * either because the file is still being looked up or because it is not there. - * Matches `FileViewer`'s own frame so the header does not move when one - * replaces the other. + * Built from `FileViewer`'s own frame and header so nothing moves when one + * replaces the other -- including the hairline under the chrome, which sits a + * row lower for a format whose viewer opens a toolbar. */ function ArtifactPanelShell({ children, @@ -298,16 +301,18 @@ function ArtifactPanelShell({ filePath: string; onClose: () => void; }) { + const filename = filePath.slice(filePath.lastIndexOf("/") + 1); + return ( -
-
-
- {filePath} -
- -
+
+ {/* No mime type: that is part of what the panel is still waiting on. + Every format whose viewer opens a toolbar is identified by its + extension anyway, so the chrome band lays out the same either way. */} + {children}
); diff --git a/apps/studio/src/client/styles/globals.css b/apps/studio/src/client/styles/globals.css index dcc356c28..8d06563ce 100644 --- a/apps/studio/src/client/styles/globals.css +++ b/apps/studio/src/client/styles/globals.css @@ -581,21 +581,19 @@ body { } /* Hairline closing the file viewer's chrome band, inset so it costs no height. - * Goes on every chrome row; a row followed by a document toolbar isn't the last - * one, so it drops the stroke and lets that row close the band instead. */ + * Worn by whichever row is last: the document toolbar where the format has one, + * and the title row where it does not. Which of the two that is comes from the + * viewer registry rather than from the shape of the rendered tree, so it does + * not change under a document that is still loading. */ @utility viewer-chrome-stroke { - /* Only the color is themed, so the rule below stays the only one setting - `box-shadow` and wins in both themes. */ + /* Only the color is themed, so `box-shadow` is declared once here rather than + restated per theme. */ --viewer-chrome-stroke-color: rgb(0 0 0 / 0.05); box-shadow: inset 0 -1px 0 0 var(--viewer-chrome-stroke-color); :is(.dark *) & { --viewer-chrome-stroke-color: rgb(255 255 255 / 0.05); } - - &:has(+ * [data-viewer-toolbar]) { - box-shadow: none; - } } /* Very subtle top-to-bottom sheen layered over a button's background color: