Unify surface modal header actions

Add a shared grouped action rail for Browser, Desktop, and Editor floating surface modals so modality switches, canvas docking, focus mode, New, and close controls appear in a consistent order with separators. Fix the Desktop focus button class collision that hid the canvas docking button, and align dock button labels with canvas terminology.

Alessandro committed May 22, 2026 at 14:19 UTC b64c43e736f21643b6db2bd720da189fe2f15b40
10 files changed +234 -48
plugins/_browser/webui/browser-store.js
+29 -7
@@ -7,7 +7,11 @@ import { store as chatInputStore } from "/components/chat/input/input-store.js";
7 import { store as pluginSettingsStore } from "/components/plugins/plugin-settings-store.js";
8 import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
9 import { store as rightCanvasStore } from "/components/canvas/right-canvas-store.js";
10 -import { openLatest as openLatestSurface, registerUrlHandler } from "/js/surfaces.js";
10 +import {
11 + openLatest as openLatestSurface,
12 + placeSurfaceModalHeaderAction,
13 + registerUrlHandler,
14 +} from "/js/surfaces.js";
15
16 const websocket = getNamespacedClient("/ws");
17 websocket.addHandlers(["ws_webui"]);
@@ -2634,6 +2638,27 @@ const model = {
2638 };
2639 clampGeometry();
2640
2641 + const newAction = globalThis.document.createElement("div");
2642 + newAction.className = "browser-header-actions surface-modal-new-action";
2643 + newAction.innerHTML = `
2644 + <button type="button" class="browser-header-new-button surface-modal-new-button" title="New Browser" aria-label="New Browser">
2645 + <span class="material-symbols-outlined" aria-hidden="true">add</span>
2646 + <span>New</span>
2647 + </button>
2648 + `;
2649 + const newButton = newAction.querySelector(".browser-header-new-button");
2650 + const onNewClick = async () => {
2651 + if (!newButton || newButton.disabled || this.isBusy()) return;
2652 + newButton.disabled = true;
2653 + try {
2654 + await this.openNewBrowser();
2655 + } finally {
2656 + if (globalThis.document?.contains?.(newButton)) newButton.disabled = false;
2657 + }
2658 + };
2659 + newButton?.addEventListener("click", onNewClick);
2660 + placeSurfaceModalHeaderAction(header, newAction, "new");
2661 +
2662 const focusButton = globalThis.document.createElement("button");
2663 focusButton.type = "button";
2664 focusButton.className = "surface-button browser-modal-focus-button";
@@ -2658,12 +2683,7 @@ const model = {
2683 updateFocusButton(false);
2684 };
2685 updateFocusButton(false);
2661 - const closeButton = inner.querySelector(".modal-close");
2662 - if (closeButton) {
2663 - closeButton.insertAdjacentElement("beforebegin", focusButton);
2664 - } else {
2665 - header.appendChild(focusButton);
2666 - }
2686 + placeSurfaceModalHeaderAction(header, focusButton, "window");
2687 const onFocusClick = () => setFocusMode(!inner.classList.contains("is-focus-mode"));
2688 focusButton.addEventListener("click", onFocusClick);
2689
@@ -2723,6 +2743,8 @@ const model = {
2743 header.addEventListener("pointerdown", onPointerDown);
2744
2745 this._floatingCleanup = () => {
2746 + newButton?.removeEventListener("click", onNewClick);
2747 + newAction.remove();
2748 focusButton.removeEventListener("click", onFocusClick);
2749 focusButton.remove();
2750 header.removeEventListener("pointerdown", onPointerDown);
plugins/_browser/webui/main.html
+1 -1
@@ -2,7 +2,7 @@
2 class="browser-modal"
3 data-surface-id="browser"
4 data-surface-modal-path="/plugins/_browser/webui/main.html"
5 - data-surface-dock-title="Open Browser in surface"
5 + data-surface-dock-title="Open Browser in canvas"
6 data-surface-dock-icon="dock_to_right"
7 data-canvas-surface="browser"
8 data-canvas-modal-path="/plugins/_browser/webui/main.html"
plugins/_desktop/webui/desktop-panel.html
+1 -1
@@ -137,7 +137,7 @@
137 }
138
139 .modal-inner.office-modal .modal-header {
140 - grid-template-columns: minmax(0, 1fr) repeat(5, auto);
140 + grid-template-columns: minmax(0, 1fr) auto auto;
141 }
142
143 .office-modal-input-shield {
plugins/_desktop/webui/desktop-store.js
+7 -18
@@ -2,7 +2,7 @@ import { createStore } from "/js/AlpineStore.js";
2 import { callJsonApi } from "/js/api.js";
3 import { getNamespacedClient } from "/js/websocket.js";
4 import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
5 -import { handleUrlIntent } from "/js/surfaces.js";
5 +import { handleUrlIntent, placeSurfaceModalHeaderAction } from "/js/surfaces.js";
6
7 const officeSocket = getNamespacedClient("/ws");
8 officeSocket.addHandlers(["ws_webui"]);
@@ -2337,9 +2337,9 @@ const model = {
2337 if (!header || header.querySelector(".office-header-actions")) return () => {};
2338
2339 const root = globalThis.document.createElement("div");
2340 - root.className = "office-header-actions";
2340 + root.className = "office-header-actions surface-modal-new-action";
2341 root.innerHTML = `
2342 - <button type="button" class="office-header-new-button" aria-haspopup="menu" aria-expanded="false">
2342 + <button type="button" class="office-header-new-button surface-modal-new-button" aria-haspopup="menu" aria-expanded="false">
2343 <span class="material-symbols-outlined" aria-hidden="true">add</span>
2344 <span>New</span>
2345 <span class="material-symbols-outlined office-new-chevron" aria-hidden="true">expand_more</span>
@@ -2396,14 +2396,7 @@ const model = {
2396 globalThis.document.addEventListener("click", onDocumentClick);
2397 globalThis.document.addEventListener("keydown", onDocumentKeydown);
2398
2399 - const firstHeaderAction = header.querySelector(
2400 - ".modal-surface-switcher, .modal-dock-button, .office-modal-focus-button, .modal-close",
2401 - );
2402 - if (firstHeaderAction) {
2403 - firstHeaderAction.insertAdjacentElement("beforebegin", root);
2404 - } else {
2405 - header.appendChild(root);
2406 - }
2399 + placeSurfaceModalHeaderAction(header, root, "new");
2400
2401 setOpen(false);
2402 return () => {
@@ -2501,20 +2494,16 @@ const model = {
2494
2495 const focusButton = globalThis.document.createElement("button");
2496 focusButton.type = "button";
2504 - focusButton.className = "modal-dock-button office-modal-focus-button";
2497 + focusButton.className = "surface-button office-modal-focus-button";
2498 focusButton.innerHTML = '<span class="material-symbols-outlined" aria-hidden="true">fullscreen</span>';
2499 const updateFocusButton = (active) => {
2500 const label = active ? "Restore size" : "Focus mode";
2501 focusButton.setAttribute("aria-label", label);
2502 + focusButton.setAttribute("title", label);
2503 focusButton.querySelector(".material-symbols-outlined").textContent = active ? "fullscreen_exit" : "fullscreen";
2504 };
2505 updateFocusButton(false);
2512 - const closeButton = inner.querySelector(".modal-close");
2513 - if (closeButton) {
2514 - closeButton.insertAdjacentElement("beforebegin", focusButton);
2515 - } else {
2516 - header.appendChild(focusButton);
2517 - }
2506 + placeSurfaceModalHeaderAction(header, focusButton, "window");
2507 cleanup.push(() => focusButton.remove());
2508
2509 const setFocusMode = (enabled) => {
plugins/_desktop/webui/main.html
+1 -1
@@ -2,7 +2,7 @@
2 class="surface-modal office-modal modal-no-backdrop"
3 data-surface-id="desktop"
4 data-surface-modal-path="/plugins/_desktop/webui/main.html"
5 - data-surface-dock-title="Open Desktop in surface"
5 + data-surface-dock-title="Open Desktop in canvas"
6 data-surface-dock-icon="dock_to_right"
7 data-canvas-surface="desktop"
8 data-canvas-modal-path="/plugins/_desktop/webui/main.html"
plugins/_editor/webui/editor-panel.html
+1 -1
@@ -322,7 +322,7 @@
322 }
323
324 .modal-inner.editor-modal .modal-header {
325 - grid-template-columns: minmax(0, 1fr) repeat(4, auto);
325 + grid-template-columns: minmax(0, 1fr) auto auto;
326 }
327
328 .editor-tabs {
plugins/_editor/webui/editor-store.js
+6 -15
@@ -2,6 +2,7 @@ import { createStore } from "/js/AlpineStore.js";
2 import { callJsonApi } from "/js/api.js";
3 import { getNamespacedClient } from "/js/websocket.js";
4 import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
5 +import { placeSurfaceModalHeaderAction } from "/js/surfaces.js";
6 import {
7 buildMarkdownPages,
8 isExternalHref,
@@ -1591,9 +1592,9 @@ const model = {
1592 if (!header || header.querySelector(".editor-header-actions")) return () => {};
1593
1594 const root = document.createElement("div");
1594 - root.className = "editor-header-actions";
1595 + root.className = "editor-header-actions surface-modal-new-action";
1596 root.innerHTML = `
1596 - <button type="button" class="editor-header-new-button" aria-haspopup="menu" aria-expanded="false">
1597 + <button type="button" class="editor-header-new-button surface-modal-new-button" aria-haspopup="menu" aria-expanded="false">
1598 <span class="material-symbols-outlined" aria-hidden="true">add</span>
1599 <span>New</span>
1600 <span class="material-symbols-outlined editor-new-chevron" aria-hidden="true">expand_more</span>
@@ -1642,12 +1643,7 @@ const model = {
1643 document.addEventListener("click", onMarkdownClick);
1644 document.addEventListener("keydown", onMarkdownKeydown);
1645
1645 - const firstHeaderAction = header.querySelector(".modal-close");
1646 - if (firstHeaderAction) {
1647 - firstHeaderAction.insertAdjacentElement("beforebegin", root);
1648 - } else {
1649 - header.appendChild(root);
1650 - }
1646 + placeSurfaceModalHeaderAction(header, root, "new");
1647
1648 setOpen(false);
1649 return () => {
@@ -1666,10 +1662,9 @@ const model = {
1662 inner.dataset.editorModalReady = "1";
1663 inner.classList.add("editor-modal");
1664 const cleanup = [];
1669 - const closeButton = inner.querySelector(".modal-close");
1665 const focusButton = document.createElement("button");
1666 focusButton.type = "button";
1672 - focusButton.className = "modal-dock-button editor-modal-focus-button";
1667 + focusButton.className = "surface-button editor-modal-focus-button";
1668 focusButton.innerHTML = '<span class="material-symbols-outlined" aria-hidden="true">fullscreen</span>';
1669 const updateFocusButton = (active) => {
1670 const label = active ? "Restore size" : "Focus mode";
@@ -1684,11 +1679,7 @@ const model = {
1679 updateFocusButton(active);
1680 };
1681 focusButton.addEventListener("click", onFocusClick);
1687 - if (closeButton) {
1688 - closeButton.insertAdjacentElement("beforebegin", focusButton);
1689 - } else {
1690 - header.appendChild(focusButton);
1691 - }
1682 + placeSurfaceModalHeaderAction(header, focusButton, "window");
1683 cleanup.push(() => focusButton.removeEventListener("click", onFocusClick));
1684 cleanup.push(() => focusButton.remove());
1685
plugins/_editor/webui/main.html
+1 -1
@@ -2,7 +2,7 @@
2 class="surface-modal editor-modal modal-no-backdrop"
3 data-surface-id="editor"
4 data-surface-modal-path="/plugins/_editor/webui/main.html"
5 - data-surface-dock-title="Open Editor in surface"
5 + data-surface-dock-title="Open Editor in canvas"
6 data-surface-dock-icon="dock_to_right"
7 data-canvas-surface="editor"
8 data-canvas-modal-path="/plugins/_editor/webui/main.html"
webui/css/surfaces.css
+92
@@ -29,6 +29,42 @@
29 gap: 5px;
30 }
31
32 +.surface-modal-actions {
33 + display: inline-flex;
34 + align-items: center;
35 + gap: 5px;
36 + min-width: 0;
37 +}
38 +
39 +.surface-modal-action-group {
40 + display: inline-flex;
41 + align-items: center;
42 + gap: 5px;
43 + min-width: 0;
44 +}
45 +
46 +.surface-modal-action-group[hidden],
47 +.surface-modal-action-separator[hidden] {
48 + display: none;
49 +}
50 +
51 +.surface-modal-action-separator {
52 + display: block;
53 + flex: 0 0 1px;
54 + width: 1px;
55 + height: 20px;
56 + margin: 0 5px;
57 + border-radius: 999px;
58 + background: color-mix(in srgb, var(--color-border) 72%, transparent);
59 +}
60 +
61 +.surface-modal-new-action {
62 + position: relative;
63 + display: inline-flex;
64 + align-items: center;
65 + flex: 0 0 auto;
66 +}
67 +
68 .surface-dock-button,
69 .surface-button,
70 .modal-dock-button,
@@ -68,6 +104,62 @@
104 font-size: 19px;
105 }
106
107 +.surface-modal-new-button {
108 + appearance: none;
109 + display: inline-flex;
110 + align-items: center;
111 + justify-content: center;
112 + gap: 5px;
113 + height: 34px;
114 + min-height: 34px;
115 + padding: 0 9px 0 8px;
116 + border: 1px solid transparent;
117 + border-radius: 7px;
118 + background: transparent;
119 + color: var(--color-text);
120 + cursor: pointer;
121 + font: inherit;
122 + font-size: 12px;
123 + font-weight: 750;
124 + letter-spacing: 0;
125 + line-height: 1;
126 + opacity: 0.82;
127 + white-space: nowrap;
128 + transition: background-color 0.16s ease, border-color 0.16s ease, opacity 0.16s ease;
129 +}
130 +
131 +.surface-modal-new-button:hover:not(:disabled),
132 +.surface-modal-new-action.is-open .surface-modal-new-button {
133 + opacity: 1;
134 + border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
135 + background: color-mix(in srgb, var(--color-background-hover) 72%, transparent);
136 +}
137 +
138 +.surface-modal-new-button:disabled {
139 + cursor: default;
140 + opacity: 0.45;
141 +}
142 +
143 +.surface-modal-new-button .material-symbols-outlined {
144 + font-size: 18px;
145 + line-height: 1;
146 +}
147 +
148 +@media (max-width: 560px) {
149 + .surface-modal-actions,
150 + .surface-modal-action-group {
151 + gap: 3px;
152 + }
153 +
154 + .surface-modal-action-separator {
155 + margin: 0 3px;
156 + }
157 +
158 + .surface-modal-new-button span:not(.material-symbols-outlined) {
159 + display: none;
160 + }
161 +}
162 +
163 .surface-image,
164 .modal-surface-image {
165 display: block;
webui/js/surfaces.js
+95 -3
@@ -8,6 +8,7 @@ const LEGACY_SURFACE_IDS = new Map([
8
9 const registeredSurfaces = new Map();
10 const urlHandlers = new Set();
11 +const SURFACE_MODAL_ACTION_GROUPS = ["surfaces", "window", "new"];
12
13 export const CORE_SURFACES = [
14 {
@@ -277,6 +278,97 @@ function getModalSwitchSurfaces(metadata) {
278 .sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
279 }
280
281 +function directChildByClass(parent, className) {
282 + return Array.from(parent?.children || []).find((child) => child.classList?.contains(className)) || null;
283 +}
284 +
285 +function ensureSurfaceModalActionRail(header) {
286 + if (!header) return null;
287 + let rail = directChildByClass(header, "surface-modal-actions");
288 + if (!rail) {
289 + rail = document.createElement("div");
290 + rail.className = "surface-modal-actions";
291 + rail.setAttribute("aria-label", "Surface modal actions");
292 +
293 + const closeButton = directChildByClass(header, "modal-close") || header.querySelector?.(".modal-close");
294 + if (closeButton) {
295 + closeButton.insertAdjacentElement("beforebegin", rail);
296 + } else {
297 + header.appendChild(rail);
298 + }
299 + }
300 +
301 + for (const [index, groupName] of SURFACE_MODAL_ACTION_GROUPS.entries()) {
302 + if (!rail.querySelector(`[data-surface-modal-action-group="${groupName}"]`)) {
303 + if (index > 0 && !rail.querySelector(`[data-surface-modal-separator-before="${groupName}"]`)) {
304 + const separator = document.createElement("span");
305 + separator.className = "surface-modal-action-separator";
306 + separator.dataset.surfaceModalSeparatorBefore = groupName;
307 + separator.setAttribute("aria-hidden", "true");
308 + rail.appendChild(separator);
309 + }
310 +
311 + const group = document.createElement("div");
312 + group.className = `surface-modal-action-group surface-modal-action-group-${groupName}`;
313 + group.dataset.surfaceModalActionGroup = groupName;
314 + rail.appendChild(group);
315 + }
316 + }
317 +
318 + refreshSurfaceModalActionRail(header);
319 + return rail;
320 +}
321 +
322 +function surfaceModalActionGroup(header, groupName) {
323 + const rail = ensureSurfaceModalActionRail(header);
324 + return rail?.querySelector?.(`[data-surface-modal-action-group="${groupName}"]`) || null;
325 +}
326 +
327 +export function refreshSurfaceModalActionRail(header) {
328 + const rail = directChildByClass(header, "surface-modal-actions");
329 + if (!rail) return;
330 +
331 + const groups = Object.fromEntries(
332 + SURFACE_MODAL_ACTION_GROUPS.map((groupName) => [
333 + groupName,
334 + rail.querySelector(`[data-surface-modal-action-group="${groupName}"]`),
335 + ]),
336 + );
337 + const hasActions = Object.fromEntries(
338 + Object.entries(groups).map(([groupName, group]) => [
339 + groupName,
340 + Boolean(group?.children?.length),
341 + ]),
342 + );
343 +
344 + for (const [groupName, group] of Object.entries(groups)) {
345 + if (group) group.hidden = !hasActions[groupName];
346 + }
347 +
348 + const beforeWindow = rail.querySelector('[data-surface-modal-separator-before="window"]');
349 + if (beforeWindow) beforeWindow.hidden = !(hasActions.surfaces && (hasActions.window || hasActions.new));
350 +
351 + const beforeNew = rail.querySelector('[data-surface-modal-separator-before="new"]');
352 + if (beforeNew) beforeNew.hidden = !(hasActions.window && hasActions.new);
353 +}
354 +
355 +export function placeSurfaceModalHeaderAction(header, element, groupName = "window", options = {}) {
356 + if (!header || !element) return;
357 + const normalizedGroup = SURFACE_MODAL_ACTION_GROUPS.includes(groupName) ? groupName : "window";
358 + const group = surfaceModalActionGroup(header, normalizedGroup);
359 + if (!group) return;
360 +
361 + if (options.prepend) {
362 + if (element.parentElement !== group || group.firstElementChild !== element) {
363 + group.insertBefore(element, group.firstElementChild);
364 + }
365 + } else if (element.parentElement !== group) {
366 + group.appendChild(element);
367 + }
368 +
369 + refreshSurfaceModalActionRail(header);
370 +}
371 +
372 function markSurfaceModal(modal, metadata) {
373 const element = modal?.element;
374 const inner = modal?.inner || element?.querySelector?.(".modal-inner");
@@ -350,11 +442,11 @@ function configureModalSurfaceSwitcher(modal, metadata) {
442 switcher.appendChild(createModalSurfaceButton(surface, metadata, modal));
443 }
444
353 - modal.close?.insertAdjacentElement("beforebegin", switcher);
445 + placeSurfaceModalHeaderAction(modal.header, switcher, "surfaces");
446 }
447
448 function configureModalDockButton(modal, metadata) {
357 - if (!metadata || !modal?.header || modal.header.querySelector(".surface-dock-button, .modal-dock-button")) {
449 + if (!metadata || !modal?.header || modal.header.querySelector(".surface-dock-button")) {
450 return;
451 }
452
@@ -384,7 +476,7 @@ function configureModalDockButton(modal, metadata) {
476 }
477 });
478
387 - modal.close?.insertAdjacentElement("beforebegin", button);
479 + placeSurfaceModalHeaderAction(modal.header, button, "window", { prepend: true });
480 }
481
482 async function configureSurfaceModal(event) {