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

@ -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");