Skip to content

Commit fb48def

Browse files
committed
feat(studio): panel tabs read as one designed control
Dockview's tab strip uses one pill treatment, with a reserved close target and colours only from theme tokens. A paper host overrides those same token names.
1 parent 2cc384f commit fb48def

5 files changed

Lines changed: 247 additions & 12 deletions

File tree

‎packages/studio/src/components/dock/dock.css‎

Lines changed: 153 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,170 @@
22

33
.hf-dock {
44
--dv-group-view-background-color: var(--color-panel-bg);
5-
--dv-tabs-and-actions-container-background-color: var(--color-panel-surface);
6-
--dv-activegroup-visiblepanel-tab-background-color: var(--color-panel-bg);
7-
--dv-activegroup-hiddenpanel-tab-background-color: var(--color-panel-surface);
8-
--dv-inactivegroup-visiblepanel-tab-background-color: var(--color-panel-bg);
9-
--dv-inactivegroup-hiddenpanel-tab-background-color: var(--color-panel-surface);
10-
--dv-activegroup-visiblepanel-tab-color: var(--color-panel-text-1);
11-
--dv-activegroup-hiddenpanel-tab-color: var(--color-panel-text-3);
5+
--dv-tabs-and-actions-container-background-color: var(--panel-strip-bg);
6+
--dv-tabs-and-actions-container-height: 36px;
7+
--dv-activegroup-visiblepanel-tab-background-color: var(--panel-tab-bg-active);
8+
--dv-activegroup-hiddenpanel-tab-background-color: transparent;
9+
--dv-inactivegroup-visiblepanel-tab-background-color: var(--panel-tab-bg-active-muted);
10+
--dv-inactivegroup-hiddenpanel-tab-background-color: transparent;
11+
--dv-activegroup-visiblepanel-tab-color: var(--panel-tab-text-active);
12+
--dv-activegroup-hiddenpanel-tab-color: var(--panel-tab-text);
1213
--dv-inactivegroup-visiblepanel-tab-color: var(--color-panel-text-2);
13-
--dv-inactivegroup-hiddenpanel-tab-color: var(--color-panel-text-4);
14+
--dv-inactivegroup-hiddenpanel-tab-color: var(--panel-tab-text);
1415
--dv-tab-divider-color: var(--color-panel-border);
1516
--dv-separator-border: var(--color-panel-border);
1617
--dv-paneview-header-border-color: var(--color-panel-border);
1718
--dv-sash-color: var(--color-panel-hover);
1819
--dv-active-sash-color: var(--color-panel-accent);
1920
--dv-drag-over-background-color: color-mix(in srgb, var(--color-panel-accent) 12%, transparent);
2021
--dv-drag-over-border-color: var(--color-panel-accent);
21-
--dv-tab-font-size: 11px;
22-
--dv-tabs-and-actions-container-font-size: 11px;
22+
--dv-tab-font-size: 12px;
23+
--dv-tabs-and-actions-container-font-size: 12px;
24+
--dv-tab-margin: 0;
2325
}
2426

2527
.hf-dock .dv-sash:focus-visible {
2628
background-color: var(--dv-active-sash-color);
2729
outline: none;
2830
}
31+
32+
.hf-dock .dv-tabs-and-actions-container {
33+
align-items: center;
34+
height: 36px;
35+
padding: 4px 6px;
36+
background-color: var(--panel-strip-bg);
37+
border-bottom: 1px solid var(--panel-strip-border);
38+
}
39+
40+
.hf-dock .dv-tabs-container {
41+
align-items: center;
42+
gap: 2px;
43+
}
44+
45+
/* Dockview draws a 1px rule from --dv-tab-divider-color on every tab but the
46+
first. The token also strokes menus, so the rule is removed here only. */
47+
.hf-dock .dv-tabs-container .dv-tab::before {
48+
content: none;
49+
}
50+
51+
.hf-dock .dv-tab {
52+
height: 28px;
53+
padding: 0 10px;
54+
border: 1px solid transparent;
55+
border-radius: 8px;
56+
font-weight: 500;
57+
letter-spacing: 0.01em;
58+
line-height: 1;
59+
transition:
60+
background-color 120ms ease-out,
61+
color 120ms ease-out;
62+
}
63+
64+
/* A lone preview or timeline tab is a pill, not a bar stretched across the header. */
65+
.hf-dock .dv-tabs-and-actions-container.dv-single-tab.dv-full-width-single-tab .dv-tabs-container .dv-tab {
66+
flex-grow: 0;
67+
width: auto;
68+
padding: 0 10px;
69+
}
70+
71+
.hf-dock .dv-tab:has(.dv-default-tab-action) {
72+
padding-right: 6px;
73+
}
74+
75+
.hf-dock .dv-groupview.dv-active-group > .dv-tabs-and-actions-container .dv-tabs-container > .dv-tab.dv-inactive-tab:hover,
76+
.hf-dock .dv-groupview.dv-inactive-group > .dv-tabs-and-actions-container .dv-tabs-container > .dv-tab.dv-inactive-tab:hover {
77+
background-color: var(--panel-tab-bg-hover);
78+
color: var(--color-panel-text-2);
79+
}
80+
81+
.hf-dock .dv-groupview.dv-active-group > .dv-tabs-and-actions-container .dv-tabs-container > .dv-tab.dv-active-tab {
82+
background-image: linear-gradient(var(--panel-tab-indicator), var(--panel-tab-indicator));
83+
background-repeat: no-repeat;
84+
background-position: center bottom;
85+
background-size: 16px 2px;
86+
}
87+
88+
.hf-dock .dv-groupview.dv-inactive-group > .dv-tabs-and-actions-container .dv-tabs-container > .dv-tab.dv-active-tab {
89+
background-image: linear-gradient(
90+
color-mix(in srgb, var(--panel-tab-indicator) 50%, transparent),
91+
color-mix(in srgb, var(--panel-tab-indicator) 50%, transparent)
92+
);
93+
background-repeat: no-repeat;
94+
background-position: center bottom;
95+
background-size: 16px 2px;
96+
}
97+
98+
/* Dockview's own focus ring is `outline: 1px solid var(--dv-tab-divider-color) !important`. */
99+
.hf-dock .dv-tab:focus-visible::after,
100+
.hf-dock .dv-tab:has(:focus-visible)::after {
101+
outline: 1.5px solid var(--panel-tab-ring) !important;
102+
outline-offset: -1.5px;
103+
}
104+
105+
.hf-dock .dv-tab .dv-default-tab {
106+
gap: 6px;
107+
}
108+
109+
.hf-dock .dv-tab .dv-default-tab .dv-default-tab-content {
110+
flex-grow: 0;
111+
min-width: 0;
112+
max-width: 140px;
113+
margin-right: 0;
114+
overflow: hidden;
115+
text-overflow: ellipsis;
116+
}
117+
118+
.hf-dock .dv-tab .dv-default-tab .dv-default-tab-action {
119+
position: relative;
120+
visibility: visible;
121+
opacity: 0;
122+
flex: 0 0 20px;
123+
width: 20px;
124+
height: 20px;
125+
padding: 0;
126+
border-radius: 6px;
127+
color: inherit;
128+
}
129+
130+
.hf-dock .dv-tab:hover .dv-default-tab .dv-default-tab-action,
131+
.hf-dock .dv-tab:focus-within .dv-default-tab .dv-default-tab-action,
132+
.hf-dock .dv-tab.dv-active-tab .dv-default-tab .dv-default-tab-action {
133+
opacity: 0.7;
134+
}
135+
136+
.hf-dock .dv-tab .dv-default-tab .dv-default-tab-action:hover {
137+
opacity: 1;
138+
border-radius: 6px;
139+
background-color: transparent;
140+
}
141+
142+
@media (hover: none) {
143+
.hf-dock .dv-tab .dv-default-tab .dv-default-tab-action {
144+
opacity: 0.7;
145+
}
146+
}
147+
148+
/* Filled 11px glyph, replaced by a 12px stroke centered in the 20px target. */
149+
.hf-dock .dv-default-tab-action .dv-svg {
150+
display: none;
151+
}
152+
153+
.hf-dock .dv-default-tab-action::before,
154+
.hf-dock .dv-default-tab-action::after {
155+
content: "";
156+
position: absolute;
157+
left: 4px;
158+
top: 9.25px;
159+
width: 12px;
160+
height: 1.5px;
161+
border-radius: 1px;
162+
background: currentColor;
163+
}
164+
165+
.hf-dock .dv-default-tab-action::before {
166+
transform: rotate(45deg);
167+
}
168+
169+
.hf-dock .dv-default-tab-action::after {
170+
transform: rotate(-45deg);
171+
}

‎packages/studio/src/components/dock/dockAccessibility.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,4 +163,21 @@ describe("dock tab strips", () => {
163163
press(tabs[3] as HTMLElement, "Home");
164164
expect(api.getPanel("design")?.group.activePanel?.id).toBe("design");
165165
});
166+
167+
it("names each closable tab's close control after that panel", () => {
168+
const tabs = tabsOf("design");
169+
const labels = tabs.map((tab) =>
170+
tab.querySelector(".dv-default-tab-action")?.getAttribute("aria-label"),
171+
);
172+
expect(labels).toEqual(["Close Design", "Close Layers", "Close Renders", "Close Variables"]);
173+
expect(tabs[0]?.getAttribute("aria-selected")).toBe("true");
174+
});
175+
176+
it("renames the close control when the panel title changes", () => {
177+
api.getPanel("renders")?.api.setTitle("Renders (2)");
178+
const renders = tabsOf("design").find((tab) => tab.dataset.tabPanelId === "renders");
179+
expect(renders?.querySelector(".dv-default-tab-action")?.getAttribute("aria-label")).toBe(
180+
"Close Renders (2)",
181+
);
182+
});
166183
});

‎packages/studio/src/components/dock/dockAccessibility.ts‎

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,16 +74,49 @@ function onTabKeyDown(event: KeyboardEvent, tab: HTMLElement, api: DockviewApi)
7474
target.focus();
7575
}
7676

77+
/** Dockview's default close control says "Close tab". Name the panel. */
78+
function labelCloseButtons(root: HTMLElement) {
79+
for (const button of root.querySelectorAll<HTMLButtonElement>(".dv-default-tab-action")) {
80+
const title = button
81+
.closest(".dv-default-tab")
82+
?.querySelector(".dv-default-tab-content")
83+
?.textContent?.trim();
84+
if (!title) continue;
85+
const label = `Close ${title}`;
86+
if (button.getAttribute("aria-label") !== label) button.setAttribute("aria-label", label);
87+
}
88+
}
89+
7790
/** Keyboard and ARIA support dockview 8.3 lacks for sashes and tab arrows. Returns a disposer. */
7891
export function installDockAccessibility(api: DockviewApi, root: HTMLElement): () => void {
79-
const decorate = () => decorateSashes(root);
80-
decorate();
92+
const titleDisposers = new Map<string, { dispose: () => void }>();
93+
const watchTitles = () => {
94+
const live = new Set(api.panels.map((panel) => panel.id));
95+
for (const [id, disposer] of titleDisposers) {
96+
if (live.has(id)) continue;
97+
disposer.dispose();
98+
titleDisposers.delete(id);
99+
}
100+
for (const panel of api.panels) {
101+
if (titleDisposers.has(panel.id)) continue;
102+
titleDisposers.set(
103+
panel.id,
104+
panel.api.onDidTitleChange(() => labelCloseButtons(root)),
105+
);
106+
}
107+
};
108+
const decorate = () => {
109+
decorateSashes(root);
110+
labelCloseButtons(root);
111+
watchTitles();
112+
};
81113
const subscriptions = [
82114
api.onDidLayoutChange(decorate),
83115
api.onDidLayoutFromJSON(decorate),
84116
api.onDidAddPanel(decorate),
85117
api.onDidRemovePanel(decorate),
86118
];
119+
decorate();
87120
// Capture phase so the tab handler runs before dockview's own focus-only arrows.
88121
const onKeyDown = (event: KeyboardEvent) => {
89122
if (!(event.target instanceof HTMLElement)) return;
@@ -94,5 +127,7 @@ export function installDockAccessibility(api: DockviewApi, root: HTMLElement): (
94127
return () => {
95128
root.removeEventListener("keydown", onKeyDown, true);
96129
for (const subscription of subscriptions) subscription.dispose();
130+
for (const disposer of titleDisposers.values()) disposer.dispose();
131+
titleDisposers.clear();
97132
};
98133
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { readFileSync } from "node:fs";
2+
import path from "node:path";
3+
import { describe, expect, it } from "vitest";
4+
5+
const dockCss = readFileSync(path.join(import.meta.dirname, "dock.css"), "utf8");
6+
const themeCss = readFileSync(path.join(import.meta.dirname, "../../styles/theme.css"), "utf8");
7+
8+
describe("panel tab contract", () => {
9+
it("paints selection, hover and focus from the panel-tab tokens", () => {
10+
expect(dockCss).toContain(
11+
"--dv-activegroup-visiblepanel-tab-background-color: var(--panel-tab-bg-active)",
12+
);
13+
expect(dockCss).toContain("background-color: var(--panel-tab-bg-hover)");
14+
expect(dockCss).toContain("outline: 1.5px solid var(--panel-tab-ring)");
15+
expect(dockCss).toContain("background-size: 16px 2px");
16+
expect(dockCss).not.toMatch(/#[0-9a-f]{3,8}|rgba?\(/i);
17+
});
18+
19+
it("keeps one dark value per tab token and documents the paper host override", () => {
20+
expect(themeCss).toContain("--panel-tab-bg-active: rgba(255, 255, 255, 0.08)");
21+
expect(themeCss).toContain("--panel-tab-bg-active-muted: rgba(255, 255, 255, 0.04)");
22+
expect(themeCss).toContain("active rgba(0, 0, 0, 0.06)");
23+
expect(themeCss).toContain("hover rgba(0, 0, 0, 0.035)");
24+
expect(themeCss).not.toContain("[data-theme");
25+
expect(themeCss).not.toContain("[data-chrome");
26+
});
27+
});

‎packages/studio/src/styles/theme.css‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,19 @@
248248
--color-panel-media: var(--color-media);
249249
--color-panel-container: var(--color-container);
250250

251+
/* panel tabs. Dark values. A paper host overrides these same names:
252+
strip #f5f5f4, border #18181b14, active rgba(0, 0, 0, 0.06),
253+
muted rgba(0, 0, 0, 0.03), hover rgba(0, 0, 0, 0.035), text #18181b / #71717a. */
254+
--panel-strip-bg: var(--color-panel-surface);
255+
--panel-strip-border: var(--color-panel-border);
256+
--panel-tab-bg-active: rgba(255, 255, 255, 0.08);
257+
--panel-tab-bg-active-muted: rgba(255, 255, 255, 0.04);
258+
--panel-tab-bg-hover: rgba(255, 255, 255, 0.04);
259+
--panel-tab-text-active: var(--color-text-1);
260+
--panel-tab-text: var(--color-text-3);
261+
--panel-tab-indicator: var(--color-accent);
262+
--panel-tab-ring: var(--color-accent);
263+
251264
/* Names Studio's markup invented that no config ever defined, so the classes
252265
* compiled to nothing. Aliased to the token that was meant, rather than
253266
* given values of their own, so there is still one source per decision.

0 commit comments

Comments
 (0)