docs(28): create phase 28 plans (index utilities + context detection shell)

This commit is contained in:
Research Assistant 2026-05-04 14:55:59 +08:00
parent 09fcded42e
commit 4bc246c080
4 changed files with 1145 additions and 11 deletions

View file

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

View file

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

View file

@ -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)"
---
<objective>
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.
</objective>
<execution_context>
@C:/Users/Lin/.opencode/get-shit-done/workflows/execute-plan.md
@C:/Users/Lin/.opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/phases/28-dashboard-shell-context-detection/28-CONTEXT.md
@paperforge/plugin/main.js
@paperforge/plugin/styles.css
</context>
<tasks>
<task type="auto">
<name>Task 1: Add index loading utilities (_loadIndex, _getCachedIndex, _findEntry, _filterByDomain)</name>
<files>paperforge/plugin/main.js</files>
<read_first>
- 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)
</read_first>
<action>
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).
</action>
<acceptance_criteria>
- `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
</acceptance_criteria>
<verify>
<automated>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')
"</automated>
</verify>
<done>
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.
</done>
</task>
<task type="auto">
<name>Task 2: Add CSS for mode-aware header context and content area layout</name>
<files>paperforge/plugin/styles.css</files>
<read_first>
- 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)
</read_first>
<action>
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).
</action>
<acceptance_criteria>
- `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)
</acceptance_criteria>
<verify>
<automated>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')
"</automated>
</verify>
<done>
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.
</done>
</task>
</tasks>
<verification>
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`
</verification>
<success_criteria>
- [ ] 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
</success_criteria>
<output>
After completion, create `.planning/phases/28-dashboard-shell-context-detection/28-01-SUMMARY.md`
</output>

View file

@ -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"
---
<objective>
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.
</objective>
<execution_context>
@C:/Users/Lin/.opencode/get-shit-done/workflows/execute-plan.md
@C:/Users/Lin/.opencode/get-shit-done/templates/summary.md
</execution_context>
<context>
@.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
</context>
<tasks>
<task type="auto">
<name>Task 1: Add _currentMode state, _detectAndSwitch(), and _switchMode() infrastructure</name>
<files>paperforge/plugin/main.js</files>
<read_first>
- 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)
</read_first>
<action>
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.
</action>
<acceptance_criteria>
- `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
</acceptance_criteria>
<verify>
<automated>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')
"</automated>
</verify>
<done>
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.
</done>
</task>
<task type="auto">
<name>Task 2: Add event subscriptions (auto-refresh), mode-aware header rendering, and onClose cleanup</name>
<files>paperforge/plugin/main.js</files>
<read_first>
- 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)
</read_first>
<action>
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.
</action>
<acceptance_criteria>
- `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)
</acceptance_criteria>
<verify>
<automated>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')
"</automated>
</verify>
<done>
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.
</done>
</task>
</tasks>
<verification>
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
</verification>
<success_criteria>
- [ ] .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
</success_criteria>
<output>
After completion, create `.planning/phases/28-dashboard-shell-context-detection/28-02-SUMMARY.md`
</output>