# Import Floating Window Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace the full-screen import modal with a draggable, resizable, minimizable floating window while preserving all existing import behaviors. **Architecture:** All UI code lives in a single `index.html` file (template served by FastAPI). The change modifies CSS classes (modal → window frame), adds window control HTML elements, adds drag/resize/minimize/maximize JavaScript, and persists window state to `sessionStorage`. **Tech Stack:** Vanilla JS, CSS custom properties, FastAPI serving as single-page app. **Files:** - Modify: `src/zotero_kb/templates/index.html` - Modify: `tests/test_ui.py` --- ## Task 1: Replace Modal CSS with Window Frame CSS **Files:** - Modify: `src/zotero_kb/templates/index.html:147-206` - [ ] **Step 1: Replace `.modal-shell` CSS with `.window-shell`** Old (lines 147–154): ```css .modal-shell[hidden] { display: none; } .modal-shell { position: fixed; inset: 0; z-index: 40; } ``` New: ```css .window-shell[hidden] { display: none; } .window-shell { position: fixed; inset: 0; z-index: 40; pointer-events: none; } .window-shell:not([hidden]) { pointer-events: auto; } ``` - [ ] **Step 2: Replace `.modal-overlay` CSS with `.window-frame` base** Old (lines 155–173): ```css .modal-overlay { position: absolute; inset: 0; border-radius: 0; background: rgba(24, 28, 22, 0.42); } .modal-card { position: relative; z-index: 1; width: 100vw; height: 100vh; display: grid; grid-template-rows: auto auto 1fr; background: var(--surface); border: 0; border-radius: 0; box-shadow: none; overflow: hidden; } ``` New: ```css .window-frame { position: fixed; z-index: 40; width: var(--fw-width, 90vw); height: var(--fw-height, 75vh); left: var(--fw-left, calc((100vw - var(--fw-width, 90vw)) / 2)); top: var(--fw-top, calc((100vh - var(--fw-height, 75vh)) / 2)); min-width: 40rem; min-height: 30rem; border-radius: 16px; box-shadow: 0 24px 64px rgba(35, 31, 21, 0.22); display: grid; grid-template-rows: auto 1fr auto; overflow: hidden; background: var(--surface); border: 1px solid var(--line); } .window-frame.maximized { width: 100vw; height: 100vh; left: 0 !important; top: 0 !important; border-radius: 0; --fw-width: 100vw; --fw-height: 100vh; } .window-frame:not(.maximized):not(.minimized) { resize: both; overflow: auto; } ``` - [ ] **Step 3: Add `.window-minimized-bar` CSS** After `.window-frame.maximized` block, add: ```css .window-minimized-bar { position: fixed; bottom: 1rem; right: 1rem; z-index: 50; height: 2.5rem; width: 14rem; border-radius: 8px; background: var(--surface); border: 1px solid var(--line); box-shadow: 0 8px 24px rgba(35, 31, 21, 0.14); display: none; align-items: center; padding: 0 0.75rem; gap: 0.5rem; cursor: pointer; font-size: 0.9rem; font-weight: 600; color: var(--ink); } .window-minimized-bar:not([hidden]) { display: flex; } .window-minimized-bar:hover { background: var(--panel); } ``` - [ ] **Step 4: Update `.modal-toolbar` to `.window-toolbar`** Find `.modal-toolbar` (line ~174) and `.modal-toolbar-actions` — rename to `.window-toolbar` and `.window-toolbar-actions`. Find `.modal-body` and `.modal-pane` — rename to `.window-body` and `.window-pane`. Find `.modal-scroll` — rename to `.window-scroll`. - [ ] **Step 5: Add drag cursor CSS** In the existing `button:hover` block or nearby, add: ```css .window-toolbar { cursor: grab; } .window-toolbar:active { cursor: grabbing; } ``` - [ ] **Step 6: Commit** ```bash git add src/zotero_kb/templates/index.html git commit -m "feat(import-window): replace modal CSS with window frame CSS" ``` --- ## Task 2: Replace Modal HTML Structure with Window Frame **Files:** - Modify: `src/zotero_kb/templates/index.html:342–370` - [ ] **Step 1: Replace the modal HTML with window shell + window frame + minimized bar** Old (lines 342–370): ```html ``` New: ```html ``` - [ ] **Step 2: Commit** ```bash git add src/zotero_kb/templates/index.html git commit -m "feat(import-window): replace modal HTML with window frame structure" ``` --- ## Task 3: Rename JS State Keys and Element References **Files:** - Modify: `src/zotero_kb/templates/index.html:419–498` - [ ] **Step 1: Rename `IMPORT_SESSION_STORAGE_KEY`** Old: ```js const IMPORT_SESSION_STORAGE_KEY = "zotero-kb.import-modal"; ``` New: ```js const WINDOW_SESSION_STORAGE_KEY = "zotero-kb.import-window"; const IMPORT_SESSION_STORAGE_KEY = "zotero-kb.import-modal"; ``` - [ ] **Step 2: Add `readWindowSessionState` function** After `readImportSessionState()` function (~line 434), add: ```js function readWindowSessionState() { try { const raw = window.sessionStorage.getItem(WINDOW_SESSION_STORAGE_KEY); if (!raw) return {}; const payload = JSON.parse(raw); return typeof payload === "object" && payload ? payload : {}; } catch (_error) { return {}; } } const windowSessionState = readWindowSessionState(); ``` - [ ] **Step 3: Add window state to the `state` object** In the `state` object (~line 438), add after `isImportModalOpen`: ```js isImportWindowOpen: false, windowX: typeof windowSessionState.x === "number" ? windowSessionState.x : null, windowY: typeof windowSessionState.y === "number" ? windowSessionState.y : null, windowWidth: typeof windowSessionState.width === "number" ? windowSessionState.width : null, windowHeight: typeof windowSessionState.height === "number" ? windowSessionState.height : null, isWindowMinimized: Boolean(windowSessionState.isMinimized), isWindowMaximized: Boolean(windowSessionState.isMaximized), isWindowDragging: false, windowDragStartX: 0, windowDragStartY: 0, windowDragStartLeft: 0, windowDragStartTop: 0, ``` - [ ] **Step 4: Rename `elements.importModal` and `importModalOverlay` references** Update the `elements` object (lines ~467–498): ```js importWindowShell: document.getElementById("import-window-shell"), importWindow: document.getElementById("import-window"), windowToolbar: document.getElementById("import-window-toolbar"), windowMinimizeButton: document.getElementById("window-minimize-button"), windowMaximizeButton: document.getElementById("window-maximize-button"), windowCloseButton: document.getElementById("window-close-button"), windowRestoreButton: document.getElementById("window-restore-button"), importWindowMinimizedBar: document.getElementById("import-window-minimized-bar"), windowProjectLabel: document.getElementById("import-window-project-label"), windowSelectedCount: document.getElementById("window-selected-count"), windowClearSelectionButton: document.getElementById("window-clear-selection-button"), windowImportSelectedItemsButton: document.getElementById("window-import-selected-items-button"), windowCollectionStatus: document.getElementById("window-collection-status"), windowCollectionTree: document.getElementById("window-collection-tree"), windowCollectionItems: document.getElementById("window-collection-items"), ``` Remove: `importModalOverlay`, `openImportModalButton`, `closeImportModalButton`, `importModalProjectLabel`, `collectionStatus`, `collectionTree`, `collectionItems`, `selectedCount`, `clearSelectionButton`, `importSelectedItemsButton`. - [ ] **Step 5: Add `persistWindowSessionState` function** After `persistImportSessionState()` function (~line 509): ```js function persistWindowSessionState() { const fw = elements.importWindow; if (!fw) return; const style = window.getComputedStyle(fw); const width = parseFloat(style.width); const height = parseFloat(style.height); const left = parseFloat(style.left); const top = parseFloat(style.top); window.sessionStorage.setItem( WINDOW_SESSION_STORAGE_KEY, JSON.stringify({ x: left, y: top, width: width, height: height, isMinimized: state.isWindowMinimized, isMaximized: state.isWindowMaximized, }) ); } ``` - [ ] **Step 6: Commit** ```bash git add src/zotero_kb/templates/index.html git commit -m "feat(import-window): add window state management to JS" ``` --- ## Task 4: Implement Drag, Minimize, Maximize, and Close Logic **Files:** - Modify: `src/zotero_kb/templates/index.html` - [ ] **Step 1: Add window apply position/size helper** After `persistWindowSessionState()`, add: ```js function applyWindowPositionAndSize() { const fw = elements.importWindow; if (!fw) return; fw.style.left = state.windowX !== null ? `${state.windowX}px` : ""; fw.style.top = state.windowY !== null ? `${state.windowY}px` : ""; fw.style.width = state.windowWidth !== null ? `${state.windowWidth}px` : ""; fw.style.height = state.windowHeight !== null ? `${state.windowHeight}px` : ""; } function initWindowFromSession() { const fw = elements.importWindow; const bar = elements.importWindowMinimizedBar; if (state.isWindowMinimized) { fw.classList.add("minimized"); fw.classList.remove("maximized"); bar.hidden = false; } else if (state.isWindowMaximized) { fw.classList.add("maximized"); fw.classList.remove("minimized"); fw.style.width = "100vw"; fw.style.height = "100vh"; fw.style.left = "0"; fw.style.top = "0"; bar.hidden = true; } else { if (state.windowX !== null) { applyWindowPositionAndSize(); } fw.classList.remove("minimized", "maximized"); bar.hidden = true; } } ``` - [ ] **Step 2: Add drag handlers** After `initWindowFromSession()`: ```js function startWindowDrag(event) { const fw = elements.importWindow; if (!fw || fw.classList.contains("maximized") || fw.classList.contains("minimized")) return; event.preventDefault(); state.isWindowDragging = true; state.windowDragStartX = event.clientX; state.windowDragStartY = event.clientY; state.windowDragStartLeft = parseFloat(fw.style.left) || 0; state.windowDragStartTop = parseFloat(fw.style.top) || 0; fw.style.transition = "none"; } function moveWindowDrag(event) { if (!state.isWindowDragging) return; event.preventDefault(); const fw = elements.importWindow; const dx = event.clientX - state.windowDragStartX; const dy = event.clientY - state.windowDragStartY; let newLeft = state.windowDragStartLeft + dx; let newTop = state.windowDragStartTop + dy; // boundary clamp const maxLeft = window.innerWidth - (parseFloat(fw.style.width) || fw.offsetWidth); const maxTop = window.innerHeight - (parseFloat(fw.style.height) || fw.offsetHeight); newLeft = Math.max(0, Math.min(newLeft, maxLeft)); newTop = Math.max(0, Math.min(newTop, maxTop)); fw.style.left = `${newLeft}px`; fw.style.top = `${newTop}px`; state.windowX = newLeft; state.windowY = newTop; } function endWindowDrag() { if (!state.isWindowDragging) return; state.isWindowDragging = false; persistWindowSessionState(); } function minimizeWindow() { state.isWindowMinimized = true; state.isWindowMaximized = false; elements.importWindow.classList.add("minimized"); elements.importWindow.classList.remove("maximized"); elements.importWindowMinimizedBar.hidden = false; persistWindowSessionState(); } function maximizeWindow() { state.isWindowMaximized = !state.isWindowMaximized; if (state.isWindowMaximized) { state.isWindowMinimized = false; elements.importWindow.classList.add("maximized"); elements.importWindow.classList.remove("minimized"); elements.importWindowMinimizedBar.hidden = true; elements.importWindow.style.width = "100vw"; elements.importWindow.style.height = "100vh"; elements.importWindow.style.left = "0"; elements.importWindow.style.top = "0"; } else { elements.importWindow.classList.remove("maximized"); applyWindowPositionAndSize(); } persistWindowSessionState(); } function restoreWindow() { state.isWindowMinimized = false; elements.importWindow.classList.remove("minimized"); elements.importWindowMinimizedBar.hidden = true; applyWindowPositionAndSize(); persistWindowSessionState(); } ``` - [ ] **Step 3: Wire up drag event listeners** Find where `elements.projectForm.addEventListener` is set up (~line 1079). Before that block, add: ```js elements.windowToolbar.addEventListener("pointerdown", startWindowDrag); document.addEventListener("pointermove", moveWindowDrag); document.addEventListener("pointerup", endWindowDrag); elements.windowMinimizeButton.addEventListener("click", minimizeWindow); elements.windowMaximizeButton.addEventListener("click", maximizeWindow); elements.windowCloseButton.addEventListener("click", closeImportWindow); elements.windowRestoreButton.addEventListener("click", restoreWindow); elements.importWindowMinimizedBar.addEventListener("click", (e) => { if (e.target === elements.windowRestoreButton) return; restoreWindow(); }); ``` - [ ] **Step 4: Rename `openImportModal` → `openImportWindow` and `closeImportModal` → `closeImportWindow`** In the JS, rename: - `function openImportModal()` → `function openImportWindow()` - `function closeImportModal()` → `function closeImportWindow()` - `openImportModalButton` → (removed, not needed as separate element) - `closeImportModalButton` → (removed) Find all call sites and update them. The `Esc` key handler should call `closeImportWindow`. - [ ] **Step 5: Update the open function to use window state** The `openImportWindow` function should call `initWindowFromSession()` instead of the modal open logic: ```js state.isImportWindowOpen = true; elements.importWindowShell.hidden = false; elements.importWindowShell.setAttribute("aria-hidden", "false"); document.body.classList.toggle("modal-open", true); cancelAbstractPreview(); updateImportActionState(); initWindowFromSession(); ``` - [ ] **Step 6: Update the close function** `closeImportWindow` should: ```js state.isImportWindowOpen = false; elements.importWindowShell.hidden = true; elements.importWindowShell.setAttribute("aria-hidden", "true"); document.body.classList.toggle("modal-open", false); cancelAbstractPreview(); // Note: does NOT clear window position/size/minimized state ``` - [ ] **Step 7: Update the button that opens the window** The `导入文献` button (`open-import-modal-button`) handler should call `openImportWindow()`: ```js elements.openImportModalButton.addEventListener("click", () => { openImportWindow(); }); ``` The element `openImportModalButton` should be kept in `elements` for this reference. - [ ] **Step 8: Update Esc key handler** Old: ```js document.addEventListener("keydown", (event) => { if (event.key === "Escape" && state.isImportModalOpen) { closeImportModal(); } }); ``` New: ```js document.addEventListener("keydown", (event) => { if (event.key === "Escape" && state.isImportWindowOpen) { closeImportWindow(); } }); ``` - [ ] **Step 9: Update `syncCurrentProjectLabels` and `updateImportActionState`** Update all references: - `elements.importModalProjectLabel` → `elements.windowProjectLabel` - `elements.collectionStatus` → `elements.windowCollectionStatus` - `elements.collectionTree` → `elements.windowCollectionTree` - `elements.collectionItems` → `elements.windowCollectionItems` - `elements.selectedCount` → `elements.windowSelectedCount` - `elements.clearSelectionButton` → `elements.windowClearSelectionButton` - `elements.importSelectedItemsButton` → `elements.windowImportSelectedItemsButton` - [ ] **Step 10: Update `renderCollectionTree`, `renderCollectionItems`, `renderSelectedCount`, `setStatus` call sites** Update all `elements.collectionTree`, `elements.collectionItems`, `elements.collectionStatus`, `elements.selectedCount` references to use the renamed `window*` variants. - [ ] **Step 11: Update `closeImportWindow` call inside `importSelectedItems`** When import succeeds, call `closeImportWindow()` instead of `closeImportModal()`. - [ ] **Step 12: Update `openImportWindow` call inside `loadCollectionTree` and `loadCollectionItems`** There are call sites inside `openImportModal()` (now `openImportWindow()`) that call `loadCollectionTree()` and `loadCollectionItems()`. These remain — just the function name changed. - [ ] **Step 13: Commit** ```bash git add src/zotero_kb/templates/index.html git commit -m "feat(import-window): implement drag, minimize, maximize, close logic" ``` --- ## Task 5: Update Tests **Files:** - Modify: `tests/test_ui.py` - [ ] **Step 1: Update `test_index_contains_import_modal_controls`** Replace the old test with: ```python def test_index_contains_import_window_controls(tmp_path) -> None: html = _get_index_html(tmp_path) assert 'id="open-import-modal-button"' in html assert 'id="import-window-shell"' in html assert 'id="import-window"' in html assert 'id="window-minimize-button"' in html assert 'id="window-maximize-button"' in html assert 'id="window-close-button"' in html assert 'id="window-restore-button"' in html assert 'id="import-window-minimized-bar"' in html assert 'id="window-project-label"' in html assert 'id="window-collection-tree"' in html assert 'id="window-collection-items"' in html assert 'id="window-selected-count"' in html assert 'id="window-clear-selection-button"' in html assert 'id="window-import-selected-items-button"' in html assert 'id="item-preview-popover"' in html assert 'const ABSTRACT_PREVIEW_DELAY_MS = 3000;' in html assert "isImportWindowOpen: false" in html assert "function openImportWindow()" in html assert "function closeImportWindow()" in html assert "function updateImportActionState()" in html assert "function scheduleAbstractPreview(item, target)" in html assert "function cancelAbstractPreview()" in html assert "function showAbstractPreview(item, target)" in html assert "details-button" in html assert "?include_descendants=false" in html assert "window.classList.add(\"minimized\")" in html assert "window.classList.add(\"maximized\")" in html assert "WINDOW_SESSION_STORAGE_KEY" in html ``` - [ ] **Step 2: Add new test for window CSS classes** After the above test, add: ```python def test_index_window_frame_css(tmp_path) -> None: html = _get_index_html(tmp_path) assert ".window-frame" in html assert ".window-shell" in html assert ".window-toolbar" in html assert ".window-body" in html assert ".window-minimized-bar" in html assert '"zotero-kb.import-window"' in html assert "startWindowDrag" in html assert "minimizeWindow" in html assert "maximizeWindow" in html assert "restoreWindow" in html assert "initWindowFromSession" in html assert "persistWindowSessionState" in html ``` - [ ] **Step 3: Run tests to verify** ```bash UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -v ``` Expected: All tests pass. - [ ] **Step 4: Commit** ```bash git add tests/test_ui.py git commit -m "test(import-window): update UI tests for floating window" ``` --- ## Self-Review Checklist 1. **Spec coverage:** Each requirement in `2026-04-15-import-floating-window-design.md` is implemented: - Window attributes (90vw×75vh, centered, rounded, shadow) → Task 1 - Toolbar drag → Task 4 Steps 1–3 - Minimize/maximize/close buttons → Task 4 Steps 4–5 - Minimized bar → Task 2 HTML + Task 4 Step 5 - Session state persistence → Task 3 Steps 2, 5, Task 4 Step 3 - Esc closes window → Task 4 Step 8 - All existing behaviors unchanged → Task 4 Steps 9–12 2. **Placeholder scan:** No "TBD", "TODO", or vague language. All code is concrete. 3. **Type consistency:** All renamed JS state keys and element references use consistent `window*` prefix throughout. The `state` object uses `isWindowMinimized`, `isWindowMaximized`, `isWindowDragging` etc. consistently across all tasks. 4. **Spec completeness check:** `window-minimized-bar` has `hidden` attribute in HTML and `display:none` → `display:flex` in CSS. Maximized removes rounded corners. Drag is bounded to viewport. Import button disabled when no project selected is unchanged.