zotero-kb/docs/superpowers/plans/2026-04-15-zotero-collection-import-fullscreen-modal.md
2026-04-16 13:34:39 +08:00

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
  • 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