|
| 1 | +--- |
| 2 | +depth: standard |
| 3 | +id: PRD-032 |
| 4 | +kind: prd |
| 5 | +status: active |
| 6 | +title: Multi-tab artifact viewing — Shift+click adds tabs, individual close, graph highlights all opened |
| 7 | +--- |
| 8 | + |
| 9 | +--- |
| 10 | +id: PRD-032 |
| 11 | +title: "Multi-tab artifact viewing — Shift+click adds tabs, individual close, graph highlights all opened" |
| 12 | +status: Draft |
| 13 | +author: fedorovvvv |
| 14 | +created: 2026-05-09 |
| 15 | +updated: 2026-05-09 |
| 16 | +priority: P2 |
| 17 | +depth: standard |
| 18 | +domain: general |
| 19 | +projectType: web_app |
| 20 | +stepsCompleted: [] |
| 21 | +--- |
| 22 | + |
| 23 | +# PRD-032: Multi-tab artifact viewing |
| 24 | + |
| 25 | +> Issue: <https://github.com/ForgePlan/forgeplan-web/issues/116> |
| 26 | +
|
| 27 | +## Executive Summary |
| 28 | + |
| 29 | +### Vision |
| 30 | + |
| 31 | +Researcher reading a Forgeplan workspace can keep several artifacts open |
| 32 | +side-by-side as a tab strip in the right panel — `Shift+click` on any |
| 33 | +artifact-opening control adds a new tab, plain click replaces the active |
| 34 | +tab, and every opened artifact is visually highlighted in the dependency |
| 35 | +graph until its tab is closed. |
| 36 | + |
| 37 | +### Problem |
| 38 | + |
| 39 | +Today the right panel renders a **single** artifact at a time |
| 40 | +(`selectedId: string | null` on `HomePage`). Comparing two artifacts — |
| 41 | +e.g. a PRD and its RFC, or two evidence packs against the same PRD — |
| 42 | +forces the user to context-switch back and forth, losing the panel |
| 43 | +scroll position, the impact root, and the in-panel graph highlight on |
| 44 | +each switch. A standard tab strip pattern (Shift = "open in new tab", |
| 45 | +plain click = "open here") removes that friction. |
| 46 | + |
| 47 | +**Impact**: review/audit flows touch 3–5 artifacts per pass; without |
| 48 | +tabs each pass costs `(N-1)` reloads of `/api/get/<id>` and a re-mount |
| 49 | +of the markdown renderer. With tabs that drops to `0`. |
| 50 | + |
| 51 | +### Target Users |
| 52 | + |
| 53 | +| Persona | Description | Key pain | |
| 54 | +|-------------|-----------------------------------------------------|-----------------------------------------------------------------------------| |
| 55 | +| Reviewer | Runs `/fpl-skills:audit` then walks the graph by eye| Comparing PRD against linked evidence requires repeated round-trips | |
| 56 | +| Maintainer | Triaging blind spots / stale artifacts | Wants to keep the offending PRD open while drilling into linked RFC + ADR | |
| 57 | + |
| 58 | +### Differentiators |
| 59 | + |
| 60 | +- Modifier-driven, not menu-driven — matches browser tab UX (`Shift`/`Ctrl`+click). |
| 61 | +- Centralised `useOpen(event, id)` helper so every entry point (graph, |
| 62 | + rail, in-panel link, NodeRef chip) gets identical Shift handling. |
| 63 | + |
| 64 | +--- |
| 65 | + |
| 66 | +## Success Criteria |
| 67 | + |
| 68 | +| ID | Criterion | Metric | Current | Target | How to Measure | |
| 69 | +|------|------------------------------------------------------|---------------|---------|--------|-------------------------------------------| |
| 70 | +| SC-1 | `Shift+click` on any artifact-opening surface opens a new tab without closing the active one | tabs after action | 1 | 2 | Browser smoke (issue #116 acceptance walk)| |
| 71 | +| SC-2 | Plain click on an artifact replaces the active tab | tabs after action | 1 | 1 | Browser smoke | |
| 72 | +| SC-3 | Closing the last tab hides the panel + clears store | panel visible | true | false | Browser smoke | |
| 73 | +| SC-4 | Every opened artifact is highlighted in the graph | `.selected` count | 1 | N | DOM inspection in the smoke walk | |
| 74 | +| SC-5 | Reload (= window close) clears the tab store | tabs after F5 | N | 0 | Browser smoke (no `localStorage` write) | |
| 75 | + |
| 76 | +--- |
| 77 | + |
| 78 | +## Product Scope |
| 79 | + |
| 80 | +### MVP (In-Scope) |
| 81 | + |
| 82 | +- New rune-store module owning `tabs: string[]` + `activeId: string | null`. |
| 83 | +- `useOpen(event: MouseEvent | KeyboardEvent | undefined, id: string)` |
| 84 | + helper that branches on `event?.shiftKey`. |
| 85 | +- Replacement of `selectedId` in `HomePage` with the store's |
| 86 | + `activeId`; render a `TabBar` above `ArtifactPanel`. |
| 87 | +- Update of every open-call site so the underlying event reaches |
| 88 | + `useOpen`: `ForceView` graph node click, `InsightsRail` rows, |
| 89 | + `ArtifactPanel.onNavigate` for in-body links, `NodeRef` chip click. |
| 90 | +- Extension of `ForceView` `.selected` styling so it matches a |
| 91 | + `Set<string>` of opened ids (not a single id). |
| 92 | +- Per-tab close button in the tab strip; close-active falls back to |
| 93 | + the first remaining tab (issue spec §2). |
| 94 | +- No persistence — closing the window / reloading the page clears the |
| 95 | + store (issue spec §3). |
| 96 | + |
| 97 | +### Out of Scope |
| 98 | + |
| 99 | +- Drag-to-reorder tabs. |
| 100 | +- Cross-window / cross-session persistence (deliberate per issue §3). |
| 101 | +- Pin / unpin, "reopen closed tab", browser-history integration. |
| 102 | +- Keyboard-driven tab switching (Ctrl+Tab) — follow-up. |
| 103 | +- Multi-select in graph (lasso) — follow-up. |
| 104 | + |
| 105 | +### Growth Vision |
| 106 | + |
| 107 | +- A `Ctrl/Cmd+click` shortcut that opens-without-activating. |
| 108 | +- A `Cmd+W` shortcut for closing the active tab. |
| 109 | +- Per-tab independent scroll state inside `ArtifactPanel`. |
| 110 | + |
| 111 | +--- |
| 112 | + |
| 113 | +## User Journeys |
| 114 | + |
| 115 | +### Journey 1: Reviewer compares PRD with its evidence pack |
| 116 | + |
| 117 | +| Step | User action | System response | |
| 118 | +|------|--------------------------------------------------|--------------------------------------------------------| |
| 119 | +| 1 | Click PRD-032 in graph | Tab strip shows `[PRD-032]`, panel renders PRD-032 | |
| 120 | +| 2 | `Shift+click` EVID-035 in the same graph | Tab strip shows `[PRD-032 | EVID-035*]`, panel switches to EVID-035, both nodes highlighted | |
| 121 | +| 3 | Click `[PRD-032]` in the tab strip | Panel switches back to PRD-032 without re-fetching | |
| 122 | +| 4 | Click the close (`×`) on `[EVID-035]` | Tab dropped, panel stays on PRD-032, only PRD-032 highlighted | |
| 123 | + |
| 124 | +### Journey 2: Maintainer dives a stale artifact chain |
| 125 | + |
| 126 | +| Step | User action | System response | |
| 127 | +|------|--------------------------------------------------|--------------------------------------------------------| |
| 128 | +| 1 | Click "Stale" tab in `InsightsRail`, click row 1 | Panel shows the stale PRD | |
| 129 | +| 2 | `Shift+click` the linked RFC chip in the panel | Tab strip grows, panel jumps to RFC, both highlighted | |
| 130 | +| 3 | `Shift+click` the linked ADR chip in the panel | Tab strip grows again, panel jumps to ADR | |
| 131 | +| 4 | Reload the page | Tab strip is empty, panel hidden — no resurrected tabs | |
| 132 | + |
| 133 | +--- |
| 134 | + |
| 135 | +## Functional Requirements |
| 136 | + |
| 137 | +| ID | Category | Priority | Requirement | Journey | |
| 138 | +|--------|-------------|----------|------------------------------------------------------------------------------------------------------------|-----------| |
| 139 | +| FR-001 | Core | Must | User can `Shift+click` an artifact-opening control to add the artifact as a new tab and make it active | Journey 1 | |
| 140 | +| FR-002 | Core | Must | User can plain-click an artifact-opening control to replace the active tab with that artifact | Journey 1 | |
| 141 | +| FR-003 | Core | Must | User can close any individual tab; closing the active tab activates the first remaining tab; closing the last tab hides the panel | Journey 1 | |
| 142 | +| FR-004 | UX | Must | Every artifact present in the tab strip is visually highlighted in the dependency graph | Journey 1 | |
| 143 | +| FR-005 | Core | Must | When the window is closed/reloaded, the tab store is cleared (no resurrection) | Journey 2 | |
| 144 | +| FR-006 | UX | Should | All open-call sites (graph node click, rail rows, in-panel links, NodeRef chips) honour `Shift` identically | Journey 2 | |
| 145 | +| FR-007 | UX | Should | Active tab is visually distinct from inactive tabs (border / weight) | Journey 1 | |
| 146 | + |
| 147 | +--- |
| 148 | + |
| 149 | +## Non-Functional Requirements |
| 150 | + |
| 151 | +| ID | Category | Requirement | Metric | Condition | Measurement | |
| 152 | +|---------|-----------------|------------------------------------------------------------|--------------------|------------------------------------|----------------------------| |
| 153 | +| NFR-001 | Performance | Switching the active tab shall be instant (no fetch) | < 16ms | Tab already in store | Browser devtools timeline | |
| 154 | +| NFR-002 | Maintainability | Shift-modifier logic shall live in exactly one helper | 1 implementation | All open call-sites | grep `event.shiftKey` count | |
| 155 | +| NFR-003 | Architecture | Tab store shall not import from `widgets/` or `pages/` | layer respect | per FSD layer rule | manual review | |
| 156 | +| NFR-004 | A11y | Tabs shall be keyboard-focusable; close button has aria-label| 100% coverage | Tab strip | manual screen-reader pass | |
| 157 | + |
| 158 | +--- |
| 159 | + |
| 160 | +## Acceptance Criteria |
| 161 | + |
| 162 | +### AC-1: Shift+click adds a tab |
| 163 | + |
| 164 | +```gherkin |
| 165 | +Given the artifact panel shows PRD-001 (one tab) |
| 166 | +When the user Shift+clicks node "RFC-001" in the graph |
| 167 | +Then the tab strip shows [PRD-001 | RFC-001*] |
| 168 | +And the panel renders RFC-001 |
| 169 | +And both PRD-001 and RFC-001 are .selected in the graph |
| 170 | +``` |
| 171 | + |
| 172 | +### AC-2: Plain click replaces |
| 173 | + |
| 174 | +```gherkin |
| 175 | +Given the tab strip shows [PRD-001* | RFC-001] |
| 176 | +When the user plain-clicks node "ADR-001" in the graph |
| 177 | +Then the tab strip shows [ADR-001* | RFC-001] |
| 178 | +And PRD-001 is no longer in the strip |
| 179 | +``` |
| 180 | + |
| 181 | +### AC-3: Close active falls back to first remaining |
| 182 | + |
| 183 | +```gherkin |
| 184 | +Given the tab strip shows [PRD-001 | RFC-001* | ADR-001] |
| 185 | +When the user clicks the (×) on RFC-001 |
| 186 | +Then the tab strip shows [PRD-001* | ADR-001] |
| 187 | +And the panel renders PRD-001 |
| 188 | +``` |
| 189 | + |
| 190 | +### AC-4: Close last hides panel |
| 191 | + |
| 192 | +```gherkin |
| 193 | +Given the tab strip shows [PRD-001*] |
| 194 | +When the user clicks (×) on PRD-001 |
| 195 | +Then the panel is hidden |
| 196 | +And no node is .selected in the graph |
| 197 | +``` |
| 198 | + |
| 199 | +### AC-5: Reload clears store |
| 200 | + |
| 201 | +```gherkin |
| 202 | +Given the tab strip shows [PRD-001 | RFC-001*] |
| 203 | +When the user reloads the page |
| 204 | +Then the tab strip is empty |
| 205 | +And the panel is hidden |
| 206 | +``` |
| 207 | + |
| 208 | +--- |
| 209 | + |
| 210 | +## Risks & Mitigations |
| 211 | + |
| 212 | +| ID | Risk | Probability | Impact | Mitigation | |
| 213 | +|-----|----------------------------------------------------------------------|-------------|--------|----------------------------------------------------------------------------------| |
| 214 | +| R-1 | Shift+click on graph nodes conflicts with existing pan/select gesture | Low | Medium | `e.stopPropagation()` already on node click; verify no accidental drag-start | |
| 215 | +| R-2 | `.selected` CSS extended to a Set breaks edge dimming logic | Medium | Low | Audit `nodeClass` / `edgeClass` callers; add Set-aware variant, keep old narrow callers happy | |
| 216 | +| R-3 | Notification-bus auto-focus replaces tabs unexpectedly | Low | Low | `notifyBus.pendingFocus` continues to call the plain-open path → replaces active tab, expected | |
| 217 | + |
| 218 | +--- |
| 219 | + |
| 220 | +## Affected Files |
| 221 | + |
| 222 | +- `template/src/entities/artifact-tabs/` (new) — store + hook. |
| 223 | +- `template/src/widgets/artifact-tabs/` (new) — TabBar UI. |
| 224 | +- `template/src/pages/home/ui/HomePage.svelte` — replace `selectedId` with store. |
| 225 | +- `template/src/widgets/dependency-graph/ui/ForceView.svelte` — pass `MouseEvent`, accept Set. |
| 226 | +- `template/src/widgets/dependency-graph/ui/DependencyGraph.svelte` — relay event up. |
| 227 | +- `template/src/widgets/insights-rail/ui/InsightsRail.svelte` — pass `MouseEvent` to `selectId`. |
| 228 | +- `template/src/widgets/artifact-panel/ui/ArtifactPanel.svelte` — pass `MouseEvent` from in-body links + NodeRef. |
| 229 | + |
| 230 | +## Related Artifacts |
| 231 | + |
| 232 | +| Artifact | Relation | Status | |
| 233 | +|------------|-----------------|--------| |
| 234 | +| Issue #116 | Source request | open | |
| 235 | + |
| 236 | + |
0 commit comments