diff --git a/docs/superpowers/plans/2026-04-16-import-window-responsive.md b/docs/superpowers/plans/2026-04-16-import-window-responsive.md new file mode 100644 index 0000000..85297e7 --- /dev/null +++ b/docs/superpowers/plans/2026-04-16-import-window-responsive.md @@ -0,0 +1,254 @@ +# Import Window Responsive 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:** Make the floating import window recompute a viewport-appropriate size and position every time it opens, and stack collection/item panes vertically on narrower viewports. + +**Architecture:** Keep the existing floating-window shell and collection importer flow, but replace reopen-time geometry reuse with viewport-derived geometry helpers. Add a narrow-layout breakpoint in the template CSS and a resize handler that constrains normal-mode geometry without overriding minimized or maximized state. + +**Tech Stack:** FastAPI template rendering, inline HTML/CSS/JavaScript, pytest, Node syntax checking + +--- + +### Task 1: Update UI Contract Tests For Responsive Window Behavior + +**Files:** +- Modify: `tests/test_ui.py` +- Test: `tests/test_ui.py` + +- [ ] **Step 1: Write the failing test** + +```python +def test_index_has_responsive_import_window_geometry_helpers(tmp_path) -> None: + html = _get_index_html(tmp_path) + + assert "function computeResponsiveWindowRect()" in html + assert "function applyResponsiveWindowRect()" in html + assert "function syncWindowToViewport()" in html + assert "window.addEventListener(\"resize\"" in html +``` + +```python +def test_index_has_responsive_import_window_layout_rules(tmp_path) -> None: + html = _get_index_html(tmp_path) + + assert ".window-pane.items" in html + assert "@media (max-width: 980px)" in html + assert ".window-body {" in html + assert "grid-template-columns: 1fr;" in html + assert "border-bottom: 1px solid var(--line);" in html +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -q` +Expected: FAIL because the new responsive geometry helper names are not present yet. + +- [ ] **Step 3: Keep the existing syntax guard test** + +Do not remove `test_index_inline_script_is_valid_javascript`; it remains the regression guard for the inline script. + +- [ ] **Step 4: Run test to verify the contract file still parses** + +Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_inline_script_is_valid_javascript -q` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add tests/test_ui.py +git commit -m "test: define responsive import window contract" +``` + +### Task 2: Implement Viewport-Derived Window Geometry + +**Files:** +- Modify: `src/zotero_kb/templates/index.html` +- Test: `tests/test_ui.py` + +- [ ] **Step 1: Write the failing test** + +Use the Task 1 tests as the failing contract. Do not add production code first. + +- [ ] **Step 2: Run the failing test** + +Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -q` +Expected: FAIL with missing responsive helper function names or missing layout assertions. + +- [ ] **Step 3: Write minimal implementation** + +Add responsive geometry helpers to the inline script in `src/zotero_kb/templates/index.html`: + +```javascript + function computeResponsiveWindowRect() { + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const margin = viewportWidth <= 980 ? 8 : 24; + const minWidth = viewportWidth <= 980 ? 320 : 640; + const minHeight = viewportHeight <= 980 ? 420 : 480; + const maxWidth = Math.max(minWidth, viewportWidth - margin * 2); + const maxHeight = Math.max(minHeight, viewportHeight - margin * 2); + const width = Math.min(maxWidth, Math.max(minWidth, Math.round(viewportWidth * 0.88))); + const height = Math.min(maxHeight, Math.max(minHeight, Math.round(viewportHeight * 0.82))); + const left = Math.max(margin, Math.round((viewportWidth - width) / 2)); + const top = Math.max(margin, Math.round((viewportHeight - height) / 2)); + return { width, height, left, top }; + } + + function applyResponsiveWindowRect() { + const fw = elements.importWindow; + if (!fw || state.isWindowMinimized || state.isWindowMaximized) return; + const rect = computeResponsiveWindowRect(); + fw.style.width = `${rect.width}px`; + fw.style.height = `${rect.height}px`; + fw.style.left = `${rect.left}px`; + fw.style.top = `${rect.top}px`; + state.windowWidth = rect.width; + state.windowHeight = rect.height; + state.windowX = rect.left; + state.windowY = rect.top; + } + + function syncWindowToViewport() { + const fw = elements.importWindow; + if (!fw || state.isWindowMinimized || state.isWindowMaximized) return; + const margin = window.innerWidth <= 980 ? 8 : 24; + const rect = computeResponsiveWindowRect(); + const currentWidth = parseFloat(fw.style.width) || rect.width; + const currentHeight = parseFloat(fw.style.height) || rect.height; + const width = Math.min(currentWidth, rect.width); + const height = Math.min(currentHeight, rect.height); + const maxLeft = Math.max(margin, window.innerWidth - width - margin); + const maxTop = Math.max(margin, window.innerHeight - height - margin); + const left = Math.min(Math.max(parseFloat(fw.style.left) || rect.left, margin), maxLeft); + const top = Math.min(Math.max(parseFloat(fw.style.top) || rect.top, margin), maxTop); + + fw.style.width = `${width}px`; + fw.style.height = `${height}px`; + fw.style.left = `${left}px`; + fw.style.top = `${top}px`; + state.windowWidth = width; + state.windowHeight = height; + state.windowX = left; + state.windowY = top; + } +``` + +Update `openImportWindow()` to call `applyResponsiveWindowRect()` after `initWindowFromSession()` when the window is not minimized or maximized. + +- [ ] **Step 4: Run the focused tests** + +Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -q` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/zotero_kb/templates/index.html tests/test_ui.py +git commit -m "feat: derive import window geometry from viewport" +``` + +### Task 3: Add Responsive Layout Switching And Resize Wiring + +**Files:** +- Modify: `src/zotero_kb/templates/index.html` +- Test: `tests/test_ui.py` + +- [ ] **Step 1: Write the failing test** + +Use the layout assertions from Task 1 as the failing contract. No new production code before running them. + +- [ ] **Step 2: Run the failing test** + +Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -q` +Expected: FAIL if the resize listener or stacked layout rules are still missing. + +- [ ] **Step 3: Write minimal implementation** + +Extend the existing media query and event wiring in `src/zotero_kb/templates/index.html`: + +```css + @media (max-width: 980px) { + .window-frame { + min-width: 0; + width: calc(100vw - 1rem); + height: calc(100vh - 1rem); + left: 0.5rem; + top: 0.5rem; + } + .window-toolbar, + .window-toolbar-actions { + align-items: stretch; + } + .window-body { + grid-template-columns: 1fr; + } + .window-pane.collections { + border-right: 0; + border-bottom: 1px solid var(--line); + } + } +``` + +Wire the resize handler: + +```javascript + window.addEventListener("resize", () => { + syncWindowToViewport(); + }); +``` + +- [ ] **Step 4: Run the focused tests** + +Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -q` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/zotero_kb/templates/index.html tests/test_ui.py +git commit -m "feat: make import window layout responsive" +``` + +### Task 4: End-To-End Verification + +**Files:** +- Modify: none +- Test: `tests/test_ui.py`, `tests/test_api.py` + +- [ ] **Step 1: Run the UI test suite** + +Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -q` +Expected: PASS with all UI contract tests green. + +- [ ] **Step 2: Run the API regression suite** + +Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_api.py -q` +Expected: PASS with all API tests green. + +- [ ] **Step 3: Run fresh JS syntax verification through pytest** + +Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_inline_script_is_valid_javascript -q` +Expected: PASS + +- [ ] **Step 4: Manual verification** + +Run the app: + +```bash +UV_CACHE_DIR=/tmp/uv-cache uv run python main.py +``` + +Manual checks: + +- open the import window at one browser zoom level and note the size +- close it, change browser zoom, reopen it, confirm it recenters and resizes +- shrink the viewport below the narrow breakpoint, reopen it, confirm the panes stack vertically +- maximize and minimize the window, confirm those modes still behave correctly + +- [ ] **Step 5: Commit** + +```bash +git add src/zotero_kb/templates/index.html tests/test_ui.py docs/superpowers/specs/2026-04-16-import-window-responsive-design.md docs/superpowers/plans/2026-04-16-import-window-responsive.md +git commit -m "feat: make import window adapt to viewport changes" +``` diff --git a/docs/superpowers/plans/2026-04-16-project-items-and-inline-card-detail.md b/docs/superpowers/plans/2026-04-16-project-items-and-inline-card-detail.md new file mode 100644 index 0000000..8fb889c --- /dev/null +++ b/docs/superpowers/plans/2026-04-16-project-items-and-inline-card-detail.md @@ -0,0 +1,406 @@ +# Project Items And Inline Card Detail 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:** Show imported project items immediately after import, improve collection row readability, and render item detail inline under the active entry instead of in a fixed detail area. + +**Architecture:** Normalize project view data around a single `items` list in `ProjectService`, with each item carrying `card_status` plus card fields when available. Update the center panel renderer in the inline template script to render both pending and done items, and expand one item’s detail inline at a time. + +**Tech Stack:** Python, FastAPI template rendering, inline HTML/CSS/JavaScript, pytest + +--- + +### Task 1: Define Pending-Item Project View Contract + +**Files:** +- Modify: `tests/test_projects.py` +- Modify: `tests/test_api.py` +- Test: `tests/test_projects.py` +- Test: `tests/test_api.py` + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_projects.py`: + +```python +def test_add_item_to_project_returns_pending_item_when_card_not_generated(tmp_path: Path) -> None: + config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero") + workspace = Workspace(config) + workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini") + + items_index_path = config.workspace_dir / "library" / "index" / "items.json" + items_index_path.parent.mkdir(parents=True, exist_ok=True) + items_index_path.write_text( + json.dumps( + { + "PAPER0001": { + "item_key": "PAPER0001", + "title": "Pending Item", + "creators": ["Alice Smith"], + "year": "2024", + "item_type": "journalArticle", + } + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + + service = ProjectService(config.workspace_dir) + payload = service.add_items("thesis-ch2", ["PAPER0001"]) + + assert payload["selected_items"] == ["PAPER0001"] + assert len(payload["items"]) == 1 + assert payload["items"][0]["item_key"] == "PAPER0001" + assert payload["items"][0]["card_status"] == "pending" +``` + +Add to `tests/test_api.py`: + +```python +def test_import_item_keys_endpoint_returns_pending_item_in_project_view(tmp_path: Path) -> None: + config = make_test_config(tmp_path) + config.bridge_file.unlink() + app = create_app(config, llm_client=FakeLlmClient()) + create_project = _route(app, "/api/projects", "POST") + import_keys = _route(app, "/api/projects/{project_id}/imports/item-keys", "POST") + + create_project( + CreateProjectRequest( + project_id="thesis-ch2", + name="Thesis Chapter 2", + llm_provider="openai", + llm_model="gpt-5-mini", + ) + ) + + payload = import_keys("thesis-ch2", {"item_keys": ["PAPER0001"]}) + + assert payload["project_view"]["selected_items"] == ["PAPER0001"] + assert payload["project_view"]["items"][0]["item_key"] == "PAPER0001" + assert payload["project_view"]["items"][0]["card_status"] == "pending" +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_projects.py tests/test_api.py -q` +Expected: FAIL because project view currently only returns `cards`, not normalized `items`. + +- [ ] **Step 3: Write minimal implementation** + +Modify `src/zotero_kb/projects.py` so `_rebuild_project_index()` returns normalized items for both pending and done entries: + +```python + def _rebuild_project_index(self, project_id: str, selected_items: list[str]) -> dict[str, object]: + cards_index = self._read_json(self.index_dir / "cards.json") + items_index = self._read_json(self.index_dir / "items.json") + collections_index = self._read_json(self.index_dir / "collections.json") + + project_items: list[dict[str, object]] = [] + project_cards: list[dict[str, object]] = [] + for item_key in selected_items: + item_data = items_index.get(item_key, {}) + card_data = cards_index.get(item_key) + item_payload = { + "item_key": item_key, + "title": item_data.get("title", "Untitled"), + "creators": item_data.get("creators", []), + "year": item_data.get("year"), + "item_type": item_data.get("item_type", "unknown"), + "card_status": "done" if card_data else "pending", + "summary": card_data.get("summary") if card_data else None, + "claims": card_data.get("claims", []) if card_data else [], + "quotable_spans": card_data.get("quotable_spans", []) if card_data else [], + } + project_items.append(item_payload) + if card_data: + project_cards.append(card_data) + + project_collections = [ + payload + for payload in collections_index.values() + if set(payload.get("item_keys", [])) & set(selected_items) + ] + payload = { + "project_id": project_id, + "selected_items": selected_items, + "items": project_items, + "cards": project_cards, + "collections": project_collections, + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_projects.py tests/test_api.py -q` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add tests/test_projects.py tests/test_api.py src/zotero_kb/projects.py +git commit -m "feat: add pending project items to project view" +``` + +### Task 2: Define UI Contract For Inline Project Item Detail + +**Files:** +- Modify: `tests/test_ui.py` +- Test: `tests/test_ui.py` + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_ui.py`: + +```python +def test_index_has_project_item_list_and_inline_detail_hooks(tmp_path) -> None: + html = _get_index_html(tmp_path) + + assert 'id="project-item-list"' in html + assert "expandedProjectItemKey" in html + assert "function renderProjectItems(items)" in html + assert "function toggleProjectItemDetail(itemKey)" in html + assert 'class="project-item-detail"' in html +``` + +```python +def test_index_collection_rows_use_readable_unselected_text(tmp_path) -> None: + html = _get_index_html(tmp_path) + + assert ".project-item strong" in html + assert ".project-item .meta" in html + assert ".collection-row-toggle" in html +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -q` +Expected: FAIL because the page still renders only cards and uses the fixed detail region. + +- [ ] **Step 3: Write minimal implementation** + +Update `src/zotero_kb/templates/index.html`: + +- replace the center list container with: + +```html +
+``` + +- replace the fixed detail surface with a passive note: + +```html +${item.card_status === "done" ? (item.summary || "暂无摘要") : "已导入项目,尚未生成卡片。"}
+${item.summary || "暂无摘要"}
+这篇文献已经导入到当前项目,但还没有生成卡片内容。
+ `; + card.appendChild(detail); + } + + elements.projectItemList.appendChild(card); + } + } +``` + +- add CSS: + +```css + .project-item strong, + .project-item .meta { + color: var(--ink); + } + .project-item.active strong, + .project-item.active .meta { + color: #f5f7ef; + } + .collection-row-toggle { + color: var(--accent-strong); + font-weight: 700; + } + .project-item-detail { + margin-top: 0.85rem; + padding-top: 0.85rem; + border-top: 1px solid var(--line); + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py -q` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add tests/test_ui.py src/zotero_kb/templates/index.html +git commit -m "feat: render project items with inline detail" +``` + +### Task 3: Wire Project Item Rendering Into Import And Project Selection Flows + +**Files:** +- Modify: `src/zotero_kb/templates/index.html` +- Test: `tests/test_api.py` +- Test: `tests/test_ui.py` + +- [ ] **Step 1: Write the failing test** + +Reuse the UI/API failures from Tasks 1 and 2. No new production code first. + +- [ ] **Step 2: Run tests to verify the current behavior fails** + +Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_projects.py tests/test_api.py tests/test_ui.py -q` +Expected: FAIL until the project list renderer uses `items`. + +- [ ] **Step 3: Write minimal implementation** + +Update the inline script in `src/zotero_kb/templates/index.html`: + +- state: + +```javascript + currentProjectItems: [], +``` + +- elements: + +```javascript + projectItemList: document.getElementById("project-item-list"), +``` + +- remove `showCardDetail()` and stop using `elements.cardDetail` + +- change empty-state and load paths: + +```javascript + async function selectProject(projectId, rerender = true) { + state.currentProjectId = projectId; + updateImportActionState(); + if (rerender) { + renderProjects(); + } + const payload = await api(`/api/projects/${projectId}/cards`); + renderProjectItems(payload.items || []); + } +``` + +```javascript + renderProjectItems(payload.project_view?.items || []); +``` + +```javascript + renderProjectItems([]); +``` + +- [ ] **Step 4: Run full targeted tests** + +Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_projects.py tests/test_api.py tests/test_ui.py -q` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/zotero_kb/templates/index.html tests/test_projects.py tests/test_api.py tests/test_ui.py +git commit -m "feat: show imported project items before card generation" +``` + +### Task 4: Final Verification + +**Files:** +- Modify: none +- Test: `tests/test_projects.py` +- Test: `tests/test_api.py` +- Test: `tests/test_ui.py` + +- [ ] **Step 1: Run full test suite** + +Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q` +Expected: PASS + +- [ ] **Step 2: Run JS syntax guard** + +Run: `UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_ui.py::test_index_inline_script_is_valid_javascript -q` +Expected: PASS + +- [ ] **Step 3: Manual verification** + +Run: + +```bash +UV_CACHE_DIR=/tmp/uv-cache uv run python main.py +``` + +Manual checks: + +- unselected collections remain readable in the import window +- importing an item into a project makes it appear immediately in the center panel +- pending item detail expands inline below the clicked item +- generated item detail expands inline below the clicked item + +- [ ] **Step 4: Commit** + +```bash +git add src/zotero_kb/projects.py src/zotero_kb/templates/index.html tests/test_projects.py tests/test_api.py tests/test_ui.py docs/superpowers/specs/2026-04-16-project-items-and-inline-card-detail-design.md docs/superpowers/plans/2026-04-16-project-items-and-inline-card-detail.md +git commit -m "feat: show project items inline before card generation" +``` diff --git a/docs/superpowers/specs/2026-04-16-import-window-responsive-design.md b/docs/superpowers/specs/2026-04-16-import-window-responsive-design.md new file mode 100644 index 0000000..332572d --- /dev/null +++ b/docs/superpowers/specs/2026-04-16-import-window-responsive-design.md @@ -0,0 +1,129 @@ +# 2026-04-16 Import Window Responsive Design + +## Goal + +Make the floating import window adapt to the current browser viewport automatically so the user does not need to manually resize it after changing browser zoom or window size. + +The floating window shell remains: + +- draggable +- minimizable +- maximizable +- closable + +The import content remains the restored collection importer: + +- left pane: Zotero collection tree +- right pane: collection items + +## User-Approved Behavior + +### Window sizing policy + +- Each time the import window opens, it recalculates its size and position from the current viewport. +- Previously saved manual width, height, and position are not reused on reopen. +- The window opens centered in the viewport. +- The default size is viewport-relative, using a large but bounded footprint. + +Recommended sizing rule: + +- width: about `88vw` +- height: about `82vh` +- clamp width and height to safe min/max values so content remains usable on smaller screens + +### Resize and zoom behavior + +- Browser zoom changes are treated the same as viewport changes. +- On `resize`, if the window is in normal mode, its dimensions and position are adjusted to remain visible within the viewport. +- If the window is maximized, existing maximize behavior remains authoritative. +- If the window is minimized, existing minimize behavior remains authoritative. + +### Layout adaptation + +- Wide viewport: keep the current two-pane horizontal layout. +- Narrow viewport: switch the import window body to a vertical stack. +- In stacked mode: + - top pane: collections + - bottom pane: collection items + +This avoids crushed side-by-side panes and avoids requiring horizontal scrolling. + +## Implementation Design + +### State model + +Keep the current floating-window state structure, but change how normal-mode geometry is derived: + +- persisted maximize/minimize flags can remain +- persisted normal-mode width/height/left/top are no longer the source of truth on reopen +- on open, recompute width, height, left, and top from the current viewport + +### New geometry helpers + +Add small helpers in the template script: + +- `computeResponsiveWindowRect()` + - derives width/height/left/top from `window.innerWidth` and `window.innerHeight` + - clamps to minimum and maximum bounds + - returns a centered rect +- `applyResponsiveWindowRect()` + - applies the computed rect to the floating window when in normal mode +- `syncWindowToViewport()` + - runs on resize + - keeps the window inside the visible viewport + - does nothing destructive when minimized or maximized + +### Layout switching + +Use CSS plus a narrow-width breakpoint for `.window-body`: + +- default: `grid-template-columns: 22rem minmax(0, 1fr)` +- narrow mode: `grid-template-columns: 1fr` + +The existing `.window-pane.collections` separator changes from right border to bottom border in stacked mode. + +### Interaction rules + +- Opening the window always recomputes the normal-mode rect. +- Manual dragging still works during the current open session. +- If the viewport changes while the window is open, normal mode is re-constrained to the viewport. +- Closing and reopening discards the session’s manual geometry and recomputes from the viewport again. + +## Testing + +Add or update UI tests to verify: + +- the responsive helper logic is present in the inline script +- opening the window uses viewport-based sizing instead of reopening from stale manual geometry +- the template contains the narrow-layout CSS for stacked panes +- the inline script remains valid JavaScript + +Manual verification target: + +- open import window at normal zoom +- change browser zoom or viewport size +- close and reopen +- confirm the window opens centered and proportionate to the new viewport +- confirm narrow viewport stacks collections above items + +## Risks and Mitigations + +### Risk: resize fights user drag + +Mitigation: + +- only recompute automatically on open +- on live resize, constrain only enough to keep the window visible + +### Risk: minimized/maximized modes get overwritten + +Mitigation: + +- gate responsive normal-mode logic behind checks for non-minimized and non-maximized state + +### Risk: small screens become unusable + +Mitigation: + +- stack panes vertically below the chosen breakpoint +- clamp dimensions and leave a small viewport margin diff --git a/docs/superpowers/specs/2026-04-16-project-items-and-inline-card-detail-design.md b/docs/superpowers/specs/2026-04-16-project-items-and-inline-card-detail-design.md new file mode 100644 index 0000000..058d6c3 --- /dev/null +++ b/docs/superpowers/specs/2026-04-16-project-items-and-inline-card-detail-design.md @@ -0,0 +1,138 @@ +# 2026-04-16 Project Items And Inline Card Detail Design + +## Goal + +Fix three related UI/data issues: + +- collection 列表里未选中项文字太浅,保证未选中时也清晰可读 +- 文献导入项目后,项目主区要立刻显示这些条目,而不是等生成卡片后才出现 +- 卡片/条目详情改成在当前条目下方展开,不再集中显示在固定详情区域 + +## Approved Direction + +Use a single project-scoped item list as the main source of truth in the center panel. + +Each project item carries a `card_status`: + +- `pending`: 已导入项目,但还没生成卡片 +- `done`: 已生成卡片 + +The center panel renders all project items, not only completed cards. + +## Data Model + +### Backend project view + +`ProjectService.get_project_view()` should return: + +- `project_id` +- `selected_items` +- `items` +- `collections` + +Each `items[]` element should contain: + +- `item_key` +- `title` +- `creators` +- `year` +- `item_type` +- `card_status` +- card fields when available: + - `summary` + - `claims` + - `quotable_spans` + +This makes imported-but-not-generated items visible immediately after import. + +### Status semantics + +- imported via `imports/item-keys` and not yet generated => `pending` +- present in `cards.json` => `done` + +## UI Behavior + +### Collection tree readability + +Unselected collection cards keep the current light theme, but their text contrast must be increased: + +- title remains readable on light background +- meta line remains readable without relying on hover or selection state + +### Center panel item list + +Replace the current “only cards” rendering with a project item list: + +- `pending` items render as project entries with a clear “未生成卡片” state +- `done` items render as completed card entries +- remove the assumption that invisible means “not imported” + +### Inline detail expansion + +Clicking “查看详情” or the item itself expands detail directly below that same entry. + +Rules: + +- only one item detail is expanded at a time +- clicking the same item again collapses it +- clicking another item moves the expanded detail to that item + +### Expanded detail content + +For `pending`: + +- title +- item key / year / type +- a short message that the item is already in the project but card generation has not happened yet + +For `done`: + +- existing summary +- claims +- quotable spans + +## Frontend Structure + +### State + +Add a current expanded item key in the page script, e.g.: + +- `expandedProjectItemKey` + +### Rendering + +Replace the current `renderCards()` + fixed `showCardDetail()` pattern with: + +- a renderer for all project items +- inline expansion markup inserted under the active item + +The fixed side detail area can be removed or converted into a passive empty state, because detail no longer lives there. + +## Testing + +Add or update tests to cover: + +- project view includes imported pending items +- index template still contains the main page and valid inline script +- center panel contract no longer depends on a fixed detail-only area for current item detail +- inline project item detail hooks exist in the template script + +## Risks + +### Risk: center panel mixes two incompatible data shapes + +Mitigation: + +- make backend project view emit a single normalized `items` list + +### Risk: pending items have no detail data + +Mitigation: + +- pending inline detail shows metadata plus explicit status messaging, not empty summary sections + +### Risk: old fixed detail panel becomes misleading + +Mitigation: + +- remove dependence on it from the item interaction flow diff --git a/src/zotero_kb/api.py b/src/zotero_kb/api.py index 3999e1c..a2805b5 100644 --- a/src/zotero_kb/api.py +++ b/src/zotero_kb/api.py @@ -23,6 +23,10 @@ class CreateProjectRequest(BaseModel): llm_model: str = "gpt-5-mini" +class RenameProjectRequest(BaseModel): + name: str = Field(min_length=1) + + class WritingPromptRequest(BaseModel): prompt: str = Field(min_length=1) @@ -76,6 +80,26 @@ def create_app( result.append(_read_json(project_file)) return result + @app.patch("/api/projects/{project_id}") + def rename_project(project_id: str, payload: RenameProjectRequest) -> dict[str, object]: + try: + project = workspace.rename_project(project_id, payload.name) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail="Project not found") from exc + return {"id": project.project_id, "name": project.name} + + @app.delete("/api/projects/{project_id}") + def delete_project(project_id: str) -> dict[str, object]: + try: + workspace.delete_project(project_id) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail="Project not found") from exc + return {"deleted": project_id} + + @app.post("/api/projects/{project_id}/delete") + def delete_project_via_post(project_id: str) -> dict[str, object]: + return delete_project(project_id) + @app.get("/api/zotero/collections/tree") def zotero_collection_tree() -> dict[str, object]: return {"collections": reader.get_collection_tree()} diff --git a/src/zotero_kb/projects.py b/src/zotero_kb/projects.py index b21900c..4d51899 100644 --- a/src/zotero_kb/projects.py +++ b/src/zotero_kb/projects.py @@ -30,8 +30,27 @@ class ProjectService: def _rebuild_project_index(self, project_id: str, selected_items: list[str]) -> dict[str, object]: cards_index = self._read_json(self.index_dir / "cards.json") + items_index = self._read_json(self.index_dir / "items.json") collections_index = self._read_json(self.index_dir / "collections.json") - project_cards = [cards_index[item_key] for item_key in selected_items if item_key in cards_index] + project_items: list[dict[str, object]] = [] + project_cards: list[dict[str, object]] = [] + for item_key in selected_items: + item_data = items_index.get(item_key, {}) + card_data = cards_index.get(item_key) + item_payload = { + "item_key": item_key, + "title": item_data.get("title", "Untitled"), + "creators": item_data.get("creators", []), + "year": item_data.get("year"), + "item_type": item_data.get("item_type", "unknown"), + "card_status": "done" if card_data else "pending", + "summary": card_data.get("summary") if card_data else None, + "claims": card_data.get("claims", []) if card_data else [], + "quotable_spans": card_data.get("quotable_spans", []) if card_data else [], + } + project_items.append(item_payload) + if card_data: + project_cards.append(card_data) project_collections = [ payload for payload in collections_index.values() @@ -40,6 +59,7 @@ class ProjectService: payload = { "project_id": project_id, "selected_items": selected_items, + "items": project_items, "cards": project_cards, "collections": project_collections, } @@ -64,4 +84,3 @@ class ProjectService: @staticmethod def _write_json(path: Path, payload: object) -> None: path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") - diff --git a/src/zotero_kb/templates/index.html b/src/zotero_kb/templates/index.html index 82092a0..e38be62 100644 --- a/src/zotero_kb/templates/index.html +++ b/src/zotero_kb/templates/index.html @@ -114,10 +114,145 @@ padding: 0.9rem; background: rgba(255, 255, 255, 0.65); } + .project-item strong, + .project-item .meta { + color: var(--ink); + } .project-item.active { border-color: var(--accent); + background: var(--accent-strong); + color: #f7f2e8; box-shadow: 0 0 0 1px rgba(44, 110, 73, 0.15); } + .project-item.active strong, + .project-item.active .meta { + color: inherit; + } + .project-list-entry { + display: grid; + gap: 0.75rem; + } + .project-list-main { + width: 100%; + min-height: auto; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + color: inherit; + text-align: left; + box-shadow: none; + } + .project-list-main:hover { + transform: none; + background: transparent; + } + .project-list-main strong, + .project-list-main .meta { + color: inherit; + } + .project-list-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.5rem; + } + .project-list-actions button { + width: auto; + min-height: 2rem; + padding: 0.45rem 0.85rem; + } + .project-inline-form, + .project-delete-confirmation { + display: grid; + gap: 0.7rem; + } + .project-inline-form input, + .project-delete-confirmation p { + margin: 0; + } + .project-delete-confirmation p { + color: inherit; + } + .project-list-entry.active .project-list-actions .secondary { + background: rgba(247, 242, 232, 0.14); + color: #f7f2e8; + } + .project-list-entry.active .project-list-actions .danger { + background: rgba(139, 58, 42, 0.22); + color: #fff1ea; + } + .project-item-detail { + margin-top: 0.85rem; + padding-top: 0.85rem; + border-top: 1px solid var(--line); + } + .project-item-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + } + .project-item-card-main { + min-width: 0; + } + .project-item-card-main strong { + display: block; + } + .project-item-actions { + display: inline-flex; + align-items: center; + gap: 0.45rem; + flex-shrink: 0; + } + .project-item-status-pill { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 2rem; + padding: 0.2rem 0.8rem; + border-radius: 999px; + font-size: 0.82rem; + font-weight: 700; + white-space: nowrap; + border: 1px solid transparent; + } + .project-item-status-pill.pending { + background: rgba(185, 132, 53, 0.14); + color: #855d1d; + border-color: rgba(185, 132, 53, 0.25); + } + .project-item-status-pill.generating { + background: rgba(44, 110, 73, 0.14); + color: var(--accent-strong); + border-color: rgba(44, 110, 73, 0.22); + } + .project-item-status-pill.done { + background: rgba(44, 110, 73, 0.18); + color: var(--accent-strong); + border-color: rgba(44, 110, 73, 0.25); + } + .project-item-status-pill.failed { + background: rgba(139, 58, 42, 0.12); + color: var(--danger); + border-color: rgba(139, 58, 42, 0.2); + } + .project-item-generate-button { + width: auto; + min-height: 2rem; + padding: 0.45rem 0.9rem; + font-size: 0.82rem; + white-space: nowrap; + } + .project-item-status { + display: inline-flex; + align-items: center; + justify-content: center; + } + .collection-row-toggle { + color: var(--accent-strong); + font-weight: 700; + } .row { display: flex; gap: 0.6rem; @@ -303,6 +438,44 @@ .window-pane.items { background: rgba(255, 253, 248, 0.96); } + .batch-generate-frame { + width: min(52rem, calc(100vw - 2rem)); + height: min(42rem, calc(100vh - 2rem)); + left: max(1rem, calc((100vw - min(52rem, calc(100vw - 2rem))) / 2)); + top: max(1rem, calc((100vh - min(42rem, calc(100vh - 2rem))) / 2)); + min-width: 0; + min-height: 0; + } + .batch-generate-body { + display: grid; + grid-template-rows: auto 1fr; + min-height: 0; + } + .batch-generate-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem 1.25rem 0.5rem; + } + .batch-generate-list { + padding: 0.5rem 1.25rem 1.25rem; + } + .batch-generate-item { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 0.85rem; + align-items: start; + } + .batch-generate-item input[type="checkbox"] { + margin-top: 0.35rem; + } + .batch-generate-item-main strong { + display: block; + } + .batch-generate-item[aria-disabled="true"] { + opacity: 0.72; + } .window-scroll { min-height: 0; overflow: auto; @@ -457,20 +630,50 @@当前未选择项目。
两个入口独立:候选引用推荐和初版引用方案。
@@ -535,9 +738,16 @@ const state = { projects: [], currentProjectId: null, - currentCards: [], + currentProjectItems: [], + projectItemGenerationState: {}, + editingProjectId: null, + editingProjectName: "", + confirmingDeleteProjectId: null, + savingProjectId: null, + deletingProjectId: null, collectionTree: [], isImportWindowOpen: false, + isBatchGenerateWindowOpen: 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, @@ -567,6 +777,9 @@ loadingCollectionKey: null, nextCollectionItemsRequestToken: 0, activeCollectionItemsRequestToken: 0, + expandedProjectItemKey: null, + batchGenerateSelectedKeys: new Set(), + batchGenerateProgress: { current: 0, total: 0, active: false }, hoveredItemKey: null, hoverTimerId: null, previewItemKey: null, @@ -594,9 +807,17 @@ windowCollectionItems: document.getElementById("window-collection-items"), previewPopover: document.getElementById("item-preview-popover"), currentProjectLabel: document.getElementById("current-project-label"), - cardList: document.getElementById("card-list"), - cardDetail: document.getElementById("card-detail"), + projectItemList: document.getElementById("project-item-list"), + openBatchGenerateButton: document.getElementById("open-batch-generate-button"), reloadCardsButton: document.getElementById("reload-cards-button"), + batchGenerateShell: document.getElementById("batch-generate-shell"), + batchGenerateWindow: document.getElementById("batch-generate-window"), + closeBatchGenerateButton: document.getElementById("close-batch-generate-button"), + selectPendingBatchButton: document.getElementById("select-pending-batch-button"), + clearBatchSelectionButton: document.getElementById("clear-batch-selection-button"), + startBatchGenerateButton: document.getElementById("start-batch-generate-button"), + batchGenerateProgress: document.getElementById("batch-generate-progress"), + batchGenerateList: document.getElementById("batch-generate-list"), refreshProjectsButton: document.getElementById("refresh-projects-button"), recommendForm: document.getElementById("recommend-form"), recommendStatus: document.getElementById("recommend-status"), @@ -851,59 +1072,405 @@ elements.projectList.innerHTML = ""; for (const project of state.projects) { - const item = document.createElement("button"); - item.type = "button"; - item.className = "project-item" + (project.id === state.currentProjectId ? " active" : ""); - item.innerHTML = ` - ${project.name} - - - `; - item.addEventListener("click", () => selectProject(project.id)); + const item = document.createElement("div"); + item.className = "project-item project-list-entry" + (project.id === state.currentProjectId ? " active" : ""); + const isEditing = state.editingProjectId === project.id; + const isConfirmingDelete = state.confirmingDeleteProjectId === project.id; + const isSaving = state.savingProjectId === project.id; + const isDeleting = state.deletingProjectId === project.id; + + if (isEditing) { + item.innerHTML = ` + + `; + const form = item.querySelector(".project-inline-form"); + const input = form.querySelector("input"); + const cancelButton = form.querySelector('button[type="button"]'); + input.addEventListener("input", (event) => { + state.editingProjectName = event.target.value; + }); + form.addEventListener("submit", (event) => { + event.preventDefault(); + saveProjectRename(project.id).catch(() => null); + }); + cancelButton.addEventListener("click", cancelProjectRename); + } else if (isConfirmingDelete) { + item.innerHTML = ` +删除后只会移除这个项目及其项目内索引,不会删除全局文献库。
+${card.summary || "暂无摘要"}
+ elements.batchGenerateList.innerHTML = ""; + for (const item of state.currentProjectItems) { + const status = getDisplayedProjectItemStatus(item); + const selectable = status !== "done" && status !== "generating"; + const row = document.createElement("label"); + row.className = "project-item batch-generate-item"; + row.setAttribute("aria-disabled", selectable ? "false" : "true"); + row.innerHTML = ` + +${status === "done" ? (escapeHtml(item.summary || "暂无摘要")) : (status === "generating" ? "正在生成卡片内容,请稍候。" : (status === "failed" ? "生成失败,可重试。" : "已导入项目,尚未生成卡片。"))}
${card.summary || "暂无摘要"}
-${escapeHtml(item.summary || "暂无摘要")}
+${status === "generating" ? "这篇文献正在生成卡片内容,请稍候刷新状态。" : (status === "failed" ? "这篇文献生成卡片失败,你可以再次点击生成重试。" : "这篇文献已经导入到当前项目,但还没有生成卡片内容。")}
+ `; + } + card.appendChild(detail); + } + + elements.projectItemList.appendChild(card); + } + updateBatchGenerateControls(); + if (state.isBatchGenerateWindowOpen) { + renderBatchGenerateList(); + } } function renderRecommendationResults(results) { @@ -1034,6 +1601,7 @@ const toggle = document.createElement("button"); toggle.type = "button"; toggle.className = "secondary"; + toggle.classList.add("collection-row-toggle"); toggle.style.flex = "0 0 auto"; toggle.style.width = "auto"; toggle.style.padding = "0.55rem 0.75rem"; @@ -1226,7 +1794,7 @@ state.selectedItemKeys.clear(); persistImportSessionState(); renderCollectionItems(); - renderCards(payload.project_view?.cards || []); + renderProjectItems(payload.project_view?.items || []); setStatus(elements.windowCollectionStatus, "导入完成。"); updateImportActionState(); closeImportWindow(); @@ -1276,6 +1844,15 @@ async function loadProjects() { const projects = await api("/api/projects"); state.projects = projects; + if (state.editingProjectId && !projects.some((project) => project.id === state.editingProjectId)) { + state.editingProjectId = null; + state.editingProjectName = ""; + state.savingProjectId = null; + } + if (state.confirmingDeleteProjectId && !projects.some((project) => project.id === state.confirmingDeleteProjectId)) { + state.confirmingDeleteProjectId = null; + state.deletingProjectId = null; + } if (state.currentProjectId && !projects.some((project) => project.id === state.currentProjectId)) { state.currentProjectId = projects.length ? projects[0].id : null; } @@ -1284,11 +1861,12 @@ } renderProjects(); updateImportActionState(); + updateBatchGenerateControls(); if (state.currentProjectId) { await selectProject(state.currentProjectId, false); return; } - renderCards([]); + renderProjectItems([]); } async function selectProject(projectId, rerender = true) { @@ -1298,7 +1876,7 @@ renderProjects(); } const payload = await api(`/api/projects/${projectId}/cards`); - renderCards(payload.cards || []); + renderProjectItems(payload.items || []); } async function removeCard(itemKey) { @@ -1333,6 +1911,13 @@ } await selectProject(state.currentProjectId); }); + elements.openBatchGenerateButton.addEventListener("click", openBatchGenerateWindow); + elements.closeBatchGenerateButton.addEventListener("click", closeBatchGenerateWindow); + elements.selectPendingBatchButton.addEventListener("click", selectAllPendingBatchItems); + elements.clearBatchSelectionButton.addEventListener("click", clearBatchGenerateSelection); + elements.startBatchGenerateButton.addEventListener("click", () => { + runBatchGenerate().catch(() => null); + }); elements.refreshProjectsButton.addEventListener("click", loadProjects); @@ -1365,6 +1950,8 @@ document.addEventListener("keydown", (event) => { if (event.key === "Escape" && state.isImportWindowOpen) { closeImportWindow(); + } else if (event.key === "Escape" && state.isBatchGenerateWindowOpen) { + closeBatchGenerateWindow(); } }); diff --git a/src/zotero_kb/workspace.py b/src/zotero_kb/workspace.py index 931eee3..9bae46f 100644 --- a/src/zotero_kb/workspace.py +++ b/src/zotero_kb/workspace.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import shutil from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path @@ -59,6 +60,23 @@ class Workspace: return ProjectRecord(project_id=project_id, name=name, project_dir=project_dir) + def rename_project(self, project_id: str, name: str) -> ProjectRecord: + project_dir = self.config.workspace_dir / "projects" / project_id + project_file = project_dir / "project.json" + if not project_file.exists(): + raise FileNotFoundError(project_id) + + project_payload = json.loads(project_file.read_text(encoding="utf-8")) + project_payload["name"] = name + self._write_json(project_file, project_payload) + return ProjectRecord(project_id=project_id, name=name, project_dir=project_dir) + + def delete_project(self, project_id: str) -> None: + project_dir = self.config.workspace_dir / "projects" / project_id + if not project_dir.exists(): + raise FileNotFoundError(project_id) + shutil.rmtree(project_dir) + @staticmethod def _write_json(path: Path, payload: object) -> None: path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") diff --git a/tests/test_api.py b/tests/test_api.py index a0b2c8e..01485a2 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,8 +1,10 @@ import json from pathlib import Path +from fastapi.testclient import TestClient + from tests.fixtures.build_zotero_fixture import build_fixture_zotero_dir -from zotero_kb.api import CreateProjectRequest, WritingPromptRequest, create_app +from zotero_kb.api import CreateProjectRequest, RenameProjectRequest, WritingPromptRequest, create_app from zotero_kb.api import GenerateCardsRequest from zotero_kb.config import AppConfig @@ -57,6 +59,71 @@ def test_create_project_endpoint(tmp_path: Path) -> None: assert response["id"] == "thesis-ch2" +def test_rename_project_endpoint_updates_project_name(tmp_path: Path) -> None: + app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient()) + create_project = _route(app, "/api/projects", "POST") + rename_project = _route(app, "/api/projects/{project_id}", "PATCH") + list_projects = _route(app, "/api/projects", "GET") + + create_project( + CreateProjectRequest( + project_id="thesis-ch2", + name="Old Name", + llm_provider="openai", + llm_model="gpt-5-mini", + ) + ) + + response = rename_project("thesis-ch2", RenameProjectRequest(name="New Name")) + + assert response["id"] == "thesis-ch2" + assert response["name"] == "New Name" + assert list_projects()[0]["name"] == "New Name" + + +def test_delete_project_endpoint_removes_project_from_list(tmp_path: Path) -> None: + app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient()) + create_project = _route(app, "/api/projects", "POST") + delete_project = _route(app, "/api/projects/{project_id}", "DELETE") + list_projects = _route(app, "/api/projects", "GET") + + create_project( + CreateProjectRequest( + project_id="thesis-ch2", + name="Thesis Chapter 2", + llm_provider="openai", + llm_model="gpt-5-mini", + ) + ) + + response = delete_project("thesis-ch2") + + assert response["deleted"] == "thesis-ch2" + assert list_projects() == [] + + +def test_delete_project_post_fallback_endpoint_removes_project_from_list(tmp_path: Path) -> None: + app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient()) + client = TestClient(app) + + create_response = client.post( + "/api/projects", + json={ + "project_id": "thesis-ch2", + "name": "Thesis Chapter 2", + "llm_provider": "openai", + "llm_model": "gpt-5-mini", + }, + ) + assert create_response.status_code == 201 + + delete_response = client.post("/api/projects/thesis-ch2/delete") + + assert delete_response.status_code == 200 + assert delete_response.json() == {"deleted": "thesis-ch2"} + assert client.get("/api/projects").json() == [] + + def test_import_selected_items_endpoint(tmp_path: Path) -> None: app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient()) create_project = _route(app, "/api/projects", "POST") @@ -300,3 +367,26 @@ def test_cards_generate_creates_cards_for_pending_items(tmp_path: Path) -> None: assert len(response["items"]) == 1 assert response["items"][0]["item_key"] == "PAPER0001" assert response["items"][0]["card_status"] == "done" + + +def test_import_item_keys_endpoint_returns_pending_item_in_project_view(tmp_path: Path) -> None: + config = make_test_config(tmp_path) + config.bridge_file.unlink() + app = create_app(config, llm_client=FakeLlmClient()) + create_project = _route(app, "/api/projects", "POST") + import_keys = _route(app, "/api/projects/{project_id}/imports/item-keys", "POST") + + create_project( + CreateProjectRequest( + project_id="thesis-ch2", + name="Thesis Chapter 2", + llm_provider="openai", + llm_model="gpt-5-mini", + ) + ) + + payload = import_keys("thesis-ch2", {"item_keys": ["PAPER0001"]}) + + assert payload["project_view"]["selected_items"] == ["PAPER0001"] + assert payload["project_view"]["items"][0]["item_key"] == "PAPER0001" + assert payload["project_view"]["items"][0]["card_status"] == "pending" diff --git a/tests/test_projects.py b/tests/test_projects.py index f73e90e..83a81e4 100644 --- a/tests/test_projects.py +++ b/tests/test_projects.py @@ -74,3 +74,35 @@ def test_remove_item_from_project_updates_selected_items(tmp_path: Path) -> None payload = service.remove_item("thesis-ch2", "PAPER0001") assert payload["selected_items"] == [] + + +def test_add_item_to_project_returns_pending_item_when_card_not_generated(tmp_path: Path) -> None: + config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero") + workspace = Workspace(config) + workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini") + + items_index_path = config.workspace_dir / "library" / "index" / "items.json" + items_index_path.parent.mkdir(parents=True, exist_ok=True) + items_index_path.write_text( + json.dumps( + { + "PAPER0001": { + "item_key": "PAPER0001", + "title": "Pending Item", + "creators": ["Alice Smith"], + "year": "2024", + "item_type": "journalArticle", + } + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + + service = ProjectService(config.workspace_dir) + payload = service.add_items("thesis-ch2", ["PAPER0001"]) + + assert payload["selected_items"] == ["PAPER0001"] + assert len(payload["items"]) == 1 + assert payload["items"][0]["item_key"] == "PAPER0001" + assert payload["items"][0]["card_status"] == "pending" diff --git a/tests/test_ui.py b/tests/test_ui.py index a10008f..9d1ca32 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -122,6 +122,76 @@ def test_index_has_responsive_import_window_layout_rules(tmp_path) -> None: assert "border-bottom: 1px solid var(--line);" in html +def test_index_has_project_item_list_and_inline_detail_hooks(tmp_path) -> None: + html = _get_index_html(tmp_path) + + assert 'id="project-item-list"' in html + assert "expandedProjectItemKey" in html + assert "function renderProjectItems(items)" in html + assert "function toggleProjectItemDetail(itemKey)" in html + assert ".project-item-detail" in html + + +def test_index_collection_rows_use_readable_unselected_text(tmp_path) -> None: + html = _get_index_html(tmp_path) + + assert ".project-item strong" in html + assert ".project-item .meta" in html + assert ".collection-row-toggle" in html + + +def test_index_project_item_active_state_uses_high_contrast_text(tmp_path) -> None: + html = _get_index_html(tmp_path) + + assert ".project-item.active {" in html + assert "background: var(--accent-strong);" in html + assert "color: #f7f2e8;" in html + assert ".project-item.active strong," in html + assert ".project-item.active .meta {" in html + assert "color: inherit;" in html + + +def test_index_has_project_rename_delete_controls(tmp_path) -> None: + html = _get_index_html(tmp_path) + + assert "editingProjectId" in html + assert "confirmingDeleteProjectId" in html + assert "function startProjectRename(projectId)" in html + assert "function cancelProjectRename()" in html + assert "function saveProjectRename(projectId)" in html + assert "function confirmProjectDelete(projectId)" in html + assert "function deleteProject(projectId)" in html + assert 'method: "DELETE"' in html + assert 'method: "POST"' in html + assert 'api(`/api/projects/${projectId}/delete`' in html + assert 'error.message === "Method Not Allowed"' in html + assert ".project-list-entry" in html + assert ".project-list-main" in html + assert ".project-list-actions" in html + assert ".project-inline-form" in html + assert ".project-delete-confirmation" in html + + +def test_index_has_project_item_generate_actions_and_batch_window(tmp_path) -> None: + html = _get_index_html(tmp_path) + + assert 'id="open-batch-generate-button"' in html + assert 'id="batch-generate-shell"' in html + assert 'id="batch-generate-window"' in html + assert 'id="batch-generate-list"' in html + assert 'id="select-pending-batch-button"' in html + assert 'id="clear-batch-selection-button"' in html + assert 'id="start-batch-generate-button"' in html + assert 'id="batch-generate-progress"' in html + assert "function openBatchGenerateWindow()" in html + assert "function closeBatchGenerateWindow()" in html + assert "function generateProjectItem(itemKey)" in html + assert "function selectAllPendingBatchItems()" in html + assert "function runBatchGenerate()" in html + assert ".project-item-status-pill" in html + assert ".project-item-generate-button" in html + + def test_index_inline_script_is_valid_javascript(tmp_path) -> None: html = _get_index_html(tmp_path) script_start = html.rfind("