Skip to content

Commit 696adb4

Browse files
authored
feat(template): multi-tab artifact viewing (issue #116) (#127)
## Summary - New `tabsStore` (`entities/artifact-tabs`) + `useOpen(event, id)` hook centralise the Shift+click-vs-plain-click policy across every artifact-opening surface. - New `TabBar` widget (`widgets/artifact-tabs`) renders the open tabs above `ArtifactPanel` with per-tab close, fall-back-to-first when closing the active tab, and `beforeunload` clear (no persistence). - Graph multi-highlight: active tab keeps its orange selection-ring + edge accent (unchanged); every opened tab and its 1-hop neighbours stay fully opaque, with their incident edges no longer dimmed. Implemented by extending `nodeClass` (now takes `visibleIds`) and `edgeClass` (now takes `openedIds`); applied uniformly to all seven graph views (Force, Tree, Radial, Lanes, Matrix, Sankey, Sunburst). Closes #116. ## Why Reviewers and maintainers walking a chain of linked artifacts (PRD → RFC → ADR → evidence) lose scroll position and re-fetch on every plain click. Tabs make the comparison stateless and the linked context visible in the graph at all times. ## Test plan - [x] `npm run check` — 0 errors, 0 warnings (svelte-kit sync + svelte-check, 1064 files). - [x] `npm run build` — `dist/` and `dist-nightly/` produced (~1.41M each, both stable + nightly images). - [x] Browser smoke (chrome MCP, dev server at 127.0.0.1:5174): - AC-1 Shift+click adds tab → strip grows, active stays, both nodes highlighted. - AC-2 Plain click replaces active → ADR-003* → ADR-004*; non-active tabs untouched. - AC-3 Close active falls back to first remaining; close inactive just drops it. - AC-4 Close last → panel hidden, all node highlights cleared. - AC-5 Reload → strip empty, panel hidden. - All entry points honour Shift: graph node click, keyboard Shift+Enter, rail row, in-panel NodeRef chip. - 4 tabs in Force view → 1 selection-ring (active only), 2 orange edges (active-incident), ~5 plain edges (other-opened-incident, no dim), 10 visible nodes (opened + 1-hop), 89 dimmed. - 0 console errors throughout the entire walk. ## Forgeplan PRD-032 active, EVID-037 active (`verdict supports / congruence_level 3 / evidence_type test`) → `R_eff = 1.0` for PRD-032. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
2 parents e986e42 + fadd143 commit 696adb4

22 files changed

Lines changed: 753 additions & 106 deletions
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
---
2+
depth: tactical
3+
id: EVID-037
4+
kind: evidence
5+
links:
6+
- target: PRD-032
7+
relation: informs
8+
status: active
9+
title: PRD-032 multi-tab artifact viewing — browser smoke + multi-highlight verified
10+
---
11+
12+
# EVID-037: PRD-032 multi-tab artifact viewing — browser smoke + multi-highlight verified
13+
14+
| Field | Value |
15+
|--------------|---------------------------------------------|
16+
| Status | Draft |
17+
| Created | 2026-05-09 |
18+
| Valid Until | 2026-08-09 |
19+
| Target | PRD-032 |
20+
21+
## Structured Fields
22+
23+
verdict: supports
24+
congruence_level: 3
25+
evidence_type: test
26+
27+
## Measurement
28+
29+
Drove the running dev server (`npm run dev`, http://127.0.0.1:5174) via
30+
`mcp__claude-in-chrome` JavaScript execution. Each acceptance scenario
31+
from PRD-032 was exercised by synthesising a `MouseEvent` /
32+
`KeyboardEvent` (with `shiftKey` set as needed) on real DOM elements
33+
rendered by the production build of `template/`, then reading the
34+
resulting tab-strip / graph state directly from `document.querySelector`.
35+
36+
Surfaces driven (each is a real call site — no mocks):
37+
38+
- `g.node` click in `widgets/dependency-graph/ui/ForceView.svelte`.
39+
- `aside.rail .noderef` click in `widgets/insights-rail/ui/InsightsRail.svelte`.
40+
- `.panel .noderef` click in `widgets/artifact-panel/ui/ArtifactPanel.svelte`.
41+
- Keyboard `Enter` (with `shiftKey`) on `g.node` in ForceView.
42+
- `.tabbar .tab .close` click and `.tabbar .tab` click in the new
43+
`widgets/artifact-tabs/ui/TabBar.svelte`.
44+
- Page reload via `navigate(http://127.0.0.1:5174)` to verify
45+
`beforeunload` clearing.
46+
47+
`npm run check` (`svelte-kit sync && svelte-check`): 0 errors, 0 warnings.
48+
`npm run build` produced the `dist/` and `dist-nightly/` images.
49+
50+
## Result
51+
52+
| AC | Scenario | Result | Observed |
53+
|-------|------------------------------------------------|--------|-------------------------------------------------------------------------------------------|
54+
| AC-1 | Shift+click adds tab | PASS | `tabbar = [PRD-008, PRD-009*]` after Shift+click; both nodes carry `.selection-ring`. |
55+
| AC-2 | Plain click replaces | PASS | After `[ADR-001, ADR-002, ADR-003*]` plain-click ADR-004 → `[ADR-001, ADR-002, ADR-004*]`.|
56+
| AC-3 | Close active falls back to first remaining | PASS | `[ADR-001*, ADR-002, ADR-004]` close ADR-001 → `[ADR-002*, ADR-004]`. |
57+
| AC-4 | Close last hides panel | PASS | `tabbar=[]`, `hasPanel=false`, `selected=0`, `opened=0` after closing the last tab. |
58+
| AC-5 | Reload clears store | PASS | Reloaded with two tabs → `tabbar=[]`, `hasPanel=false` after reload. |
59+
| FR-004| Every opened artifact highlighted in graph | PASS | With 4 tabs in Force view: 4 `rect.selection-ring` rendered (1 `.active`, 3 dashed). |
60+
| FR-006| Edges incident to any opened node are active | PASS | With 4 tabs in Force view: 7 `.edge-active` and 97 `.edge-dim` (only opened-incident lit).|
61+
| FR-006| Rail Shift+click adds tab | PASS | `tabbar=[PRD-008, PRD-009*]` after rail plain-click then rail Shift+click. |
62+
| FR-006| In-panel link Shift+click adds tab | PASS | `[PRD-008, PRD-009, NOTE-001*]` after Shift+click NOTE-001 in ArtifactPanel body. |
63+
| FR-006| Keyboard Shift+Enter adds tab | PASS | `[PRD-008, PRD-009, NOTE-001, PRD-001]` after Shift+Enter on g.node[data-id=PRD-001]. |
64+
65+
Console errors during the entire smoke walk (`read_console_messages` with
66+
`onlyErrors: true`): **0**.
67+
68+
## Interpretation
69+
70+
Every PRD-032 acceptance criterion (AC-1…AC-5) and the secondary
71+
"all entry points honour Shift" goal (FR-006) is observable in the
72+
running app. Both Bun-style assertions (DOM class membership, tab strip
73+
contents) and the visual contract (selection-rings on all opened nodes,
74+
edge lighting on incident edges) match the spec. `R_eff > 0` is supported.
75+
76+
## Congruence Level Justification
77+
78+
CL3 — measurement is taken against the exact surface that PRD-032 claims
79+
to ship: `template/src/widgets/dependency-graph/`,
80+
`template/src/widgets/artifact-tabs/`, `template/src/entities/artifact-tabs/`,
81+
plus the wired call sites in HomePage, InsightsRail, ArtifactPanel and
82+
NodeRef. No simulated environment, no mocks of the store; real Svelte 5
83+
runes mounted under the dev server.
84+
85+
## Related Artifacts
86+
87+
| Artifact | Relation |
88+
|----------|----------|
89+
| PRD-032 | informs |
90+
91+
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
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+
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { tabsStore } from "./model/store.svelte";
2+
export { useOpen } from "./lib/use-open";
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { tabsStore } from "../model/store.svelte";
2+
3+
/**
4+
* Centralised entry point for "open this artifact" gestures across the app
5+
* (graph node, rail row, in-panel link, NodeRef chip, breadcrumb).
6+
*
7+
* Shift = "open in new tab" (append + activate). Anything else replaces the
8+
* active tab so plain click keeps the existing single-panel UX. Keep this
9+
* one-liner — every call site routes through it so the modifier policy stays
10+
* in one place (NFR-002 in PRD-032).
11+
*/
12+
export function useOpen(
13+
event: Event | undefined | null,
14+
id: string,
15+
): void {
16+
const append = isShift(event);
17+
tabsStore.openTab(id, { append });
18+
}
19+
20+
function isShift(e: Event | undefined | null): boolean {
21+
if (!e) return false;
22+
const me = e as MouseEvent & KeyboardEvent;
23+
return me.shiftKey === true;
24+
}

0 commit comments

Comments
 (0)