diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index f692280a..819db1d4 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -67,12 +67,15 @@ Plans:
**Depends on**: Phase 27 (components must exist to render into each mode)
**Requirements**: DASH-01, DASH-02, DASH-03, DASH-04, REFR-01, REFR-02
**Success Criteria** (what must be TRUE):
- 1. User opens a `.base` file → dashboard header shows the domain name and the view switches to collection mode, without any manual button click.
- 2. User opens a paper card (`.md` with `zotero_key` in frontmatter) → dashboard header shows the paper title and the view switches to per-paper mode, without any manual button click.
- 3. User opens any other file or no file → dashboard shows the existing global library overview (metric cards, OCR pipeline, Quick Actions).
- 4. User switches active file via tab click or Ctrl+Tab → dashboard transitions to the correct mode within observable time (< 500ms), with the previous mode's data released from memory.
- 5. When `formal-library.json` is modified externally (by sync/ocr/repair workers) → dashboard detects the change within 2 seconds and refreshes the current view with updated data, preserving the current mode.
-**Plans**: TBD
+ 1. User opens a `.base` file → dashboard header shows the domain name and the view switches to collection mode, without any manual button click.
+ 2. User opens a paper card (`.md` with `zotero_key` in frontmatter) → dashboard header shows the paper title and the view switches to per-paper mode, without any manual button click.
+ 3. User opens any other file or no file → dashboard shows the existing global library overview (metric cards, OCR pipeline, Quick Actions).
+ 4. User switches active file via tab click or Ctrl+Tab → dashboard transitions to the correct mode within observable time (< 500ms), with the previous mode's data released from memory.
+ 5. When `formal-library.json` is modified externally (by sync/ocr/repair workers) → dashboard detects the change within 2 seconds and refreshes the current view with updated data, preserving the current mode.
+**Plans**: 2 plans
+Plans:
+- [ ] 28-01-PLAN.md — Index utilities (load/cache/lookup) + CSS for mode-aware shell
+- [ ] 28-02-PLAN.md — Context detection, mode switching, event subscriptions, auto-refresh, mode-aware header
**UI hint**: yes
### Phase 29: Per-Paper View
@@ -112,7 +115,7 @@ Plans:
| 25. Surface Convergence, Doctor & Repair | v1.6 | 3/3 | Complete | 2026-05-04 |
| 26. Traceable AI Context Packs | v1.6 | 3/3 | Complete | 2026-05-04 |
| 27. Component Library | v1.7 | 2/2 | Complete | 2026-05-04 |
-| 28. Dashboard Shell & Context Detection | v1.7 | 0/TBD | Not started | - |
+| 28. Dashboard Shell & Context Detection | v1.7 | 2/2 | Planned | - |
| 29. Per-Paper View | v1.7 | 0/TBD | Not started | - |
| 30. Collection View | v1.7 | 0/TBD | Not started | - |
diff --git a/.planning/STATE.md b/.planning/STATE.md
index 9dc8add9..fde6c8aa 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -3,12 +3,13 @@ gsd_state_version: 1.0
milestone: v1.7
milestone_name: Context-Aware Dashboard
status: Phase complete — ready for verification
-stopped_at: Completed 27-02-PLAN.md
+stopped_at: Planned 28-01-PLAN.md + 28-02-PLAN.md
last_updated: "2026-05-04T06:42:49.547Z"
progress:
total_phases: 4
completed_phases: 1
- total_plans: 2
+ total_plans: 4
+ planned_plans: 2
completed_plans: 2
---
@@ -23,8 +24,8 @@ See: .planning/PROJECT.md (updated 2026-05-03)
## Current Position
-Phase: 23 (canonical-asset-index-safe-rebuilds) — EXECUTING
-Plan: 3 of 3
+Phase: 28 (dashboard-shell-context-detection) — PLANNING COMPLETE
+Plans: 2 of 2 planned
## Performance Metrics
diff --git a/.planning/phases/28-dashboard-shell-context-detection/28-01-PLAN.md b/.planning/phases/28-dashboard-shell-context-detection/28-01-PLAN.md
new file mode 100644
index 00000000..1866e9f0
--- /dev/null
+++ b/.planning/phases/28-dashboard-shell-context-detection/28-01-PLAN.md
@@ -0,0 +1,363 @@
+---
+phase: 28-dashboard-shell-context-detection
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - paperforge/plugin/main.js
+ - paperforge/plugin/styles.css
+autonomous: true
+requirements:
+ - DASH-01 # Index loading required for collection mode lookup
+ - DASH-02 # _findEntry(key) required for per-paper mode
+ - REFR-01 # _loadIndex() required for index refresh
+
+must_haves:
+ truths:
+ - "PaperForgeStatusView can load and parse formal-library.json via _loadIndex() returning the parsed JSON object or null on failure"
+ - "PaperForgeStatusView can look up a single paper entry by zotero_key via _findEntry(key) returning the entry or null"
+ - "PaperForgeStatusView can filter papers by domain name via _filterByDomain(domain) returning an array of matching entries"
+ - "PaperForgeStatusView has a lazy-loaded cached index via _getCachedIndex() that returns items array after first call"
+ - "styles.css has a .paperforge-content-area class for the mode-switched content region"
+ - "styles.css has a .paperforge-mode-context class for the header mode indicator area"
+ artifacts:
+ - path: "paperforge/plugin/main.js"
+ provides: "Index loading utilities: _loadIndex, _getCachedIndex, _findEntry, _filterByDomain"
+ min_lines: 50 # new JS content for 4 methods
+ - path: "paperforge/plugin/styles.css"
+ provides: "CSS for mode-aware header and content layout"
+ min_lines: 60 # new CSS for header mode context + content area
+ key_links:
+ - from: "main.js PaperForgeStatusView._loadIndex"
+ to: "formal-library.json path"
+ via: "app.vault.adapter.basePath + plugin.settings.system_dir + 'PaperForge/indexes/formal-library.json'"
+ - from: "main.js PaperForgeStatusView._findEntry"
+ to: "_getCachedIndex() items array"
+ via: "Array.find(item => item.zotero_key === key)"
+ - from: "main.js PaperForgeStatusView._filterByDomain"
+ to: "_getCachedIndex() items array"
+ via: "Array.filter(item => item.domain === domain)"
+---
+
+
+Add index loading utility methods to `PaperForgeStatusView` in `paperforge/plugin/main.js` and add CSS for the mode-aware dashboard layout in `paperforge/plugin/styles.css`. These are the foundational building blocks that Plan 28-02 (Context Detection & Mode Switching) depends on.
+
+Purpose: Establish data access layer and visual shell so Plan 28-02 can focus purely on context detection logic and mode switching without getting bogged down in index I/O or layout CSS.
+
+Output: `paperforge/plugin/main.js` with 4 new methods; `paperforge/plugin/styles.css` with 2 new CSS sections.
+
+
+
+@C:/Users/Lin/.opencode/get-shit-done/workflows/execute-plan.md
+@C:/Users/Lin/.opencode/get-shit-done/templates/summary.md
+
+
+
+@.planning/phases/28-dashboard-shell-context-detection/28-CONTEXT.md
+@paperforge/plugin/main.js
+@paperforge/plugin/styles.css
+
+
+
+
+
+ Task 1: Add index loading utilities (_loadIndex, _getCachedIndex, _findEntry, _filterByDomain)
+ paperforge/plugin/main.js
+
+
+ - paperforge/plugin/main.js — PaperForgeStatusView class (lines 210-815), especially the existing _fetchStats() method at lines 285-375 which already reads formal-library.json with path resolution at lines 294-297
+ - .planning/phases/28-dashboard-shell-context-detection/28-CONTEXT.md (decisions D-11, D-12, D-13, D-14, D-16, D-17, D-19)
+
+
+
+ Add FOUR methods to the `PaperForgeStatusView` class in `paperforge/plugin/main.js`.
+
+ **Insertion point:** Add these methods after the `_buildMetricBar` method (currently around line 399) and before the `_renderStats` method (currently around line 402). This keeps all data-loading utilities grouped together, before the render methods.
+
+ **Method 1: `_loadIndex()` — reads and parses formal-library.json (D-11, D-17, D-19)**
+
+ ```javascript
+ /* ── Index Loading (D-11, D-17, D-19) ── */
+ _loadIndex() {
+ const vp = this.app.vault.adapter.basePath;
+ const plugin = this.app.plugins.plugins['paperforge'];
+ const systemDir = plugin?.settings?.system_dir || '99_System';
+ const indexPath = path.join(vp, systemDir, 'PaperForge', 'indexes', 'formal-library.json');
+ try {
+ const raw = fs.readFileSync(indexPath, 'utf-8');
+ return JSON.parse(raw);
+ } catch {
+ return null;
+ }
+ }
+ ```
+
+ Uses the same path resolution pattern already established in `_fetchStats()` (lines 294-297 of current main.js). Does NOT cache — always reads from disk. Returns `null` (not an empty object) on failure so callers can distinguish "file missing/corrupt" from "empty index" per D-17.
+
+ **Method 2: `_getCachedIndex()` — lazy-load and cache the items array (D-14)**
+
+ ```javascript
+ /* ── Cached Index Accessor (D-14) ── */
+ _getCachedIndex() {
+ if (!this._cachedItems) {
+ const index = this._loadIndex();
+ this._cachedItems = index ? (index.items || []) : [];
+ }
+ return this._cachedItems;
+ }
+ ```
+
+ - First call loads from disk and caches in `this._cachedItems`
+ - Subsequent calls return cached array without disk I/O
+ - Returns empty array (not null) when index is missing
+ - Cached items must be invalidated by setting `this._cachedItems = null` when a refresh is needed (done in Plan 28-02)
+
+ **Method 3: `_findEntry(key)` — lookup single paper by zotero_key (D-12, D-18)**
+
+ ```javascript
+ /* ── Single Paper Lookup by Key (D-12, D-18) ── */
+ _findEntry(key) {
+ if (!key) return null;
+ return this._getCachedIndex().find(item => item.zotero_key === key) || null;
+ }
+ ```
+
+ - Returns the first matching entry, or `null` if not found (per D-18)
+ - Returns `null` when key is falsy (empty/undefined/null)
+
+ **Method 4: `_filterByDomain(domain)` — filter index items by domain (D-13, D-16)**
+
+ ```javascript
+ /* ── Filter Papers by Domain (D-13, D-16) ── */
+ _filterByDomain(domain) {
+ if (!domain) return [];
+ return this._getCachedIndex().filter(item => item.domain === domain);
+ }
+ ```
+
+ - Returns array of matching items
+ - Returns empty array when domain is falsy
+
+ **IMPORTANT:** Do NOT modify the existing `_fetchStats()` method. It currently has its own inline path resolution — leave it as-is to minimize risk. The new `_loadIndex()` duplicates path resolution intentionally (self-contained, no hidden coupling).
+
+
+
+ - `main.js` contains a method `_loadIndex()` that reads from `formal-library.json` using path.join with `basePath`, `system_dir`, `PaperForge`, `indexes`, `formal-library.json`
+ - `_loadIndex()` returns `null` inside the `catch` block (D-17: null means missing/corrupt)
+ - `main.js` contains a method `_getCachedIndex()` that stores result in `this._cachedItems`
+ - `_getCachedIndex()` returns `[]` (not null) when `_loadIndex()` returns null
+ - `main.js` contains a method `_findEntry(key)` using `Array.find(item => item.zotero_key === key)`
+ - `_findEntry(key)` returns `null` when key is falsy or not found
+ - `main.js` contains a method `_filterByDomain(domain)` using `Array.filter(item => item.domain === domain)`
+ - `_filterByDomain(domain)` returns `[]` when domain is falsy
+ - All 4 methods use proper class method syntax (not arrow functions assigned to `this`)
+ - grep: `_loadIndex(` exists, `_getCachedIndex(` exists, `_findEntry(` exists, `_filterByDomain(` exists
+ - grep: `this._cachedItems` appears at least twice (assignment + read)
+ - grep: `item.zotero_key === key` appears in _findEntry
+ - grep: `item.domain === domain` appears in _filterByDomain
+
+
+
+ python -c "
+with open('paperforge/plugin/main.js') as f:
+ c = f.read()
+assert '_loadIndex(' in c, 'Missing _loadIndex method'
+assert '_getCachedIndex(' in c, 'Missing _getCachedIndex method'
+assert '_findEntry(' in c, 'Missing _findEntry method'
+assert '_filterByDomain(' in c, 'Missing _filterByDomain method'
+assert 'this._cachedItems' in c, 'Missing _cachedItems caching'
+assert 'item.zotero_key === key' in c, 'Missing zotero_key lookup'
+assert 'item.domain === domain' in c, 'Missing domain filter'
+assert 'formal-library.json' in c, 'Missing formal-library.json path reference'
+assert 'JSON.parse(raw)' in c, 'Missing JSON parse'
+assert 'return null' in c or 'return null;' in c, 'Missing null return on error'
+print('Task 1 index utilities: ALL CHECKS PASSED')
+"
+
+
+
+ PaperForgeStatusView has four new methods: _loadIndex() (reads formal-library.json, returns parsed object or null), _getCachedIndex() (lazy-loads and caches items array), _findEntry(key) (lookup by zotero_key, returns entry or null), _filterByDomain(domain) (filters items by domain). All methods handle null/empty inputs gracefully. Cached index invalidatable via this._cachedItems = null.
+
+
+
+
+ Task 2: Add CSS for mode-aware header context and content area layout
+ paperforge/plugin/styles.css
+
+
+ - paperforge/plugin/styles.css (existing file, to append after Section 12 at the end)
+ - .planning/phases/28-dashboard-shell-context-detection/28-CONTEXT.md (decisions D-06, D-07, D-15)
+
+
+
+ Append TWO new CSS sections to `paperforge/plugin/styles.css` at the end of the file (after the last line, currently Section 12 — Bar Chart ending at line 1048).
+
+ **Section 13 — Mode-Aware Content Area (D-06)**
+
+ This section provides the container that gets cleared and repopulated on each mode switch:
+
+ ```css
+ /* ==========================================================================
+ SECTION 13 — Mode-Aware Content Area
+ ========================================================================== */
+ .paperforge-content-area {
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+ transition: opacity 0.2s ease;
+ }
+
+ .paperforge-content-area.switching {
+ opacity: 0.4;
+ pointer-events: none;
+ }
+
+ .paperforge-content-placeholder {
+ text-align: center;
+ padding: 32px 12px;
+ color: var(--text-muted);
+ font-size: var(--font-ui-small);
+ border: 1px dashed var(--background-modifier-border);
+ border-radius: var(--radius-m);
+ background: var(--background-secondary);
+ font-style: italic;
+ }
+ ```
+
+ **Section 14 — Mode-Aware Header Context (D-07, D-15)**
+
+ This section styles the mode context area in the dashboard header that shows the current mode's identifier (paper title, domain name, or "PaperForge"):
+
+ ```css
+ /* ==========================================================================
+ SECTION 14 — Mode-Aware Header Context
+ ========================================================================== */
+ .paperforge-mode-context {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+ flex: 1;
+ }
+
+ .paperforge-mode-badge {
+ font-size: 10px;
+ font-weight: var(--font-semibold);
+ padding: 2px 8px;
+ border-radius: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ line-height: 1.4;
+ flex-shrink: 0;
+ }
+
+ .paperforge-mode-badge.global {
+ background: var(--interactive-accent);
+ color: var(--text-on-accent);
+ }
+
+ .paperforge-mode-badge.paper {
+ background: var(--color-cyan);
+ color: var(--text-on-accent);
+ }
+
+ .paperforge-mode-badge.collection {
+ background: var(--color-purple);
+ color: var(--text-on-accent);
+ }
+
+ .paperforge-mode-name {
+ font-size: var(--font-ui-small);
+ font-weight: var(--font-semibold);
+ color: var(--text-normal);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+ line-height: 1.3;
+ }
+
+ .paperforge-mode-warning {
+ font-size: var(--font-ui-smaller);
+ color: var(--text-warning);
+ margin-left: auto;
+ }
+ ```
+
+ **IMPORTANT:** Add BOTH sections at the very end of styles.css, separated by a blank line. Do not modify any existing CSS. The sections must be appended in order (Section 13 first, then Section 14).
+
+
+
+ - `styles.css` contains a new Section 13 comment `/* ===== SECTION 13 — Mode-Aware Content Area ===== */`
+ - `styles.css` contains `.paperforge-content-area` with `flex-direction: column` and `gap: 24px`
+ - `styles.css` contains `.paperforge-content-area.switching` with `opacity: 0.4` and `pointer-events: none`
+ - `styles.css` contains `.paperforge-content-placeholder` with `border: 1px dashed`, `border-radius: var(--radius-m)`, `font-style: italic`
+ - `styles.css` contains a new Section 14 comment `/* ===== SECTION 14 — Mode-Aware Header Context ===== */`
+ - `styles.css` contains `.paperforge-mode-context` with `display: flex`, `align-items: center`, `flex: 1`
+ - `styles.css` contains `.paperforge-mode-badge.global` with `var(--interactive-accent)`
+ - `styles.css` contains `.paperforge-mode-badge.paper` with `var(--color-cyan)`
+ - `styles.css` contains `.paperforge-mode-badge.collection` with `var(--color-purple)`
+ - `styles.css` contains `.paperforge-mode-name` with `text-overflow: ellipsis` for truncation
+ - `styles.css` contains `.paperforge-mode-warning` with `var(--text-warning)`
+ - All colors use `var(--*)` — zero hardcoded hex/rgb values
+ - File length increases by at least 60 lines (two new sections)
+
+
+
+ python -c "
+with open('paperforge/plugin/styles.css') as f:
+ c = f.read()
+assert 'SECTION 13' in c, 'Missing Section 13 header'
+assert '.paperforge-content-area' in c, 'Missing content area class'
+assert 'flex-direction: column' in c, 'Missing flex column'
+assert 'opacity: 0.4' in c, 'Missing switching opacity'
+assert '.paperforge-content-placeholder' in c, 'Missing placeholder class'
+assert 'SECTION 14' in c, 'Missing Section 14 header'
+assert '.paperforge-mode-context' in c, 'Missing mode context class'
+assert '.paperforge-mode-badge.global' in c, 'Missing global badge'
+assert '.paperforge-mode-badge.paper' in c, 'Missing paper badge'
+assert '.paperforge-mode-badge.collection' in c, 'Missing collection badge'
+assert '.paperforge-mode-name' in c, 'Missing mode name class'
+assert '.paperforge-mode-warning' in c, 'Missing mode warning class'
+assert 'var(--text-warning)' in c, 'Hardcoded warning color'
+print('Task 2 CSS mode shell: ALL CHECKS PASSED')
+"
+
+
+
+ styles.css has two new sections: Section 13 (content area container with switching fade transition and placeholder styling) and Section 14 (mode-aware header context with color-coded mode badges, truncated mode name, and warning text). All colors use Obsidian CSS variables. Existing CSS untouched.
+
+
+
+
+
+
+1. 4 new index utility methods exist on PaperForgeStatusView: `_loadIndex()`, `_getCachedIndex()`, `_findEntry(key)`, `_filterByDomain(domain)`
+2. All 4 methods handle null/empty inputs without throwing
+3. `_loadIndex()` returns `null` on file read/parse failure (distinguishable from empty array)
+4. `_getCachedIndex()` stores cached items in `this._cachedItems` for invalidation
+5. All path resolution uses the same pattern as existing `_fetchStats()`: `basePath + system_dir + 'PaperForge/indexes/formal-library.json'`
+6. styles.css has Section 13 and Section 14 appended at end
+7. Content area has switching state with opacity fade
+8. Placeholder styled as dashed border container for Phase 29/30 content
+9. Mode badges have 3 color states: global (accent), paper (cyan), collection (purple)
+10. Mode name truncates with `text-overflow: ellipsis`
+
+
+
+- [ ] main.js has `_loadIndex()` that reads formal-library.json via the established path pattern, returns parsed JSON or null
+- [ ] main.js has `_getCachedIndex()` that lazy-loads and caches items in `this._cachedItems`
+- [ ] main.js has `_findEntry(key)` that looks up by zotero_key using Array.find, returns entry or null
+- [ ] main.js has `_filterByDomain(domain)` that filters by domain using Array.filter, returns array or []
+- [ ] All 4 methods are class methods on PaperForgeStatusView
+- [ ] Existing `_fetchStats()` is untouched (still has its own path resolution)
+- [ ] styles.css has Section 13: `.paperforge-content-area` with switching state
+- [ ] styles.css has Section 14: `.paperforge-mode-context` with `.paperforge-mode-badge.global/paper/collection`, `.paperforge-mode-name`, `.paperforge-mode-warning`
+- [ ] All new CSS uses Obsidian CSS variables only
+- [ ] Existing CSS unchanged
+
+
+
diff --git a/.planning/phases/28-dashboard-shell-context-detection/28-02-PLAN.md b/.planning/phases/28-dashboard-shell-context-detection/28-02-PLAN.md
new file mode 100644
index 00000000..e97d4714
--- /dev/null
+++ b/.planning/phases/28-dashboard-shell-context-detection/28-02-PLAN.md
@@ -0,0 +1,767 @@
+---
+phase: 28-dashboard-shell-context-detection
+plan: 02
+type: execute
+wave: 2
+depends_on:
+ - 28-01 # Index utilities (_loadIndex, _findEntry, _filterByDomain) + mode shell CSS must exist
+files_modified:
+ - paperforge/plugin/main.js
+ - paperforge/plugin/styles.css
+autonomous: true
+requirements:
+ - DASH-01 # .base file -> collection mode
+ - DASH-02 # .md with zotero_key -> per-paper mode
+ - DASH-03 # other files / no file -> global mode
+ - DASH-04 # file switch -> auto-refresh correct mode
+ - REFR-01 # formal-library.json change -> refresh
+ - REFR-02 # active file change -> refresh
+
+must_haves:
+ truths:
+ - "Opening a .base file switches dashboard to collection mode with domain name in header"
+ - "Opening a .md file with zotero_key in frontmatter switches dashboard to per-paper mode with paper title in header"
+ - "Opening any other file or no file switches dashboard to global mode"
+ - "Tab click or Ctrl+Tab triggers mode switch within observable time, previous mode data released"
+ - "External modification of formal-library.json triggers dashboard refresh within 2 seconds, preserving current mode"
+ - "Index cache invalidates on file modify and file switch events"
+ - "Quick Actions bar remains visible in all three modes"
+ artifacts:
+ - path: "paperforge/plugin/main.js"
+ provides: "_currentMode, _detectAndSwitch, _switchMode, event subscriptions, mode-aware header render, mode-specific render stubs, onClose cleanup"
+ min_lines: 150
+ - path: "paperforge/plugin/styles.css"
+ provides: "Contextual action card variants (if any CSS additions needed)"
+ min_lines: 0
+ key_links:
+ - from: "PaperForgeStatusView.onOpen()"
+ to: "_detectAndSwitch()"
+ via: "call at end of onOpen after structural build"
+ - from: "app.workspace.on('active-leaf-change')"
+ to: "_detectAndSwitch()"
+ via: "debounced callback with 300ms delay"
+ - from: "app.vault.on('modify', file)"
+ to: "_refreshCurrentMode()"
+ via: "filter: file.path includes formal-library.json, then invalidate cache + re-detect"
+ - from: "_detectAndSwitch()"
+ to: "_switchMode(mode)"
+ via: "determines mode from active file, then calls _switchMode"
+ - from: "_switchMode(mode)"
+ to: "_renderGlobalMode / _renderPaperMode / _renderCollectionMode"
+ via: "switch statement, each clears _contentEl.empty() then renders"
+---
+
+
+Implement context detection, mode switching, auto-refresh, and mode-aware header rendering in PaperForgeStatusView. The dashboard automatically detects the active file type (`.base`, `.md` with `zotero_key`, or other/no file) and switches to the correct view mode without manual intervention. It also auto-refreshes when the canonical index changes or the user switches files.
+
+Purpose: Deliver DASH-01 through DASH-04 and REFR-01 through REFR-02. Phase 29 and 30 will flesh out the per-paper and collection views with actual components; this plan provides the routing shell that makes those phases possible.
+
+Output: `paperforge/plugin/main.js` with mode detection, switching, event subscriptions, and mode-aware rendering. `paperforge/plugin/styles.css` with contextual action variant CSS.
+
+
+
+@C:/Users/Lin/.opencode/get-shit-done/workflows/execute-plan.md
+@C:/Users/Lin/.opencode/get-shit-done/templates/summary.md
+
+
+
+@.planning/phases/28-dashboard-shell-context-detection/28-CONTEXT.md
+@.planning/phases/28-dashboard-shell-context-detection/28-01-SUMMARY.md
+@paperforge/plugin/main.js
+@paperforge/plugin/styles.css
+
+
+
+
+
+ Task 1: Add _currentMode state, _detectAndSwitch(), and _switchMode() infrastructure
+ paperforge/plugin/main.js
+
+
+ - paperforge/plugin/main.js — PaperForgeStatusView class (lines 210-815), especially:
+ * `onOpen()` at lines 217-225 (must refactor to call _detectAndSwitch)
+ * `_buildPanel()` at lines 232-280 (must refactor to separate structural header from content)
+ * `_fetchStats()` at lines 285-375 (will become part of global mode render)
+ * `_renderOcr()` at lines 432-498 (will become part of global mode render)
+ - .planning/phases/28-dashboard-shell-context-detection/28-CONTEXT.md
+ * D-01 through D-07 (context detection and mode switching)
+ * D-10 (initial data load via _detectAndSwitch)
+ * D-11 (index loading)
+ * D-15 (base filename = domain)
+ * D-17, D-18 (error handling)
+ * "Specific Ideas" section (contextual actions per mode)
+ - .planning/phases/28-dashboard-shell-context-detection/28-01-SUMMARY.md (index utilities that now exist)
+
+
+
+ Refactor `PaperForgeStatusView` to support mode-aware rendering. This is the largest refactor in the phase.
+
+ **=== Step 1: Refactor onOpen() — replace direct content build with mode-detected init ===**
+
+ Replace the existing `onOpen()` method (lines 217-225) with:
+
+ ```javascript
+ async onOpen() {
+ this._buildPanel();
+ this._contentEl = this.containerEl.querySelector('.paperforge-content-area');
+ this._modeSubscribers = []; // reused by both workspace and vault events
+ this._leafChangeTimer = null; // debounce timer for active-leaf-change
+
+ // Initial data load per D-10
+ this._detectAndSwitch();
+ }
+ ```
+
+ **=== Step 2: Refactor _buildPanel() — separate structural shell from content ===**
+
+ The current `_buildPanel()` (lines 232-280) renders the entire dashboard inline: header, message, metrics, OCR, and quick actions. We need to:
+
+ 1. Keep the header (logo, title, badge, refresh button) in `_buildPanel()` — it stays across modes
+ 2. Move the metrics (`_metricsEl`) and OCR section (`_ocrSection`) into a NEW structural container that will be swapped per mode
+ 3. Add a `.paperforge-content-area` div between the header and the Quick Actions section
+ 4. Keep Quick Actions in `_buildPanel()` — they stay across modes
+
+ Replace the existing `_buildPanel()` method (lines 232-280) with this refactored version:
+
+ ```javascript
+ _buildPanel() {
+ const root = this.containerEl;
+ root.empty();
+ root.addClass('paperforge-status-panel');
+
+ /* ── Header ── */
+ const header = root.createEl('div', { cls: 'paperforge-header' });
+
+ const headerLeft = header.createEl('div', { cls: 'paperforge-header-left' });
+ headerLeft.createEl('div', { cls: 'paperforge-header-logo', text: 'P' });
+
+ // Mode context area (populated by _renderModeHeader)
+ this._modeContextEl = headerLeft.createEl('div', { cls: 'paperforge-mode-context' });
+
+ this._headerTitle = headerLeft.createEl('h3', { cls: 'paperforge-header-title', text: 'PaperForge' });
+ this._versionBadge = headerLeft.createEl('span', { cls: 'paperforge-header-badge', text: 'v\u2014' });
+
+ const refreshBtn = header.createEl('button', { cls: 'paperforge-header-refresh', attr: { 'aria-label': 'Refresh' } });
+ refreshBtn.innerHTML = '\u21BB';
+ refreshBtn.addEventListener('click', () => {
+ this._invalidateIndex();
+ this._detectAndSwitch();
+ });
+
+ /* ── Status Message (command output) ── */
+ this._messageEl = root.createEl('div', { cls: 'paperforge-message' });
+
+ /* ── Mode-Switched Content Area (per D-06) ── */
+ this._contentEl = root.createEl('div', { cls: 'paperforge-content-area' });
+
+ /* ── Quick Actions (visible in all modes per D-07 / "Specific Ideas") ── */
+ const actions = root.createEl('div', { cls: 'paperforge-actions-section' });
+ actions.createEl('h4', { cls: 'paperforge-actions-title', text: 'Quick Actions' });
+ this._actionsGrid = actions.createEl('div', { cls: 'paperforge-actions-grid' });
+ this._renderActions(); // extracted so actions can be re-rendered per mode
+ }
+ ```
+
+ Key changes from current:
+ - Removed `_metricsEl`, `_ocrSection`, `_ocrBadge`, `_ocrTrack`, `_ocrCounts`, `_ocrEmpty` creation from `_buildPanel()` — these will be created dynamically in mode render methods
+ - Added `_modeContextEl` — populated by `_renderModeHeader()` in Task 2
+ - Added `_headerTitle` — reference to update header text per mode
+ - Added `_contentEl` — the mode-switched content area
+ - Added `_actionsGrid` — reference to action grid for re-rendering
+ - Extracted action rendering into `_renderActions()` method
+ - Refresh button now calls `_invalidateIndex()` + `_detectAndSwitch()` instead of `_fetchStats()` — since mode detection now owns data loading
+ - Removed the old `_metricsEl`, `_ocrSection` instance variable references from `_buildPanel` — these will be created inside mode render methods instead
+
+ **=== Step 3: Add _renderActions() — extracted from old _buildPanel() ===**
+
+ ```javascript
+ /* ── Render Quick Actions (extracted from _buildPanel for mode-aware reuse) ── */
+ _renderActions() {
+ this._actionsGrid.empty();
+ // Base actions always visible (D-07)
+ const baseActions = [
+ { id: 'paperforge-sync', icon: '\u21BB', title: 'Sync Library', desc: 'Pull new references from Zotero and generate literature notes' },
+ { id: 'paperforge-ocr', icon: '\u229E', title: 'Run OCR', desc: 'Extract full text and figures from PDFs via PaddleOCR' },
+ { id: 'paperforge-doctor', icon: '\u2695', title: 'Run Doctor', desc: 'Verify PaperForge setup — check configs, Zotero, paths, and index health' },
+ { id: 'paperforge-repair', icon: '\u21BA', title: 'Repair Issues', desc: 'Fix three-way state divergence, path errors, and rebuild index' },
+ ];
+
+ for (const a of baseActions) {
+ const card = this._actionsGrid.createEl('div', { cls: 'paperforge-action-card' });
+ card.createEl('div', { cls: 'paperforge-action-card-icon', text: a.icon });
+ card.createEl('div', { cls: 'paperforge-action-card-title', text: a.title });
+ card.createEl('div', { cls: 'paperforge-action-card-desc', text: a.desc });
+ card.createEl('div', { cls: 'paperforge-action-card-hint', text: 'Click to run' });
+ const actionDef = ACTIONS.find(x => x.id === a.id);
+ if (actionDef) {
+ card.addEventListener('click', () => this._runAction(actionDef, card));
+ }
+ }
+ }
+ ```
+
+ Wait — looking at the existing code, `ACTIONS` is a module-level constant array (lines 157-208). The actions in `_buildPanel` iterate over `ACTIONS` directly. Let me simplify — just use `ACTIONS` directly instead of duplicating action definitions:
+
+ ```javascript
+ /* ── Render Quick Actions (extracted from _buildPanel for mode-aware reuse) ── */
+ _renderActions() {
+ this._actionsGrid.empty();
+ for (const a of ACTIONS) {
+ const card = this._actionsGrid.createEl('div', { cls: 'paperforge-action-card' });
+ card.createEl('div', { cls: 'paperforge-action-card-icon', text: a.icon });
+ card.createEl('div', { cls: 'paperforge-action-card-title', text: a.title });
+ card.createEl('div', { cls: 'paperforge-action-card-desc', text: a.desc });
+ card.createEl('div', { cls: 'paperforge-action-card-hint', text: 'Click to run' });
+ card.addEventListener('click', () => this._runAction(a, card));
+ }
+ }
+ ```
+
+ Actually this is exactly what the existing code in `_buildPanel()` does (lines 271-279). The refactored `_buildPanel()` calls `_renderActions()` instead. The `ACTIONS` constant is already defined in module scope. Use the exact same iteration as lines 271-279.
+
+ **=== Step 4: Add _invalidateIndex() helper ===**
+
+ ```javascript
+ /* ── Invalidate cached index (D-14) ── */
+ _invalidateIndex() {
+ this._cachedItems = null;
+ }
+ ```
+
+ **=== Step 5: Add _currentMode state variables ===**
+
+ Add these as instance property initializations. The cleanest approach is to initialize them at the top of the constructor. The current constructor (line 211) is `constructor(leaf) { super(leaf); }`. Replace it with:
+
+ ```javascript
+ constructor(leaf) {
+ super(leaf);
+ this._currentMode = null; // 'global' | 'paper' | 'collection' (D-05)
+ this._currentDomain = null; // domain name when in collection mode (D-15)
+ this._currentPaperKey = null; // zotero_key when in per-paper mode (D-03)
+ this._currentPaperEntry = null; // full entry when in per-paper mode
+ this._cachedItems = null; // lazy-loaded index items (Plan 28-01)
+ this._modeSubscribers = []; // event handler refs for cleanup
+ this._leafChangeTimer = null; // debounce timer for active-leaf-change
+ }
+ ```
+
+ **=== Step 6: Add _detectAndSwitch() — core context detection (D-01, D-02, D-03, D-04, D-10) ===**
+
+ Insert this method after `_renderActions()`:
+
+ ```javascript
+ /* ── Context Detection & Mode Switch (D-01, D-02, D-03, D-04, D-10) ── */
+ _detectAndSwitch() {
+ const activeFile = this.app.workspace.getActiveFile();
+
+ if (!activeFile) {
+ // No active file -> global mode (D-04)
+ this._switchMode('global');
+ return;
+ }
+
+ const ext = activeFile.extension;
+
+ if (ext === 'base') {
+ // .base file -> collection mode (D-02, D-15)
+ this._currentDomain = activeFile.basename;
+ this._currentPaperKey = null;
+ this._currentPaperEntry = null;
+ this._switchMode('collection');
+ return;
+ }
+
+ if (ext === 'md') {
+ // .md file — check for zotero_key in frontmatter (D-03)
+ const cache = this.app.metadataCache.getFileCache(activeFile);
+ const key = cache && cache.frontmatter && cache.frontmatter.zotero_key;
+
+ if (key) {
+ // Has zotero_key -> per-paper mode (D-03)
+ this._currentPaperKey = key;
+ this._currentPaperEntry = this._findEntry(key);
+ this._currentDomain = null;
+ this._switchMode('paper');
+ } else {
+ // .md without zotero_key -> global mode (D-04)
+ this._currentDomain = null;
+ this._currentPaperKey = null;
+ this._currentPaperEntry = null;
+ this._switchMode('global');
+ }
+ return;
+ }
+
+ // Any other file type -> global mode (D-04)
+ this._currentDomain = null;
+ this._currentPaperKey = null;
+ this._currentPaperEntry = null;
+ this._switchMode('global');
+ }
+ ```
+
+ **=== Step 7: Add _switchMode() — renders the correct view for the detected mode (D-05, D-06) ===**
+
+ ```javascript
+ /* ── Mode Switching (D-05, D-06) ── */
+ _switchMode(mode) {
+ if (this._currentMode === mode) {
+ // Already in this mode — just refresh if needed (D-04)
+ this._refreshCurrentMode();
+ return;
+ }
+
+ this._currentMode = mode;
+
+ // Clear existing content (D-06)
+ this._contentEl.empty();
+ this._contentEl.removeClass('switching');
+
+ // Update header (D-07)
+ this._renderModeHeader(mode);
+
+ // Render mode-specific content (D-06)
+ switch (mode) {
+ case 'global':
+ this._renderGlobalMode();
+ break;
+ case 'paper':
+ this._renderPaperMode();
+ break;
+ case 'collection':
+ this._renderCollectionMode();
+ break;
+ }
+ }
+ ```
+
+ **=== Step 8: Add mode-specific render stubs (placeholder for Phase 29/30) ===**
+
+ These are initial implementations. Phase 29 (Per-Paper View) and Phase 30 (Collection View) will expand these with actual lifecycle, health, maturity, and chart components.
+
+ ```javascript
+ /* ── Global Mode Render (existing dashboard, extracted from _fetchStats + _renderOcr) ── */
+ _renderGlobalMode() {
+ // Metric cards
+ this._metricsEl = this._contentEl.createEl('div', { cls: 'paperforge-metrics' });
+ const ocrSection = this._contentEl.createEl('div', { cls: 'paperforge-ocr-section' });
+ ocrSection.style.display = 'none';
+
+ // Reuse existing _fetchStats logic — it reads data and renders to _metricsEl
+ this._cachedStats = null; // force fresh load
+ this._fetchStats();
+ }
+
+ /* ── Per-Paper Mode Render (placeholder — Phase 29 fills this in) ── */
+ _renderPaperMode() {
+ if (this._currentPaperEntry) {
+ // Entry found — render paper info placeholder (D-18)
+ this._contentEl.createEl('div', {
+ cls: 'paperforge-content-placeholder',
+ text: `Per-paper view for "${this._currentPaperEntry.title || this._currentPaperKey}" — Phase 29 will add lifecycle stepper, health matrix, maturity gauge, and next-step panel here.`,
+ });
+ } else if (this._currentPaperKey) {
+ // Key exists but entry not found in index (D-18)
+ this._contentEl.createEl('div', {
+ cls: 'paperforge-content-placeholder',
+ text: `Paper "${this._currentPaperKey}" not found in canonical index. The paper may not have been synced yet.`,
+ });
+ } else {
+ // No key and no entry (defensive fallback)
+ this._contentEl.createEl('div', {
+ cls: 'paperforge-content-placeholder',
+ text: 'No paper data available.',
+ });
+ }
+ }
+
+ /* ── Collection Mode Render (placeholder — Phase 30 fills this in) ── */
+ _renderCollectionMode() {
+ const domain = this._currentDomain || 'Unknown';
+ const domainItems = this._filterByDomain(domain);
+
+ if (domainItems.length > 0) {
+ this._contentEl.createEl('div', {
+ cls: 'paperforge-content-placeholder',
+ text: `Collection view for "${domain}" — ${domainItems.length} papers. Phase 30 will add lifecycle distribution bar chart, aggregated health summary, and paper count here.`,
+ });
+ } else {
+ this._contentEl.createEl('div', {
+ cls: 'paperforge-content-placeholder',
+ text: `No papers found in domain "${domain}". Sync some papers first.`,
+ });
+ }
+ }
+ ```
+
+ **=== Step 9: Add _refreshCurrentMode() — re-renders current mode without switching ===**
+
+ ```javascript
+ /* ── Refresh current mode (called on index change, D-09, REFR-01) ── */
+ _refreshCurrentMode() {
+ if (!this._currentMode) return;
+ this._contentEl.empty();
+ this._contentEl.addClass('switching');
+ this._invalidateIndex(); // force fresh data load
+
+ switch (this._currentMode) {
+ case 'global':
+ this._renderGlobalMode();
+ break;
+ case 'paper':
+ this._renderPaperMode();
+ break;
+ case 'collection':
+ this._renderCollectionMode();
+ break;
+ }
+
+ // Brief delay before removing switching class for smooth fade transition
+ setTimeout(() => {
+ if (this._contentEl) this._contentEl.removeClass('switching');
+ }, 50);
+ }
+ ```
+
+ **IMPORTANT — Operation order:**
+ 1. Replace constructor (add instance vars)
+ 2. Replace `onOpen()` (use _detectAndSwitch)
+ 3. Replace `_buildPanel()` (add _modeContextEl, _headerTitle, _contentEl, _actionsGrid; remove _metricsEl, _ocrSection)
+ 4. Add `_renderActions()` (extracted from old _buildPanel loop)
+ 5. Add `_invalidateIndex()`
+ 6. Add `_detectAndSwitch()`
+ 7. Add `_switchMode()`
+ 8. Add `_renderGlobalMode()`, `_renderPaperMode()`, `_renderCollectionMode()`
+ 9. Add `_refreshCurrentMode()`
+
+ Do NOT modify `_fetchStats()`, `_renderStats()`, `_renderOcr()`, or any action-related methods yet.
+
+
+
+ - `main.js` PaperForgeStatusView constructor initializes: `_currentMode`, `_currentDomain`, `_currentPaperKey`, `_currentPaperEntry`, `_cachedItems`, `_modeSubscribers`, `_leafChangeTimer`
+ - `onOpen()` calls `this._detectAndSwitch()` and sets up `this._contentEl`
+ - `_buildPanel()` creates `this._modeContextEl` (div.paperforge-mode-context), `this._headerTitle` (h3.paperforge-header-title), `this._contentEl` (div.paperforge-content-area), `this._actionsGrid` (div.paperforge-actions-grid)
+ - `_buildPanel()` calls `this._renderActions()` instead of inline rendering
+ - `_renderActions()` iterates over `ACTIONS` constant and renders action cards
+ - `_detectAndSwitch()` checks `this.app.workspace.getActiveFile()` (D-01)
+ - `_detectAndSwitch()` checks `activeFile.extension === 'base'` (D-02) and `activeFile.extension === 'md'` (D-03)
+ - `_detectAndSwitch()` reads frontmatter via `this.app.metadataCache.getFileCache(activeFile)` (D-03)
+ - `_detectAndSwitch()` calls `this._switchMode(mode)` with appropriate mode
+ - `_switchMode()` checks `this._currentMode === mode` to avoid redundant switches (D-04)
+ - `_switchMode()` calls `this._contentEl.empty()` on mode change (D-06)
+ - `_switchMode()` calls `this._renderModeHeader(mode)` (D-07)
+ - `_switchMode()` switch/case handles 'global', 'paper', 'collection'
+ - `_renderGlobalMode()` creates div.paperforge-metrics and renders _fetchStats()
+ - `_renderPaperMode()` checks `this._currentPaperEntry` for "not found" case (D-18)
+ - `_renderCollectionMode()` calls `this._filterByDomain(domain)` for data
+ - `_refreshCurrentMode()` calls `this._invalidateIndex()` then re-renders current mode
+ - `_invalidateIndex()` sets `this._cachedItems = null`
+ - grep: `this._detectAndSwitch()` is called from `onOpen`
+ - grep: `this._switchMode(` appears in _detectAndSwitch and _switchMode body
+ - grep: `this._renderModeHeader(` appears in _switchMode body
+ - grep: `this._renderActions()` appears in _buildPanel
+ - grep: `this._invalidateIndex()` appears in _refreshCurrentMode and refresh button
+ - grep: `this._currentMode` appears in _switchMode, _detectAndSwitch, _refreshCurrentMode
+
+
+
+ python -c "
+with open('paperforge/plugin/main.js') as f:
+ c = f.read()
+# Core methods
+assert '_detectAndSwitch()' in c or 'this._detectAndSwitch' in c, 'Missing _detectAndSwitch method'
+assert '_switchMode(' in c or 'this._switchMode' in c, 'Missing _switchMode method'
+assert '_refreshCurrentMode' in c, 'Missing _refreshCurrentMode method'
+assert '_invalidateIndex' in c, 'Missing _invalidateIndex method'
+assert '_renderActions' in c or '_renderActions()' in c, 'Missing _renderActions method'
+assert '_renderGlobalMode' in c, 'Missing _renderGlobalMode method'
+assert '_renderPaperMode' in c, 'Missing _renderPaperMode method'
+assert '_renderCollectionMode' in c, 'Missing _renderCollectionMode method'
+# Mode state
+assert '_currentMode' in c, 'Missing _currentMode state'
+assert '_currentDomain' in c, 'Missing _currentDomain state'
+assert '_currentPaperKey' in c, 'Missing _currentPaperKey state'
+assert '_currentPaperEntry' in c, 'Missing _currentPaperEntry state'
+# Detection logic
+assert \"getActiveFile()\" in c or \"getActiveFile())\" in c, 'Missing getActiveFile call (D-01)'
+assert \"'base'\" in c or \"\\\"base\\\"\" in c, 'Missing .base extension check (D-02)'
+assert \"getFileCache\" in c, 'Missing getFileCache call (D-03)'
+assert \"zotero_key\" in c, 'Missing zotero_key check (D-03)'
+# Content management
+assert \"empty()\" in c, 'Missing empty() call for content clear (D-06)'
+assert '_modeContextEl' in c, 'Missing _modeContextEl (D-07)'
+assert '_contentEl' in c, 'Missing _contentEl'
+assert '_actionsGrid' in c, 'Missing _actionsGrid'
+# Existing structural elements preserved
+assert 'paperforge-status-panel' in c, 'Missing root panel class'
+assert 'paperforge-header' in c, 'Missing header'
+assert 'paperforge-actions-section' in c, 'Missing actions section'
+print('Task 1 mode detection infrastructure: ALL CHECKS PASSED')
+"
+
+
+
+ PaperForgeStatusView has mode detection infrastructure: constructor initializes mode state variables, _buildPanel() creates mode-switched content area and header context elements, onOpen() calls _detectAndSwitch(), _renderActions() replaces inline action card loop, _detectAndSwitch() resolves active file type (base/md-with-key/other), _switchMode() clears and renders correct mode, mode-specific render methods (_renderGlobalMode/_renderPaperMode/_renderCollectionMode) exist with content stubs, _refreshCurrentMode() and _invalidateIndex() support auto-refresh workflow.
+
+
+
+
+ Task 2: Add event subscriptions (auto-refresh), mode-aware header rendering, and onClose cleanup
+ paperforge/plugin/main.js
+
+
+ - paperforge/plugin/main.js (output after Task 1 — has the mode infrastructure but no event subscriptions yet)
+ - .planning/phases/28-dashboard-shell-context-detection/28-CONTEXT.md (decisions D-07, D-08, D-09, D-19; also Specific Ideas about contextual actions)
+
+
+
+ Add event subscriptions, mode-aware header rendering, and lifecycle cleanup to PaperForgeStatusView.
+
+ **WARNING:** There is a duplicate `_showMessage` method issue in the existing code. After the original `_showMessage` at lines 788-793, there's an exact duplicate at lines 795-800, followed by a duplicated `child.on('close', ...)` block (lines 754-786) that was accidentally left in from a previous edit. The executor MUST be careful to insert new code AFTER the first `_showMessage` (ending at line 793) and BEFORE the duplicate block (starting at line 754). To be safe, identify the true end of the class (the `static async open` method at lines 802-814) and insert the new methods immediately before the `static async open` method.
+
+ Insert these methods in the PaperForgeStatusView class, right before the `static async open()` method.
+
+ **Method A: _renderModeHeader(mode) — update header context area (D-07)**
+
+ ```javascript
+ /* ── Mode-Aware Header (D-07) ── */
+ _renderModeHeader(mode) {
+ this._modeContextEl.empty();
+
+ // Build mode badge
+ const badge = this._modeContextEl.createEl('span', { cls: 'paperforge-mode-badge' });
+ let modeName = '';
+
+ switch (mode) {
+ case 'global':
+ badge.addClass('global');
+ badge.setText('Global');
+ this._headerTitle.setText('PaperForge');
+ break;
+
+ case 'paper':
+ badge.addClass('paper');
+ badge.setText('Paper');
+ if (this._currentPaperEntry && this._currentPaperEntry.title) {
+ modeName = this._currentPaperEntry.title;
+ } else if (this._currentPaperKey) {
+ modeName = this._currentPaperKey;
+ // Show warning if entry not found (D-18)
+ this._modeContextEl.createEl('span', {
+ cls: 'paperforge-mode-warning',
+ text: 'Not found in index',
+ });
+ } else {
+ modeName = 'Unknown paper';
+ }
+ this._headerTitle.setText(modeName);
+ break;
+
+ case 'collection':
+ badge.addClass('collection');
+ badge.setText('Collection');
+ modeName = this._currentDomain || 'Unknown Domain';
+ this._headerTitle.setText(modeName);
+ break;
+ }
+
+ if (modeName) {
+ this._modeContextEl.createEl('span', {
+ cls: 'paperforge-mode-name',
+ text: modeName,
+ });
+ }
+ }
+ ```
+
+ NOTE: For the `paper` case, the modeName text is shown both in the header title AND in the `.paperforge-mode-name` span. This is intentional — the header title is the primary display (large text) and the mode context area shows the supplementary badge + truncated name. The Phase 29 development may adjust this.
+
+ **Method B: _setupEventSubscriptions() — wire up workspace + vault events (D-08, D-09, D-19)**
+
+ ```javascript
+ /* ── Event Subscriptions (D-08, D-09, D-19) ── */
+ _setupEventSubscriptions() {
+ // D-08: Active leaf change — debounced with 300ms delay
+ const leafHandler = this.app.workspace.on('active-leaf-change', () => {
+ clearTimeout(this._leafChangeTimer);
+ this._leafChangeTimer = setTimeout(() => {
+ this._detectAndSwitch();
+ }, 300);
+ });
+ this._modeSubscribers.push({ event: 'active-leaf-change', ref: leafHandler });
+
+ // D-09: File modification — filter to formal-library.json only
+ const modifyHandler = this.app.vault.on('modify', (file) => {
+ if (file && file.path && file.path.endsWith('formal-library.json')) {
+ this._invalidateIndex(); // D-14: invalidate cache
+ this._refreshCurrentMode();
+ }
+ });
+ this._modeSubscribers.push({ event: 'modify', ref: modifyHandler });
+ }
+ ```
+
+ Note on event API: Obsidian's `workspace.on()` and `vault.on()` return an event ref that is passed to `off()` for cleanup. The returned ref may be a number (index) or a function, depending on the Obsidian version. Storing these refs in `this._modeSubscribers` and passing them to `off()` in `onClose()` handles both patterns.
+
+ **Method C: Update onOpen() to call _setupEventSubscriptions()**
+
+ The `onOpen()` method (refactored in Task 1) should also call `_setupEventSubscriptions()`:
+
+ ```javascript
+ async onOpen() {
+ this._buildPanel();
+ this._contentEl = this.containerEl.querySelector('.paperforge-content-area');
+ this._modeSubscribers = [];
+ this._leafChangeTimer = null;
+
+ // Subscribe to file change events (D-08, D-09)
+ this._setupEventSubscriptions();
+
+ // Initial data load per D-10
+ this._detectAndSwitch();
+ }
+ ```
+
+ **Method D: Update onClose() — proper event cleanup with defensive guards**
+
+ ```javascript
+ onClose() {
+ // Unsubscribe from all event subscriptions (D-08, D-09)
+ if (this._modeSubscribers && this._modeSubscribers.length > 0) {
+ for (const sub of this._modeSubscribers) {
+ if (sub.event === 'active-leaf-change') {
+ this.app.workspace.off('active-leaf-change', sub.ref);
+ } else if (sub.event === 'modify') {
+ this.app.vault.off('modify', sub.ref);
+ }
+ }
+ this._modeSubscribers = [];
+ }
+
+ // Clear debounce timer
+ if (this._leafChangeTimer) {
+ clearTimeout(this._leafChangeTimer);
+ this._leafChangeTimer = null;
+ }
+
+ // Clear cached data
+ this._cachedItems = null;
+ this._cachedStats = null;
+ }
+ ```
+
+ **IMPORTANT — Insertion order:**
+ 1. Replace `onOpen()` with version that calls `_setupEventSubscriptions()`
+ 2. Replace `onClose()` with cleanup version
+ 3. Add `_renderModeHeader(mode)` before `static async open()`
+ 4. Add `_setupEventSubscriptions()` before `static async open()`
+
+ The refactored `onOpen()` from Task 1 will now include the `_setupEventSubscriptions()` call. The executor should make sure both Task 1 and Task 2 changes are present in the final output — specifically, `onOpen()` must call BOTH `_buildPanel()` and `_setupEventSubscriptions()` and `_detectAndSwitch()`.
+
+ The `onClose()` in current main.js (line 227) is a no-op `onClose() { /* no-op */ }`. Replace it completely.
+
+ **No CSS changes** needed for this task. The Task 2 CSS additions from Plan 28-01 already define .paperforge-mode-badge, .paperforge-mode-name, and .paperforge-mode-warning classes.
+
+
+
+ - `onOpen()` calls `this._setupEventSubscriptions()` in addition to `_buildPanel()` and `_detectAndSwitch()`
+ - `onClose()` iterates `this._modeSubscribers` and calls `this.app.workspace.off()` and `this.app.vault.off()` for each subscription
+ - `onClose()` clears `this._leafChangeTimer` and nulls cached data
+ - `_setupEventSubscriptions()` subscribes to `this.app.workspace.on('active-leaf-change', ...)` (D-08)
+ - `_setupEventSubscriptions()` subscribes to `this.app.vault.on('modify', ...)` (D-09)
+ - `modify` handler filters for `file.path.endsWith('formal-library.json')` (D-19)
+ - `modify` handler calls `this._invalidateIndex()` and `this._refreshCurrentMode()` (D-09, REFR-01)
+ - `active-leaf-change` handler debounces with `setTimeout(..., 300)` before calling `_detectAndSwitch()` (D-08, D-04)
+ - Subscriptions are stored in `this._modeSubscribers` array as `{event, ref}` objects
+ - `_renderModeHeader(mode)` creates `span.paperforge-mode-badge` with `.global/.paper/.collection` class
+ - `_renderModeHeader(mode)` creates `span.paperforge-mode-name` with mode context text
+ - `_renderModeHeader('paper')` shows warning when entry not found (D-18)
+ - `_renderModeHeader('collection')` sets header title to `this._currentDomain`
+ - `_renderModeHeader('global')` sets header title to 'PaperForge'
+ - grep: `_setupEventSubscriptions` in main.js
+ - grep: `'active-leaf-change'` in main.js
+ - grep: `'modify'` in main.js
+ - grep: `formal-library.json` in main.js (within event handler filter)
+ - grep: `this._modeSubscribers.push` in main.js
+ - grep: `this.app.vault.off(` and `this.app.workspace.off(` in onClose
+ - grep: `this._leafChangeTimer` and `clearTimeout` in onClose
+ - grep: `_renderModeHeader` in main.js
+ - grep: `paperforge-mode-badge` in main.js
+ - grep: `paperforge-mode-warning` in main.js (paper not found case)
+
+
+
+ python -c "
+with open('paperforge/plugin/main.js') as f:
+ c = f.read()
+# Event subscriptions
+assert '_setupEventSubscriptions' in c, 'Missing _setupEventSubscriptions method'
+assert \"'active-leaf-change'\" in c, 'Missing active-leaf-change subscription (D-08)'
+assert \"'modify'\" in c, 'Missing modify subscription (D-09)'
+assert 'formal-library.json' in c, 'Missing formal-library.json filter (D-19)'
+assert '_invalidateIndex' in c, 'Missing _invalidateIndex call in modify handler'
+assert '_refreshCurrentMode' in c, 'Missing _refreshCurrentMode call'
+assert 'this._modeSubscribers.push' in c, 'Missing subscriber tracking'
+# Debounce
+assert '_leafChangeTimer' in c, 'Missing leaf debounce timer'
+assert 'setTimeout' in c, 'Missing setTimeout debounce (D-08)'
+# Cleanup
+assert 'this.app.workspace.off(\"active-leaf-change\"' in c or 'this.app.workspace.off(' in c, 'Missing workspace off in onClose'
+assert 'this.app.vault.off(' in c, 'Missing vault off in onClose'
+# Header rendering
+assert '_renderModeHeader' in c, 'Missing _renderModeHeader method'
+assert \"'paperforge-mode-badge'\" in c, 'Missing mode badge class'
+assert \"'paperforge-mode-name'\" in c, 'Missing mode name class'
+assert 'addClass(\"global\")' in c, 'Missing global badge class'
+assert 'addClass(\"paper\")' in c, 'Missing paper badge class'
+assert 'addClass(\"collection\")' in c, 'Missing collection badge class'
+assert 'paperforge-mode-warning' in c, 'Missing mode warning (D-18)'
+# Lifecycle
+assert '_setupEventSubscriptions()' in c or 'this._setupEventSubscriptions' in c, 'onOpen missing setup call'
+print('Task 2 events + header + cleanup: ALL CHECKS PASSED')
+"
+
+
+
+ PaperForgeStatusView has full auto-refresh: _setupEventSubscriptions() subscribes to active-leaf-change (debounced 300ms) and vault modify (filtered to formal-library.json), onClose() properly unsubscribes all events, clears timers, and nulls cached data. _renderModeHeader() renders mode-specific badge and context text in the header, with warning for missing papers.
+
+
+
+
+
+
+1. PaperForgeStatusView detects active file via getActiveFile() (D-01) and switches to the correct mode:
+ - .base file -> _switchMode('collection') (DASH-01)
+ - .md with zotero_key -> _switchMode('paper') (DASH-02)
+ - other/no file -> _switchMode('global') (DASH-03)
+2. File switch via tab click or Ctrl+Tab triggers mode switch within 300ms debounce (DASH-04)
+3. formal-library.json modification triggers refresh within 2 seconds (REFR-01) — reactive, no polling
+4. Active file change triggers refresh within debounce window (REFR-02)
+5. onOpen() calls _detectAndSwitch() for initial load (D-10)
+6. _switchMode() clears content via empty() before rendering new mode (D-06)
+7. Quick actions (Sync, OCR, Doctor, Repair) remain visible in all modes
+8. Mode header shows: global -> "PaperForge", paper -> paper title, collection -> domain name (D-07)
+9. Paper not found in index shows mode warning (D-18)
+10. onClose() cleans up subscriptions, prevents memory leaks
+11. Index cache invalidated on refresh button, file modify event, and onClose
+
+
+
+- [ ] .base file -> collection mode with domain name in header (DASH-01)
+- [ ] .md with zotero_key -> per-paper mode with paper title in header (DASH-02)
+- [ ] Other/no file -> global mode with existing dashboard (DASH-03)
+- [ ] File switch triggers mode switch within 300ms (DASH-04)
+- [ ] formal-library.json modify triggers refresh, mode preserved (REFR-01)
+- [ ] Active leaf change triggers refresh (REFR-02)
+- [ ] Quick actions visible in all modes
+- [ ] Content cleared on mode switch (empty() + re-render)
+- [ ] Mode context displayed in header area (badge + name)
+- [ ] Index cache invalidated on modify events
+- [ ] Proper event cleanup in onClose()
+- [ ] No regressions in existing quick action execution
+- [ ] Index not found shows appropriate warning to user
+
+
+