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.
This commit is contained in:
Miguel Ángel
2026-09-22 21:57:56 -04:00
parent 6a018defcb
commit b20ff32a40
17 changed files with 654 additions and 250 deletions
@@ -286,9 +286,7 @@ function EditorShellBody({
return (
<div
ref={containerRef}
// Shell canvas is a step LIGHTER than the near-black panel cards so the
// gaps between panels read as visible seams (CapCut-style).
className="flex flex-col flex-1 min-h-0 bg-panel-surface"
className="flex flex-col flex-1 min-h-0"
onKeyDown={handleKeyDown}
tabIndex={-1}
>
@@ -28,7 +28,7 @@ interface StudioLeftPanelsProps {
function PanelColumn({ footer, children }: { footer: ReactNode; children: ReactNode }) {
return (
<div className="flex h-full min-h-0 flex-col overflow-hidden bg-neutral-950">
<div className="flex h-full min-h-0 flex-col overflow-hidden">
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
{footer}
</div>
@@ -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<HTMLElement>(".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}
@@ -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 (
<div className="hf-dock-strip-actions">
<Menu
align="end"
aria-label="Panel menu"
trigger={<IconButton size="sm" aria-label="Panel menu" icon={<DotsThree size={16} />} />}
>
{menuPanels.map((id) => (
<MenuCheckboxItem
key={id}
checked={openPanels.has(id)}
onCheckedChange={() => togglePanel(id)}
>
{PANEL_DEFINITIONS[id].title}
</MenuCheckboxItem>
))}
<MenuSeparator />
<MenuItem onClick={resetLayout}>Reset layout</MenuItem>
</Menu>
<IconButton
size="sm"
aria-label={maximized ? "Restore panel" : "Maximize panel"}
icon={maximized ? <CornersIn size={16} /> : <CornersOut size={16} />}
onClick={() => (maximized ? api.exitMaximized() : api.maximize())}
/>
<IconButton
size="sm"
aria-label="Close group"
icon={<X size={16} />}
onClick={() => api.close()}
/>
</div>
);
}
@@ -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<PanelId, Icon> = {
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 (
<div className="hf-dock-tab">
{TypeIcon ? <TypeIcon className="hf-dock-tab-icon" size={14} aria-hidden /> : null}
<span className="hf-dock-tab-label">{title}</span>
{shown ? (
// Same shape as dockview's own close control: a tab cannot hold a focusable button.
<div
role="button"
tabIndex={-1}
aria-label={`Close ${title}`}
className="hf-dock-tab-close"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
event.stopPropagation();
api.close();
}}
>
<X size={12} aria-hidden />
</div>
) : null}
</div>
);
}
@@ -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(
<Dock.Root projectId="p1">
{PANEL_IDS.map((id) => (
<Dock.Panel key={id} id={id}>
<div>{id}</div>
</Dock.Panel>
))}
</Dock.Root>,
);
});
});
afterEach(() => {
act(() => root?.unmount());
root = null;
document.body.innerHTML = "";
vi.useRealTimers();
});
const tab = (id: PanelId) => host.querySelector<HTMLElement>(`[data-tab-panel-id="${id}"]`);
const stripOf = (id: PanelId) => tab(id)?.closest<HTMLElement>(".dv-tabs-container");
const groupOf = (id: PanelId) => tab(id)?.closest<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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)`);
});
});
+158 -158
View File
@@ -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;
}
}
@@ -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)",
);
});
});
@@ -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<HTMLButtonElement>(".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<string, { dispose: () => 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();
};
}
@@ -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<HTMLElement>(`: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<HTMLElement>(":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<HTMLElement>(".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();
};
}
@@ -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);
});
});
@@ -115,10 +115,10 @@ export function PreviewPane({
return (
<div
ref={containerRef}
// Panel chrome (rounded border) is dropped in fullscreen so the preview
// fills the screen edge-to-edge.
// A recessed well inset in its dock card; dropped in fullscreen so the
// preview fills the screen edge-to-edge.
className={`flex-1 min-h-0 flex flex-col overflow-hidden bg-[var(--studio-preview-bg,var(--color-neutral-950))] ${
isFullscreen ? "" : "rounded-lg border border-neutral-800/50"
isFullscreen ? "" : "m-3 rounded-md border border-neutral-800/50"
}`}
data-studio-fullscreen-target=""
>
@@ -262,7 +262,7 @@ export function TimelinePane({
aria-disabled={timelineDisabled || undefined}
>
<div
className="flex flex-col flex-1 min-h-0 overflow-hidden rounded-lg border border-neutral-800/50 bg-neutral-950"
className="flex flex-col flex-1 min-h-0 overflow-hidden"
onDoubleClick={(e) => {
if ((e.target as HTMLElement).closest("[data-clip]")) return;
if (timelineDisabled) return;
@@ -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<typeof BaseMenu.CheckboxItem>) {
return (
<BaseMenu.CheckboxItem className={cn(itemBase, className)} {...props}>
<span className="truncate">{children}</span>
<span className="flex size-3 shrink-0 items-center justify-center text-accent">
<BaseMenu.CheckboxItemIndicator>✓</BaseMenu.CheckboxItemIndicator>
</span>
</BaseMenu.CheckboxItem>
);
}
/** A hairline between two groups of items. */
export function MenuSeparator({ className, ...props }: StyledProps<typeof BaseMenu.Separator>) {
return <BaseMenu.Separator className={cn("my-1 h-px bg-hairline", className)} {...props} />;
@@ -11,6 +11,7 @@ export { Tooltip } from "./Tooltip";
export {
ContextMenu,
Menu,
MenuCheckboxItem,
MenuItem,
MenuRadioGroup,
MenuRadioItem,
+7 -12
View File
@@ -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
+33
View File
@@ -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"
]
}
]
},