17 KiB
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:
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:
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:
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:
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
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:
.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:
.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:
<header class="modal-header">...</header>
<div id="collection-status" class="status"></div>
<div class="modal-body">...</div>
<footer class="modal-action-bar">...</footer>
with a full-screen toolbar plus two-column body:
<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 id="collection-status" class="status"></div>
<div class="modal-body">
<div class="modal-pane collections">
<small>Collections</small>
<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>
- Step 3: Remove the old collection-level bulk select button from the markup
Delete this old button entirely:
<button id="modal-select-descendants-button" type="button" class="secondary">全选当前目录及子目录</button>
- Step 4: Run the targeted UI test
Run:
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
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:
const payload = await api(`/api/zotero/collections/${collectionKey}/items`);
with:
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:
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:
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:
row.innerHTML = `
<input type="checkbox" ${state.selectedItemKeys.has(item.item_key) ? "checked" : ""} />
<div>
<strong>${item.title}</strong>
<div class="meta">${item.item_key} · ${item.year || "-"} · ${item.item_type || "-"}</div>
</div>
`;
with:
row.innerHTML = `
<input type="checkbox" ${state.selectedItemKeys.has(item.item_key) ? "checked" : ""} />
<div class="item-row-body">
<button type="button" class="title-button">${item.title}</button>
<div class="meta">${item.year || "-"}</div>
<button type="button" class="secondary details-button" aria-label="查看摘要">i</button>
</div>
`;
- Step 5: Run focused tests
Run:
UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py tests/test_api.py -q
Expected: PASS
- Step 6: Commit
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:
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():
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:
elements.collectionItems.addEventListener("scroll", cancelAbstractPreview);
and call cancelAbstractPreview() inside:
-
loadCollectionItems() -
toggleItemSelection() -
clearSelectedItems() -
importSelectedItems() -
openImportModal() -
closeImportModal() -
Step 3: Add compact-row and preview styles
Add:
.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:
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
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:
5. 在左侧点击 `导入文献`
- 先选择一个项目
- 点击 `导入文献` 打开全屏导入界面
- 左侧点击一个 Zotero collection
- 右侧会显示该 collection 直接包含的文献
- 逐篇勾选需要导入的文献
- 点击顶部 `导入所选`
- 鼠标悬停标题 3 秒会显示摘要预览
- 在窄屏或不方便 hover 的场景下,可点击 `i` 按钮查看摘要
- Step 2: Run the full test suite
Run:
UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q
Expected: PASS
- Step 3: Commit
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
- no
- Type consistency:
- ids and helpers remain consistent across tasks:
open-import-modal-buttonimport-modalimport-modal-project-labelmodal-selected-countmodal-clear-selection-buttonmodal-import-selected-items-buttonmodal-collection-treemodal-collection-itemsscheduleAbstractPreviewcancelAbstractPreviewshowAbstractPreview
- ids and helpers remain consistent across tasks: