diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..e7b6b7d --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Bash(git add:*)", + "Bash(git check-ignore:*)" + ] + } +} diff --git a/.codex b/.codex new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fe49836 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv +workspace/ \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..c8cfe39 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/.superpowers/brainstorm/31471-1776242964/state/server-stopped b/.superpowers/brainstorm/31471-1776242964/state/server-stopped new file mode 100644 index 0000000..9b78476 --- /dev/null +++ b/.superpowers/brainstorm/31471-1776242964/state/server-stopped @@ -0,0 +1 @@ +{"reason":"idle timeout","timestamp":1776244824573} diff --git a/.superpowers/brainstorm/31471-1776242964/state/server.log b/.superpowers/brainstorm/31471-1776242964/state/server.log new file mode 100644 index 0000000..573c281 --- /dev/null +++ b/.superpowers/brainstorm/31471-1776242964/state/server.log @@ -0,0 +1,2 @@ +{"type":"server-started","port":56756,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:56756","screen_dir":"/root/code/zotero-kb/.superpowers/brainstorm/31471-1776242964/content","state_dir":"/root/code/zotero-kb/.superpowers/brainstorm/31471-1776242964/state"} +{"type":"server-stopped","reason":"idle timeout"} diff --git a/.superpowers/brainstorm/31471-1776242964/state/server.pid b/.superpowers/brainstorm/31471-1776242964/state/server.pid new file mode 100644 index 0000000..c699daf --- /dev/null +++ b/.superpowers/brainstorm/31471-1776242964/state/server.pid @@ -0,0 +1 @@ +31479 diff --git a/README.md b/README.md new file mode 100644 index 0000000..48163f9 --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# Zotero KB + +本地 Zotero 文献卡片工作台。 + +## 当前能力 + +- 从本地 Zotero 数据目录读取条目、标签、笔记、collection 和附件文本 +- 通过项目管理指定文献子集 +- 生成 Markdown 知识卡片和 JSON 索引 +- 提供项目内引用推荐和初版引用方案 API +- 提供最小 Web 控制台 +- 提供 Claude Code / Codex 可直接读取的 skill 文件 +- 支持按 Zotero collection 树浏览并逐篇选择文献导入到当前项目 + +## 本地运行 + +1. 安装依赖 + +```bash +UV_CACHE_DIR=/tmp/uv-cache uv sync --extra dev +``` + +2. 设置环境变量 + +```bash +export DEEPSEEK_API_KEY=你的_deepseek_api_key +export ZOTERO_DATA_DIR=/mnt/c/Users/WSX/Zotero +export ZOTERO_KB_WORKSPACE=workspace +export ZOTERO_BRIDGE_FILE=workspace/bridge/selected-items.json +``` + +如果你已经在 `~/.bashrc` 里配过这些变量,这一步可以跳过。 + +3. 启动服务 + +```bash +UV_CACHE_DIR=/tmp/uv-cache uv run python main.py +``` + +4. 打开浏览器访问 + +```text +http://127.0.0.1:8000 +``` + +5. 在左侧点击 `导入文献` + +- 先选择一个项目 +- 点击 `导入文献` 打开全屏导入界面 +- 左侧点击一个 Zotero collection +- 右侧会显示该 collection 直接包含的文献 +- 逐篇勾选需要导入的文献 +- 点击顶部 `导入所选` +- 文献列表默认只显示标题和年份 +- 鼠标悬停标题 3 秒后会显示摘要预览 +- 在窄屏或不方便 hover 的场景下,可点击 `i` 按钮查看摘要 + +默认环境变量: + +- `ZOTERO_DATA_DIR=/mnt/c/Users/WSX/Zotero` +- `ZOTERO_KB_WORKSPACE=workspace` +- `ZOTERO_BRIDGE_FILE=workspace/bridge/selected-items.json` +- `DEEPSEEK_API_KEY=...` + +如果项目创建时使用: + +- `llm_provider=deepseek` +- `llm_model=deepseek-chat` + +导入文献生成卡片时会自动读取 `DEEPSEEK_API_KEY` 调用 DeepSeek。 + +## 测试 + +```bash +UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q +``` diff --git a/docs/superpowers/plans/2026-04-15-import-floating-window.md b/docs/superpowers/plans/2026-04-15-import-floating-window.md new file mode 100644 index 0000000..df0a905 --- /dev/null +++ b/docs/superpowers/plans/2026-04-15-import-floating-window.md @@ -0,0 +1,705 @@ +# 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. diff --git a/docs/superpowers/plans/2026-04-15-zotero-collection-import-fullscreen-modal.md b/docs/superpowers/plans/2026-04-15-zotero-collection-import-fullscreen-modal.md new file mode 100644 index 0000000..5e9d2ce --- /dev/null +++ b/docs/superpowers/plans/2026-04-15-zotero-collection-import-fullscreen-modal.md @@ -0,0 +1,601 @@ +# Zotero Collection Import Fullscreen Modal 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:** Convert the current centered import modal into a full-screen modal with left-side collection browsing, right-side direct-item selection, top-bar actions, and delayed abstract preview. + +**Architecture:** Keep the existing FastAPI backend and reuse the current collection tree and item import endpoints. Refactor the single-page `index.html` template so the import flow becomes a full-screen modal with a fixed top bar, a left collection tree, and a right item list that only loads direct collection items (`include_descendants=false`). Preserve session-scoped selection state across collection switches and modal reopen, but remove collection-level bulk-select behavior. + +**Tech Stack:** FastAPI, inline HTML/CSS/vanilla JavaScript, pytest + +--- + +## File Structure + +- Modify: `src/zotero_kb/templates/index.html` + - Convert the current centered modal into a full-screen modal shell + - Remove collection-level bulk-select behavior + - Change collection item loading to direct-only + - Update item rows to `title + year` + - Add delayed preview behavior and mobile details-button fallback +- Modify: `tests/test_ui.py` + - Update UI contract assertions for the new full-screen modal structure and preview hooks +- Modify: `README.md` + - Update the user walkthrough to describe the full-screen modal and one-by-one item selection + +### Task 1: Update The UI Contract For The Fullscreen Modal + +**Files:** +- Modify: `tests/test_ui.py` +- Read: `src/zotero_kb/templates/index.html` + +- [ ] **Step 1: Write the failing assertions** + +Extend `test_index_contains_import_modal_controls` so it asserts the new full-screen modal hooks and removes the old collection-bulk-select assumption. + +Use this function body: + +```python +def test_index_contains_import_modal_controls(tmp_path) -> None: + html = _get_index_html(tmp_path) + + assert 'id="open-import-modal-button"' in html + assert 'id="import-modal"' in html + assert 'id="close-import-modal-button"' in html + assert 'id="import-modal-project-label"' in html + assert 'id="modal-selected-count"' in html + assert 'id="modal-clear-selection-button"' in html + assert 'id="modal-import-selected-items-button"' in html + assert 'id="modal-collection-tree"' in html + assert 'id="modal-collection-items"' in html + assert 'id="item-preview-popover"' in html + assert 'const ABSTRACT_PREVIEW_DELAY_MS = 3000;' 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 'isImportModalOpen: false' in html + assert 'document.body.classList.toggle("modal-open"' in html + assert 'id="modal-select-descendants-button"' not in html +``` + +- [ ] **Step 2: Run the targeted UI test and confirm it fails** + +Run: + +```bash +UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_contains_import_modal_controls -v +``` + +Expected: FAIL because the current template still contains the old top-bar/selection contract and may still include `modal-select-descendants-button`. + +- [ ] **Step 3: Update the test file** + +Edit `tests/test_ui.py` so the full file becomes: + +```python +from zotero_kb.api import create_app +from zotero_kb.config import AppConfig + + +def _get_index_html(tmp_path) -> str: + app = create_app( + AppConfig( + workspace_dir=tmp_path / "workspace", + zotero_data_dir=tmp_path / "zotero", + bridge_file=tmp_path / "bridge.json", + ) + ) + + for route in app.routes: + if getattr(route, "path", None) == "/" and "GET" in getattr(route, "methods", set()): + return route.endpoint() + + raise AssertionError("GET / route not found") + + +def test_index_contains_base_page_forms(tmp_path) -> None: + html = _get_index_html(tmp_path) + + assert 'id="create-project-form"' in html + assert 'id="recommend-form"' in html + assert 'id="plan-form"' in html + + +def test_index_contains_import_modal_controls(tmp_path) -> None: + html = _get_index_html(tmp_path) + + assert 'id="open-import-modal-button"' in html + assert 'id="import-modal"' in html + assert 'id="close-import-modal-button"' in html + assert 'id="import-modal-project-label"' in html + assert 'id="modal-selected-count"' in html + assert 'id="modal-clear-selection-button"' in html + assert 'id="modal-import-selected-items-button"' in html + assert 'id="modal-collection-tree"' in html + assert 'id="modal-collection-items"' in html + assert 'id="item-preview-popover"' in html + assert 'const ABSTRACT_PREVIEW_DELAY_MS = 3000;' 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 'isImportModalOpen: false' in html + assert 'document.body.classList.toggle("modal-open"' in html + assert 'id="modal-select-descendants-button"' not in html +``` + +- [ ] **Step 4: Run the targeted UI test again** + +Run: + +```bash +UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_contains_import_modal_controls -v +``` + +Expected: still FAIL, now because the current template does not yet match the new full-screen modal contract. + +- [ ] **Step 5: Commit** + +```bash +git add tests/test_ui.py +git commit -m "test: define fullscreen import modal contract" +``` + +### Task 2: Convert The Centered Modal To A Fullscreen Modal Shell + +**Files:** +- Modify: `src/zotero_kb/templates/index.html` +- Test: `tests/test_ui.py` + +- [ ] **Step 1: Replace the current modal container styles** + +In `src/zotero_kb/templates/index.html`, replace the current centered-card modal CSS: + +```css +.modal-card { + position: relative; + z-index: 1; + width: min(1100px, calc(100vw - 2rem)); + max-height: calc(100vh - 2rem); + margin: 1rem auto; + display: grid; + grid-template-rows: auto auto 1fr auto; + background: var(--surface); + border: 1px solid var(--line); + border-radius: 24px; + box-shadow: 0 24px 60px rgba(35, 31, 21, 0.2); + overflow: hidden; +} +``` + +with a full-screen shell: + +```css +.modal-card { + position: relative; + z-index: 1; + width: 100vw; + height: 100vh; + display: grid; + grid-template-rows: auto 1fr; + background: var(--surface); + border: 0; + border-radius: 0; + box-shadow: none; + overflow: hidden; +} +.modal-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--line); + background: rgba(255, 253, 248, 0.98); +} +.modal-toolbar-actions { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} +.modal-body { + display: grid; + grid-template-columns: 22rem minmax(0, 1fr); + min-height: 0; +} +.modal-pane { + min-height: 0; + padding: 1rem 1.25rem; +} +.modal-pane.collections { + border-right: 1px solid var(--line); +} +``` + +- [ ] **Step 2: Replace the modal markup** + +Replace the current modal header/footer structure: + +```html +Click an item to preview
-