Promote Files into shared canvas surface

Add Files to the universal canvas rail and sidebar flow, with a reusable floating surface modal chrome that matches Browser/Desktop behavior.

Make the File Browser modal draggable/resizable with Focus mode, keep Editor on the same draggable modal helper, and preserve dock/undock handoff state.

Harden File Browser startup so empty paths resolve to the default workdir, restyle the Up control, compact New file/New folder actions, and hide Modified before Name/Size in narrow containers.

Update DOX contracts and focused regression coverage for the new Files surface, modal chrome, default-path fallback, and compact layout behavior.
This commit is contained in:
Alessandro 2026-06-23 11:19:58 +02:00
parent 51f3ed00a1
commit ffddc3ebcb
20 changed files with 879 additions and 110 deletions

View file

@ -9,7 +9,7 @@ class GetWorkDirFiles(ApiHandler):
return ["GET"]
async def process(self, input: dict, request: Request) -> dict | Response:
current_path = request.args.get("path", "")
current_path = request.args.get("path", "") or "$WORK_DIR"
if current_path == "$WORK_DIR":
# if runtime.is_development():
# current_path = "work_dir"

View file

@ -29,6 +29,7 @@
## Key Concepts
- Important called helpers/classes observed in the source: `FileBrowser`, `browser.get_files`, `runtime.call_development_function`.
- Empty `path` requests and explicit `$WORK_DIR` requests resolve to the default workdir path before `FileBrowser` is called, so the WebUI never receives an empty startup path for the default file browser view.
- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
## Work Guidance

View file

@ -41,6 +41,7 @@ Direct child DOX files:
| [initFw_end/AGENTS.md](initFw_end/AGENTS.md) | Post-WebUI-framework-initialization extensions. |
| [json_api_call_after/AGENTS.md](json_api_call_after/AGENTS.md) | Frontend hooks after `callJsonApi()` calls. |
| [json_api_call_before/AGENTS.md](json_api_call_before/AGENTS.md) | Frontend hooks before `callJsonApi()` calls. |
| [right-canvas-panels/AGENTS.md](right-canvas-panels/AGENTS.md) | Built-in right-canvas panel HTML contributions. |
| [right_canvas_register_surfaces/AGENTS.md](right_canvas_register_surfaces/AGENTS.md) | Built-in right-canvas surface registrations. |
| [set_messages_after_loop/AGENTS.md](set_messages_after_loop/AGENTS.md) | Frontend hooks after message DOM updates. |
| [set_messages_before_loop/AGENTS.md](set_messages_before_loop/AGENTS.md) | Frontend hooks before message DOM updates. |

View file

@ -0,0 +1,28 @@
# Right Canvas Panel Extensions DOX
## Purpose
- Own built-in HTML panel contributions for the right-canvas surface area.
## Ownership
- `.html` files mount WebUI components into the `right-canvas-panels` extension point.
- Panel wrappers own `data-surface-id` anchors and active/mounted visibility bindings.
## Local Contracts
- Each panel must correspond to a registered right-canvas surface ID.
- Use `<x-component>` for reusable component content instead of duplicating panel implementations.
- Keep canvas panels compatible with `.right-canvas-surface-panel` layout semantics.
## Work Guidance
- Prefer thin wrappers that delegate lifecycle and state to the owning component store.
## Verification
- Smoke-test opening the matching surface from the right-canvas rail.
## Child DOX Index
No child DOX files.

View file

@ -0,0 +1,11 @@
<div
class="right-canvas-surface-panel files-canvas-surface"
data-surface-id="files"
:class="{
'is-active': $store.rightCanvas?.isSurfaceVisible('files'),
'is-mounted': $store.rightCanvas?.isSurfaceRendered('files')
}"
:aria-hidden="(!$store.rightCanvas?.isSurfaceVisible('files')).toString()"
>
<x-component path="modals/file-browser/file-browser.html" mode="canvas"></x-component>
</div>

View file

@ -6,7 +6,7 @@
## Ownership
- JavaScript files own registration of remote link, space agent, and future core canvas surfaces.
- JavaScript files own registration of remote link, space agent, file browser, and future core canvas surfaces.
## Local Contracts

View file

@ -0,0 +1,44 @@
import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
function waitForElement(selector, timeoutMs = 3000) {
const found = document.querySelector(selector);
if (found) return Promise.resolve(found);
return new Promise((resolve) => {
const timeout = globalThis.setTimeout(() => {
observer.disconnect();
resolve(document.querySelector(selector));
}, timeoutMs);
const observer = new MutationObserver(() => {
const element = document.querySelector(selector);
if (!element) return;
globalThis.clearTimeout(timeout);
observer.disconnect();
resolve(element);
});
observer.observe(document.body, { childList: true, subtree: true });
});
}
export default async function registerFilesSurface(surfaces) {
surfaces.registerSurface({
id: "files",
title: "Files",
icon: "folder",
order: 5,
modalPath: "modals/file-browser/file-browser.html",
beginDockHandoff() {
fileBrowserStore.beginSurfaceHandoff?.();
},
finishDockHandoff(payload = {}) {
fileBrowserStore.finishSurfaceHandoff?.(payload);
},
cancelDockHandoff() {
fileBrowserStore.cancelSurfaceHandoff?.();
},
async open(payload = {}) {
const panel = await waitForElement('[data-surface-id="files"] .file-browser-root');
if (!panel) throw new Error("Files surface panel did not mount.");
await fileBrowserStore.openSurface(payload.path || payload.filePath || payload.directory || "");
},
});
}

View file

@ -16,6 +16,7 @@
- Keep editor session state synchronized across API, WebSocket, and WebUI panel behavior.
- Do not expose unsaved content or local paths beyond intended chat/context surfaces.
- Keep the floating Editor modal on the shared surface modal chrome so the header remains draggable while existing Focus mode continues to work.
## Work Guidance

View file

@ -2,7 +2,10 @@ import { createStore } from "/js/AlpineStore.js";
import { callJsonApi } from "/js/api.js";
import { getNamespacedClient } from "/js/websocket.js";
import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
import { placeSurfaceModalHeaderAction } from "/js/surfaces.js";
import {
placeSurfaceModalHeaderAction,
setupFloatingSurfaceModalChrome,
} from "/js/surfaces.js";
import {
buildMarkdownPages,
isExternalHref,
@ -1658,38 +1661,21 @@ const model = {
if (!inner || !header || inner.dataset.editorModalReady === "1") return;
inner.dataset.editorModalReady = "1";
inner.classList.add("editor-modal");
const cleanup = [];
const focusButton = document.createElement("button");
focusButton.type = "button";
focusButton.className = "surface-button editor-modal-focus-button";
focusButton.innerHTML = '<span class="material-symbols-outlined" aria-hidden="true">fullscreen</span>';
const updateFocusButton = (active) => {
const label = active ? "Restore size" : "Focus mode";
focusButton.setAttribute("aria-label", label);
focusButton.setAttribute("title", label);
focusButton.querySelector(".material-symbols-outlined").textContent = active ? "fullscreen_exit" : "fullscreen";
};
updateFocusButton(false);
const onFocusClick = () => {
const active = !inner.classList.contains("is-focus-mode");
inner.classList.toggle("is-focus-mode", active);
updateFocusButton(active);
};
focusButton.addEventListener("click", onFocusClick);
placeSurfaceModalHeaderAction(header, focusButton, "window");
cleanup.push(() => focusButton.removeEventListener("click", onFocusClick));
cleanup.push(() => focusButton.remove());
this._headerCleanup = () => {
cleanup.splice(0).reverse().forEach((entry) => entry());
delete inner.dataset.editorModalReady;
inner.classList.remove("editor-modal", "is-focus-mode");
};
const floatingCleanup = setupFloatingSurfaceModalChrome({
root,
modalClass: "editor-modal",
focusButtonClass: "editor-modal-focus-button",
minWidth: 640,
minHeight: 460,
onBoundsChange: () => this.refreshSourceEditorLayout(),
onFocusChange: () => this.refreshSourceEditorLayout(),
});
const menuCleanup = this.installHeaderNewMenu(header);
const previousCleanup = this._headerCleanup;
this._headerCleanup = () => {
menuCleanup?.();
previousCleanup?.();
floatingCleanup?.();
delete inner.dataset.editorModalReady;
inner.classList.remove("editor-modal", "is-focus-mode");
};
},
};

View file

@ -28,6 +28,13 @@ def test_file_browser_editable_path_bar_and_remembered_directory_contract() -> N
workdir_settings = read("webui", "components", "settings", "agent", "workdir.html")
assert 'class="path-navigator"' in html
assert 'class="nav-button back-button"' in html
assert 'class="text-button back-button"' not in html
assert ".nav-button:focus-visible" in html
assert ".nav-button .material-symbols-outlined" in html
assert 'class="nav-button-label">Up</span>' in html
assert "flex-direction: column;" in html
assert ".nav-button-label" in html
assert 'x-model="$store.fileBrowser.pathInput"' in html
assert '@submit.prevent="$store.fileBrowser.submitPath()"' in html
assert "Go to directory" in html
@ -39,6 +46,12 @@ def test_file_browser_editable_path_bar_and_remembered_directory_contract() -> N
assert "getRememberedDirectory()" in store
assert "rememberCurrentDirectory(this.browser.currentPath)" in store
assert "clearRememberedDirectory()" in store
assert "scheduleMountedDefaultLoad()" in store
assert 'this.browser.currentPath = "";' in store
assert 'this.browser.parentPath = "";' in store
assert 'const requestedPath = this.normalizeOpeningPath(path) || "$WORK_DIR";' in store
assert "`/get_work_dir_files?path=${encodeURIComponent(requestedPath)}`" in store
assert 'result.current_path || (requestedPath === "$WORK_DIR" ? "/a0" : requestedPath)' in store
explicit_path_index = store.index("const explicitPath = this.normalizeOpeningPath")
remembered_path_index = store.index("const rememberedPath = !explicitPath")
@ -48,6 +61,71 @@ def test_file_browser_editable_path_bar_and_remembered_directory_contract() -> N
assert "$store.settings.settings.file_browser_remember_last_directory" in workdir_settings
def test_file_browser_compact_controls_and_narrow_layout_contract() -> None:
html = read("webui", "components", "modals", "file-browser", "file-browser.html")
dox = read("webui", "components", "modals", "file-browser", "AGENTS.md")
assert 'aria-label="New file"' in html
assert 'title="New file"' in html
assert 'aria-label="New folder"' in html
assert 'title="New folder"' in html
assert ">New File<" not in html
assert ">New Folder<" not in html
assert ".btn-new-item" in html
assert "width: 2.8rem;" in html
assert "height: 2.8rem;" in html
assert "container: file-browser / inline-size;" in html
assert "@container file-browser (max-width: 620px)" in html
assert "grid-template-columns: 2.25rem minmax(0, 1fr) minmax(4.25rem, max-content) 5.25rem;" in html
assert ".file-cell-date,\n .file-date {\n display: none;" in html
assert ".file-cell-size,\n .file-size" not in html
assert "hiding the Modified date column" in dox
assert "New file and New folder controls icon-only" in dox
def test_file_browser_empty_api_path_uses_default_workdir_contract() -> None:
api_source = read("api", "get_work_dir_files.py")
api_dox = read("api", "get_work_dir_files.py.dox.md")
assert 'current_path = request.args.get("path", "") or "$WORK_DIR"' in api_source
assert 'current_path = "/a0"' in api_source
assert "Empty `path` requests and explicit `$WORK_DIR` requests resolve" in api_dox
def test_file_browser_is_registered_as_right_canvas_surface() -> None:
html = read("webui", "components", "modals", "file-browser", "file-browser.html")
store = read("webui", "components", "modals", "file-browser", "file-browser-store.js")
surfaces = read("webui", "js", "surfaces.js")
register = read("extensions", "webui", "right_canvas_register_surfaces", "register-files.js")
panel = read("extensions", "webui", "right-canvas-panels", "files-panel.html")
input_store = read("webui", "components", "chat", "input", "input-store.js")
assert 'id: "files"' in surfaces
assert 'title: "Files"' in surfaces
assert 'modalPath: "modals/file-browser/file-browser.html"' in surfaces
assert 'await store.openSurface(payload.path || payload.filePath || payload.directory || "")' in surfaces
assert 'data-surface-id="files"' in html
assert 'data-surface-modal-path="modals/file-browser/file-browser.html"' in html
assert 'class="surface-modal file-browser-modal modal-no-backdrop"' in html
assert 'class="file-browser-modal-body"' in html
assert 'x-create="$store.fileBrowser.onMount($el, xAttrs($el) || {})"' in html
assert 'x-destroy="$store.fileBrowser.onUnmount(xAttrs($el) || {})"' in html
assert ".modal-inner.file-browser-modal" in html
assert "resize: both" in html
assert "openSurface(path" in store
assert "setupFloatingSurfaceModalChrome" in store
assert 'focusButtonClass: "file-browser-modal-focus-button"' in store
assert "beginSurfaceHandoff()" in store
assert "finishSurfaceHandoff()" in store
assert 'id: "files"' in register
assert "fileBrowserStore.openSurface" in register
assert 'data-surface-id="files"' in panel
assert 'path="modals/file-browser/file-browser.html" mode="canvas"' in panel
assert 'openLatestSurface("files"' in input_store
def test_file_browser_reports_missing_directory(tmp_path: Path) -> None:
missing_directory = tmp_path / "missing"

View file

@ -201,6 +201,36 @@ def test_browser_surface_restores_focus_mode_chrome():
assert ".modal-inner.browser-modal.is-focus-mode" in browser_panel
def test_files_and_editor_surface_modals_have_draggable_focus_chrome():
surfaces_js = read("webui", "js", "surfaces.js")
surfaces_css = read("webui", "css", "surfaces.css")
file_store = read("webui", "components", "modals", "file-browser", "file-browser-store.js")
file_modal = read("webui", "components", "modals", "file-browser", "file-browser.html")
editor_store = read("plugins", "_editor", "webui", "editor-store.js")
editor_panel = read("plugins", "_editor", "webui", "editor-panel.html")
assert "setupFloatingSurfaceModalChrome" in surfaces_js
assert "is-draggable-surface-modal" in surfaces_js
assert "surface-modal-focus-button" in surfaces_js
assert "fullscreen_exit" in surfaces_js
assert "Focus mode" in surfaces_js
assert "Restore size" in surfaces_js
assert ".modal-inner.surface-modal.is-draggable-surface-modal .modal-header" in surfaces_css
assert "cursor: move" in surfaces_css
assert ".surface-modal-focus-button.is-active" in surfaces_css
assert "setupFloatingSurfaceModalChrome" in file_store
assert 'focusButtonClass: "file-browser-modal-focus-button"' in file_store
assert ".modal-inner.file-browser-modal" in file_modal
assert "resize: both" in file_modal
assert "setupFloatingSurfaceModalChrome" in editor_store
assert 'focusButtonClass: "editor-modal-focus-button"' in editor_store
assert "onBoundsChange: () => this.refreshSourceEditorLayout()" in editor_store
assert "editor.resize?.(true)" in editor_store
assert ".modal-inner.editor-modal.is-focus-mode" in editor_panel
def test_office_frontend_is_document_only_and_does_not_import_browser_or_desktop_runtime_code():
office_store = read("plugins", "_office", "webui", "office-store.js")
office_panel = read("plugins", "_office", "webui", "office-panel.html")

View file

@ -1,6 +1,7 @@
import { createStore } from "/js/AlpineStore.js";
import * as shortcuts from "/js/shortcuts.js";
import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js";
import { openLatest as openLatestSurface } from "/js/surfaces.js";
import { store as messageQueueStore } from "/components/chat/message-queue/message-queue-store.js";
import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js";
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
@ -330,7 +331,13 @@ const model = {
}
}
}
await fileBrowserStore.open(path);
let opened = false;
try {
opened = await openLatestSurface("files", { path, source: "sidebar" });
} catch (error) {
console.error("Error opening Files surface", error);
}
if (!opened) await fileBrowserStore.open(path);
},
focus() {

View file

@ -27,4 +27,8 @@
## Child DOX Index
No child DOX files.
Direct child DOX files:
| Child | Scope |
| --- | --- |
| [file-browser/AGENTS.md](file-browser/AGENTS.md) | File browser modal and right-canvas Files surface workflow. |

View file

@ -0,0 +1,36 @@
# File Browser Modal DOX
## Purpose
- Own the WebUI file browser workflow for modal and right-canvas Files surface entry points.
## Ownership
- `file-browser.html` owns file list markup, path/search controls, scoped styles, and modal/canvas footer behavior.
- `file-browser-store.js` owns directory loading, remembered-location state, selection, upload/download/delete actions, and surface handoff state.
- `rename-modal.html` owns rename and create-folder prompts that reuse the file-browser store.
## Local Contracts
- Keep `open(path)` as the modal entry point for workflows that await browser close.
- Keep `openSurface(path)` as the right-canvas entry point; it must load files without opening or awaiting a modal.
- The floating file-browser modal must use the shared surface modal chrome so it remains draggable/resizable and exposes Focus mode.
- Preserve remembered-directory behavior: explicit paths win, then remembered path, then `$WORK_DIR`.
- Empty mounted startup states must self-heal to the `$WORK_DIR` default instead of rendering a blank path and empty list.
- Keep the file list readable in narrow canvas/modal containers by hiding the Modified date column before sacrificing the Name or Size columns.
- Keep New file and New folder controls icon-only across canvas and modal modes while preserving accessible labels.
- Preserve surface actions that route supported files to Browser, Desktop, or Editor.
## Work Guidance
- Share markup and store behavior between modal and canvas modes; branch only on explicit component `mode`.
- Keep modal footer relocation compatible with `data-modal-footer` while allowing canvas mode to render inline controls.
## Verification
- Smoke-test opening Files as a modal and from the right-canvas rail.
- Run targeted file-browser tests after behavior changes.
## Child DOX Index
No child DOX files.

View file

@ -2,8 +2,12 @@ import { createStore } from "/js/AlpineStore.js";
import { callJsonApi, fetchApi } from "/js/api.js";
import { formatDateTime } from "/js/time-utils.js";
import { store as fileEditorStore } from "/components/modals/file-editor/file-editor-store.js";
import { openLatest as openLatestSurface } from "/js/surfaces.js";
import {
openLatest as openLatestSurface,
setupFloatingSurfaceModalChrome,
} from "/js/surfaces.js";
const FILE_BROWSER_MODAL_PATH = "modals/file-browser/file-browser.html";
const FILE_BROWSER_LAST_DIRECTORY_STORAGE_KEY = "fileBrowser.lastDirectory";
const DEFAULT_REMEMBER_LAST_DIRECTORY = true;
const MARKDOWN_EXTENSIONS = new Set(["md", "markdown", "mdown"]);
@ -61,6 +65,8 @@ const model = {
history: [], // navigation stack
initialPath: "", // Store path for open() call
closePromise: null,
isSurfaceHandoff: false,
surfaceHandoffPath: "",
error: null,
pathInput: "",
pathError: "",
@ -68,6 +74,8 @@ const model = {
rememberLastDirectory: DEFAULT_REMEMBER_LAST_DIRECTORY,
settingsLoadPromise: null,
settingsUpdatedHandler: null,
_floatingCleanup: null,
_mountedDefaultLoadTimer: null,
renameTarget: null,
renameName: "",
renameMode: "rename",
@ -92,43 +100,36 @@ const model = {
document.addEventListener("settings-updated", this.settingsUpdatedHandler);
},
onMount(element = null, options = {}) {
this._floatingCleanup?.();
this._floatingCleanup = null;
const mode = options?.mode === "canvas" ? "canvas" : "modal";
if (mode === "modal") {
this.setupFloatingModal(element);
} else {
this.scheduleMountedDefaultLoad();
}
},
onUnmount() {
this._floatingCleanup?.();
this._floatingCleanup = null;
this.cancelMountedDefaultLoad();
},
// --- Public API (called from button/link) --------------------------------
async open(path = "") {
if (this.isLoading) return; // Prevent double-open
this.isLoading = true;
this.error = null;
this.history = [];
this.searchQuery = "";
this.isBulkBusy = false;
this.pathError = "";
this.isPathSubmitting = false;
this.resetOpenState();
try {
// Open modal FIRST (immediate UI feedback)
this.closePromise = window.openModal(
"modals/file-browser/file-browser.html"
);
await this.loadDirectoryPreference();
const explicitPath = this.normalizeOpeningPath(path || this.initialPath);
const rememberedPath = !explicitPath ? this.getRememberedDirectory() : "";
path = explicitPath || rememberedPath || "$WORK_DIR";
this.browser.currentPath = path;
this.syncPathInput();
// Fetch files
const loaded = await this.fetchFiles(this.browser.currentPath, {
preserveOnError: Boolean(rememberedPath && path === rememberedPath),
suppressErrorToast: Boolean(rememberedPath && path === rememberedPath),
});
if (!loaded && rememberedPath && path === rememberedPath) {
this.clearRememberedDirectory();
await this.fetchFiles("$WORK_DIR");
}
this.closePromise = window.openModal(FILE_BROWSER_MODAL_PATH);
await this.loadOpeningPath(path);
// await modal close
await this.closePromise;
this.destroy();
if (!this.isSurfaceHandoff) this.destroy();
} catch (error) {
console.error("File browser error:", error);
@ -137,17 +138,45 @@ const model = {
}
},
async openSurface(path = "") {
if (this.isLoading) return false;
this.resetOpenState();
try {
const retainedPath = this.normalizeOpeningPath(
path
|| this.surfaceHandoffPath
|| this.browser.currentPath
|| this.initialPath
);
return await this.loadOpeningPath(retainedPath);
} catch (error) {
console.error("File browser surface error:", error);
this.error = error?.message || "Failed to load files";
this.isLoading = false;
return false;
}
},
handleClose() {
// Close the modal manually
this.disposeScopedTooltips();
window.closeModal();
window.closeModal(FILE_BROWSER_MODAL_PATH);
},
destroy() {
this._floatingCleanup?.();
this._floatingCleanup = null;
this.cancelMountedDefaultLoad();
// Reset state when modal closes
this.isLoading = false;
this.history = [];
this.initialPath = "";
this.closePromise = null;
this.isSurfaceHandoff = false;
this.surfaceHandoffPath = "";
this.browser.currentPath = "";
this.browser.parentPath = "";
this.browser.entries = [];
this.openDropdownPath = null;
this.searchQuery = "";
@ -158,7 +187,87 @@ const model = {
this.resetRenameState();
},
setupFloatingModal(element = null) {
this._floatingCleanup?.();
this._floatingCleanup = setupFloatingSurfaceModalChrome({
root: element,
modalClass: "file-browser-modal",
focusButtonClass: "file-browser-modal-focus-button",
minWidth: 420,
minHeight: 360,
});
},
cancelMountedDefaultLoad() {
if (!this._mountedDefaultLoadTimer) return;
globalThis.clearTimeout(this._mountedDefaultLoadTimer);
this._mountedDefaultLoadTimer = null;
},
scheduleMountedDefaultLoad() {
this.cancelMountedDefaultLoad();
this._mountedDefaultLoadTimer = globalThis.setTimeout(async () => {
this._mountedDefaultLoadTimer = null;
if (this.isLoading) return;
const targetPath = this.browser.currentPath || "";
if (targetPath && this.browser.entries.length) {
this.syncPathInput();
return;
}
try {
await this.loadOpeningPath(targetPath);
} catch (error) {
console.error("File browser default path load failed:", error);
}
}, 120);
},
// --- Helpers -------------------------------------------------------------
resetOpenState() {
this.cancelMountedDefaultLoad();
this.isLoading = true;
this.error = null;
this.history = [];
this.searchQuery = "";
this.isBulkBusy = false;
this.pathError = "";
this.isPathSubmitting = false;
},
async loadOpeningPath(path = "") {
await this.loadDirectoryPreference();
const explicitPath = this.normalizeOpeningPath(path || this.initialPath);
const rememberedPath = !explicitPath ? this.getRememberedDirectory() : "";
const targetPath = explicitPath || rememberedPath || "$WORK_DIR";
this.browser.currentPath = targetPath;
this.syncPathInput();
const loaded = await this.fetchFiles(this.browser.currentPath, {
preserveOnError: Boolean(rememberedPath && targetPath === rememberedPath),
suppressErrorToast: Boolean(rememberedPath && targetPath === rememberedPath),
});
if (!loaded && rememberedPath && targetPath === rememberedPath) {
this.clearRememberedDirectory();
return await this.fetchFiles("$WORK_DIR");
}
return loaded;
},
beginSurfaceHandoff() {
this.isSurfaceHandoff = true;
this.surfaceHandoffPath = this.browser.currentPath || this.pathInput || "";
},
finishSurfaceHandoff() {
this.isSurfaceHandoff = false;
this.surfaceHandoffPath = "";
},
cancelSurfaceHandoff() {
this.isSurfaceHandoff = false;
this.surfaceHandoffPath = "";
},
isArchive(filename) {
const archiveExts = ["zip", "tar", "gz", "rar", "7z"];
const ext = filename.split(".").pop().toLowerCase();
@ -493,11 +602,13 @@ const model = {
async fetchFiles(path = "", options = {}) {
const preserveOnError = options?.preserveOnError === true;
const suppressErrorToast = options?.suppressErrorToast === true;
const requestedPath = this.normalizeOpeningPath(path) || "$WORK_DIR";
this.isLoading = true;
// Preserve scroll position if refreshing the same path
const isSamePath = this.browser.currentPath === path ||
(!path && !this.browser.currentPath);
const isSamePath =
this.browser.currentPath === requestedPath ||
(requestedPath === "$WORK_DIR" && ["/a0", "$WORK_DIR", ""].includes(this.browser.currentPath));
const scrollPos = isSamePath ? this.saveScrollPosition() : null;
const selectedPaths = isSamePath
? new Set(this.selectedFiles.map((file) => file.path))
@ -505,12 +616,14 @@ const model = {
try {
const response = await fetchApi(
`/get_work_dir_files?path=${encodeURIComponent(path)}`
`/get_work_dir_files?path=${encodeURIComponent(requestedPath)}`
);
const data = await response.json().catch(() => ({}));
const result = data.data || {};
const requestedPath = String(path || "");
const entries = result.entries || [];
const resolvedCurrentPath =
result.current_path || (requestedPath === "$WORK_DIR" ? "/a0" : requestedPath);
const resultError =
data.error ||
result.error ||
@ -518,7 +631,7 @@ const model = {
requestedPath &&
requestedPath !== "$WORK_DIR" &&
!result.current_path &&
!(result.entries || []).length
!entries.length
? "Directory not found or not accessible"
: ""
);
@ -526,10 +639,10 @@ const model = {
if (response.ok && !resultError) {
if (!isSamePath) this.searchQuery = "";
this.browser.entries = this.decorateEntries(
result.entries || [],
entries,
selectedPaths
);
this.browser.currentPath = result.current_path;
this.browser.currentPath = resolvedCurrentPath;
this.browser.parentPath = result.parent_path;
this.syncPathInput();
this.pathError = "";
@ -1020,7 +1133,7 @@ const model = {
}
this.disposeScopedTooltips();
await window.closeModal?.("modals/file-browser/file-browser.html");
await window.closeModal?.(FILE_BROWSER_MODAL_PATH);
} catch (error) {
window.toastFrontendError?.(
error?.message || "Could not open file",

View file

@ -1,14 +1,30 @@
<html>
<html
class="surface-modal file-browser-modal modal-no-backdrop"
data-surface-id="files"
data-surface-modal-path="modals/file-browser/file-browser.html"
data-surface-dock-title="Open Files in canvas"
data-surface-dock-icon="dock_to_right"
data-canvas-surface="files"
data-canvas-modal-path="modals/file-browser/file-browser.html"
data-canvas-dock-title="Open Files in canvas"
data-canvas-dock-icon="dock_to_right"
>
<head>
<title>File Browser</title>
<script type="module">
import { store } from "/components/modals/file-browser/file-browser-store.js";
</script>
</head>
<body>
<div x-data>
<body class="file-browser-modal-body">
<div
x-data
class="file-browser-shell"
:class="{ 'is-surface': xAttrs($el)?.mode === 'canvas' }"
x-create="$store.fileBrowser.onMount($el, xAttrs($el) || {})"
x-destroy="$store.fileBrowser.onUnmount(xAttrs($el) || {})"
>
<template x-if="$store.fileBrowser">
<div class="file-browser-root">
<div class="file-browser-root" :class="{ 'is-surface': xAttrs($el)?.mode === 'canvas' }">
<!-- Loading State -->
<div x-show="$store.fileBrowser.isLoading" class="loading-state">
@ -21,11 +37,16 @@
<!-- Path navigator -->
<div class="path-navigator-wrap">
<form class="path-navigator" @submit.prevent="$store.fileBrowser.submitPath()">
<button type="button" class="text-button back-button" @click="$store.fileBrowser.navigateUp()" aria-label="Navigate Up">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10.5 15">
<path d="m.75,5.25L5.25.75m0,0l4.5,4.5M5.25.75v13.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
</svg>
Up
<button
type="button"
class="nav-button back-button"
@click="$store.fileBrowser.navigateUp()"
:disabled="!$store.fileBrowser.browser.parentPath || $store.fileBrowser.isLoading"
aria-label="Navigate up"
title="Navigate up"
>
<span class="material-symbols-outlined" aria-hidden="true">arrow_upward</span>
<span class="nav-button-label">Up</span>
</button>
<div class="path-input-shell" :class="{ 'has-error': $store.fileBrowser.pathError }">
<span class="material-symbols-outlined path-input-icon" aria-hidden="true">folder_open</span>
@ -79,13 +100,23 @@
</div>
<div class="new-item-buttons">
<button class="btn btn-ok btn-new-item" @click="$store.fileBrowser.openNewFile()">
<span class="material-symbols-outlined">note_add</span>
New File
<button
type="button"
class="btn btn-ok btn-new-item"
@click="$store.fileBrowser.openNewFile()"
aria-label="New file"
title="New file"
>
<span class="material-symbols-outlined" aria-hidden="true">note_add</span>
</button>
<button class="btn btn-ok btn-new-item" @click="$store.fileBrowser.openNewFolderModal()">
<span class="material-symbols-outlined">create_new_folder</span>
New Folder
<button
type="button"
class="btn btn-ok btn-new-item"
@click="$store.fileBrowser.openNewFolderModal()"
aria-label="New folder"
title="New folder"
>
<span class="material-symbols-outlined" aria-hidden="true">create_new_folder</span>
</button>
</div>
</div>
@ -297,20 +328,81 @@
<!-- Modal Footer (outside template x-if so it exists immediately) -->
<template x-if="$store.fileBrowser">
<div class="modal-footer" data-modal-footer>
<div class="modal-footer file-browser-footer" data-modal-footer :class="{ 'is-surface': xAttrs($el)?.mode === 'canvas' }">
<label class="btn btn-upload">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5"/></svg>
Upload Files
<input type="file" multiple accept="*" @change="$store.fileBrowser.handleFileUpload" style="display:none;" />
</label>
<button class="btn btn-cancel" @click="$store.fileBrowser.handleClose()">Close Browser</button>
<button class="btn btn-cancel" x-show="xAttrs($el)?.mode !== 'canvas'" @click="$store.fileBrowser.handleClose()">Close Browser</button>
</div>
</template>
</div>
<style>
.file-browser-shell {
width: 100%;
min-width: 0;
}
.modal-inner.file-browser-modal {
box-sizing: border-box;
width: min(78vw, 1040px);
height: min(82vh, 820px);
min-width: min(420px, calc(100vw - 16px));
min-height: min(360px, calc(100vh - 16px));
max-width: calc(100vw - 16px);
max-height: calc(100vh - 16px);
resize: both;
overflow: hidden;
}
.modal-inner.file-browser-modal.is-focus-mode {
resize: none;
border-radius: 6px;
}
.modal-inner.file-browser-modal .modal-header {
min-height: 34px;
padding: 0.35rem 0.75rem 0.35rem 1rem;
background: color-mix(in srgb, var(--color-background) 92%, #000 8%);
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
}
.modal-inner.file-browser-modal .modal-scroll,
.modal-inner.file-browser-modal .modal-bd.file-browser-modal-body {
display: flex;
flex: 1 1 auto;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
max-height: none;
overflow: hidden;
padding: 0;
}
.modal-inner.file-browser-modal .modal-bd.file-browser-modal-body > div[x-data],
.modal-inner.file-browser-modal .file-browser-shell {
display: flex;
flex: 1 1 auto;
flex-direction: column;
height: 100%;
min-width: 0;
min-height: 0;
}
.file-browser-shell.is-surface {
display: flex;
flex: 1 1 auto;
flex-direction: column;
height: 100%;
min-height: 0;
}
/* File Browser Root */
.file-browser-root {
container: file-browser / inline-size;
display: flex;
flex-direction: column;
width: 100%;
@ -318,6 +410,16 @@
min-height: 400px;
}
.file-browser-root.is-surface {
flex: 1 1 auto;
min-height: 0;
}
.modal-inner.file-browser-modal .file-browser-root {
flex: 1 1 auto;
min-height: 0;
}
/* Loading State */
.loading-state {
display: flex;
@ -354,6 +456,17 @@
flex-direction: column;
padding: var(--spacing-sm) var(--spacing-sm);
gap: var(--spacing-sm);
min-height: 0;
}
.file-browser-shell.is-surface .file-browser-content {
flex: 1 1 auto;
overflow: hidden;
}
.modal-inner.file-browser-modal .file-browser-content {
flex: 1 1 auto;
overflow: hidden;
}
/* File Browser Styles */
@ -363,6 +476,34 @@
/* Removed overflow: hidden to allow dropdown menus to be visible */
}
.file-browser-shell.is-surface .files-list {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
border: 1px solid var(--color-border);
background: color-mix(in srgb, var(--color-panel) 82%, transparent);
}
.modal-inner.file-browser-modal .files-list {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
border: 1px solid var(--color-border);
background: color-mix(in srgb, var(--color-panel) 82%, transparent);
}
.file-browser-shell.is-surface .file-header {
position: sticky;
top: 0;
z-index: 2;
}
.modal-inner.file-browser-modal .file-header {
position: sticky;
top: 0;
z-index: 2;
}
/* Header Styles */
.file-header {
width: 100%;
@ -633,23 +774,51 @@
}
.nav-button {
padding: 4px 12px;
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.1rem;
min-height: 2.35rem;
padding: 0.22rem 0.62rem 0.24rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background);
border-radius: 6px;
background: color-mix(in srgb, var(--color-input) 86%, var(--color-panel) 14%);
color: var(--color-text);
font: inherit;
line-height: 1;
box-shadow: none;
cursor: pointer;
transition: background-color 0.2s;
}
.nav-button:hover {
background: var(--hover-bg);
transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, opacity 0.15s ease;
}
.nav-button.back-button {
background-color: var(--color-secondary);
flex: 0 0 auto;
flex-direction: column;
min-width: 3.1rem;
}
.nav-button .material-symbols-outlined {
font-size: 1.05rem;
line-height: 1;
}
.nav-button-label {
font-size: 0.66rem;
font-weight: 600;
letter-spacing: 0;
line-height: 1;
}
.nav-button:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--color-primary) 34%, var(--color-border));
background: color-mix(in srgb, var(--color-input-focus) 84%, var(--color-primary) 16%);
color: var(--color-text);
}
.nav-button.back-button:hover {
background-color: var(--color-secondary-dark);
.nav-button:focus-visible {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-primary) 22%, transparent);
}
.nav-button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
/* Folder Specific Styles */
.file-item[data-is-dir="true"] {
@ -681,6 +850,16 @@
background-color: #2b309c;
}
.file-browser-footer.is-surface {
display: flex;
flex: 0 0 auto;
justify-content: flex-end;
gap: var(--spacing-sm);
padding: var(--spacing-sm);
border-top: 1px solid var(--color-border);
background: color-mix(in srgb, var(--color-panel) 90%, var(--color-background) 10%);
}
/* File Actions */
.file-actions {
display: flex;
@ -697,8 +876,17 @@
.btn-new-item {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 8px 14px;
width: 2.8rem;
height: 2.8rem;
min-width: 2.8rem;
padding: 0;
border-radius: 8px;
}
.btn-new-item .material-symbols-outlined {
font-size: 1.35rem;
line-height: 1;
}
.btn-secondary {
background: var(--color-secondary);
@ -729,13 +917,6 @@
min-width: 0;
width: 100%;
}
.new-item-buttons {
width: 100%;
}
.new-item-buttons .btn-new-item {
flex: 1;
justify-content: center;
}
.file-header {
grid-template-columns: 2.25rem minmax(0, 1fr) minmax(5rem, 0.52fr) 7.5rem;
}
@ -750,15 +931,26 @@
@media (max-width: 540px) {
.file-header,
.file-item {
grid-template-columns: 2.25rem minmax(0, 1fr) 7.5rem;
grid-template-columns: 2.25rem minmax(0, 1fr) minmax(4.25rem, max-content) 5.25rem;
}
.file-cell-size,
.file-size,
.file-cell-date,
.file-date {
display: none;
}
}
@container file-browser (max-width: 620px) {
.file-header,
.file-item {
grid-template-columns: 2.25rem minmax(0, 1fr) minmax(4.25rem, max-content) 5.25rem;
}
.file-cell-date,
.file-date {
display: none;
}
.file-actions {
padding-right: 0.35rem;
}
}
</style>
</body>
</html>

View file

@ -10,6 +10,7 @@
- Each CSS file owns a named surface or primitive family such as buttons, messages, modals, notifications, scheduler, settings, surfaces, tables, or toast.
- Component-specific styles should usually stay inside the component HTML unless they are intentionally shared.
- `modals.css` owns the shared stacked modal shell, backdrop, scroll area, footer slot, modal button classes, floating/no-backdrop modal behavior, and shared modal section primitives.
- `surfaces.css` owns surface modal switchers, action rails, draggable header affordances, focus-button state, and right-canvas surface primitives.
- `index.css` defines global theme variables such as `--color-*`, `--spacing-*`, `--font-size-*`, and `--transition-speed`.
## Local Contracts

View file

@ -20,6 +20,22 @@
pointer-events: auto;
}
.modal-inner.surface-modal.is-draggable-surface-modal .modal-header {
cursor: move;
user-select: none;
}
.modal-inner.surface-modal.is-focus-mode {
resize: none;
}
.modal-inner.surface-modal.is-focus-mode .surface-modal-focus-button,
.surface-modal-focus-button.is-active {
color: var(--color-text);
border-color: color-mix(in srgb, var(--color-primary) 42%, var(--color-border));
background: color-mix(in srgb, var(--color-primary) 16%, var(--color-background));
}
.surface-switcher,
.modal-surface-switcher {
display: grid;

View file

@ -13,6 +13,7 @@
- `extensions.js` owns frontend extension loading.
- `components.js` owns `<x-component>` loading, component caching, module injection, nested component processing, and `globalThis.xAttrs`.
- `modals.js` owns the stacked modal shell, `openModal`, `closeModal`, `scrollModal`, footer relocation, backdrop, and modal z-index behavior.
- `surfaces.js` owns shared surface registration, right-canvas/modal mode routing, surface modal action rails, and reusable draggable/focus modal chrome.
- `initFw.js` owns Alpine bootstrap and custom lifecycle directives such as `x-create`, `x-destroy`, and periodic `x-every-*` hooks.
- Other modules own focused UI utilities such as modals, messages, safe markdown, shortcuts, TTS/STT, surfaces, and initialization.

View file

@ -11,6 +11,29 @@ const urlHandlers = new Set();
const SURFACE_MODAL_ACTION_GROUPS = ["surfaces", "window", "new"];
export const CORE_SURFACES = [
{
id: "files",
title: "Files",
icon: "folder",
order: 5,
modalPath: "modals/file-browser/file-browser.html",
async beginDockHandoff() {
const { store } = await import("/components/modals/file-browser/file-browser-store.js");
store.beginSurfaceHandoff?.();
},
async finishDockHandoff(payload = {}) {
const { store } = await import("/components/modals/file-browser/file-browser-store.js");
store.finishSurfaceHandoff?.(payload);
},
async cancelDockHandoff() {
const { store } = await import("/components/modals/file-browser/file-browser-store.js");
store.cancelSurfaceHandoff?.();
},
async open(payload = {}) {
const { store } = await import("/components/modals/file-browser/file-browser-store.js");
await store.openSurface(payload.path || payload.filePath || payload.directory || "");
},
},
{
id: "browser",
title: "Browser",
@ -369,6 +392,202 @@ export function placeSurfaceModalHeaderAction(header, element, groupName = "wind
refreshSurfaceModalActionRail(header);
}
export function setupFloatingSurfaceModalChrome(options = {}) {
const root = options.root || null;
const modal = options.modal || root?.closest?.(".modal") || null;
const inner = options.inner || modal?.querySelector?.(".modal-inner") || root?.closest?.(".modal-inner") || null;
const header = options.header || inner?.querySelector?.(".modal-header") || null;
if (!modal || !inner || !header) return () => {};
const viewportGap = Number.isFinite(Number(options.viewportGap)) ? Number(options.viewportGap) : 8;
const minWidth = Number.isFinite(Number(options.minWidth)) ? Number(options.minWidth) : 320;
const minHeight = Number.isFinite(Number(options.minHeight)) ? Number(options.minHeight) : 300;
const modalClass = String(options.modalClass || "").trim();
const focusButtonClass = String(options.focusButtonClass || "").trim();
const focusEnabled = options.focus !== false;
const focusLabel = options.focusLabel || "Focus mode";
const restoreLabel = options.restoreLabel || "Restore size";
const onBoundsChange = typeof options.onBoundsChange === "function" ? options.onBoundsChange : null;
const onFocusChange = typeof options.onFocusChange === "function" ? options.onFocusChange : null;
modal.classList.add("surface-floating", "modal-floating");
inner.classList.add("surface-modal", "is-draggable-surface-modal");
if (modalClass) inner.classList.add(modalClass);
const viewportWidth = () => Math.max(document.documentElement.clientWidth || 0, globalThis.innerWidth || 0);
const viewportHeight = () => Math.max(document.documentElement.clientHeight || 0, globalThis.innerHeight || 0);
const currentBounds = () => {
const bounds = inner.getBoundingClientRect();
return {
left: bounds.left,
top: bounds.top,
width: bounds.width,
height: bounds.height,
};
};
const normalizedBounds = (bounds = {}) => {
const maxWidth = Math.max(minWidth, viewportWidth() - viewportGap * 2);
const maxHeight = Math.max(minHeight, viewportHeight() - viewportGap * 2);
const width = Math.min(Math.max(minWidth, Number(bounds.width || minWidth)), maxWidth);
const height = Math.min(Math.max(minHeight, Number(bounds.height || minHeight)), maxHeight);
return {
left: Math.min(
Math.max(viewportGap, Number(bounds.left || viewportGap)),
Math.max(viewportGap, viewportWidth() - width - viewportGap),
),
top: Math.min(
Math.max(viewportGap, Number(bounds.top || viewportGap)),
Math.max(viewportGap, viewportHeight() - height - viewportGap),
),
width,
height,
};
};
const notifyBoundsChange = () => {
try {
onBoundsChange?.({
...currentBounds(),
focus: inner.classList.contains("is-focus-mode"),
});
} catch (error) {
console.error("Surface modal bounds callback failed", error);
}
};
const setBounds = (bounds = {}) => {
const next = normalizedBounds(bounds);
inner.style.position = "fixed";
inner.style.transform = "none";
inner.style.left = `${Math.round(next.left)}px`;
inner.style.top = `${Math.round(next.top)}px`;
inner.style.width = `${Math.round(next.width)}px`;
inner.style.height = `${Math.round(next.height)}px`;
inner.style.maxWidth = `${Math.max(minWidth, viewportWidth() - viewportGap * 2)}px`;
inner.style.maxHeight = `${Math.max(minHeight, viewportHeight() - viewportGap * 2)}px`;
notifyBoundsChange();
return next;
};
const focusBounds = () => ({
left: viewportGap,
top: viewportGap,
width: viewportWidth() - viewportGap * 2,
height: viewportHeight() - viewportGap * 2,
});
const clampGeometry = () => {
if (inner.classList.contains("is-focus-mode")) {
setBounds(focusBounds());
return;
}
setBounds(currentBounds());
};
const initialBounds = currentBounds();
inner.style.left = `${Math.max(viewportGap, initialBounds.left)}px`;
inner.style.top = `${Math.max(viewportGap, initialBounds.top)}px`;
inner.style.transform = "none";
clampGeometry();
let drag = null;
let resizeObserver = null;
let beforeFocusBounds = null;
let focusButton = null;
const updateFocusButton = (active) => {
if (!focusButton) return;
const label = active ? restoreLabel : focusLabel;
focusButton.setAttribute("aria-label", label);
focusButton.setAttribute("title", label);
focusButton.classList.toggle("is-active", active);
const icon = focusButton.querySelector(".material-symbols-outlined");
if (icon) icon.textContent = active ? "fullscreen_exit" : "fullscreen";
};
const setFocusMode = (enabled) => {
const active = Boolean(enabled);
if (active === inner.classList.contains("is-focus-mode")) return;
if (active) {
beforeFocusBounds = currentBounds();
inner.classList.add("is-focus-mode");
setBounds(focusBounds());
} else {
inner.classList.remove("is-focus-mode");
setBounds(beforeFocusBounds || currentBounds());
beforeFocusBounds = null;
}
updateFocusButton(active);
try {
onFocusChange?.(active);
} catch (error) {
console.error("Surface modal focus callback failed", error);
}
};
const onPointerMove = (event) => {
if (!drag) return;
setBounds({
...currentBounds(),
left: drag.left + event.clientX - drag.x,
top: drag.top + event.clientY - drag.y,
});
};
const onPointerUp = () => {
drag = null;
globalThis.removeEventListener("pointermove", onPointerMove);
globalThis.removeEventListener("pointerup", onPointerUp);
try {
header.releasePointerCapture?.(header.__surfaceModalPointerId || 0);
} catch {}
};
const onPointerDown = (event) => {
if (event.button !== 0) return;
if (event.target?.closest?.("button, input, select, textarea, a, [data-no-modal-drag], .surface-modal-actions")) return;
if (inner.classList.contains("is-focus-mode")) return;
const bounds = currentBounds();
drag = {
x: event.clientX,
y: event.clientY,
left: bounds.left,
top: bounds.top,
};
header.__surfaceModalPointerId = event.pointerId;
header.setPointerCapture?.(event.pointerId);
globalThis.addEventListener("pointermove", onPointerMove);
globalThis.addEventListener("pointerup", onPointerUp);
event.preventDefault();
};
header.addEventListener("pointerdown", onPointerDown);
if (focusEnabled) {
focusButton = globalThis.document.createElement("button");
focusButton.type = "button";
focusButton.className = ["surface-button", "surface-modal-focus-button", focusButtonClass]
.filter(Boolean)
.join(" ");
focusButton.innerHTML = '<span class="material-symbols-outlined" aria-hidden="true">fullscreen</span>';
const onFocusClick = () => setFocusMode(!inner.classList.contains("is-focus-mode"));
updateFocusButton(false);
focusButton.addEventListener("click", onFocusClick);
focusButton.__surfaceModalFocusCleanup = () => focusButton.removeEventListener("click", onFocusClick);
placeSurfaceModalHeaderAction(header, focusButton, "window");
}
globalThis.addEventListener("resize", clampGeometry);
if (globalThis.ResizeObserver) {
resizeObserver = new ResizeObserver(clampGeometry);
resizeObserver.observe(inner);
}
return () => {
focusButton?.__surfaceModalFocusCleanup?.();
focusButton?.remove();
refreshSurfaceModalActionRail(header);
header.removeEventListener("pointerdown", onPointerDown);
globalThis.removeEventListener("pointermove", onPointerMove);
globalThis.removeEventListener("pointerup", onPointerUp);
globalThis.removeEventListener("resize", clampGeometry);
resizeObserver?.disconnect?.();
inner.classList.remove("is-focus-mode", "is-draggable-surface-modal");
};
}
function markSurfaceModal(modal, metadata) {
const element = modal?.element;
const inner = modal?.inner || element?.querySelector?.(".modal-inner");