From b20ff32a40c8c0cd69e4f83d652d571f018e7dc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 22 Sep 2026 21:57:56 -0400 Subject: [PATCH] feat(studio): panel tabs take the decided Current design Replaces the pill, underline and close treatment with the Current design: panels are cards on the shell with one border each, the shown tab is a fill in the card colour that slides to the next tab, only the shown tab carries its type icon and close glyph, strip actions sit on the active group only, and narrow strips scroll with an edge fade. --- .../studio/src/components/EditorShell.tsx | 4 +- .../src/components/StudioLeftPanels.tsx | 2 +- packages/studio/src/components/dock/Dock.tsx | 8 + .../src/components/dock/DockStripActions.tsx | 62 ++++ .../studio/src/components/dock/DockTab.tsx | 70 ++++ .../src/components/dock/DockTabs.test.tsx | 151 +++++++++ packages/studio/src/components/dock/dock.css | 316 +++++++++--------- .../components/dock/dockAccessibility.test.ts | 17 - .../src/components/dock/dockAccessibility.ts | 39 +-- .../studio/src/components/dock/dockTabFill.ts | 75 +++++ .../components/dock/dockTabs.contract.test.ts | 83 ++++- .../studio/src/components/nle/PreviewPane.tsx | 6 +- .../src/components/nle/TimelinePane.tsx | 2 +- packages/studio/src/components/ui/Menu.tsx | 16 + packages/studio/src/components/ui/index.ts | 1 + packages/studio/src/styles/theme.css | 19 +- scripts/contrast-pairs.json | 33 ++ 17 files changed, 654 insertions(+), 250 deletions(-) create mode 100644 packages/studio/src/components/dock/DockStripActions.tsx create mode 100644 packages/studio/src/components/dock/DockTab.tsx create mode 100644 packages/studio/src/components/dock/DockTabs.test.tsx create mode 100644 packages/studio/src/components/dock/dockTabFill.ts diff --git a/packages/studio/src/components/EditorShell.tsx b/packages/studio/src/components/EditorShell.tsx index 64ec21aa4f..f0380125b9 100644 --- a/packages/studio/src/components/EditorShell.tsx +++ b/packages/studio/src/components/EditorShell.tsx @@ -286,9 +286,7 @@ function EditorShellBody({ return (
diff --git a/packages/studio/src/components/StudioLeftPanels.tsx b/packages/studio/src/components/StudioLeftPanels.tsx index 96d80b356a..f1a55d0cc8 100644 --- a/packages/studio/src/components/StudioLeftPanels.tsx +++ b/packages/studio/src/components/StudioLeftPanels.tsx @@ -28,7 +28,7 @@ interface StudioLeftPanelsProps { function PanelColumn({ footer, children }: { footer: ReactNode; children: ReactNode }) { return ( -
+
{children}
{footer}
diff --git a/packages/studio/src/components/dock/Dock.tsx b/packages/studio/src/components/dock/Dock.tsx index 022ae80af3..842045a62e 100644 --- a/packages/studio/src/components/dock/Dock.tsx +++ b/packages/studio/src/components/dock/Dock.tsx @@ -16,6 +16,9 @@ import { } from "dockview-react"; import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences"; import { installDockAccessibility } from "./dockAccessibility"; +import { DockStripActions } from "./DockStripActions"; +import { DockTab } from "./DockTab"; +import { installTabFill } from "./dockTabFill"; import { addRegisteredPanel, applySideMinimums, buildEditLayout } from "./dockLayout"; import { DOCK_PANEL_COMPONENT } from "./dockLayoutSchema"; import { useDockLayoutStore, type DockController, type DockSnapshot } from "./dockLayoutStore"; @@ -150,6 +153,7 @@ function Root({ projectId, children }: { projectId: string | null; children: Rea applySideMinimums(api); const root = api.groups[0]?.element.closest(".hf-dock"); const disposeAccessibility = root ? installDockAccessibility(api, root) : () => {}; + const disposeTabFill = root ? installTabFill(api, root) : () => {}; // The dock spans the window (buildEditLayout sizes against it too); its own box lags a resize. const resizeObserver = new ResizeObserver(() => applySideMinimums(api, window.innerWidth)); if (root) resizeObserver.observe(root); @@ -189,6 +193,7 @@ function Root({ projectId, children }: { projectId: string | null; children: Rea clearTimeout(timer); for (const subscription of subscriptions) subscription.dispose(); disposeAccessibility(); + disposeTabFill(); resizeObserver.disconnect(); useDockLayoutStore.getState().detach(); }; @@ -202,8 +207,11 @@ function Root({ projectId, children }: { projectId: string | null; children: Rea key={projectId ?? ""} className="hf-dock flex-1 min-h-0" components={COMPONENTS} + defaultTabComponent={DockTab} + rightHeaderActionsComponent={DockStripActions} defaultRenderer="always" disableFloatingGroups + disableTabsOverflowList onReady={onReady} /> {children} diff --git a/packages/studio/src/components/dock/DockStripActions.tsx b/packages/studio/src/components/dock/DockStripActions.tsx new file mode 100644 index 0000000000..f3d2401291 --- /dev/null +++ b/packages/studio/src/components/dock/DockStripActions.tsx @@ -0,0 +1,62 @@ +import { useEffect, useState } from "react"; +import { CornersIn, CornersOut, DotsThree, X } from "@phosphor-icons/react"; +import type { IDockviewHeaderActionsProps } from "dockview-react"; +import { IconButton, Menu, MenuCheckboxItem, MenuItem, MenuSeparator } from "../ui"; +import { useDockLayoutStore } from "./dockLayoutStore"; +import { PANEL_DEFINITIONS, isPanelId, panelsInZone } from "./panelRegistry"; + +/** Panel menu, maximise and close group, drawn on the active group's strip only. */ +export function DockStripActions({ + api, + containerApi, + panels, + isGroupActive, +}: IDockviewHeaderActionsProps) { + const [maximized, setMaximized] = useState(() => api.isMaximized()); + useEffect(() => { + const subscription = containerApi.onDidMaximizedGroupChange(() => + setMaximized(api.isMaximized()), + ); + return () => subscription.dispose(); + }, [api, containerApi]); + const openPanels = useDockLayoutStore((state) => state.openPanels); + const togglePanel = useDockLayoutStore((state) => state.togglePanel); + const resetLayout = useDockLayoutStore((state) => state.resetLayout); + if (!isGroupActive) return null; + + const first = panels.find((panel) => isPanelId(panel.id))?.id; + const menuPanels = isPanelId(first) ? panelsInZone(PANEL_DEFINITIONS[first].zone) : []; + return ( +
+ } />} + > + {menuPanels.map((id) => ( + togglePanel(id)} + > + {PANEL_DEFINITIONS[id].title} + + ))} + + Reset layout + + : } + onClick={() => (maximized ? api.exitMaximized() : api.maximize())} + /> + } + onClick={() => api.close()} + /> +
+ ); +} diff --git a/packages/studio/src/components/dock/DockTab.tsx b/packages/studio/src/components/dock/DockTab.tsx new file mode 100644 index 0000000000..439086a387 --- /dev/null +++ b/packages/studio/src/components/dock/DockTab.tsx @@ -0,0 +1,70 @@ +import { useEffect, useState } from "react"; +import { + BracketsCurly, + ChartBarHorizontal, + Code, + FilmSlate, + Image, + Layout, + Monitor, + Presentation, + SlidersHorizontal, + SquaresFour, + Stack, + X, + type Icon, +} from "@phosphor-icons/react"; +import type { IDockviewPanelHeaderProps } from "dockview-react"; +import { isPanelId, type PanelId } from "./panelRegistry"; + +const TAB_ICONS: Record = { + preview: Monitor, + timeline: ChartBarHorizontal, + compositions: Layout, + assets: Image, + code: Code, + catalog: SquaresFour, + design: SlidersHorizontal, + layers: Stack, + renders: FilmSlate, + variables: BracketsCurly, + slideshow: Presentation, +}; + +/** A dock tab. Only the shown tab carries its type icon and close glyph; the rest are labels. */ +export function DockTab({ api }: IDockviewPanelHeaderProps) { + const [title, setTitle] = useState(api.title ?? ""); + const [shown, setShown] = useState(api.isVisible); + useEffect(() => { + const subscriptions = [ + api.onDidTitleChange((event) => setTitle(event.title)), + api.onDidVisibilityChange((event) => setShown(event.isVisible)), + ]; + return () => { + for (const subscription of subscriptions) subscription.dispose(); + }; + }, [api]); + const TypeIcon = shown && isPanelId(api.id) ? TAB_ICONS[api.id] : null; + return ( +
+ {TypeIcon ? : null} + {title} + {shown ? ( + // Same shape as dockview's own close control: a tab cannot hold a focusable button. +
event.stopPropagation()} + onClick={(event) => { + event.stopPropagation(); + api.close(); + }} + > + +
+ ) : null} +
+ ); +} diff --git a/packages/studio/src/components/dock/DockTabs.test.tsx b/packages/studio/src/components/dock/DockTabs.test.tsx new file mode 100644 index 0000000000..3c98d3c5eb --- /dev/null +++ b/packages/studio/src/components/dock/DockTabs.test.tsx @@ -0,0 +1,151 @@ +// @vitest-environment happy-dom + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { Dock } from "./Dock"; +import { useDockLayoutStore } from "./dockLayoutStore"; +import { PANEL_IDS, type PanelId } from "./panelRegistry"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} +} +(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = ResizeObserverStub; + +// happy-dom has no layout: a tab sits at 100px per position and is 90px wide. +const TAB_STEP = 100; +const TAB_WIDTH = 90; +const tabIndex = (element: HTMLElement) => + element.classList.contains("dv-tab") + ? [...(element.parentElement?.querySelectorAll(":scope > .dv-tab") ?? [])].indexOf(element) + : -1; +const layout = { + offsetLeft: { + get(this: HTMLElement) { + return Math.max(tabIndex(this), 0) * TAB_STEP; + }, + }, + offsetWidth: { + get(this: HTMLElement) { + return tabIndex(this) >= 0 ? TAB_WIDTH : 0; + }, + }, +}; +const original = { + offsetLeft: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetLeft"), + offsetWidth: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth"), +}; +beforeAll(() => { + Object.defineProperties(HTMLElement.prototype, { + offsetLeft: { ...layout.offsetLeft, configurable: true }, + offsetWidth: { ...layout.offsetWidth, configurable: true }, + }); +}); +afterAll(() => { + for (const [key, descriptor] of Object.entries(original)) { + if (descriptor) Object.defineProperty(HTMLElement.prototype, key, descriptor); + } +}); + +let root: Root | null = null; +let host: HTMLElement; + +beforeEach(() => { + localStorage.clear(); + vi.useFakeTimers(); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + act(() => { + root?.render( + + {PANEL_IDS.map((id) => ( + +
{id}
+
+ ))} +
, + ); + }); +}); + +afterEach(() => { + act(() => root?.unmount()); + root = null; + document.body.innerHTML = ""; + vi.useRealTimers(); +}); + +const tab = (id: PanelId) => host.querySelector(`[data-tab-panel-id="${id}"]`); +const stripOf = (id: PanelId) => tab(id)?.closest(".dv-tabs-container"); +const groupOf = (id: PanelId) => tab(id)?.closest(".dv-groupview"); + +async function activate(id: PanelId) { + // dockview settles the active panel on a microtask; `act(async)` waits for it. + await act(async () => useDockLayoutStore.getState().activatePanel(id)); +} + +describe("dock tabs", () => { + it("draw the type icon and close glyph on the shown tab only", () => { + for (const id of ["design", "compositions"] as const) { + expect(tab(id)?.querySelector(".hf-dock-tab-icon")).not.toBeNull(); + expect(tab(id)?.querySelector(".hf-dock-tab-close")).not.toBeNull(); + } + for (const id of ["layers", "renders", "variables", "assets", "code", "catalog"] as const) { + expect(tab(id)?.querySelector(".hf-dock-tab-icon")).toBeNull(); + expect(tab(id)?.querySelector(".hf-dock-tab-close")).toBeNull(); + expect(tab(id)?.textContent).toBe(tab(id)?.getAttribute("aria-label")); + } + }); + + it("move the icon to the newly shown tab and drop it from the old one", async () => { + await activate("layers"); + expect(tab("layers")?.querySelector(".hf-dock-tab-icon")).not.toBeNull(); + expect(tab("design")?.querySelector(".hf-dock-tab-icon")).toBeNull(); + expect(tab("design")?.querySelector(".hf-dock-tab-close")).toBeNull(); + }); + + it("name the close control after the panel, and close only that panel", async () => { + const close = tab("design")?.querySelector(".hf-dock-tab-close"); + expect(close?.getAttribute("aria-label")).toBe("Close Design"); + await act(async () => close?.click()); + expect(useDockLayoutStore.getState().openPanels.has("design")).toBe(false); + expect(useDockLayoutStore.getState().openPanels.has("layers")).toBe(true); + }); +}); + +describe("dock strip actions", () => { + const groupsWithActions = () => + [...host.querySelectorAll(".hf-dock-strip-actions")].map((actions) => + actions.closest(".dv-groupview"), + ); + + it("sit on the active group's strip only, and follow the active group", async () => { + await activate("design"); + expect(groupsWithActions()).toEqual([groupOf("design")]); + const labels = [...host.querySelectorAll(".hf-dock-strip-actions button")].map((button) => + button.getAttribute("aria-label"), + ); + expect(labels).toEqual(["Panel menu", "Maximize panel", "Close group"]); + await activate("assets"); + expect(groupsWithActions()).toEqual([groupOf("assets")]); + }); +}); + +describe("dock tab fill", () => { + it("is one element per strip, under the shown tab, and follows a switch", async () => { + for (const strip of host.querySelectorAll(".dv-tabs-container")) { + expect(strip.querySelectorAll(".hf-dock-tab-fill")).toHaveLength(1); + } + const fill = () => stripOf("design")?.querySelector(".hf-dock-tab-fill"); + expect(fill()?.style.transform).toBe("translateX(0px)"); + expect(fill()?.style.width).toBe(`${TAB_WIDTH}px`); + await activate("renders"); + expect(stripOf("design")?.querySelectorAll(".hf-dock-tab-fill")).toHaveLength(1); + expect(fill()?.style.transform).toBe(`translateX(${2 * TAB_STEP}px)`); + }); +}); diff --git a/packages/studio/src/components/dock/dock.css b/packages/studio/src/components/dock/dock.css index eeb334ecff..092a755051 100644 --- a/packages/studio/src/components/dock/dock.css +++ b/packages/studio/src/components/dock/dock.css @@ -1,27 +1,29 @@ @import "dockview-react/dist/styles/dockview.css"; .hf-dock { - --dv-group-view-background-color: var(--color-panel-bg); + --dv-group-view-background-color: var(--panel-card-bg); --dv-tabs-and-actions-container-background-color: var(--panel-strip-bg); --dv-tabs-and-actions-container-height: 36px; - --dv-activegroup-visiblepanel-tab-background-color: var(--panel-tab-bg-active); + /* The fill paints the shown tab, so every tab state is transparent. */ + --dv-activegroup-visiblepanel-tab-background-color: transparent; --dv-activegroup-hiddenpanel-tab-background-color: transparent; - --dv-inactivegroup-visiblepanel-tab-background-color: var(--panel-tab-bg-active-muted); + --dv-inactivegroup-visiblepanel-tab-background-color: transparent; --dv-inactivegroup-hiddenpanel-tab-background-color: transparent; - --dv-activegroup-visiblepanel-tab-color: var(--panel-tab-text-active); - --dv-activegroup-hiddenpanel-tab-color: var(--panel-tab-text); - --dv-inactivegroup-visiblepanel-tab-color: var(--color-panel-text-2); - --dv-inactivegroup-hiddenpanel-tab-color: var(--panel-tab-text); - --dv-tab-divider-color: var(--color-panel-border); - --dv-separator-border: var(--color-panel-border); - --dv-paneview-header-border-color: var(--color-panel-border); - --dv-sash-color: var(--color-panel-hover); + --dv-activegroup-visiblepanel-tab-color: var(--color-text-1); + --dv-activegroup-hiddenpanel-tab-color: var(--color-text-2); + --dv-inactivegroup-visiblepanel-tab-color: var(--color-text-1); + --dv-inactivegroup-hiddenpanel-tab-color: var(--color-text-2); + --dv-tab-divider-color: transparent; + --dv-tab-margin: 0; + --dv-separator-border: transparent; + --dv-paneview-header-border-color: transparent; + --dv-sash-color: transparent; --dv-active-sash-color: var(--color-panel-accent); --dv-drag-over-background-color: color-mix(in srgb, var(--color-panel-accent) 12%, transparent); --dv-drag-over-border-color: var(--color-panel-accent); - --dv-tab-font-size: 12px; - --dv-tabs-and-actions-container-font-size: 12px; - --dv-tab-margin: 0; + --dv-tab-font-size: 12.5px; + --dv-tabs-and-actions-container-font-size: 12.5px; + background-color: var(--panel-shell-bg); } .hf-dock .dv-sash:focus-visible { @@ -29,162 +31,160 @@ outline: none; } +.hf-dock .dv-dockview { + background-color: var(--panel-shell-bg); +} + +/* Cards on the shell: 3px around each group's view makes a 6px gutter. The card owns the only border. */ +.hf-dock .dv-view:has(> .dv-groupview) { + box-sizing: border-box; + padding: 3px; +} + +.hf-dock .dv-groupview { + border: 1px solid var(--panel-card-border); + border-radius: 10px; +} + .hf-dock .dv-tabs-and-actions-container { - align-items: center; - height: 36px; - padding: 4px 6px; - background-color: var(--panel-strip-bg); - border-bottom: 1px solid var(--panel-strip-border); + align-items: flex-end; + padding: 6px 0 0 6px; } -.hf-dock .dv-tabs-container { - align-items: center; - gap: 2px; -} - -/* Dockview draws a 1px rule from --dv-tab-divider-color on every tab but the - first. The token also strokes menus, so the rule is removed here only. */ -.hf-dock .dv-tabs-container .dv-tab::before { - content: none; -} - -.hf-dock .dv-tab { - height: 28px; - padding: 0 10px; - border: 1px solid transparent; - border-radius: 8px; - font-weight: 500; - letter-spacing: 0.01em; - line-height: 1; - transition: - background-color 120ms ease-out, - color 120ms ease-out; -} - -/* A lone preview or timeline tab is a pill, not a bar stretched across the header. */ -.hf-dock - .dv-tabs-and-actions-container.dv-single-tab.dv-full-width-single-tab - .dv-tabs-container - .dv-tab { - flex-grow: 0; - width: auto; - padding: 0 10px; -} - -.hf-dock .dv-tab:has(.dv-default-tab-action) { - padding-right: 6px; -} - -.hf-dock - .dv-groupview.dv-active-group - > .dv-tabs-and-actions-container - .dv-tabs-container - > .dv-tab.dv-inactive-tab:hover, -.hf-dock - .dv-groupview.dv-inactive-group - > .dv-tabs-and-actions-container - .dv-tabs-container - > .dv-tab.dv-inactive-tab:hover { - background-color: var(--panel-tab-bg-hover); - color: var(--color-panel-text-2); -} - -.hf-dock - .dv-groupview.dv-active-group - > .dv-tabs-and-actions-container - .dv-tabs-container - > .dv-tab.dv-active-tab { - background-image: linear-gradient(var(--panel-tab-indicator), var(--panel-tab-indicator)); - background-repeat: no-repeat; - background-position: center bottom; - background-size: 16px 2px; -} - -.hf-dock - .dv-groupview.dv-inactive-group - > .dv-tabs-and-actions-container - .dv-tabs-container - > .dv-tab.dv-active-tab { - background-image: linear-gradient( - color-mix(in srgb, var(--panel-tab-indicator) 50%, transparent), - color-mix(in srgb, var(--panel-tab-indicator) 50%, transparent) - ); - background-repeat: no-repeat; - background-position: center bottom; - background-size: 16px 2px; -} - -/* Dockview's own focus ring is `outline: 1px solid var(--dv-tab-divider-color) !important`. */ -.hf-dock .dv-tab:focus-visible::after, -.hf-dock .dv-tab:has(:focus-visible)::after { - outline: 1.5px solid var(--panel-tab-ring) !important; - outline-offset: -1.5px; -} - -.hf-dock .dv-tab .dv-default-tab { - gap: 6px; -} - -.hf-dock .dv-tab .dv-default-tab .dv-default-tab-content { - flex-grow: 0; +.hf-dock .dv-scrollable { + height: 30px; min-width: 0; - max-width: 140px; - margin-right: 0; - overflow: hidden; - text-overflow: ellipsis; } -.hf-dock .dv-tab .dv-default-tab .dv-default-tab-action { - position: relative; - visibility: visible; - opacity: 0; - flex: 0 0 20px; - width: 20px; - height: 20px; - padding: 0; - border-radius: 6px; - color: inherit; -} - -.hf-dock .dv-tab:hover .dv-default-tab .dv-default-tab-action, -.hf-dock .dv-tab:focus-within .dv-default-tab .dv-default-tab-action, -.hf-dock .dv-tab.dv-active-tab .dv-default-tab .dv-default-tab-action { - opacity: 0.7; -} - -.hf-dock .dv-tab .dv-default-tab .dv-default-tab-action:hover { - opacity: 1; - border-radius: 6px; - background-color: transparent; -} - -@media (hover: none) { - .hf-dock .dv-tab .dv-default-tab .dv-default-tab-action { - opacity: 0.7; - } -} - -/* Filled 11px glyph, replaced by a 12px stroke centered in the 20px target. */ -.hf-dock .dv-default-tab-action .dv-svg { +/* dockview's own scrollbar lane; the strip scrolls by wheel and keeps the shown tab in view. */ +.hf-dock .dv-scrollbar { display: none; } -.hf-dock .dv-default-tab-action::before, -.hf-dock .dv-default-tab-action::after { - content: ""; +.hf-dock .dv-tabs-container { + gap: 2px; + --hf-fade-start: 0px; + --hf-fade-end: 0px; + mask-image: linear-gradient( + to right, + transparent, + black var(--hf-fade-start), + black calc(100% - var(--hf-fade-end)), + transparent + ); +} + +.hf-dock .dv-tabs-container[data-clipped~="start"] { + --hf-fade-start: 12px; +} + +.hf-dock .dv-tabs-container[data-clipped~="end"] { + --hf-fade-end: 12px; +} + +.hf-dock .dv-tab { + z-index: 1; + display: flex; + align-items: center; + height: 30px; + padding: 0 10px; + border-radius: 6px 6px 0 0; + white-space: nowrap; +} + +/* dockview's own state rule paints tabs from these variables, so hover sets them. */ +.hf-dock .dv-tab.dv-inactive-tab:hover { + --dv-activegroup-hiddenpanel-tab-background-color: var(--panel-tab-bg-hover); + --dv-inactivegroup-hiddenpanel-tab-background-color: var(--panel-tab-bg-hover); + --dv-activegroup-hiddenpanel-tab-color: var(--color-text-1); + --dv-inactivegroup-hiddenpanel-tab-color: var(--color-text-1); +} + +.hf-dock .dv-tab:focus-visible { + outline: 1.5px solid var(--color-accent); + outline-offset: -1.5px; +} + +.hf-dock .dv-tab:focus-visible::after, +.hf-dock .dv-tab:has(:focus-visible)::after { + outline: none !important; +} + +.hf-dock-tab-fill { position: absolute; - left: 4px; - top: 9.25px; - width: 12px; - height: 1.5px; - border-radius: 1px; - background: currentColor; + bottom: 0; + left: 0; + height: 30px; + border-radius: 6px 6px 0 0; + background-color: var(--panel-card-bg); + pointer-events: none; } -.hf-dock .dv-default-tab-action::before { - transform: rotate(45deg); +.hf-dock-tab-fill[data-ready] { + transition: + transform 140ms ease-out, + width 140ms ease-out; } -.hf-dock .dv-default-tab-action::after { - transform: rotate(-45deg); +.hf-dock-tab { + display: flex; + align-items: center; + gap: 6px; +} + +.hf-dock-tab-icon { + flex: none; + animation: hf-dock-tab-icon-in 80ms ease-out; +} + +@keyframes hf-dock-tab-icon-in { + from { + opacity: 0; + transform: translateX(-2px); + } +} + +/* Takes its space whenever the tab is shown, so hover never moves the label. */ +.hf-dock-tab-close { + display: flex; + margin-left: 2px; + opacity: 0; +} + +.dv-tab:hover .hf-dock-tab-close, +.dv-tab:focus-visible .hf-dock-tab-close { + opacity: 1; +} + +/* Reserved on every strip so tabs never slide under the actions when focus changes group. */ +.hf-dock .dv-right-actions-container { + flex: none; + align-self: flex-start; + width: 72px; + height: 24px; + margin-right: 4px; +} + +.hf-dock-strip-actions { + display: flex; + opacity: 0; +} + +.dv-groupview:hover .hf-dock-strip-actions { + opacity: 0.5; +} + +.hf-dock-strip-actions:hover, +.hf-dock-strip-actions:focus-within { + opacity: 1; +} + +@media (prefers-reduced-motion: reduce) { + .hf-dock-tab-fill[data-ready] { + transition: none; + } + + .hf-dock-tab-icon { + animation: none; + } } diff --git a/packages/studio/src/components/dock/dockAccessibility.test.ts b/packages/studio/src/components/dock/dockAccessibility.test.ts index 0225d886aa..7e96b58d8f 100644 --- a/packages/studio/src/components/dock/dockAccessibility.test.ts +++ b/packages/studio/src/components/dock/dockAccessibility.test.ts @@ -163,21 +163,4 @@ describe("dock tab strips", () => { press(tabs[3] as HTMLElement, "Home"); expect(api.getPanel("design")?.group.activePanel?.id).toBe("design"); }); - - it("names each closable tab's close control after that panel", () => { - const tabs = tabsOf("design"); - const labels = tabs.map((tab) => - tab.querySelector(".dv-default-tab-action")?.getAttribute("aria-label"), - ); - expect(labels).toEqual(["Close Design", "Close Layers", "Close Renders", "Close Variables"]); - expect(tabs[0]?.getAttribute("aria-selected")).toBe("true"); - }); - - it("renames the close control when the panel title changes", () => { - api.getPanel("renders")?.api.setTitle("Renders (2)"); - const renders = tabsOf("design").find((tab) => tab.dataset.tabPanelId === "renders"); - expect(renders?.querySelector(".dv-default-tab-action")?.getAttribute("aria-label")).toBe( - "Close Renders (2)", - ); - }); }); diff --git a/packages/studio/src/components/dock/dockAccessibility.ts b/packages/studio/src/components/dock/dockAccessibility.ts index e11e62960e..31160e6182 100644 --- a/packages/studio/src/components/dock/dockAccessibility.ts +++ b/packages/studio/src/components/dock/dockAccessibility.ts @@ -74,49 +74,16 @@ function onTabKeyDown(event: KeyboardEvent, tab: HTMLElement, api: DockviewApi) target.focus(); } -/** Dockview's default close control says "Close tab". Name the panel. */ -function labelCloseButtons(root: HTMLElement) { - for (const button of root.querySelectorAll(".dv-default-tab-action")) { - const title = button - .closest(".dv-default-tab") - ?.querySelector(".dv-default-tab-content") - ?.textContent?.trim(); - if (!title) continue; - const label = `Close ${title}`; - if (button.getAttribute("aria-label") !== label) button.setAttribute("aria-label", label); - } -} - /** Keyboard and ARIA support dockview 8.3 lacks for sashes and tab arrows. Returns a disposer. */ export function installDockAccessibility(api: DockviewApi, root: HTMLElement): () => void { - const titleDisposers = new Map void }>(); - const watchTitles = () => { - const live = new Set(api.panels.map((panel) => panel.id)); - for (const [id, disposer] of titleDisposers) { - if (live.has(id)) continue; - disposer.dispose(); - titleDisposers.delete(id); - } - for (const panel of api.panels) { - if (titleDisposers.has(panel.id)) continue; - titleDisposers.set( - panel.id, - panel.api.onDidTitleChange(() => labelCloseButtons(root)), - ); - } - }; - const decorate = () => { - decorateSashes(root); - labelCloseButtons(root); - watchTitles(); - }; + const decorate = () => decorateSashes(root); + decorate(); const subscriptions = [ api.onDidLayoutChange(decorate), api.onDidLayoutFromJSON(decorate), api.onDidAddPanel(decorate), api.onDidRemovePanel(decorate), ]; - decorate(); // Capture phase so the tab handler runs before dockview's own focus-only arrows. const onKeyDown = (event: KeyboardEvent) => { if (!(event.target instanceof HTMLElement)) return; @@ -127,7 +94,5 @@ export function installDockAccessibility(api: DockviewApi, root: HTMLElement): ( return () => { root.removeEventListener("keydown", onKeyDown, true); for (const subscription of subscriptions) subscription.dispose(); - for (const disposer of titleDisposers.values()) disposer.dispose(); - titleDisposers.clear(); }; } diff --git a/packages/studio/src/components/dock/dockTabFill.ts b/packages/studio/src/components/dock/dockTabFill.ts new file mode 100644 index 0000000000..9071e8e97b --- /dev/null +++ b/packages/studio/src/components/dock/dockTabFill.ts @@ -0,0 +1,75 @@ +import type { DockviewApi } from "dockview-react"; + +const TAB_FILL_CLASS = "hf-dock-tab-fill"; + +/** The strip's one fill, created on first use. Dockview inserts tabs before tabs, so it stays first. */ +function fillOf(list: HTMLElement): HTMLElement { + const existing = list.querySelector(`:scope > .${TAB_FILL_CLASS}`); + if (existing) return existing; + const fill = document.createElement("span"); + fill.className = TAB_FILL_CLASS; + fill.setAttribute("aria-hidden", "true"); + list.prepend(fill); + // Placed once without motion; only later moves slide. + requestAnimationFrame(() => fill.setAttribute("data-ready", "")); + return fill; +} + +function placeFill(list: HTMLElement) { + const fill = fillOf(list); + const tab = list.querySelector(":scope > .dv-active-tab"); + fill.hidden = !tab; + if (!tab) return; + fill.style.width = `${tab.offsetWidth}px`; + fill.style.transform = `translateX(${tab.offsetLeft}px)`; +} + +/** Marks which edges of a scrolled strip are clipped, for the CSS edge fade. */ +function markClippedEdges(list: HTMLElement) { + const edges = []; + if (list.scrollLeft > 0) edges.push("start"); + if (list.scrollLeft + list.clientWidth < list.scrollWidth - 1) edges.push("end"); + const value = edges.join(" "); + if (value) list.setAttribute("data-clipped", value); + else list.removeAttribute("data-clipped"); +} + +/** + * Slides one fill per tab strip under its shown tab, and keeps the clipped-edge marks current. + * Returns a disposer. + */ +export function installTabFill(api: DockviewApi, root: HTMLElement): () => void { + const lists = () => root.querySelectorAll(".dv-tabs-container"); + // Tab widths change when a tab gains or loses its icon, after React renders it. + const resizeObserver = new ResizeObserver(() => placeAll()); + function placeAll() { + for (const list of lists()) { + placeFill(list); + markClippedEdges(list); + resizeObserver.observe(list); + for (const tab of list.querySelectorAll(":scope > .dv-tab")) resizeObserver.observe(tab); + } + } + const onScroll = (event: Event) => { + const list = event.target; + if (list instanceof HTMLElement && list.classList.contains("dv-tabs-container")) { + markClippedEdges(list); + } + }; + const subscriptions = [ + api.onDidActivePanelChange(placeAll), + api.onDidAddPanel(placeAll), + api.onDidRemovePanel(placeAll), + api.onDidMovePanel(placeAll), + api.onDidLayoutChange(placeAll), + api.onDidLayoutFromJSON(placeAll), + ]; + // Scroll does not bubble; a capturing listener still sees every strip's. + root.addEventListener("scroll", onScroll, true); + placeAll(); + return () => { + root.removeEventListener("scroll", onScroll, true); + for (const subscription of subscriptions) subscription.dispose(); + resizeObserver.disconnect(); + }; +} diff --git a/packages/studio/src/components/dock/dockTabs.contract.test.ts b/packages/studio/src/components/dock/dockTabs.contract.test.ts index 7fd0ed0198..4e2d4ffe0c 100644 --- a/packages/studio/src/components/dock/dockTabs.contract.test.ts +++ b/packages/studio/src/components/dock/dockTabs.contract.test.ts @@ -2,26 +2,73 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; -const dockCss = readFileSync(path.join(import.meta.dirname, "dock.css"), "utf8"); -const themeCss = readFileSync(path.join(import.meta.dirname, "../../styles/theme.css"), "utf8"); +const dockCss = readFileSync(path.join(import.meta.dirname, "dock.css"), "utf8").replace( + /@import[^;]*;/, + "", +); -describe("panel tab contract", () => { - it("paints selection, hover and focus from the panel-tab tokens", () => { - expect(dockCss).toContain( - "--dv-activegroup-visiblepanel-tab-background-color: var(--panel-tab-bg-active)", - ); - expect(dockCss).toContain("background-color: var(--panel-tab-bg-hover)"); - expect(dockCss).toContain("outline: 1.5px solid var(--panel-tab-ring)"); - expect(dockCss).toContain("background-size: 16px 2px"); - expect(dockCss).not.toMatch(/#[0-9a-f]{3,8}|rgba?\(/i); +/** Each top-level rule's selector and body, with the reduced-motion block kept whole. */ +function rules(css: string): { selector: string; body: string }[] { + const found: { selector: string; body: string }[] = []; + let depth = 0; + let start = 0; + let selector = ""; + for (let index = 0; index < css.length; index++) { + if (css[index] === "{") { + if (depth === 0) { + selector = css + .slice(start, index) + .replace(/\/\*[\s\S]*?\*\//g, "") + .trim(); + start = index + 1; + } + depth++; + } else if (css[index] === "}") { + depth--; + if (depth === 0) { + found.push({ selector, body: css.slice(start, index) }); + start = index + 1; + } + } + } + return found; +} + +const bodyOf = (selector: string) => + rules(dockCss).find((rule) => rule.selector === selector)?.body; + +describe("dock panel strip contract", () => { + it("draws one border per surface: the card's, and no divider, separator or tab outline", () => { + const bordered = rules(dockCss) + .filter((rule) => /(^|[\s;{])border(-(top|right|bottom|left))?\s*:/.test(rule.body)) + .map((rule) => rule.selector); + expect(bordered).toEqual([".hf-dock .dv-groupview"]); + const dock = bodyOf(".hf-dock"); + expect(dock).toContain("--dv-tab-divider-color: transparent"); + expect(dock).toContain("--dv-separator-border: transparent"); }); - it("keeps one dark value per tab token and documents the paper host override", () => { - expect(themeCss).toContain("--panel-tab-bg-active: rgba(255, 255, 255, 0.08)"); - expect(themeCss).toContain("--panel-tab-bg-active-muted: rgba(255, 255, 255, 0.04)"); - expect(themeCss).toContain("active rgba(0, 0, 0, 0.06)"); - expect(themeCss).toContain("hover rgba(0, 0, 0, 0.035)"); - expect(themeCss).not.toContain("[data-theme"); - expect(themeCss).not.toContain("[data-chrome"); + it("slides only transform and width, 140ms ease-out, and reveals the icon in 80ms", () => { + expect(bodyOf(".hf-dock-tab-fill[data-ready]")?.replace(/\s+/g, " ").trim()).toBe( + "transition: transform 140ms ease-out, width 140ms ease-out;", + ); + expect(bodyOf(".hf-dock-tab-icon")).toContain("animation: hf-dock-tab-icon-in 80ms ease-out"); + const animated = rules(dockCss).filter( + (rule) => !rule.selector.startsWith("@") && /transition|animation/.test(rule.body), + ); + expect(animated.map((rule) => rule.selector)).toEqual([ + ".hf-dock-tab-fill[data-ready]", + ".hf-dock-tab-icon", + ]); + }); + + it("turns the slide and the icon reveal off under reduced motion", () => { + const reduced = bodyOf("@media (prefers-reduced-motion: reduce)")?.replace(/\s+/g, " "); + expect(reduced).toContain(".hf-dock-tab-fill[data-ready] { transition: none; }"); + expect(reduced).toContain(".hf-dock-tab-icon { animation: none; }"); + }); + + it("takes every colour from a token", () => { + expect(dockCss).not.toMatch(/#[0-9a-f]{3,8}\b|rgba?\(|hsla?\(|oklch\(/i); }); }); diff --git a/packages/studio/src/components/nle/PreviewPane.tsx b/packages/studio/src/components/nle/PreviewPane.tsx index 6f8bd7c10b..775a508081 100644 --- a/packages/studio/src/components/nle/PreviewPane.tsx +++ b/packages/studio/src/components/nle/PreviewPane.tsx @@ -115,10 +115,10 @@ export function PreviewPane({ return (
diff --git a/packages/studio/src/components/nle/TimelinePane.tsx b/packages/studio/src/components/nle/TimelinePane.tsx index 727fef32fb..39131066e6 100644 --- a/packages/studio/src/components/nle/TimelinePane.tsx +++ b/packages/studio/src/components/nle/TimelinePane.tsx @@ -262,7 +262,7 @@ export function TimelinePane({ aria-disabled={timelineDisabled || undefined} >
{ if ((e.target as HTMLElement).closest("[data-clip]")) return; if (timelineDisabled) return; diff --git a/packages/studio/src/components/ui/Menu.tsx b/packages/studio/src/components/ui/Menu.tsx index a0b36d54b9..b4e22b6007 100644 --- a/packages/studio/src/components/ui/Menu.tsx +++ b/packages/studio/src/components/ui/Menu.tsx @@ -220,6 +220,22 @@ export function MenuRadioItem({ className, children, ...props }: MenuRadioItemPr ); } +/** An on/off row; the tick renders only while `checked`. */ +export function MenuCheckboxItem({ + className, + children, + ...props +}: StyledProps) { + return ( + + {children} + + ✓ + + + ); +} + /** A hairline between two groups of items. */ export function MenuSeparator({ className, ...props }: StyledProps) { return ; diff --git a/packages/studio/src/components/ui/index.ts b/packages/studio/src/components/ui/index.ts index e9f4936e8e..aac1aa360f 100644 --- a/packages/studio/src/components/ui/index.ts +++ b/packages/studio/src/components/ui/index.ts @@ -11,6 +11,7 @@ export { Tooltip } from "./Tooltip"; export { ContextMenu, Menu, + MenuCheckboxItem, MenuItem, MenuRadioGroup, MenuRadioItem, diff --git a/packages/studio/src/styles/theme.css b/packages/studio/src/styles/theme.css index 3b44430f6f..a4af83a5f9 100644 --- a/packages/studio/src/styles/theme.css +++ b/packages/studio/src/styles/theme.css @@ -270,18 +270,13 @@ --color-panel-media: var(--color-media); --color-panel-container: var(--color-container); - /* panel tabs. Dark values. A paper host overrides these same names: - strip #f5f5f4, border #18181b14, active rgba(0, 0, 0, 0.06), - muted rgba(0, 0, 0, 0.03), hover rgba(0, 0, 0, 0.035), text #18181b / #71717a. */ - --panel-strip-bg: var(--color-panel-surface); - --panel-strip-border: var(--color-panel-border); - --panel-tab-bg-active: rgba(255, 255, 255, 0.08); - --panel-tab-bg-active-muted: rgba(255, 255, 255, 0.04); - --panel-tab-bg-hover: rgba(255, 255, 255, 0.04); - --panel-tab-text-active: var(--color-text-1); - --panel-tab-text: var(--color-text-3); - --panel-tab-indicator: var(--color-accent); - --panel-tab-ring: var(--color-accent); + /* Dock panels are cards on the shell. The card owns the only border; its tab + * strip sits below the card body and the shown tab is a fill in the body colour. */ + --panel-shell-bg: var(--color-bg-1); + --panel-card-bg: var(--color-surface); + --panel-card-border: #303034; + --panel-strip-bg: var(--color-bg-0); + --panel-tab-bg-hover: #202023; /* Names Studio's markup invented that no config ever defined, so the classes * compiled to nothing. Aliased to the token that was meant, rather than diff --git a/scripts/contrast-pairs.json b/scripts/contrast-pairs.json index c695cb7015..5f393dccb5 100644 --- a/scripts/contrast-pairs.json +++ b/scripts/contrast-pairs.json @@ -270,6 +270,39 @@ ], "opacity": 0.64, "format": "rgb-channels" + }, + { + "id": "dock-tab-label", + "foreground": "--color-text-2", + "background": ["--panel-strip-bg"], + "role": "text", + "minimum": 4.5, + "sources": [ + "packages/studio/src/components/dock/dock.css:13", + "packages/studio/src/components/dock/dock.css:15" + ] + }, + { + "id": "dock-shown-tab-label", + "foreground": "--color-text-1", + "background": ["--panel-card-bg"], + "role": "text", + "minimum": 4.5, + "sources": [ + "packages/studio/src/components/dock/dock.css:12", + "packages/studio/src/components/dock/dock.css:14" + ] + }, + { + "id": "dock-hovered-tab-label", + "foreground": "--color-text-1", + "background": ["--panel-strip-bg", "--panel-tab-bg-hover"], + "role": "text", + "minimum": 4.5, + "sources": [ + "packages/studio/src/components/dock/dock.css:97", + "packages/studio/src/components/dock/dock.css:99" + ] } ] },