@hyperframes/studio 0.8.66 → 0.8.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,28 +1,197 @@
1
1
  @import "dockview-react/dist/styles/dockview.css";
2
2
 
3
3
  .hf-dock {
4
- --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);
12
- --dv-inactivegroup-visiblepanel-tab-color: var(--color-panel-text-2);
13
- --dv-inactivegroup-hiddenpanel-tab-color: var(--color-panel-text-4);
14
- --dv-tab-divider-color: var(--color-panel-border);
15
- --dv-separator-border: var(--color-panel-border);
16
- --dv-paneview-header-border-color: var(--color-panel-border);
17
- --dv-sash-color: var(--color-panel-hover);
4
+ --dv-group-view-background-color: var(--panel-card-bg);
5
+ --dv-tabs-and-actions-container-background-color: var(--panel-strip-bg);
6
+ --dv-tabs-and-actions-container-height: 36px;
7
+ /* The fill paints the shown tab, so every tab state is transparent. */
8
+ --dv-activegroup-visiblepanel-tab-background-color: transparent;
9
+ --dv-activegroup-hiddenpanel-tab-background-color: transparent;
10
+ --dv-inactivegroup-visiblepanel-tab-background-color: transparent;
11
+ --dv-inactivegroup-hiddenpanel-tab-background-color: transparent;
12
+ --dv-activegroup-visiblepanel-tab-color: var(--color-text-1);
13
+ --dv-activegroup-hiddenpanel-tab-color: var(--color-text-2);
14
+ --dv-inactivegroup-visiblepanel-tab-color: var(--color-text-1);
15
+ --dv-inactivegroup-hiddenpanel-tab-color: var(--color-text-2);
16
+ --dv-tab-divider-color: transparent;
17
+ --dv-tab-margin: 0;
18
+ --dv-separator-border: transparent;
19
+ --dv-paneview-header-border-color: transparent;
20
+ --dv-sash-color: transparent;
18
21
  --dv-active-sash-color: var(--color-panel-accent);
19
22
  --dv-drag-over-background-color: color-mix(in srgb, var(--color-panel-accent) 12%, transparent);
20
23
  --dv-drag-over-border-color: var(--color-panel-accent);
21
- --dv-tab-font-size: 11px;
22
- --dv-tabs-and-actions-container-font-size: 11px;
24
+ --dv-tab-font-size: 12.5px;
25
+ --dv-tabs-and-actions-container-font-size: 12.5px;
26
+ background-color: var(--panel-shell-bg);
23
27
  }
24
28
 
25
29
  .hf-dock .dv-sash:focus-visible {
26
30
  background-color: var(--dv-active-sash-color);
27
31
  outline: none;
28
32
  }
33
+
34
+ .hf-dock .dv-dockview {
35
+ background-color: var(--panel-shell-bg);
36
+ }
37
+
38
+ /* Cards on the shell: 3px around each group's view makes a 6px gutter. The card owns the only border. */
39
+ .hf-dock .dv-view:has(> .dv-groupview) {
40
+ box-sizing: border-box;
41
+ padding: 3px;
42
+ }
43
+
44
+ .hf-dock .dv-groupview {
45
+ border: 1px solid var(--panel-card-border);
46
+ border-radius: 10px;
47
+ }
48
+
49
+ .hf-dock .dv-tabs-and-actions-container {
50
+ align-items: flex-end;
51
+ padding: 6px 0 0 4px;
52
+ }
53
+
54
+ .hf-dock .dv-scrollable {
55
+ height: 30px;
56
+ min-width: 0;
57
+ }
58
+
59
+ /* dockview's own scrollbar lane; the strip scrolls by wheel and keeps the shown tab in view. */
60
+ .hf-dock .dv-scrollbar {
61
+ display: none;
62
+ }
63
+
64
+ .hf-dock .dv-tabs-container {
65
+ --hf-fade-start: 0px;
66
+ --hf-fade-end: 0px;
67
+ }
68
+
69
+ /* A mask adds a compositor render pass; only a strip that is actually clipped needs one. */
70
+ .hf-dock .dv-tabs-container[data-clipped] {
71
+ mask-image: linear-gradient(
72
+ to right,
73
+ transparent,
74
+ black var(--hf-fade-start),
75
+ black calc(100% - var(--hf-fade-end)),
76
+ transparent
77
+ );
78
+ }
79
+
80
+ .hf-dock .dv-tabs-container[data-clipped~="start"] {
81
+ --hf-fade-start: 12px;
82
+ }
83
+
84
+ .hf-dock .dv-tabs-container[data-clipped~="end"] {
85
+ --hf-fade-end: 12px;
86
+ }
87
+
88
+ .hf-dock .dv-tab {
89
+ z-index: 1;
90
+ display: flex;
91
+ align-items: center;
92
+ height: 30px;
93
+ padding: 0 6px;
94
+ border-radius: 6px 6px 0 0;
95
+ white-space: nowrap;
96
+ }
97
+
98
+ /* dockview's own state rule paints tabs from these variables, so hover sets them. */
99
+ .hf-dock .dv-tab.dv-inactive-tab:hover {
100
+ --dv-activegroup-hiddenpanel-tab-background-color: var(--panel-tab-bg-hover);
101
+ --dv-inactivegroup-hiddenpanel-tab-background-color: var(--panel-tab-bg-hover);
102
+ --dv-activegroup-hiddenpanel-tab-color: var(--color-text-1);
103
+ --dv-inactivegroup-hiddenpanel-tab-color: var(--color-text-1);
104
+ }
105
+
106
+ .hf-dock .dv-tab:focus-visible {
107
+ outline: 1.5px solid var(--color-accent);
108
+ outline-offset: -1.5px;
109
+ }
110
+
111
+ .hf-dock .dv-tab:focus-visible::after,
112
+ .hf-dock .dv-tab:has(:focus-visible)::after {
113
+ outline: none !important;
114
+ }
115
+
116
+ .hf-dock-tab-fill {
117
+ position: absolute;
118
+ bottom: 0;
119
+ left: 0;
120
+ height: 30px;
121
+ border-radius: 6px 6px 0 0;
122
+ background-color: var(--panel-card-bg);
123
+ pointer-events: none;
124
+ }
125
+
126
+ .hf-dock-tab-fill[data-ready] {
127
+ transition:
128
+ transform 140ms ease-out,
129
+ width 140ms ease-out;
130
+ }
131
+
132
+ /* Fills dockview's tab part, so the label and icon centre in the tab's full height. */
133
+ .hf-dock-tab {
134
+ display: flex;
135
+ align-items: center;
136
+ gap: 4px;
137
+ height: 100%;
138
+ }
139
+
140
+ .hf-dock .dv-inactive-tab .hf-dock-tab-icon,
141
+ .hf-dock .dv-inactive-tab .hf-dock-tab-close {
142
+ display: none;
143
+ }
144
+
145
+ .hf-dock-tab-icon {
146
+ flex: none;
147
+ animation: hf-dock-tab-icon-in 80ms ease-out;
148
+ }
149
+
150
+ @keyframes hf-dock-tab-icon-in {
151
+ from {
152
+ opacity: 0;
153
+ transform: translateX(-2px);
154
+ }
155
+ }
156
+
157
+ /* Takes its space whenever the tab is shown, so hover never moves the label. */
158
+ .hf-dock-tab-close {
159
+ display: flex;
160
+ opacity: 0;
161
+ }
162
+
163
+ .dv-tab:hover .hf-dock-tab-close,
164
+ .dv-tab:focus-visible .hf-dock-tab-close {
165
+ opacity: 1;
166
+ }
167
+
168
+ /* Sized by the actions it draws, so a strip without them gives its tabs the whole width. */
169
+ .hf-dock .dv-right-actions-container {
170
+ flex: none;
171
+ align-self: flex-start;
172
+ }
173
+
174
+ .hf-dock-strip-actions {
175
+ display: flex;
176
+ margin-right: 4px;
177
+ opacity: 0;
178
+ }
179
+
180
+ .dv-groupview:hover .hf-dock-strip-actions {
181
+ opacity: 0.5;
182
+ }
183
+
184
+ .hf-dock-strip-actions:hover,
185
+ .hf-dock-strip-actions:focus-within {
186
+ opacity: 1;
187
+ }
188
+
189
+ @media (prefers-reduced-motion: reduce) {
190
+ .hf-dock-tab-fill[data-ready] {
191
+ transition: none;
192
+ }
193
+
194
+ .hf-dock-tab-icon {
195
+ animation: none;
196
+ }
197
+ }
@@ -0,0 +1,124 @@
1
+ import type { DockviewApi } from "dockview-react";
2
+
3
+ const TAB_FILL_CLASS = "hf-dock-tab-fill";
4
+
5
+ /** The strip's one fill, created on first use. Dockview inserts tabs before tabs, so it stays first. */
6
+ function fillOf(list: HTMLElement): HTMLElement {
7
+ const existing = list.querySelector<HTMLElement>(`:scope > .${TAB_FILL_CLASS}`);
8
+ if (existing) return existing;
9
+ const fill = document.createElement("span");
10
+ fill.className = TAB_FILL_CLASS;
11
+ fill.setAttribute("aria-hidden", "true");
12
+ list.prepend(fill);
13
+ // Placed once without motion; only later moves slide.
14
+ requestAnimationFrame(() => fill.setAttribute("data-ready", ""));
15
+ return fill;
16
+ }
17
+
18
+ /** A strip's width and where its shown tab sits, as of the last pass. */
19
+ type StripShape = { width: number; tab: HTMLElement | null; left: number; size: number };
20
+
21
+ /**
22
+ * True when the strip narrowed or its shown tab moved or resized since it was last visible. dockview
23
+ * reveals a tab only when it is activated, and the user's own scrolling changes none of these.
24
+ */
25
+ function reshaped(before: StripShape | undefined, now: StripShape) {
26
+ if (!before || now.width === 0) return false;
27
+ if (now.width < before.width) return true;
28
+ return now.tab !== before.tab || now.left !== before.left || now.size !== before.size;
29
+ }
30
+
31
+ /** The scroll that shows the whole shown tab, e.g. after the strip narrows because its actions appeared. */
32
+ function revealedScroll(shape: StripShape, scrollLeft: number) {
33
+ if (shape.left < scrollLeft) return shape.left;
34
+ // Offset sizes are rounded while the tab's edge is fractional; a pixel over is clamped at the end.
35
+ return Math.max(scrollLeft, shape.left + shape.size + 1 - shape.width);
36
+ }
37
+
38
+ /** Reads where the shown tab sits; the write happens after every strip is read, so layout runs once. */
39
+ function measureFill(list: HTMLElement, shapes: WeakMap<HTMLElement, StripShape>): () => void {
40
+ const fill = fillOf(list);
41
+ const tab = list.querySelector<HTMLElement>(":scope > .dv-active-tab");
42
+ const shape = {
43
+ width: list.clientWidth,
44
+ tab,
45
+ left: tab?.offsetLeft ?? 0,
46
+ size: tab?.offsetWidth ?? 0,
47
+ };
48
+ const reveal = tab !== null && reshaped(shapes.get(list), shape);
49
+ // A hidden strip (another group maximised) keeps its scroll, so it is compared with its last visible shape.
50
+ if (shape.width > 0) shapes.set(list, shape);
51
+ const scrollLeft = list.scrollLeft;
52
+ const target = reveal ? revealedScroll(shape, scrollLeft) : scrollLeft;
53
+ return () => {
54
+ fill.hidden = !tab;
55
+ if (!tab) return;
56
+ fill.style.width = `${shape.size}px`;
57
+ fill.style.transform = `translateX(${shape.left}px)`;
58
+ if (target !== scrollLeft) list.scrollLeft = target;
59
+ };
60
+ }
61
+
62
+ /** Marks which edges of a scrolled strip are clipped, for the CSS edge fade. */
63
+ function markClippedEdges(list: HTMLElement) {
64
+ const edges = [];
65
+ if (list.scrollLeft > 0) edges.push("start");
66
+ if (list.scrollLeft + list.clientWidth < list.scrollWidth - 1) edges.push("end");
67
+ const value = edges.join(" ");
68
+ if (value) list.setAttribute("data-clipped", value);
69
+ else list.removeAttribute("data-clipped");
70
+ }
71
+
72
+ /**
73
+ * Slides one fill per tab strip under its shown tab, keeps that tab in view, and keeps the
74
+ * clipped-edge marks current.
75
+ * Returns a disposer.
76
+ */
77
+ export function installTabFill(api: DockviewApi, root: HTMLElement): () => void {
78
+ const lists = () => root.querySelectorAll<HTMLElement>(".dv-tabs-container");
79
+ // Tab widths also change with no dockview event: a renamed title, a late web font.
80
+ const resizeObserver = new ResizeObserver(() => placeAll());
81
+ const observed = new Set<Element>();
82
+ function observe(elements: Iterable<Element>) {
83
+ for (const element of observed) {
84
+ if (element.isConnected) continue;
85
+ resizeObserver.unobserve(element);
86
+ observed.delete(element);
87
+ }
88
+ for (const element of elements) {
89
+ if (observed.has(element)) continue;
90
+ resizeObserver.observe(element);
91
+ observed.add(element);
92
+ }
93
+ }
94
+ const shapes = new WeakMap<HTMLElement, StripShape>();
95
+ function placeAll() {
96
+ const strips = [...lists()];
97
+ const writes = strips.map((list) => measureFill(list, shapes));
98
+ for (const write of writes) write();
99
+ for (const list of strips) markClippedEdges(list);
100
+ observe(strips.flatMap((list) => [list, ...list.querySelectorAll(":scope > .dv-tab")]));
101
+ }
102
+ const onScroll = (event: Event) => {
103
+ const list = event.target;
104
+ if (list instanceof HTMLElement && list.classList.contains("dv-tabs-container")) {
105
+ markClippedEdges(list);
106
+ }
107
+ };
108
+ const subscriptions = [
109
+ api.onDidActivePanelChange(placeAll),
110
+ api.onDidAddPanel(placeAll),
111
+ api.onDidRemovePanel(placeAll),
112
+ api.onDidMovePanel(placeAll),
113
+ api.onDidLayoutChange(placeAll),
114
+ api.onDidLayoutFromJSON(placeAll),
115
+ ];
116
+ // Scroll does not bubble; a capturing listener still sees every strip's.
117
+ root.addEventListener("scroll", onScroll, true);
118
+ placeAll();
119
+ return () => {
120
+ root.removeEventListener("scroll", onScroll, true);
121
+ for (const subscription of subscriptions) subscription.dispose();
122
+ resizeObserver.disconnect();
123
+ };
124
+ }
@@ -0,0 +1,80 @@
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").replace(
6
+ /@import[^;]*;/,
7
+ "",
8
+ );
9
+
10
+ /** Each top-level rule's selector and body, with the reduced-motion block kept whole. */
11
+ function rules(css: string): { selector: string; body: string }[] {
12
+ const found: { selector: string; body: string }[] = [];
13
+ let depth = 0;
14
+ let start = 0;
15
+ let selector = "";
16
+ for (let index = 0; index < css.length; index++) {
17
+ if (css[index] === "{") {
18
+ if (depth === 0) {
19
+ selector = css
20
+ .slice(start, index)
21
+ .replace(/\/\*[\s\S]*?\*\//g, "")
22
+ .trim();
23
+ start = index + 1;
24
+ }
25
+ depth++;
26
+ } else if (css[index] === "}") {
27
+ depth--;
28
+ if (depth === 0) {
29
+ found.push({ selector, body: css.slice(start, index) });
30
+ start = index + 1;
31
+ }
32
+ }
33
+ }
34
+ return found;
35
+ }
36
+
37
+ const bodyOf = (selector: string) =>
38
+ rules(dockCss).find((rule) => rule.selector === selector)?.body;
39
+
40
+ describe("dock panel strip contract", () => {
41
+ it("draws one border per surface: the card's, and no divider, separator or tab outline", () => {
42
+ const bordered = rules(dockCss)
43
+ .filter((rule) => /(^|[\s;{])border(-(top|right|bottom|left))?\s*:/.test(rule.body))
44
+ .map((rule) => rule.selector);
45
+ expect(bordered).toEqual([".hf-dock .dv-groupview"]);
46
+ const dock = bodyOf(".hf-dock");
47
+ expect(dock).toContain("--dv-tab-divider-color: transparent");
48
+ expect(dock).toContain("--dv-separator-border: transparent");
49
+ });
50
+
51
+ it("slides only transform and width, 140ms ease-out, and reveals the icon in 80ms", () => {
52
+ expect(bodyOf(".hf-dock-tab-fill[data-ready]")?.replace(/\s+/g, " ").trim()).toBe(
53
+ "transition: transform 140ms ease-out, width 140ms ease-out;",
54
+ );
55
+ expect(bodyOf(".hf-dock-tab-icon")).toContain("animation: hf-dock-tab-icon-in 80ms ease-out");
56
+ const animated = rules(dockCss).filter(
57
+ (rule) => !rule.selector.startsWith("@") && /transition|animation/.test(rule.body),
58
+ );
59
+ expect(animated.map((rule) => rule.selector)).toEqual([
60
+ ".hf-dock-tab-fill[data-ready]",
61
+ ".hf-dock-tab-icon",
62
+ ]);
63
+ });
64
+
65
+ it("turns the slide and the icon reveal off under reduced motion", () => {
66
+ const reduced = bodyOf("@media (prefers-reduced-motion: reduce)")?.replace(/\s+/g, " ");
67
+ expect(reduced).toContain(".hf-dock-tab-fill[data-ready] { transition: none; }");
68
+ expect(reduced).toContain(".hf-dock-tab-icon { animation: none; }");
69
+ });
70
+
71
+ it("centres the tab's icon and label in the tab's full height", () => {
72
+ const tab = bodyOf(".hf-dock-tab");
73
+ expect(tab).toContain("height: 100%");
74
+ expect(tab).toContain("align-items: center");
75
+ });
76
+
77
+ it("takes every colour from a token", () => {
78
+ expect(dockCss).not.toMatch(/#[0-9a-f]{3,8}\b|rgba?\(|hsla?\(|oklch\(/i);
79
+ });
80
+ });
@@ -115,10 +115,10 @@ export function PreviewPane({
115
115
  return (
116
116
  <div
117
117
  ref={containerRef}
118
- // Panel chrome (rounded border) is dropped in fullscreen so the preview
119
- // fills the screen edge-to-edge.
118
+ // A recessed well inset in its dock card; dropped in fullscreen so the
119
+ // preview fills the screen edge-to-edge.
120
120
  className={`flex-1 min-h-0 flex flex-col overflow-hidden bg-[var(--studio-preview-bg,var(--color-neutral-950))] ${
121
- isFullscreen ? "" : "rounded-lg border border-neutral-800/50"
121
+ isFullscreen ? "" : "m-3 rounded-md border border-neutral-800/50"
122
122
  }`}
123
123
  data-studio-fullscreen-target=""
124
124
  >
@@ -85,7 +85,7 @@ export function TimelinePane({
85
85
  aria-disabled={timelineDisabled || undefined}
86
86
  >
87
87
  <div
88
- className="flex flex-col flex-1 min-h-0 overflow-hidden rounded-lg border border-neutral-800/50 bg-neutral-950"
88
+ className="flex flex-col flex-1 min-h-0 overflow-hidden"
89
89
  onDoubleClick={(e) => {
90
90
  if ((e.target as HTMLElement).closest("[data-clip]")) return;
91
91
  if (timelineDisabled) return;
@@ -220,6 +220,22 @@ export function MenuRadioItem({ className, children, ...props }: MenuRadioItemPr
220
220
  );
221
221
  }
222
222
 
223
+ /** An on/off row; the tick renders only while `checked`. */
224
+ export function MenuCheckboxItem({
225
+ className,
226
+ children,
227
+ ...props
228
+ }: StyledProps<typeof BaseMenu.CheckboxItem>) {
229
+ return (
230
+ <BaseMenu.CheckboxItem className={cn(itemBase, className)} {...props}>
231
+ <span className="truncate">{children}</span>
232
+ <span className="flex size-3 shrink-0 items-center justify-center text-accent">
233
+ <BaseMenu.CheckboxItemIndicator>✓</BaseMenu.CheckboxItemIndicator>
234
+ </span>
235
+ </BaseMenu.CheckboxItem>
236
+ );
237
+ }
238
+
223
239
  /** A hairline between two groups of items. */
224
240
  export function MenuSeparator({ className, ...props }: StyledProps<typeof BaseMenu.Separator>) {
225
241
  return <BaseMenu.Separator className={cn("my-1 h-px bg-hairline", className)} {...props} />;
@@ -11,6 +11,7 @@ export { Tooltip } from "./Tooltip";
11
11
  export {
12
12
  ContextMenu,
13
13
  Menu,
14
+ MenuCheckboxItem,
14
15
  MenuItem,
15
16
  MenuRadioGroup,
16
17
  MenuRadioItem,
@@ -273,6 +273,14 @@
273
273
  --color-panel-media: var(--color-media);
274
274
  --color-panel-container: var(--color-container);
275
275
 
276
+ /* Dock panels are cards on the shell. The card owns the only border; its tab
277
+ * strip sits below the card body and the shown tab is a fill in the body colour. */
278
+ --panel-shell-bg: var(--color-bg-1);
279
+ --panel-card-bg: var(--color-surface);
280
+ --panel-card-border: #303034;
281
+ --panel-strip-bg: var(--color-bg-0);
282
+ --panel-tab-bg-hover: #202023;
283
+
276
284
  /* Names Studio's markup invented that no config ever defined, so the classes
277
285
  * compiled to nothing. Aliased to the token that was meant, rather than
278
286
  * given values of their own, so there is still one source per decision.