Improve canvas and modal surface controls

Remove the fixed right-canvas width limits so the panel can shrink to zero and grow across the available workspace.

Add Browser/Desktop surface-switch buttons to modal headers using the same registered surface metadata as the canvas controls, while preserving modal-mode preference and dock-to-canvas behavior.

Add regression coverage for the unlimited canvas sizing and modal surface switcher controls.
This commit is contained in:
Alessandro 2026-05-05 11:39:37 +02:00
parent 78570e5689
commit 3e07099d56
6 changed files with 162 additions and 16 deletions

View file

@ -670,6 +670,7 @@ def test_browser_and_desktop_surface_buttons_remember_latest_window_mode():
encoding="utf-8"
)
modals_js = (PROJECT_ROOT / "webui" / "js" / "modals.js").read_text(encoding="utf-8")
modals_css = (PROJECT_ROOT / "webui" / "css" / "modals.css").read_text(encoding="utf-8")
assert "surfaceModes: {}" in canvas_store
assert "recordSurfaceMode(surfaceId" in canvas_store
@ -685,11 +686,21 @@ def test_browser_and_desktop_surface_buttons_remember_latest_window_mode():
assert '@click="$store.rightCanvas.open(surface.id)"' in canvas_html
assert 'rightCanvasStore.recordSurfaceMode?.(metadata.surfaceId, "modal")' in modals_js
assert "configureModalSurfaceSwitcher" in modals_js
assert "modal-surface-switcher" in modals_js
assert "modal-surface-button" in modals_js
assert "rightCanvasStore.panelSurfaces" in modals_js
assert 'rightCanvasStore.recordSurfaceMode?.(surface.id, "modal")' in modals_js
assert "await closeModal(modal.path)" in modals_js
assert "modalRequiresExplicitClose" in modals_js
assert '"plugins/_browser/webui/main.html"' in modals_js
assert '"plugins/_office/webui/main.html"' in modals_js
assert "&& !modalRequiresExplicitClose(newModal)" in modals_js
assert "if (modalRequiresExplicitClose(modalStack[modalStack.length - 1])) return;" in modals_js
assert ".modal-surface-switcher" in modals_css
assert ".modal-surface-button.is-active" in modals_css
assert ".modal-surface-image" in modals_css
assert "grid-auto-flow: column" in modals_css
def test_browser_tool_does_not_auto_open_canvas_policy_is_documented():

View file

@ -416,7 +416,12 @@ def test_right_canvas_requires_explicit_open_and_is_absent_on_mobile():
assert "right-canvas-resize-end" in canvas_store
assert "dispatchResizeEvent" in canvas_store
assert "this.isOpen = false;" in canvas_store
assert "wasMobileMode && this.width <= MIN_WIDTH" in canvas_store
assert "wasMobileMode && this.width < MIN_WIDTH" in canvas_store
assert "const MIN_WIDTH = 0" in canvas_store
assert "const MAX_WIDTH" not in canvas_store
assert "0.58" not in canvas_store
assert "min(900px, 58vw)" not in canvas_css
assert "max-width: none" in canvas_css
assert "if (this.isMobileMode && !surface.actionOnly)" in canvas_store
assert "if (this.isMobileMode)" in canvas_store
assert "shouldRender()" in canvas_store

View file

@ -3,8 +3,7 @@ import { callJsExtensions } from "/js/extensions.js";
const STORAGE_KEY = "a0.rightCanvas";
const DEFAULT_WIDTH = 720;
const MIN_WIDTH = 420;
const MAX_WIDTH = 900;
const MIN_WIDTH = 0;
const DESKTOP_BREAKPOINT = 1200;
const MOBILE_BREAKPOINT = 768;
const SURFACE_MODE_CANVAS = "canvas";
@ -18,6 +17,12 @@ function viewportWidth() {
return Math.max(document.documentElement.clientWidth || 0, globalThis.innerWidth || 0);
}
function normalizeWidth(value, fallback = DEFAULT_WIDTH) {
if (value === null || value === undefined || value === "") return fallback;
const width = Number(value);
return Number.isFinite(width) ? Math.max(MIN_WIDTH, Math.round(width)) : fallback;
}
function normalizeSurfaceMode(mode = "") {
return mode === SURFACE_MODE_MODAL ? SURFACE_MODE_MODAL : SURFACE_MODE_CANVAS;
}
@ -306,15 +311,22 @@ const model = {
setWidth(px, options = {}) {
const { persist = true } = options;
const max = this.maxWidth();
const next = clamp(Number(px) || DEFAULT_WIDTH, MIN_WIDTH, max);
const next = clamp(normalizeWidth(px), MIN_WIDTH, this.maxWidth());
this.width = next;
this.applyLayoutState();
if (persist) this.persist();
},
maxWidth() {
return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, Math.floor(viewportWidth() * 0.58)));
if (this.isOverlayMode) {
return Math.max(MIN_WIDTH, viewportWidth() - 44);
}
const container = this._rootElement?.closest(".container");
const rightPanel = document.getElementById("right-panel");
const containerRight = container?.getBoundingClientRect().right ?? viewportWidth();
const panelLeft = rightPanel?.getBoundingClientRect().left ?? 0;
return Math.max(MIN_WIDTH, Math.floor(containerRight - panelLeft));
},
defaultWidth() {
@ -387,7 +399,7 @@ const model = {
normalizeSurfaceMode(mode),
]),
);
if (saved.width) this.width = Number(saved.width);
if (Number.isFinite(Number(saved.width))) this.width = Number(saved.width);
} catch (error) {
console.warn("Could not restore right canvas state", error);
}
@ -409,7 +421,7 @@ const model = {
surface.close?.({ ...payload, reason: "mobile" });
}, 0);
}
} else if (wasMobileMode && this.width <= MIN_WIDTH) {
} else if (wasMobileMode && this.width < MIN_WIDTH) {
this.width = this.defaultWidth();
}
},

View file

@ -30,8 +30,8 @@ body.right-canvas-resizing {
display: flex;
flex: 0 0 auto;
height: 100%;
min-width: 52px;
max-width: min(900px, 58vw);
min-width: 0;
max-width: none;
overflow: hidden;
border-left: 1px solid var(--right-canvas-border);
background: var(--right-canvas-chrome);

View file

@ -91,7 +91,9 @@ the old and the new system. */
/* Modal Header */
.modal-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
grid-template-columns: minmax(0, 1fr);
grid-auto-flow: column;
grid-auto-columns: auto;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
@ -150,7 +152,14 @@ the old and the new system. */
background: color-mix(in srgb, var(--color-background-hover) 72%, transparent);
}
.modal-dock-button {
.modal-surface-switcher {
display: inline-flex;
align-items: center;
gap: 4px;
}
.modal-dock-button,
.modal-surface-button {
display: inline-flex;
align-items: center;
justify-content: center;
@ -168,16 +177,27 @@ the old and the new system. */
transition: background-color 0.16s ease, border-color 0.16s ease, opacity 0.16s ease;
}
.modal-dock-button:hover {
.modal-dock-button:hover,
.modal-surface-button:hover,
.modal-surface-button.is-active {
opacity: 1;
border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
background: color-mix(in srgb, var(--color-background-hover) 72%, transparent);
}
.modal-dock-button .material-symbols-outlined {
.modal-dock-button .material-symbols-outlined,
.modal-surface-button .material-symbols-outlined {
font-size: 19px;
}
.modal-surface-image {
display: block;
width: 22px;
height: 22px;
border-radius: 6px;
object-fit: cover;
}
/* Modal Description */
.modal-description {
padding: 0.8rem 1rem 0 1rem;

View file

@ -14,6 +14,10 @@ function normalizeModalPath(modalPath = "") {
return String(modalPath || "").replace(/^\/+/, "");
}
function sameModalPath(left = "", right = "") {
return normalizeModalPath(left) === normalizeModalPath(right);
}
function modalRequiresExplicitClose(modalOrElement) {
const element = modalOrElement?.element || modalOrElement;
const path = normalizeModalPath(modalOrElement?.path || element?.path || "");
@ -23,7 +27,7 @@ function modalRequiresExplicitClose(modalOrElement) {
}
function findModalIndexByPath(modalPath) {
return modalStack.findIndex((modal) => modal.path === modalPath);
return modalStack.findIndex((modal) => sameModalPath(modal.path, modalPath));
}
function focusModal(modalPath) {
@ -201,8 +205,102 @@ function getDockMetadata(doc, modalPath) {
};
}
function configureModalDockButton(modal, doc) {
function getModalSwitchSurfaces(metadata) {
if (!metadata) return [];
const surfaces = Array.isArray(rightCanvasStore.panelSurfaces)
? rightCanvasStore.panelSurfaces
: [];
const modalSurfaces = surfaces.filter((surface) => (
surface?.id
&& surface?.modalPath
&& !surface.actionOnly
));
if (modalSurfaces.some((surface) => surface.id === metadata.surfaceId)) {
return modalSurfaces;
}
return [
{
id: metadata.surfaceId,
title: metadata.title,
icon: metadata.icon,
modalPath: metadata.modalPath,
},
...modalSurfaces,
];
}
function createModalSurfaceButton(surface, metadata, modal) {
const title = surface.title || surface.id;
const targetModalPath = surface.modalPath || "";
const isActive = surface.id === metadata.surfaceId || sameModalPath(targetModalPath, modal.path);
const button = document.createElement("button");
button.type = "button";
button.className = "modal-surface-button";
button.dataset.canvasSurface = surface.id;
button.title = title;
button.setAttribute("aria-label", title);
button.setAttribute("aria-pressed", isActive.toString());
if (isActive) button.classList.add("is-active");
if (surface.image) {
const image = document.createElement("img");
image.className = "modal-surface-image";
image.src = surface.image;
image.alt = "";
image.setAttribute("aria-hidden", "true");
button.appendChild(image);
} else {
const icon = document.createElement("span");
icon.className = "material-symbols-outlined";
icon.setAttribute("aria-hidden", "true");
icon.textContent = surface.icon || "web_asset";
button.appendChild(icon);
}
button.addEventListener("click", async () => {
if (button.disabled || isActive || !targetModalPath) return;
button.disabled = true;
try {
rightCanvasStore.recordSurfaceMode?.(surface.id, "modal");
const openPromise = ensureModalOpen(targetModalPath);
if (openPromise?.catch) {
openPromise.catch((error) => console.error(`Modal surface ${surface.id} failed to open`, error));
}
await closeModal(modal.path);
} finally {
if (document.contains(button)) button.disabled = false;
}
});
return button;
}
function configureModalSurfaceSwitcher(modal, doc) {
const metadata = getDockMetadata(doc, modal.path);
if (!metadata || !modal.header || modal.header.querySelector(".modal-surface-switcher")) {
return metadata;
}
const surfaces = getModalSwitchSurfaces(metadata);
if (surfaces.length <= 1) return metadata;
const switcher = document.createElement("div");
switcher.className = "modal-surface-switcher";
switcher.setAttribute("role", "group");
switcher.setAttribute("aria-label", "Modal surfaces");
for (const surface of surfaces) {
switcher.appendChild(createModalSurfaceButton(surface, metadata, modal));
}
modal.close?.insertAdjacentElement("beforebegin", switcher);
return metadata;
}
function configureModalDockButton(modal, doc) {
const metadata = configureModalSurfaceSwitcher(modal, doc);
if (!metadata || !modal.header || modal.header.querySelector(".modal-dock-button")) {
return;
}