23 KiB
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-shellCSS with.window-shell
Old (lines 147–154):
.modal-shell[hidden] {
display: none;
}
.modal-shell {
position: fixed;
inset: 0;
z-index: 40;
}
New:
.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-overlayCSS with.window-framebase
Old (lines 155–173):
.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:
.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-barCSS
After .window-frame.maximized block, add:
.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-toolbarto.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:
.window-toolbar {
cursor: grab;
}
.window-toolbar:active {
cursor: grabbing;
}
- Step 6: Commit
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):
<div id="import-modal" class="modal-shell" hidden aria-hidden="true">
<button id="import-modal-overlay" class="modal-overlay" type="button" aria-label="关闭导入弹窗"></button>
<section class="modal-card" role="dialog" aria-modal="true" aria-labelledby="import-modal-title">
<div class="modal-toolbar">
<button id="close-import-modal-button" type="button" class="secondary">返回主页面</button>
<div>
<h3 id="import-modal-title">Import From Zotero</h3>
<p id="import-modal-project-label" class="meta">未选择项目</p>
</div>
<div class="modal-toolbar-actions">
<div id="modal-selected-count" class="meta">已选 0 篇</div>
<button id="modal-clear-selection-button" type="button" class="danger">清空选择</button>
<button id="modal-import-selected-items-button" type="button">导入所选</button>
</div>
</div>
<div class="modal-body">
<div class="modal-pane collections">
<small>Collections</small>
<div id="collection-status" class="status"></div>
<div id="modal-collection-tree" class="modal-scroll tree-list"></div>
</div>
<div class="modal-pane items">
<small>Collection Items</small>
<div id="modal-collection-items" class="modal-scroll collection-items-list"></div>
</div>
</div>
<div id="item-preview-popover" class="preview-popover" hidden></div>
</section>
</div>
New:
<div id="import-window-shell" class="window-shell" hidden aria-hidden="true">
<section id="import-window" class="window-frame" role="dialog" aria-modal="true" aria-labelledby="import-window-title">
<div class="window-toolbar" id="import-window-toolbar">
<div style="display:flex;align-items:center;gap:0.6rem">
<span style="font-size:1.2rem;line-height:1">☰</span>
<div>
<h3 id="import-window-title">Import From Zotero</h3>
<p id="import-window-project-label" class="meta">未选择项目</p>
</div>
</div>
<div class="window-toolbar-actions">
<div id="window-selected-count" class="meta">已选 0 篇</div>
<button id="window-clear-selection-button" type="button" class="danger">清空选择</button>
<button id="window-import-selected-items-button" type="button">导入所选</button>
<button id="window-minimize-button" type="button" class="secondary" title="最小化" style="width:auto;padding:0.55rem 0.75rem">_</button>
<button id="window-maximize-button" type="button" class="secondary" title="最大化" style="width:auto;padding:0.55rem 0.75rem">□</button>
<button id="window-close-button" type="button" class="secondary" title="关闭" style="width:auto;padding:0.55rem 0.75rem">×</button>
</div>
</div>
<div class="window-body">
<div class="window-pane collections">
<small>Collections</small>
<div id="window-collection-status" class="status"></div>
<div id="window-collection-tree" class="window-scroll tree-list"></div>
</div>
<div class="window-pane items">
<small>Collection Items</small>
<div id="window-collection-items" class="window-scroll collection-items-list"></div>
</div>
</div>
<div id="item-preview-popover" class="preview-popover" hidden></div>
</section>
</div>
<div id="import-window-minimized-bar" class="window-minimized-bar" hidden>
<span style="flex:1;text-align:left">导入文献</span>
<button id="window-restore-button" type="button" class="secondary" style="width:auto;padding:0.3rem 0.5rem;font-size:0.8rem">□</button>
</div>
- Step 2: Commit
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:
const IMPORT_SESSION_STORAGE_KEY = "zotero-kb.import-modal";
New:
const WINDOW_SESSION_STORAGE_KEY = "zotero-kb.import-window";
const IMPORT_SESSION_STORAGE_KEY = "zotero-kb.import-modal";
- Step 2: Add
readWindowSessionStatefunction
After readImportSessionState() function (~line 434), add:
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
stateobject
In the state object (~line 438), add after isImportModalOpen:
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.importModalandimportModalOverlayreferences
Update the elements object (lines ~467–498):
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
persistWindowSessionStatefunction
After persistImportSessionState() function (~line 509):
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
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:
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():
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:
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→openImportWindowandcloseImportModal→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:
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:
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():
elements.openImportModalButton.addEventListener("click", () => {
openImportWindow();
});
The element openImportModalButton should be kept in elements for this reference.
- Step 8: Update Esc key handler
Old:
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && state.isImportModalOpen) {
closeImportModal();
}
});
New:
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && state.isImportWindowOpen) {
closeImportWindow();
}
});
- Step 9: Update
syncCurrentProjectLabelsandupdateImportActionState
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,setStatuscall sites
Update all elements.collectionTree, elements.collectionItems, elements.collectionStatus, elements.selectedCount references to use the renamed window* variants.
- Step 11: Update
closeImportWindowcall insideimportSelectedItems
When import succeeds, call closeImportWindow() instead of closeImportModal().
- Step 12: Update
openImportWindowcall insideloadCollectionTreeandloadCollectionItems
There are call sites inside openImportModal() (now openImportWindow()) that call loadCollectionTree() and loadCollectionItems(). These remain — just the function name changed.
- Step 13: Commit
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:
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:
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
UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -v
Expected: All tests pass.
- Step 4: Commit
git add tests/test_ui.py
git commit -m "test(import-window): update UI tests for floating window"
Self-Review Checklist
-
Spec coverage: Each requirement in
2026-04-15-import-floating-window-design.mdis 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
-
Placeholder scan: No "TBD", "TODO", or vague language. All code is concrete.
-
Type consistency: All renamed JS state keys and element references use consistent
window*prefix throughout. Thestateobject usesisWindowMinimized,isWindowMaximized,isWindowDraggingetc. consistently across all tasks. -
Spec completeness check:
window-minimized-barhashiddenattribute in HTML anddisplay:none→display:flexin CSS. Maximized removes rounded corners. Drag is bounded to viewport. Import button disabled when no project selected is unchanged.