# 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
``` with a full-screen toolbar plus two-column body: ```html ``` - [ ] **Step 3: Remove the old collection-level bulk select button from the markup** Delete this old button entirely: ```html ``` - [ ] **Step 4: Run the targeted UI test** Run: ```bash UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_contains_import_modal_controls -v ``` Expected: PASS - [ ] **Step 5: Commit** ```bash git add src/zotero_kb/templates/index.html tests/test_ui.py git commit -m "feat: convert import modal to fullscreen layout" ``` ### Task 3: Change Modal Selection Semantics To Direct-Item, One-By-One Selection **Files:** - Modify: `src/zotero_kb/templates/index.html` - Test: `tests/test_api.py` - [ ] **Step 1: Update collection item loading to direct-only** In `loadCollectionItems(collectionKey)`, replace: ```javascript const payload = await api(`/api/zotero/collections/${collectionKey}/items`); ``` with: ```javascript const payload = await api(`/api/zotero/collections/${collectionKey}/items?include_descendants=false`); ``` - [ ] **Step 2: Remove collection-level bulk-select logic** Delete the old helper and event binding: ```javascript function selectVisibleItems() { for (const item of state.visibleCollectionItems) { state.selectedItemKeys.add(item.item_key); } persistImportSessionState(); renderCollectionItems(); updateImportActionState(); } elements.selectDescendantsButton.addEventListener("click", selectVisibleItems); ``` Do not replace it with another collection-level bulk action. - [ ] **Step 3: Keep only clear/import actions in the top bar** Update `updateImportActionState()` so it no longer references `elements.selectDescendantsButton`. Use: ```javascript function updateImportActionState() { const hasProject = Boolean(state.currentProjectId); const hasSelection = state.selectedItemKeys.size > 0; syncCurrentProjectLabels(); elements.importSelectedItemsButton.disabled = !hasProject || !hasSelection; elements.importSelectedItemsButton.title = !hasProject ? "请先选择项目" : (hasSelection ? "" : "请先选择文献"); elements.clearSelectionButton.disabled = !state.selectedItemKeys.size; renderSelectedCount(); } ``` - [ ] **Step 4: Make the item row default render title + year only** Replace the current item row template: ```javascript row.innerHTML = `
${item.title}
${item.item_key} · ${item.year || "-"} · ${item.item_type || "-"}
`; ``` with: ```javascript row.innerHTML = `
${item.year || "-"}
`; ``` - [ ] **Step 5: Run focused tests** Run: ```bash UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py tests/test_api.py -q ``` Expected: PASS - [ ] **Step 6: Commit** ```bash git add src/zotero_kb/templates/index.html git commit -m "feat: switch import modal to direct-item selection" ``` ### Task 4: Add Delayed Abstract Preview And Touch Fallback **Files:** - Modify: `src/zotero_kb/templates/index.html` - Test: `tests/test_ui.py` - [ ] **Step 1: Add preview helpers** Add these helpers near the modal item rendering code: ```javascript function clearAbstractPreviewTimer() { if (state.hoverTimerId !== null) { window.clearTimeout(state.hoverTimerId); state.hoverTimerId = null; } } function cancelAbstractPreview() { clearAbstractPreviewTimer(); state.hoveredItemKey = null; state.previewItemKey = null; elements.previewPopover.hidden = true; elements.previewPopover.textContent = ""; } function showAbstractPreview(item, target) { clearAbstractPreviewTimer(); const abstract = typeof item.abstract === "string" ? item.abstract.trim() : ""; if (!abstract) { cancelAbstractPreview(); return; } state.previewItemKey = item.item_key; elements.previewPopover.textContent = abstract; elements.previewPopover.hidden = false; } function scheduleAbstractPreview(item, target) { const abstract = typeof item.abstract === "string" ? item.abstract.trim() : ""; if (!abstract) { return; } cancelAbstractPreview(); state.hoveredItemKey = item.item_key; state.hoverTimerId = window.setTimeout(() => { if (state.hoveredItemKey === item.item_key) { showAbstractPreview(item, target); } }, ABSTRACT_PREVIEW_DELAY_MS); } ``` - [ ] **Step 2: Wire preview behavior into item rows** Bind these events in `renderCollectionItems()`: ```javascript checkbox.addEventListener("change", () => { cancelAbstractPreview(); toggleItemSelection(item.item_key); }); titleButton.addEventListener("mouseenter", () => scheduleAbstractPreview(item, titleButton)); titleButton.addEventListener("mouseleave", cancelAbstractPreview); detailsButton.addEventListener("click", (event) => { event.preventDefault(); if (state.previewItemKey === item.item_key && !elements.previewPopover.hidden) { cancelAbstractPreview(); return; } showAbstractPreview(item, detailsButton); }); ``` Also add: ```javascript elements.collectionItems.addEventListener("scroll", cancelAbstractPreview); ``` and call `cancelAbstractPreview()` inside: - `loadCollectionItems()` - `toggleItemSelection()` - `clearSelectedItems()` - `importSelectedItems()` - `openImportModal()` - `closeImportModal()` - [ ] **Step 3: Add compact-row and preview styles** Add: ```css .collection-item { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 0.75rem; align-items: center; padding: 0.75rem 0.9rem; border: 1px solid var(--line); border-radius: 14px; background: rgba(255, 255, 255, 0.72); } .item-row-body { display: flex; align-items: center; gap: 0.55rem; min-width: 0; } .title-button { flex: 1; min-width: 0; padding: 0; border: 0; border-radius: 0; background: transparent; color: var(--ink); text-align: left; font-weight: 600; } .details-button { width: 2.2rem; min-width: 2.2rem; padding: 0.45rem 0; } .preview-popover { position: fixed; z-index: 60; } ``` - [ ] **Step 4: Run the UI test and full suite** Run: ```bash UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_contains_import_modal_controls -v UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q ``` Expected: - first command: PASS - second command: all tests PASS - [ ] **Step 5: Commit** ```bash git add src/zotero_kb/templates/index.html tests/test_ui.py git commit -m "feat: add delayed preview to fullscreen import modal" ``` ### Task 5: Update The User Walkthrough **Files:** - Modify: `README.md` - [ ] **Step 1: Replace the old inline-import instructions** Replace the current import walkthrough bullets with: ```md 5. 在左侧点击 `导入文献` - 先选择一个项目 - 点击 `导入文献` 打开全屏导入界面 - 左侧点击一个 Zotero collection - 右侧会显示该 collection 直接包含的文献 - 逐篇勾选需要导入的文献 - 点击顶部 `导入所选` - 鼠标悬停标题 3 秒会显示摘要预览 - 在窄屏或不方便 hover 的场景下,可点击 `i` 按钮查看摘要 ``` - [ ] **Step 2: Run the full test suite** Run: ```bash UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q ``` Expected: PASS - [ ] **Step 3: Commit** ```bash git add README.md git commit -m "docs: describe fullscreen import modal flow" ``` ## Self-Review Checklist - Spec coverage: - full-screen modal shell: Task 2 - direct-item-only collection loading: Task 3 - one-by-one selection semantics: Task 3 - delayed abstract preview and details-button fallback: Task 4 - updated user walkthrough: Task 5 - Placeholder scan: - no `TODO`, `TBD`, or vague “handle later” instructions remain - Type consistency: - ids and helpers remain consistent across tasks: - `open-import-modal-button` - `import-modal` - `import-modal-project-label` - `modal-selected-count` - `modal-clear-selection-button` - `modal-import-selected-items-button` - `modal-collection-tree` - `modal-collection-items` - `scheduleAbstractPreview` - `cancelAbstractPreview` - `showAbstractPreview`