egregore-artifacts 0.5.0 → 0.9.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,297 +1,376 @@
1
1
  // Handoff subgraph → inline static SVG
2
2
  //
3
- // Shows the graph-only context around a handoffthings the header doesn't
4
- // already say. Author/recipients/project are in the metadata row above this.
3
+ // Renders the session's knowledge-graph neighborhood AS A GRAPHtyped
4
+ // nodes with labeled edges rather than as a flat list or zones. Gives
5
+ // the recipient a visual orientation: where this sits, who/what it's
6
+ // connected to, which quests it's part of, what it produced.
5
7
  //
6
- // Central spine is VERTICAL so the SVG fits comfortably in the ~720px
7
- // artifact column without scaling down and losing legibility:
8
+ // Layered DAG layout (top-to-bottom):
8
9
  //
9
- // [prior session]
10
- // by Oz
11
- // implements
12
- // ┌──────────────┐
13
- // THIS HANDOFF
14
- // Topic │
15
- // └──────────────┘
16
- // ↓ picked up
17
- // [later session]
18
- // by Ali
10
+ // Row 0 (optional) [prior session]
11
+ // IMPLEMENTS / CONTINUES
12
+ // Row 1 [ THIS HANDOFF ]
13
+ // ╱ BY │ABOUT ╲ ADVANCED
14
+ // Row 2 [author, recipients] [project] [quests ...]
15
+ //
16
+ // │ HAS_ACTIVITY ╭─ PART_OF ──╯
17
+ // ▼ │
18
+ // Row 3 [artifacts ...] [PRs ...] [later sessions]
19
+ // PRODUCED ▲
20
+ // │ IMPLEMENTS (incoming)
19
21
  //
20
- // PRODUCED
21
- // [PR #603 · egregore] [harvest · …] [decision · …]
22
+ // Legend ● Session ● Person ● Project ● Quest ● Artifact ● PR
22
23
  //
23
- // PART OF (shown only when quests exist rare)
24
- // [quest · Launch Priorities] [quest · Activity Wall]
24
+ // Rows collapse when empty. If the session has no graph-only context
25
+ // (subgraph is null or has only header-duplicating data), the whole
26
+ // section is skipped.
25
27
  //
26
- // Any zone with no data is skipped entirely; if no zones have data, the
27
- // whole section is skipped (parser returns null upstream).
28
- //
29
- // Zero runtime JS. Colors emitted as var(--token) so dark-mode reaches them.
28
+ // Zero runtime JS pure SSR SVG. All colors via var(--token) so dark
29
+ // mode reaches them.
30
30
  import React from 'react';
31
31
  import { fonts } from '../tokens.js';
32
32
 
33
33
  const h = React.createElement;
34
34
 
35
+ // ── Text sizing heuristics ────────────────────────────────────────
35
36
  const CHAR_W_SANS_12 = 7.0;
36
37
  const CHAR_W_SANS_13 = 7.6;
37
- const CHAR_W_MONO_10 = 6.0;
38
38
  const CHAR_W_MONO_9 = 5.4;
39
39
 
40
- const PILL_H = 34;
41
- const PILL_PAD_X = 26;
42
- const PILL_MIN_W = 160;
43
- const PILL_MAX_W = 360;
44
-
45
- const HANDOFF_H = 56;
46
- const HANDOFF_MIN_W = 220;
47
- const HANDOFF_MAX_W = 420;
48
-
49
- const VERT_EDGE = 42; // vertical space for an arrow + its label
50
- const SIDE_PAD = 24;
51
- const ZONE_GAP = 26;
52
- const CAPTION_H = 16;
53
- const CHIP_H = 26;
54
- const SUB_LINE_H = 14;
55
-
56
- const LABEL_TRUNCATE = 50;
40
+ // ── Node geometry ──────────────────────────────────────────────────
41
+ const PILL_H = 32;
42
+ const PILL_PAD_X = 22;
43
+ const PILL_MIN_W = 110;
44
+ const PILL_MAX_W = 260;
45
+ const SESSION_H = 56;
46
+ const SESSION_MIN_W = 220;
47
+ const SESSION_MAX_W = 360;
48
+
49
+ // ── Row y-coordinates ──────────────────────────────────────────────
50
+ const ROW_H = 90;
51
+ const TOP_PAD = 30;
52
+ const SIDE_PAD = 18;
53
+ const LEGEND_H = 34;
54
+
55
+ // ── Truncation caps ────────────────────────────────────────────────
56
+ const TRUNC_SESSION = 60;
57
+ const TRUNC_PILL = 26;
58
+ const TRUNC_ARTIFACT = 28;
59
+
60
+ // ── Count caps (to keep the SVG within the container width) ────────
61
+ const MAX_AUTHORS = 2;
62
+ const MAX_RECIPIENTS = 2;
63
+ const MAX_QUESTS = 3;
64
+ const MAX_ARTIFACTS = 4;
65
+ const MAX_PRS = 3;
66
+ const MAX_LATER = 2;
67
+
68
+ // ── Colors (CSS vars) ──────────────────────────────────────────────
69
+ const STYLE = {
70
+ session: { fill: 'var(--terracotta-chip)', stroke: 'var(--terracotta)', text: 'var(--black)' },
71
+ person: { fill: 'var(--blue-chip)', stroke: 'var(--blue-muted)', text: 'var(--blue-muted)' },
72
+ project: { fill: 'var(--subtle-fill)', stroke: 'var(--border)', text: 'var(--dark)' },
73
+ quest: { fill: 'var(--quest-fill)', stroke: 'var(--green-p1)', text: 'var(--green-p1)' },
74
+ artifact: { fill: 'var(--artifact-fill)', stroke: 'var(--artifact-stroke)', text: 'var(--artifact-stroke)' },
75
+ pr: { fill: 'var(--pr-fill)', stroke: 'var(--border)', text: 'var(--dark)' },
76
+ };
57
77
 
58
78
  export function renderSubgraph({ subgraph, handoffTopic }) {
59
79
  if (!subgraph) return null;
60
80
 
61
81
  const {
62
- continues = [],
63
- implementsHandoff = [],
64
- implementedBy = [],
65
- quests = [],
66
- artifacts = [],
67
- prs = [],
82
+ authors = [], recipients = [], project = null,
83
+ continues = [], implementsHandoff = [], implementedBy = [],
84
+ quests = [], artifacts = [], artifactQuestLinks = [], prs = [],
68
85
  } = subgraph;
69
86
 
87
+ // Prior = the most-specific upstream node (implements > continues, pick first).
70
88
  const priorNode = implementsHandoff[0] || continues[0] || null;
71
- const priorLabel = implementsHandoff[0] ? 'implements' : (continues[0] ? 'continues' : null);
72
- const priorMore = (implementsHandoff.length + continues.length) - (priorNode ? 1 : 0);
73
-
74
- const laterNode = implementedBy[0] || null;
75
- const laterMore = implementedBy.length - (laterNode ? 1 : 0);
76
-
77
- const shownQuests = quests.slice(0, 4);
78
- const questOverflow = quests.length - shownQuests.length;
79
-
80
- const outputs = [
81
- ...prs.slice(0, 4).map(p => ({
82
- label: `PR #${p.number || p.id}${p.repo ? ` · ${p.repo}` : ''}`,
83
- tone: 'pr',
84
- })),
85
- ...artifacts.slice(0, 4).map(a => ({
86
- label: `${a.type || 'artifact'} · ${truncate(a.title || a.id || a.path || 'artifact', 32)}`,
87
- tone: 'artifact',
88
- })),
89
- ].slice(0, 6);
90
- const outputsOverflow = (prs.length + artifacts.length) - outputs.length;
91
-
92
- const hasQuests = shownQuests.length > 0;
93
- const hasLineage = Boolean(priorNode || laterNode);
94
- const hasOutputs = outputs.length > 0;
95
-
96
- if (!hasQuests && !hasLineage && !hasOutputs) return null;
97
-
98
- // ── Widths ──────────────────────────────────────────────────────
99
- const handoffText = truncate(handoffTopic || 'this handoff', 60);
100
- const handoffW = clamp(
101
- textW(handoffText, CHAR_W_SANS_13) + PILL_PAD_X + 16,
102
- HANDOFF_MIN_W, HANDOFF_MAX_W,
103
- );
104
-
105
- const priorText = priorNode ? truncate(priorNode.topic || priorNode.id || '', LABEL_TRUNCATE) : '';
106
- const priorW = priorNode ? pillWidth(priorText) : 0;
89
+ const priorRel = implementsHandoff[0] ? 'IMPLEMENTS' : (continues[0] ? 'CONTINUES' : null);
90
+
91
+ const authorList = authors.slice(0, MAX_AUTHORS);
92
+ const recipientList = recipients.slice(0, MAX_RECIPIENTS);
93
+ const questList = quests.slice(0, MAX_QUESTS);
94
+ const artifactList = artifacts.slice(0, MAX_ARTIFACTS);
95
+ const prList = prs.slice(0, MAX_PRS);
96
+ const laterList = implementedBy.slice(0, MAX_LATER);
97
+ const overflowQuests = quests.length - questList.length;
98
+ const overflowArtifacts = artifacts.length - artifactList.length;
99
+ const overflowPrs = prs.length - prList.length;
100
+
101
+ // Gate: we render the section if there's any *graph-only* context —
102
+ // things not already in the header. Author/recipients/project alone
103
+ // aren't enough (they duplicate the metadata row).
104
+ const hasGraphOnlyContext =
105
+ priorNode || laterList.length || questList.length ||
106
+ artifactList.length || prList.length;
107
+ if (!hasGraphOnlyContext) return null;
108
+
109
+ // ── Assign nodes to layout slots ──────────────────────────────────
110
+ // Each node = { id, kind, label, x, y, w }. We compute widths first
111
+ // from label text, then pack rows, then assign y per row.
112
+
113
+ const mkNode = (id, kind, label, sub = null) => ({
114
+ id, kind, label, sub, w: pillWidth(label, kind === 'session'),
115
+ });
116
+
117
+ const nodeSession = mkNode('session', 'session', truncate(handoffTopic || 'this handoff', TRUNC_SESSION));
118
+
119
+ const r1Left = authorList.map((a, i) => mkNode(`author-${i}`, 'person', truncate(a.name || a.github || 'author', TRUNC_PILL)))
120
+ .concat(recipientList.map((r, i) => mkNode(`recip-${i}`, 'person', truncate(r.name || r.github || 'recipient', TRUNC_PILL))));
121
+ const r1Center = project ? [mkNode('project', 'project', truncate(project, TRUNC_PILL))] : [];
122
+ const r1Right = questList.map((q, i) => mkNode(`quest-${q.id || i}`, 'quest', truncate(q.title || q.id || 'quest', TRUNC_PILL)));
123
+
124
+ const r2Left = artifactList.map((a, i) => mkNode(
125
+ `art-${a.id || i}`, 'artifact',
126
+ truncate(a.title || a.id || 'artifact', TRUNC_ARTIFACT),
127
+ a.type || null,
128
+ ));
129
+ const r2Right = prList.map((p, i) => mkNode(
130
+ `pr-${p.id || p.number || i}`, 'pr',
131
+ `PR #${p.number || p.id}${p.repo ? ' · ' + p.repo : ''}`,
132
+ )).concat(laterList.map((l, i) => mkNode(
133
+ `later-${i}`, 'session',
134
+ truncate(l.topic || l.id || 'session', TRUNC_PILL),
135
+ )));
136
+
137
+ // Row 1 (identity): Persons on the left, Project in the center.
138
+ // Row 2 (quests): on its own row so longer quest titles don't push widths.
139
+ // Row 3 (outputs): Artifacts on the left, PRs + later sessions on the right.
140
+ const hasRow0 = Boolean(priorNode);
141
+ const hasRow1 = r1Left.length || r1Center.length; // persons + project only
142
+ const hasQuestsRow = r1Right.length > 0; // quests
143
+ const hasRow2 = r2Left.length || r2Right.length;
144
+
145
+ // ── Compute canvas width from widest row ─────────────────────────
146
+ const GAP = 14;
147
+ const widthOfRow = (items, extra = 0) =>
148
+ items.reduce((a, n) => a + n.w, 0) + Math.max(0, items.length - 1) * GAP + extra;
149
+
150
+ const r1W = widthOfRow(r1Left, r1Center.length ? 32 : 0) + widthOfRow(r1Center);
151
+ const questsW = widthOfRow(r1Right);
152
+ const r2W = widthOfRow(r2Left, r2Right.length ? 32 : 0) + widthOfRow(r2Right);
153
+ const priorW = priorNode ? pillWidth(truncate(priorNode.topic || priorNode.id || '', TRUNC_PILL)) : 0;
154
+ const sessionW = nodeSession.w;
155
+
156
+ const contentW = Math.max(r1W, questsW, r2W, priorW, sessionW, 460);
157
+ const WIDTH = contentW + SIDE_PAD * 2;
158
+ const CX = WIDTH / 2;
159
+
160
+ // ── Assign y coordinates ─────────────────────────────────────────
161
+ let cy = TOP_PAD;
162
+ let yPrior = null, ySession = null, yRow1 = null, yQuests = null, yRow2 = null;
163
+
164
+ if (hasRow0) {
165
+ yPrior = cy + PILL_H / 2;
166
+ cy += ROW_H;
167
+ }
168
+ ySession = cy + SESSION_H / 2;
169
+ cy += SESSION_H + 34;
107
170
 
108
- const laterText = laterNode ? truncate(laterNode.topic || laterNode.id || '', LABEL_TRUNCATE) : '';
109
- const laterW = laterNode ? pillWidth(laterText) : 0;
171
+ if (hasRow1) {
172
+ yRow1 = cy + PILL_H / 2;
173
+ cy += ROW_H - 6;
174
+ }
175
+ if (hasQuestsRow) {
176
+ yQuests = cy + PILL_H / 2;
177
+ cy += ROW_H - 6;
178
+ }
179
+ if (hasRow2) {
180
+ yRow2 = cy + PILL_H / 2;
181
+ cy += ROW_H - 6;
182
+ }
183
+ const HEIGHT = cy + LEGEND_H + 8;
184
+
185
+ // ── Lay out nodes horizontally per row ───────────────────────────
186
+ let priorPlaced = null;
187
+ if (hasRow0) {
188
+ priorPlaced = mkNode('prior', 'session',
189
+ truncate(priorNode.topic || priorNode.id || 'prior session', TRUNC_PILL),
190
+ priorNode.author ? `by ${priorNode.author}` : null);
191
+ priorPlaced.x = CX - priorPlaced.w / 2;
192
+ priorPlaced.y = yPrior - PILL_H / 2;
193
+ priorPlaced.h = PILL_H;
194
+ }
110
195
 
111
- // Quests and outputs laid out in horizontal rows with wrap
112
- const questRows = packRows(
113
- shownQuests.map(q => ({
114
- label: `quest · ${truncate(q.title || q.id || 'quest', 32)}`,
115
- tone: 'quest',
116
- w: chipWidth(`quest · ${truncate(q.title || q.id || 'quest', 32)}`),
117
- })),
118
- 640,
119
- );
120
- const outputRows = packRows(
121
- outputs.map(o => ({ ...o, w: chipWidth(o.label) })),
122
- 640,
123
- );
196
+ nodeSession.x = CX - nodeSession.w / 2;
197
+ nodeSession.y = ySession - SESSION_H / 2;
198
+ nodeSession.h = SESSION_H;
199
+
200
+ // Row 1 (persons + project): persons left, project centered-right.
201
+ if (hasRow1) layoutRow(r1Left, r1Center, [], yRow1, WIDTH);
202
+ // Quests row: center the whole group.
203
+ if (hasQuestsRow) {
204
+ const total = questsW;
205
+ const startX = Math.max(SIDE_PAD, (WIDTH - total) / 2);
206
+ placeHorizontally(r1Right, startX, yQuests - PILL_H / 2, GAP);
207
+ }
208
+ // Outputs row: artifacts left, PRs + later right.
209
+ if (hasRow2) layoutRow(r2Left, [], r2Right, yRow2, WIDTH);
124
210
 
125
- // Content width = max of everything
126
- const lineageW = Math.max(priorW, handoffW, laterW);
127
- const questsMaxRowW = questRows.reduce((m, r) => Math.max(m, rowWidth(r)), 0)
128
- + (questOverflow > 0 ? 80 : 0);
129
- const outputsMaxRowW = outputRows.reduce((m, r) => Math.max(m, rowWidth(r)), 0)
130
- + (outputsOverflow > 0 ? 80 : 0);
211
+ // ── Collect SVG children ─────────────────────────────────────────
212
+ const children = [];
131
213
 
132
- const contentW = Math.max(lineageW, questsMaxRowW, outputsMaxRowW, 420);
133
- const VIEW_W = contentW + SIDE_PAD * 2;
134
- const CX = VIEW_W / 2;
214
+ // Arrow marker
215
+ children.push(h('defs', { key: 'defs' },
216
+ h('marker', {
217
+ id: 'sg-arrow',
218
+ viewBox: '0 0 10 10',
219
+ refX: 9, refY: 5,
220
+ markerWidth: 6, markerHeight: 6,
221
+ orient: 'auto-start-reverse',
222
+ },
223
+ h('path', { d: 'M0,0 L10,5 L0,10 z', fill: 'var(--border)' })
224
+ ),
225
+ ));
135
226
 
136
- // ── Vertical layout ─────────────────────────────────────────────
137
- const children = [];
138
- let cy = 14;
139
-
140
- // Zone: Lineage (vertical spine)
141
- if (hasLineage) {
142
- if (priorNode) {
143
- const y = cy;
144
- const x = CX - priorW / 2;
145
- children.push(pill({
146
- key: 'prior', x, y, width: priorW,
147
- label: priorText, tone: 'neutral',
148
- }));
149
- if (priorNode.author) {
150
- children.push(subLabel({
151
- key: 'prior-sub', x: CX, y: y + PILL_H + 12,
152
- text: `by ${priorNode.author}`,
153
- }));
154
- }
155
- cy += PILL_H + SUB_LINE_H;
156
-
157
- // Edge from prior to handoff (arrow pointing down, out of prior toward this)
158
- const e1 = cy + 2;
159
- const e2 = cy + VERT_EDGE - 6;
160
- children.push(h('line', {
161
- key: 'prior-edge',
162
- x1: CX, y1: e1, x2: CX, y2: e2,
163
- stroke: 'var(--border)', strokeWidth: 1.5,
164
- markerEnd: 'url(#sg-arrow)',
165
- }));
166
- const lbl = `${priorLabel}${priorMore > 0 ? ` · +${priorMore}` : ''}`;
167
- children.push(edgeLabel({
168
- key: 'prior-edge-lbl',
169
- x: CX + 10, y: (e1 + e2) / 2 + 3,
170
- text: lbl, anchor: 'start',
171
- }));
172
- cy += VERT_EDGE;
173
- }
227
+ // ── Edges (drawn first so they sit behind nodes) ─────────────────
174
228
 
175
- // THIS HANDOFF
176
- const hx = CX - handoffW / 2;
177
- const hy = cy;
178
- children.push(h('rect', {
179
- key: 'main-bg',
180
- x: hx, y: hy, width: handoffW, height: HANDOFF_H,
181
- rx: 10, ry: 10,
182
- fill: 'var(--terracotta-chip)',
183
- stroke: 'var(--terracotta)', strokeWidth: 1.5,
229
+ // Row 0 → session (IMPLEMENTS or CONTINUES)
230
+ if (hasRow0 && priorPlaced) {
231
+ children.push(edge({
232
+ key: 'e-prior',
233
+ x1: CX, y1: priorPlaced.y + PILL_H,
234
+ x2: CX, y2: nodeSession.y,
235
+ label: priorRel,
184
236
  }));
185
- children.push(h('text', {
186
- key: 'main-caption',
187
- x: CX, y: hy + 17,
188
- textAnchor: 'middle',
189
- fontFamily: fonts.mono, fontSize: 9,
190
- fill: 'var(--terracotta)',
191
- letterSpacing: '0.08em',
192
- }, 'THIS HANDOFF'));
193
- children.push(h('text', {
194
- key: 'main-title',
195
- x: CX, y: hy + 39,
196
- textAnchor: 'middle',
197
- fontFamily: fonts.sans, fontSize: 13, fontWeight: 500,
198
- fill: 'var(--black)',
199
- }, handoffText));
200
- cy += HANDOFF_H;
201
-
202
- if (laterNode) {
203
- const e1 = cy + 6;
204
- const e2 = cy + VERT_EDGE - 2;
205
- children.push(h('line', {
206
- key: 'later-edge',
207
- x1: CX, y1: e1, x2: CX, y2: e2,
208
- stroke: 'var(--border)', strokeWidth: 1.5,
209
- markerEnd: 'url(#sg-arrow)',
237
+ }
238
+
239
+ // Session Row 1 nodes
240
+ if (hasRow1) {
241
+ r1Left.forEach((n, i) => {
242
+ const rel = n.id.startsWith('author-') ? 'BY' : 'HANDED_TO';
243
+ children.push(edge({
244
+ key: `e-s-${n.id}`,
245
+ x1: nodeSession.x + sessionW * (0.35 - 0.1 * Math.min(i, 2)),
246
+ y1: nodeSession.y + SESSION_H,
247
+ x2: n.x + n.w / 2,
248
+ y2: n.y,
249
+ label: rel,
250
+ }));
251
+ });
252
+ r1Center.forEach((n) => {
253
+ children.push(edge({
254
+ key: `e-s-${n.id}`,
255
+ x1: CX, y1: nodeSession.y + SESSION_H,
256
+ x2: n.x + n.w / 2, y2: n.y,
257
+ label: 'ABOUT',
210
258
  }));
211
- const lbl = `picked up${laterMore > 0 ? ` · +${laterMore}` : ''}`;
212
- children.push(edgeLabel({
213
- key: 'later-edge-lbl',
214
- x: CX + 10, y: (e1 + e2) / 2 + 3,
215
- text: lbl, anchor: 'start',
259
+ });
260
+ }
261
+ if (hasQuestsRow) {
262
+ // Quests live on their own row (below persons+project), so these edges
263
+ // need to curve around the Row 1 nodes rather than crossing them.
264
+ r1Right.forEach((n, i) => {
265
+ const sx = nodeSession.x + sessionW * (0.4 + 0.1 * i);
266
+ children.push(edge({
267
+ key: `e-s-${n.id}`,
268
+ x1: sx,
269
+ y1: nodeSession.y + SESSION_H,
270
+ x2: n.x + n.w / 2,
271
+ y2: n.y,
272
+ label: i === 0 ? 'ADVANCED' : null,
273
+ curved: true,
216
274
  }));
217
- cy += VERT_EDGE;
275
+ });
276
+ }
218
277
 
219
- const y = cy;
220
- const x = CX - laterW / 2;
221
- children.push(pill({
222
- key: 'later', x, y, width: laterW,
223
- label: laterText, tone: 'neutral',
278
+ // Session Row 2 (artifacts and PRs), Later → Session
279
+ if (hasRow2) {
280
+ r2Left.forEach((n, i) => {
281
+ children.push(edge({
282
+ key: `e-s-${n.id}`,
283
+ x1: nodeSession.x + sessionW * 0.3,
284
+ y1: nodeSession.y + SESSION_H,
285
+ x2: n.x + n.w / 2, y2: n.y,
286
+ label: i === 0 ? 'HAS_ACTIVITY' : null,
287
+ curved: true,
224
288
  }));
225
- if (laterNode.author) {
226
- children.push(subLabel({
227
- key: 'later-sub', x: CX, y: y + PILL_H + 12,
228
- text: `by ${laterNode.author}`,
289
+ });
290
+ r2Right.forEach((n, i) => {
291
+ if (n.id.startsWith('later-')) {
292
+ // incoming IMPLEMENTS
293
+ children.push(edge({
294
+ key: `e-${n.id}-s`,
295
+ x1: n.x + n.w / 2, y1: n.y,
296
+ x2: nodeSession.x + sessionW * 0.7, y2: nodeSession.y + SESSION_H,
297
+ label: i === 0 ? 'IMPLEMENTS' : null,
298
+ curved: true,
299
+ }));
300
+ } else {
301
+ children.push(edge({
302
+ key: `e-s-${n.id}`,
303
+ x1: nodeSession.x + sessionW * 0.7,
304
+ y1: nodeSession.y + SESSION_H,
305
+ x2: n.x + n.w / 2, y2: n.y,
306
+ label: n.id.startsWith('pr-') ? (i === 0 ? 'PRODUCED' : null) : null,
307
+ curved: true,
229
308
  }));
230
309
  }
231
- cy += PILL_H + SUB_LINE_H;
232
- }
310
+ });
311
+ }
233
312
 
234
- cy += ZONE_GAP;
313
+ // 2-hop: artifact → quest (PART_OF). Curved lines routed up from Row 2
314
+ // artifacts to their Row 1 quests.
315
+ if (hasRow1 && hasRow2 && artifactQuestLinks.length > 0) {
316
+ const questNodeById = new Map(r1Right.map(n => [n.id.replace(/^quest-/, ''), n]));
317
+ const artNodeById = new Map(r2Left.map(n => [n.id.replace(/^art-/, ''), n]));
318
+ let edgeIdx = 0;
319
+ for (const link of artifactQuestLinks) {
320
+ const artNode = artNodeById.get(link.artifactId);
321
+ const questNode = questNodeById.get(link.questId);
322
+ if (!artNode || !questNode) continue;
323
+ children.push(edge({
324
+ key: `e-a-q-${edgeIdx++}`,
325
+ x1: artNode.x + artNode.w / 2, y1: artNode.y,
326
+ x2: questNode.x + questNode.w / 2, y2: questNode.y + PILL_H,
327
+ label: edgeIdx === 1 ? 'PART_OF' : null,
328
+ curved: true,
329
+ dashed: true,
330
+ }));
331
+ }
235
332
  }
236
333
 
237
- // Zone: PRODUCED (before quests — outputs are the everyday signal)
238
- if (hasOutputs) {
239
- children.push(caption({ key: 'out-cap', x: SIDE_PAD, y: cy + 10, text: 'PRODUCED' }));
240
- cy += CAPTION_H;
241
- const rows = outputRows;
242
- rows.forEach((row, rIdx) => {
243
- const rowW = rowWidth(row);
244
- let ox = SIDE_PAD + Math.max(0, (contentW - rowW) / 2);
245
- if (ox < SIDE_PAD) ox = SIDE_PAD;
246
- row.forEach((c, i) => {
247
- children.push(chip({
248
- key: `o-${rIdx}-${i}`, x: ox, y: cy + CHIP_H,
249
- width: c.w, label: c.label, tone: c.tone,
250
- }));
251
- ox += c.w + 8;
252
- });
253
- if (rIdx === rows.length - 1 && outputsOverflow > 0) {
254
- children.push(h('text', {
255
- key: 'o-more',
256
- x: ox + 6, y: cy + CHIP_H - 6,
257
- fontFamily: fonts.mono, fontSize: 10,
258
- fill: 'var(--muted)',
259
- }, `+${outputsOverflow} more`));
260
- }
261
- cy += CHIP_H + 8;
262
- });
263
- cy += ZONE_GAP;
334
+ // ── Nodes ────────────────────────────────────────────────────────
335
+ if (hasRow0 && priorPlaced) children.push(renderNode({ ...priorPlaced, key: 'n-prior' }));
336
+ children.push(renderSessionNode({ ...nodeSession, key: 'n-session' }));
337
+ if (hasRow1) [...r1Left, ...r1Center].forEach((n) => children.push(renderNode({ ...n, key: `n-${n.id}` })));
338
+ if (hasQuestsRow) r1Right.forEach((n) => children.push(renderNode({ ...n, key: `n-${n.id}` })));
339
+ if (hasRow2) [...r2Left, ...r2Right].forEach((n) => children.push(renderNode({ ...n, key: `n-${n.id}` })));
340
+
341
+ // ── Overflow indicators ──────────────────────────────────────────
342
+ if (overflowQuests > 0 && r1Right.length > 0) {
343
+ const last = r1Right[r1Right.length - 1];
344
+ children.push(overflowLabel({ key: 'of-q', x: last.x + last.w + 10, y: last.y + PILL_H / 2 + 3, n: overflowQuests }));
345
+ }
346
+ if (overflowArtifacts > 0 && r2Left.length > 0) {
347
+ const last = r2Left[r2Left.length - 1];
348
+ children.push(overflowLabel({ key: 'of-a', x: last.x + last.w / 2, y: last.y + PILL_H + 24, n: overflowArtifacts, anchor: 'middle' }));
349
+ }
350
+ if (overflowPrs > 0 && r2Right.length > 0) {
351
+ const lastPr = r2Right.filter(n => n.id.startsWith('pr-')).pop();
352
+ if (lastPr) {
353
+ children.push(overflowLabel({ key: 'of-p', x: lastPr.x + lastPr.w / 2, y: lastPr.y + PILL_H + 24, n: overflowPrs, anchor: 'middle' }));
354
+ }
264
355
  }
265
356
 
266
- // Zone: PART OF (quests) — last; rare, de-emphasized
267
- if (hasQuests) {
268
- children.push(caption({ key: 'q-cap', x: SIDE_PAD, y: cy + 10, text: 'PART OF' }));
269
- cy += CAPTION_H;
270
- const rows = questRows;
271
- rows.forEach((row, rIdx) => {
272
- const rowW = rowWidth(row);
273
- let ox = SIDE_PAD + Math.max(0, (contentW - rowW) / 2);
274
- if (ox < SIDE_PAD) ox = SIDE_PAD;
275
- row.forEach((c, i) => {
276
- children.push(chip({
277
- key: `q-${rIdx}-${i}`, x: ox, y: cy + CHIP_H,
278
- width: c.w, label: c.label, tone: c.tone,
279
- }));
280
- ox += c.w + 8;
281
- });
282
- if (rIdx === rows.length - 1 && questOverflow > 0) {
283
- children.push(h('text', {
284
- key: 'q-more',
285
- x: ox + 6, y: cy + CHIP_H - 6,
357
+ // ── Legend ───────────────────────────────────────────────────────
358
+ const legendTypes = ['session', 'person', 'project', 'quest', 'artifact', 'pr'];
359
+ const legendLabels = { session: 'Session', person: 'Person', project: 'Project', quest: 'Quest', artifact: 'Artifact', pr: 'PR' };
360
+ children.push(h('g', { key: 'legend', transform: `translate(${SIDE_PAD}, ${HEIGHT - LEGEND_H + 4})` },
361
+ ...legendTypes.map((t, i) => {
362
+ const x = i * 88;
363
+ return h('g', { key: `lg-${t}` },
364
+ h('circle', { cx: x + 6, cy: 10, r: 5, fill: STYLE[t].stroke, fillOpacity: 0.9 }),
365
+ h('text', {
366
+ x: x + 17, y: 14,
286
367
  fontFamily: fonts.mono, fontSize: 10,
287
368
  fill: 'var(--muted)',
288
- }, `+${questOverflow} more`));
289
- }
290
- cy += CHIP_H + 8;
291
- });
292
- }
293
-
294
- const VIEW_H = cy + 10;
369
+ letterSpacing: '0.03em',
370
+ }, legendLabels[t]),
371
+ );
372
+ }),
373
+ ));
295
374
 
296
375
  return h('div', {
297
376
  style: {
@@ -302,151 +381,172 @@ export function renderSubgraph({ subgraph, handoffTopic }) {
302
381
  },
303
382
  h('div', {
304
383
  style: {
305
- fontFamily: fonts.mono,
306
- fontSize: '11px',
307
- textTransform: 'uppercase',
308
- letterSpacing: '0.08em',
384
+ fontFamily: fonts.mono, fontSize: '11px',
385
+ textTransform: 'uppercase', letterSpacing: '0.08em',
309
386
  color: 'var(--muted)',
310
387
  marginBottom: '0.75rem',
311
388
  paddingLeft: '0.25rem',
312
389
  },
313
390
  }, 'Subgraph'),
314
391
  h('svg', {
315
- viewBox: `0 0 ${VIEW_W} ${VIEW_H}`,
392
+ viewBox: `0 0 ${WIDTH} ${HEIGHT}`,
316
393
  width: '100%',
317
394
  height: 'auto',
318
395
  preserveAspectRatio: 'xMidYMid meet',
319
- style: { display: 'block', maxHeight: '520px' },
396
+ style: { display: 'block', maxHeight: '640px' },
320
397
  role: 'img',
321
398
  'aria-label': 'Handoff knowledge-graph neighborhood',
322
- },
323
- h('defs', null,
324
- h('marker', {
325
- id: 'sg-arrow',
326
- viewBox: '0 0 10 10',
327
- refX: 9, refY: 5,
328
- markerWidth: 7, markerHeight: 7,
329
- orient: 'auto-start-reverse',
330
- },
331
- h('path', { d: 'M0,0 L10,5 L0,10 z', fill: 'var(--border)' })
332
- ),
333
- ),
334
- ...children,
335
- ),
399
+ }, ...children),
336
400
  );
337
401
  }
338
402
 
339
- // ── helpers ────────────────────────────────────────────────────────
403
+ // ── layout helpers ──────────────────────────────────────────────────
404
+
405
+ function layoutRow(leftNodes, centerNodes, rightNodes, yCenter, width) {
406
+ const GAP = 14;
407
+ const leftW = rowSpan(leftNodes, GAP);
408
+ const centerW = rowSpan(centerNodes, GAP);
409
+ const rightW = rowSpan(rightNodes, GAP);
410
+ const total = leftW + centerW + rightW;
411
+
412
+ // If three zones fit comfortably, space them; otherwise compress.
413
+ const available = width - SIDE_PAD * 2;
414
+ const slack = Math.max(0, available - total);
415
+ const padBetween = centerNodes.length > 0 ? slack / 2 : slack;
416
+
417
+ let cursor = SIDE_PAD;
418
+ placeHorizontally(leftNodes, cursor, yCenter - PILL_H / 2, GAP);
419
+ cursor = SIDE_PAD + leftW + (leftNodes.length ? padBetween : 0);
420
+ if (centerNodes.length) {
421
+ // Center the center zone relative to the canvas
422
+ const centerStart = (width - centerW) / 2;
423
+ placeHorizontally(centerNodes, centerStart, yCenter - PILL_H / 2, GAP);
424
+ cursor = centerStart + centerW + padBetween;
425
+ }
426
+ // Right zone flush to right
427
+ const rightStart = width - SIDE_PAD - rightW;
428
+ placeHorizontally(rightNodes, rightStart, yCenter - PILL_H / 2, GAP);
429
+ }
340
430
 
341
- function pillWidth(label) {
342
- return clamp(textW(label, CHAR_W_SANS_12) + PILL_PAD_X, PILL_MIN_W, PILL_MAX_W);
431
+ function rowSpan(nodes, gap) {
432
+ if (nodes.length === 0) return 0;
433
+ return nodes.reduce((a, n) => a + n.w, 0) + (nodes.length - 1) * gap;
343
434
  }
344
435
 
345
- function chipWidth(label) {
346
- return Math.max(96, textW(label, CHAR_W_MONO_10) + 20);
436
+ function placeHorizontally(nodes, startX, y, gap) {
437
+ let x = startX;
438
+ for (const n of nodes) {
439
+ n.x = x;
440
+ n.y = y;
441
+ n.h = PILL_H;
442
+ x += n.w + gap;
443
+ }
347
444
  }
348
445
 
349
- function textW(label, perChar) {
350
- return Math.ceil((label || '').length * perChar);
446
+ function pillWidth(label, isSession) {
447
+ const minW = isSession ? SESSION_MIN_W : PILL_MIN_W;
448
+ const maxW = isSession ? SESSION_MAX_W : PILL_MAX_W;
449
+ const charW = isSession ? CHAR_W_SANS_13 : CHAR_W_SANS_12;
450
+ const raw = Math.ceil((label || '').length * charW) + PILL_PAD_X;
451
+ return clamp(raw, minW, maxW);
351
452
  }
352
453
 
454
+ function clamp(n, lo, hi) { return Math.max(lo, Math.min(hi, n)); }
455
+
353
456
  function truncate(s, max) {
354
457
  if (!s) return '';
355
458
  if (s.length <= max) return s;
356
459
  return s.slice(0, max - 1) + '…';
357
460
  }
358
461
 
359
- function clamp(n, lo, hi) { return Math.max(lo, Math.min(hi, n)); }
360
-
361
- // Pack chips into rows that fit within maxW.
362
- function packRows(items, maxW) {
363
- const rows = [[]];
364
- let cur = 0;
365
- for (const it of items) {
366
- if (cur > 0 && cur + it.w + 8 > maxW) {
367
- rows.push([it]);
368
- cur = it.w;
369
- } else {
370
- rows[rows.length - 1].push(it);
371
- cur += (cur > 0 ? 8 : 0) + it.w;
372
- }
373
- }
374
- return rows;
375
- }
376
-
377
- function rowWidth(row) {
378
- if (row.length === 0) return 0;
379
- return row.reduce((a, c) => a + c.w, 0) + (row.length - 1) * 8;
380
- }
381
-
382
- function pill({ key, x, y, width, label, tone }) {
383
- const styles = {
384
- neutral: { fill: 'var(--subtle-fill)', stroke: 'var(--border)', text: 'var(--dark)' },
385
- person: { fill: 'var(--blue-chip)', stroke: 'var(--blue-muted)', text: 'var(--blue-muted)' },
386
- }[tone] || { fill: 'var(--subtle-fill)', stroke: 'var(--border)', text: 'var(--dark)' };
462
+ // ── rendering helpers ──────────────────────────────────────────────
387
463
 
464
+ function renderSessionNode({ x, y, w, label, key }) {
465
+ const s = STYLE.session;
388
466
  return h('g', { key },
389
467
  h('rect', {
390
- x, y, width, height: PILL_H,
391
- rx: PILL_H / 2, ry: PILL_H / 2,
392
- fill: styles.fill, stroke: styles.stroke, strokeWidth: 1,
468
+ x, y, width: w, height: SESSION_H,
469
+ rx: 10, ry: 10,
470
+ fill: s.fill, stroke: s.stroke, strokeWidth: 1.5,
393
471
  }),
394
472
  h('text', {
395
- x: x + width / 2,
396
- y: y + PILL_H / 2 + 4,
473
+ x: x + w / 2, y: y + 18,
474
+ textAnchor: 'middle',
475
+ fontFamily: fonts.mono, fontSize: 9,
476
+ fill: 'var(--terracotta)',
477
+ letterSpacing: '0.08em',
478
+ }, 'THIS HANDOFF'),
479
+ h('text', {
480
+ x: x + w / 2, y: y + 40,
397
481
  textAnchor: 'middle',
398
482
  fontFamily: fonts.sans, fontSize: 13, fontWeight: 500,
399
- fill: styles.text,
483
+ fill: s.text,
400
484
  }, label),
401
485
  );
402
486
  }
403
487
 
404
- function chip({ key, x, y, width, label, tone }) {
405
- const styles = {
406
- quest: { fill: 'var(--terracotta-chip)', text: 'var(--terracotta)' },
407
- artifact: { fill: 'var(--blue-chip)', text: 'var(--blue-muted)' },
408
- pr: { fill: 'var(--subtle-fill)', text: 'var(--dark)' },
409
- }[tone] || { fill: 'var(--subtle-fill)', text: 'var(--muted)' };
410
-
411
- return h('g', { key },
488
+ function renderNode({ x, y, w, label, sub, kind, key }) {
489
+ const s = STYLE[kind] || STYLE.project;
490
+ const children = [
412
491
  h('rect', {
413
- x, y: y - CHIP_H + 4, width, height: CHIP_H,
414
- rx: 5, ry: 5,
415
- fill: styles.fill,
492
+ key: 'bg',
493
+ x, y, width: w, height: PILL_H,
494
+ rx: PILL_H / 2, ry: PILL_H / 2,
495
+ fill: s.fill, stroke: s.stroke, strokeWidth: 1,
416
496
  }),
417
497
  h('text', {
418
- x: x + 10, y: y - CHIP_H / 2 + 8,
419
- fontFamily: fonts.mono, fontSize: 11, fontWeight: 500,
420
- fill: styles.text,
498
+ key: 'lbl',
499
+ x: x + w / 2, y: y + PILL_H / 2 + 4,
500
+ textAnchor: 'middle',
501
+ fontFamily: fonts.sans, fontSize: 12, fontWeight: 500,
502
+ fill: s.text,
421
503
  }, label),
422
- );
423
- }
424
-
425
- function caption({ key, x, y, text }) {
426
- return h('text', {
427
- key, x, y,
428
- fontFamily: fonts.mono, fontSize: 10,
429
- fill: 'var(--muted)',
430
- letterSpacing: '0.1em',
431
- }, text);
504
+ ];
505
+ if (sub) {
506
+ children.push(h('text', {
507
+ key: 'sub',
508
+ x: x + w / 2, y: y + PILL_H + 11,
509
+ textAnchor: 'middle',
510
+ fontFamily: fonts.mono, fontSize: 9,
511
+ fill: 'var(--muted)',
512
+ fontStyle: 'italic',
513
+ }, sub));
514
+ }
515
+ return h('g', { key }, ...children);
432
516
  }
433
517
 
434
- function edgeLabel({ key, x, y, text, anchor = 'start' }) {
518
+ function overflowLabel({ key, x, y, n, anchor = 'start' }) {
435
519
  return h('text', {
436
520
  key, x, y,
437
521
  textAnchor: anchor,
438
522
  fontFamily: fonts.mono, fontSize: 10,
439
523
  fill: 'var(--muted)',
440
- letterSpacing: '0.04em',
441
- }, text);
524
+ }, `+${n} more`);
442
525
  }
443
526
 
444
- function subLabel({ key, x, y, text }) {
445
- return h('text', {
446
- key, x, y,
447
- textAnchor: 'middle',
448
- fontFamily: fonts.mono, fontSize: 10,
449
- fill: 'var(--muted)',
450
- fontStyle: 'italic',
451
- }, text);
527
+ function edge({ key, x1, y1, x2, y2, label, curved, dashed }) {
528
+ const midx = (x1 + x2) / 2;
529
+ const midy = (y1 + y2) / 2;
530
+ const path = curved
531
+ ? `M ${x1} ${y1} C ${x1} ${midy}, ${x2} ${midy}, ${x2} ${y2}`
532
+ : `M ${x1} ${y1} L ${x2} ${y2}`;
533
+ const strokeProps = {
534
+ fill: 'none',
535
+ stroke: 'var(--border)',
536
+ strokeWidth: 1.2,
537
+ markerEnd: 'url(#sg-arrow)',
538
+ };
539
+ if (dashed) strokeProps.strokeDasharray = '3 3';
540
+ const elems = [h('path', { key: 'p', d: path, ...strokeProps })];
541
+ if (label) {
542
+ // Offset label above midpoint for vertical edges, to the right for others
543
+ elems.push(h('text', {
544
+ key: 'lbl',
545
+ x: midx + 4, y: midy + 3,
546
+ fontFamily: fonts.mono, fontSize: 9,
547
+ fill: 'var(--muted)',
548
+ letterSpacing: '0.06em',
549
+ }, label));
550
+ }
551
+ return h('g', { key }, ...elems);
452
552
  }