egregore-artifacts 0.3.0 → 0.5.0

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.
@@ -25,7 +25,29 @@ export function parseBoard(input) {
25
25
 
26
26
  const today = new Date().toISOString().split('T')[0];
27
27
 
28
- // Flatten all cards from the hierarchy for person and timeline views
28
+ // Compute most recent Monday for weekly Done-tab auto-cleanse
29
+ const now = new Date();
30
+ const dayOfWeek = now.getDay();
31
+ const daysSinceMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
32
+ const weekStart = new Date(now);
33
+ weekStart.setHours(0, 0, 0, 0);
34
+ weekStart.setDate(weekStart.getDate() - daysSinceMonday);
35
+ const weekStartStr = weekStart.toISOString().split('T')[0];
36
+
37
+ // Auto-archive: filter out done cards completed before this Monday
38
+ for (const activity of data.activities || []) {
39
+ const filterStale = (cards) => (cards || []).filter(
40
+ c => !(c.status === 'done' && c.completedAt && c.completedAt < weekStartStr)
41
+ );
42
+ if (activity.cards) activity.cards = filterStale(activity.cards);
43
+ if (activity.subactivities) {
44
+ for (const sub of activity.subactivities) {
45
+ if (sub.cards) sub.cards = filterStale(sub.cards);
46
+ }
47
+ }
48
+ }
49
+
50
+ // Flatten all cards from the hierarchy
29
51
  const allCards = [];
30
52
  for (const activity of data.activities || []) {
31
53
  if (activity.subactivities) {
@@ -40,9 +62,13 @@ export function parseBoard(input) {
40
62
  }
41
63
  }
42
64
 
43
- // Group by person
65
+ // Split active vs done for different views
66
+ const activeCards = allCards.filter(c => c.status !== 'done');
67
+ const doneCards = allCards.filter(c => c.status === 'done');
68
+
69
+ // Group active cards by person
44
70
  const personMap = {};
45
- for (const card of allCards) {
71
+ for (const card of activeCards) {
46
72
  for (const owner of card.owners || []) {
47
73
  if (!personMap[owner]) personMap[owner] = [];
48
74
  personMap[owner].push(card);
@@ -66,8 +92,8 @@ export function parseBoard(input) {
66
92
  }))
67
93
  .sort((a, b) => b.stats.p0 - a.stats.p0 || b.stats.total - a.stats.total);
68
94
 
69
- // Timeline: cards with dates, sorted by startDate
70
- const timeline = allCards
95
+ // Timeline: active cards with dates, sorted by startDate
96
+ const timeline = activeCards
71
97
  .filter(c => c.startDate || c.dueDate)
72
98
  .sort((a, b) => (a.startDate || a.dueDate || '').localeCompare(b.startDate || b.dueDate || ''));
73
99
 
@@ -102,6 +128,9 @@ export function parseBoard(input) {
102
128
  updatedAt: data.updated,
103
129
  activities: data.activities,
104
130
  allCards,
131
+ activeCards,
132
+ doneCards,
133
+ weekStart: weekStartStr,
105
134
  people,
106
135
  timeline,
107
136
  summary,
@@ -3,28 +3,36 @@
3
3
  // Detects referenced memory files and inlines their content
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
- import { execSync } from 'node:child_process';
6
+ import { execSync, execFileSync } from 'node:child_process';
7
7
 
8
8
  export function parseHandoff(input) {
9
9
  let content;
10
10
  let inputDir = null;
11
+ let inputPath = null;
11
12
 
12
13
  if (typeof input === 'string' && (input.endsWith('.md') || input.includes('/'))) {
13
14
  if (!fs.existsSync(input)) throw new Error(`File not found: ${input}`);
14
15
  content = fs.readFileSync(input, 'utf-8');
15
16
  inputDir = path.dirname(input);
17
+ inputPath = input;
16
18
  } else if (typeof input === 'string') {
17
19
  content = input;
18
20
  } else {
19
21
  throw new Error('parseHandoff expects a file path or markdown string');
20
22
  }
21
23
 
22
- const title = extractTitle(content);
23
- const meta = extractMeta(content);
24
- const sections = extractSections(content);
24
+ // Resolve graph subgraph first (reads the raw content), then strip the
25
+ // embedded block so it doesn't leak into section bodies or rendered text.
26
+ const gitRoot = findGitRoot();
27
+ const subgraph = resolveSubgraph({ content, inputPath, gitRoot });
28
+ const cleanContent = content.replace(/<!--\s*subgraph[\s\S]*?-->/g, '').trim();
29
+
30
+ const title = extractTitle(cleanContent);
31
+ const meta = extractMeta(cleanContent);
32
+ const sections = extractSections(cleanContent);
25
33
 
26
34
  // Detect referenced files (memory/*.md paths in backticks) and inline them
27
- const attachments = extractReferencedFiles(content, inputDir);
35
+ const attachments = extractReferencedFiles(cleanContent, inputDir);
28
36
 
29
37
  return {
30
38
  title,
@@ -37,6 +45,7 @@ export function parseHandoff(input) {
37
45
  entryPoints: extractBullets(sections['Entry Points'] || ''),
38
46
  context: sections.Context || null,
39
47
  attachments,
48
+ subgraph,
40
49
  // Catch-all for non-standard sections
41
50
  extraSections: Object.entries(sections)
42
51
  .filter(([k]) => !['Summary', 'Briefing', 'Current State', 'Decisions',
@@ -47,6 +56,141 @@ export function parseHandoff(input) {
47
56
  };
48
57
  }
49
58
 
59
+ // ── Subgraph resolution ────────────────────────────────────────────
60
+ //
61
+ // The subgraph shows the handoff's *graph-only* context — things not already
62
+ // in the markdown header (author/recipients/project). Source priority:
63
+ // 1. Embedded <!-- subgraph ... --> block if present (defensive — we don't
64
+ // write one today, but a future caller could snapshot manually).
65
+ // 2. Live Cypher query via bin/graph.sh in connected mode.
66
+ // If neither yields real data, the subgraph is null and the template skips
67
+ // the section entirely — no "degraded" placeholder.
68
+
69
+ function resolveSubgraph({ content, inputPath, gitRoot }) {
70
+ const embedded = extractEmbeddedSubgraph(content);
71
+
72
+ let live = null;
73
+ if (inputPath && gitRoot && isConnectedMode(gitRoot)) {
74
+ const sessionId = deriveSessionIdFromPath(inputPath);
75
+ if (sessionId) live = tryLiveSubgraphQuery(sessionId, gitRoot);
76
+ }
77
+
78
+ const sourced = live || embedded;
79
+ if (sourced && hasAnySubgraphData(sourced)) return sourced;
80
+ return null;
81
+ }
82
+
83
+ function extractEmbeddedSubgraph(content) {
84
+ const m = content.match(/<!--\s*subgraph\s*([\s\S]*?)-->/);
85
+ if (!m) return null;
86
+ try {
87
+ return JSON.parse(m[1].trim());
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ function hasAnySubgraphData(sg) {
94
+ if (!sg) return false;
95
+ return (sg.continues && sg.continues.length)
96
+ || (sg.implementsHandoff && sg.implementsHandoff.length)
97
+ || (sg.implementedBy && sg.implementedBy.length)
98
+ || (sg.quests && sg.quests.length)
99
+ || (sg.artifacts && sg.artifacts.length)
100
+ || (sg.prs && sg.prs.length);
101
+ }
102
+
103
+ function findGitRoot() {
104
+ try {
105
+ return execSync('git rev-parse --show-toplevel 2>/dev/null', { encoding: 'utf-8' }).trim();
106
+ } catch {
107
+ return null;
108
+ }
109
+ }
110
+
111
+ function isConnectedMode(gitRoot) {
112
+ try {
113
+ const configPath = path.join(gitRoot, 'egregore.json');
114
+ if (!fs.existsSync(configPath)) return false;
115
+ const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
116
+ if (config.mode === 'local') return false;
117
+ return Boolean(config.api_url) || config.mode === 'connected';
118
+ } catch {
119
+ return false;
120
+ }
121
+ }
122
+
123
+ function deriveSessionIdFromPath(filePath) {
124
+ // memory/handoffs/2026-04/22-author-topic.md → 2026-04-22-author-topic
125
+ const base = path.basename(filePath, '.md');
126
+ const dir = path.basename(path.dirname(filePath));
127
+ if (/^\d{4}-\d{2}$/.test(dir)) return `${dir}-${base}`;
128
+ // Filename already starts with YYYY-MM-DD
129
+ if (/^\d{4}-\d{2}-\d{2}-/.test(base)) return base;
130
+ return null;
131
+ }
132
+
133
+ function tryLiveSubgraphQuery(sessionId, gitRoot) {
134
+ const cypher = `MATCH (s:Session {id: $sessionId})
135
+ OPTIONAL MATCH (s)-[:IMPLEMENTS]->(prior:Session)
136
+ OPTIONAL MATCH (prior)-[:BY]->(priorAuthor:Person)
137
+ OPTIONAL MATCH (later:Session)-[:IMPLEMENTS]->(s)
138
+ OPTIONAL MATCH (later)-[:BY]->(laterAuthor:Person)
139
+ OPTIONAL MATCH (s)-[:CONTINUES]->(cont:Session)
140
+ OPTIONAL MATCH (cont)-[:BY]->(contAuthor:Person)
141
+ OPTIONAL MATCH (s)-[:ADVANCED|INVOLVES]->(quest:Quest)
142
+ OPTIONAL MATCH (s)-[:HAS_ACTIVITY]->(art:Artifact)
143
+ OPTIONAL MATCH (s)-[:PRODUCED]->(pr:PR)
144
+ RETURN
145
+ collect(DISTINCT {id:prior.id, topic:prior.topic, author:priorAuthor.name}) AS implementsHandoff,
146
+ collect(DISTINCT {id:later.id, topic:later.topic, author:laterAuthor.name}) AS implementedBy,
147
+ collect(DISTINCT {id:cont.id, topic:cont.topic, author:contAuthor.name}) AS continues,
148
+ collect(DISTINCT {id:quest.id, title:quest.title}) AS quests,
149
+ collect(DISTINCT {id:art.id, title:art.title, type:art.type, path:art.path}) AS artifacts,
150
+ collect(DISTINCT {id:pr.id, number:pr.number, title:pr.title, repo:pr.repo}) AS prs`;
151
+
152
+ try {
153
+ const out = execFileSync(
154
+ 'bash',
155
+ [path.join(gitRoot, 'bin/graph.sh'), 'query', cypher, JSON.stringify({ sessionId })],
156
+ { encoding: 'utf-8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] }
157
+ );
158
+ return parseGraphResponse(out);
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+
164
+ function parseGraphResponse(raw) {
165
+ if (!raw || !raw.trim()) return null;
166
+ let body;
167
+ try {
168
+ body = JSON.parse(raw);
169
+ } catch {
170
+ return null;
171
+ }
172
+ const result = body.results?.[0] || body;
173
+ const fields = result.fields;
174
+ const row = result.values?.[0];
175
+ if (!Array.isArray(fields) || !Array.isArray(row)) return null;
176
+
177
+ const obj = {};
178
+ fields.forEach((f, i) => { obj[f] = row[i]; });
179
+
180
+ // OPTIONAL MATCH + collect() emits sentinel {id: null, ...} entries — strip.
181
+ const clean = (list, ...keys) => Array.isArray(list)
182
+ ? list.filter(x => x && keys.some(k => x[k] != null))
183
+ : [];
184
+ return {
185
+ implementsHandoff: clean(obj.implementsHandoff, 'id'),
186
+ implementedBy: clean(obj.implementedBy, 'id'),
187
+ continues: clean(obj.continues, 'id'),
188
+ quests: clean(obj.quests, 'id', 'title'),
189
+ artifacts: clean(obj.artifacts, 'id', 'title', 'path'),
190
+ prs: clean(obj.prs, 'id', 'number'),
191
+ };
192
+ }
193
+
50
194
  function extractReferencedFiles(content, inputDir) {
51
195
  const attachments = [];
52
196
  // Find backtick-wrapped paths that look like memory files
@@ -31,6 +31,11 @@ function BoardCardEl({ card, showActivity }) {
31
31
  : 'var(--muted)';
32
32
 
33
33
  return h('div', {
34
+ className: 'eg-board-card',
35
+ 'data-card-id': card.id,
36
+ 'data-original-status': card.status,
37
+ 'data-original-priority': card.priority,
38
+ 'data-original-owners': (card.owners || []).join(','),
34
39
  style: {
35
40
  background: 'var(--surface)',
36
41
  border: '1px solid var(--border)',
@@ -38,10 +43,21 @@ function BoardCardEl({ card, showActivity }) {
38
43
  borderRadius: '8px',
39
44
  padding: '12px 16px',
40
45
  marginBottom: '8px',
46
+ position: 'relative',
41
47
  },
42
48
  },
49
+ // Edit toggle (top-right)
50
+ h('button', {
51
+ className: 'eg-card-edit-btn',
52
+ style: {
53
+ position: 'absolute', top: '8px', right: '8px',
54
+ background: 'none', border: 'none', cursor: 'pointer',
55
+ fontFamily: fonts.mono, fontSize: '11px', color: 'var(--muted)',
56
+ padding: '2px 6px', borderRadius: '3px',
57
+ },
58
+ }, 'edit'),
43
59
  // Title row
44
- h('div', { style: { display: 'flex', alignItems: 'baseline', gap: '8px', marginBottom: '6px' } },
60
+ h('div', { style: { display: 'flex', alignItems: 'baseline', gap: '8px', marginBottom: '6px', paddingRight: '40px' } },
45
61
  h('span', {
46
62
  style: { fontFamily: fonts.mono, fontSize: '11px', color: pColor, fontWeight: 600, flexShrink: 0 },
47
63
  }, PRIORITY_LABELS[card.priority]),
@@ -85,6 +101,54 @@ function BoardCardEl({ card, showActivity }) {
85
101
  card.description && h('p', {
86
102
  style: { fontSize: '13px', color: 'var(--muted)', margin: '6px 0 0', lineHeight: 1.4 },
87
103
  }, card.description),
104
+ // Inline editor (hidden by default)
105
+ h('div', {
106
+ className: 'eg-card-editor',
107
+ style: {
108
+ display: 'none', marginTop: '10px', padding: '10px',
109
+ background: 'var(--neutral-chip)', borderRadius: '6px',
110
+ fontFamily: fonts.mono, fontSize: '12px',
111
+ },
112
+ },
113
+ h('div', { style: { display: 'flex', gap: '12px', alignItems: 'center', flexWrap: 'wrap' } },
114
+ h('label', null,
115
+ h('span', { style: { color: 'var(--muted)', marginRight: '6px' } }, 'status'),
116
+ h('select', {
117
+ className: 'eg-edit-status', 'data-card-id': card.id,
118
+ defaultValue: card.status,
119
+ style: { fontFamily: fonts.mono, fontSize: '12px', padding: '2px 4px' },
120
+ },
121
+ h('option', { value: 'todo' }, 'todo'),
122
+ h('option', { value: 'in-progress' }, 'in-progress'),
123
+ h('option', { value: 'review' }, 'review'),
124
+ h('option', { value: 'done' }, 'done'),
125
+ ),
126
+ ),
127
+ h('label', null,
128
+ h('span', { style: { color: 'var(--muted)', marginRight: '6px' } }, 'priority'),
129
+ h('select', {
130
+ className: 'eg-edit-priority', 'data-card-id': card.id,
131
+ defaultValue: String(card.priority),
132
+ style: { fontFamily: fonts.mono, fontSize: '12px', padding: '2px 4px' },
133
+ },
134
+ h('option', { value: '0' }, 'P0'),
135
+ h('option', { value: '1' }, 'P1'),
136
+ h('option', { value: '2' }, 'P2'),
137
+ h('option', { value: '3' }, 'P3'),
138
+ ),
139
+ ),
140
+ h('label', null,
141
+ h('span', { style: { color: 'var(--muted)', marginRight: '6px' } }, 'owners'),
142
+ h('input', {
143
+ className: 'eg-edit-owners', 'data-card-id': card.id,
144
+ type: 'text',
145
+ defaultValue: (card.owners || []).join(', '),
146
+ placeholder: 'comma-separated',
147
+ style: { fontFamily: fonts.mono, fontSize: '12px', padding: '2px 6px', width: '180px' },
148
+ }),
149
+ ),
150
+ ),
151
+ ),
88
152
  );
89
153
  }
90
154
 
@@ -96,7 +160,7 @@ function ActivityView({ activities }) {
96
160
  for (const activity of activities) {
97
161
  if (activity.subactivities) {
98
162
  for (const sub of activity.subactivities) {
99
- const cards = sub.cards || [];
163
+ const cards = (sub.cards || []).filter(c => c.status !== 'done');
100
164
  if (cards.length === 0) continue;
101
165
  sections.push(
102
166
  h('div', { key: `${activity.id}-${sub.id}`, style: { marginBottom: '1.5rem' } },
@@ -114,7 +178,7 @@ function ActivityView({ activities }) {
114
178
  );
115
179
  }
116
180
  }
117
- const cards = activity.cards || [];
181
+ const cards = (activity.cards || []).filter(c => c.status !== 'done');
118
182
  if (cards.length === 0) continue;
119
183
  sections.push(
120
184
  h('div', { key: activity.id, style: { marginBottom: '1.5rem' } },
@@ -137,6 +201,85 @@ function ActivityView({ activities }) {
137
201
 
138
202
  // ── Person View ────────────────────────────────────────────────
139
203
 
204
+ // ── Priority View ──────────────────────────────────────────────
205
+
206
+ function PriorityView({ activeCards }) {
207
+ const groups = [
208
+ { level: 0, label: 'P0 — Breaking', sub: 'Drop everything' },
209
+ { level: 1, label: 'P1 — This cycle', sub: 'The working set' },
210
+ { level: 2, label: 'P2 — Next cycle', sub: 'Scoped, waiting' },
211
+ { level: 3, label: 'P3 — Parked', sub: 'Captured for later' },
212
+ ];
213
+ return h('div', null,
214
+ ...groups.map(g => {
215
+ const cards = (activeCards || []).filter(c => c.priority === g.level);
216
+ if (cards.length === 0) return null;
217
+ const pColor = PRIORITY_COLORS[g.level];
218
+ return h('div', { key: g.level, style: { marginBottom: '1.5rem' } },
219
+ h('div', {
220
+ style: {
221
+ display: 'flex', alignItems: 'baseline', gap: '10px',
222
+ marginBottom: '8px', paddingBottom: '4px',
223
+ borderBottom: `2px solid ${pColor}`,
224
+ },
225
+ },
226
+ h('span', {
227
+ style: {
228
+ fontFamily: fonts.mono, fontSize: '13px', fontWeight: 700,
229
+ color: pColor, textTransform: 'uppercase', letterSpacing: '0.06em',
230
+ },
231
+ }, g.label),
232
+ h('span', {
233
+ style: { fontFamily: fonts.mono, fontSize: '11px', color: 'var(--muted)' },
234
+ }, `${cards.length} · ${g.sub}`),
235
+ ),
236
+ ...cards.map((card, i) => h(BoardCardEl, { key: i, card, showActivity: true })),
237
+ );
238
+ }).filter(Boolean),
239
+ );
240
+ }
241
+
242
+ // ── Done View ──────────────────────────────────────────────────
243
+
244
+ function DoneView({ doneCards, weekStart }) {
245
+ if (!doneCards || doneCards.length === 0) {
246
+ return h('div', { style: { padding: '2rem', textAlign: 'center', color: 'var(--muted)', fontStyle: 'italic' } },
247
+ `Nothing done yet this week. The Done tab clears every Monday.`
248
+ );
249
+ }
250
+ const byActivity = {};
251
+ for (const card of doneCards) {
252
+ const key = card.subactivity ? `${card.activity} · ${card.subactivity}` : card.activity;
253
+ if (!byActivity[key]) byActivity[key] = [];
254
+ byActivity[key].push(card);
255
+ }
256
+ return h('div', null,
257
+ h('div', {
258
+ style: {
259
+ fontFamily: fonts.mono, fontSize: '11px', color: 'var(--muted)',
260
+ marginBottom: '1rem', padding: '8px 12px', background: 'var(--neutral-chip)',
261
+ borderRadius: '6px',
262
+ },
263
+ }, `Showing ${doneCards.length} done this week (since ${weekStart}). This tab auto-clears every Monday.`),
264
+ ...Object.entries(byActivity).map(([activityLabel, cards], i) =>
265
+ h('div', { key: i, style: { marginBottom: '1.5rem' } },
266
+ h('div', {
267
+ style: {
268
+ fontFamily: fonts.mono, fontSize: '12px', textTransform: 'uppercase',
269
+ letterSpacing: '0.06em', color: 'var(--muted)', marginBottom: '8px', paddingBottom: '4px',
270
+ borderBottom: '1px solid var(--border)',
271
+ },
272
+ }, activityLabel),
273
+ ...cards.map((card, j) =>
274
+ h(BoardCardEl, { key: j, card, showActivity: false })
275
+ ),
276
+ )
277
+ ),
278
+ );
279
+ }
280
+
281
+ // ── Person View ────────────────────────────────────────────────
282
+
140
283
  function PersonView({ people }) {
141
284
  return h('div', null,
142
285
  ...people.map((person, pi) =>
@@ -350,25 +493,31 @@ function SummaryBar({ summary }) {
350
493
 
351
494
  // ── Tab Switcher (rendered as HTML with inline JS) ─────────────
352
495
 
353
- function ViewTabs() {
354
- // The tabs work via a small inline script injected in the shell
496
+ function ViewTabs({ doneCount }) {
497
+ const tabs = [
498
+ { key: 'activity', label: 'Activity' },
499
+ { key: 'priority', label: 'Priority' },
500
+ { key: 'person', label: 'Person' },
501
+ { key: 'timeline', label: 'Timeline' },
502
+ { key: 'done', label: `Done${doneCount > 0 ? ` (${doneCount})` : ''}` },
503
+ ];
355
504
  return h('div', {
356
505
  style: {
357
506
  display: 'flex', gap: '4px', marginBottom: '1.5rem',
358
507
  borderBottom: '2px solid var(--border)', paddingBottom: '0',
359
508
  },
360
509
  },
361
- ['Activity', 'Person', 'Timeline'].map(tab =>
510
+ ...tabs.map(tab =>
362
511
  h('button', {
363
- key: tab,
512
+ key: tab.key,
364
513
  className: 'eg-board-tab',
365
- 'data-view': tab.toLowerCase(),
514
+ 'data-view': tab.key,
366
515
  style: {
367
516
  fontFamily: fonts.mono, fontSize: '13px', padding: '8px 16px',
368
517
  background: 'none', border: 'none', borderBottom: '2px solid transparent',
369
518
  marginBottom: '-2px', cursor: 'pointer', color: 'var(--muted)',
370
519
  },
371
- }, tab)
520
+ }, tab.label)
372
521
  ),
373
522
  );
374
523
  }
@@ -396,7 +545,7 @@ export function boardTemplate(data) {
396
545
  sections.push(h(SummaryBar, { key: 'summary', summary: data.summary }));
397
546
 
398
547
  // Tab switcher
399
- sections.push(h(ViewTabs, { key: 'tabs' }));
548
+ sections.push(h(ViewTabs, { key: 'tabs', doneCount: (data.doneCards || []).length }));
400
549
 
401
550
  // Activity view (default visible)
402
551
  sections.push(
@@ -405,6 +554,13 @@ export function boardTemplate(data) {
405
554
  )
406
555
  );
407
556
 
557
+ // Priority view (hidden by default)
558
+ sections.push(
559
+ h('div', { key: 'view-priority', className: 'eg-board-view', 'data-view': 'priority', style: { display: 'none' } },
560
+ h(PriorityView, { activeCards: data.activeCards })
561
+ )
562
+ );
563
+
408
564
  // Person view (hidden by default)
409
565
  sections.push(
410
566
  h('div', { key: 'view-person', className: 'eg-board-view', 'data-view': 'person', style: { display: 'none' } },
@@ -419,6 +575,13 @@ export function boardTemplate(data) {
419
575
  )
420
576
  );
421
577
 
578
+ // Done view (hidden by default)
579
+ sections.push(
580
+ h('div', { key: 'view-done', className: 'eg-board-view', 'data-view': 'done', style: { display: 'none' } },
581
+ h(DoneView, { doneCards: data.doneCards || [], weekStart: data.weekStart })
582
+ )
583
+ );
584
+
422
585
  // Inline script for tab switching
423
586
  sections.push(
424
587
  h('script', {
@@ -446,6 +609,126 @@ export function boardTemplate(data) {
446
609
  })
447
610
  );
448
611
 
612
+ // Floating "Copy changes" button — live editor output
613
+ sections.push(
614
+ h('button', {
615
+ key: 'copy-changes-btn',
616
+ id: 'eg-copy-changes-btn',
617
+ style: {
618
+ position: 'fixed', bottom: '24px', right: '24px', zIndex: 1000,
619
+ background: 'var(--terracotta)', color: 'white', border: 'none',
620
+ borderRadius: '24px', padding: '12px 20px', fontFamily: fonts.mono,
621
+ fontSize: '13px', fontWeight: 600, cursor: 'pointer',
622
+ boxShadow: '0 4px 12px rgba(0,0,0,0.15)', display: 'none',
623
+ },
624
+ }, 'Copy 0 changes')
625
+ );
626
+
627
+ // Interactive editor script — toggle editors, sync across duplicate cards,
628
+ // track changes, produce paste-back command on click
629
+ sections.push(
630
+ h('script', {
631
+ key: 'editor-script',
632
+ dangerouslySetInnerHTML: {
633
+ __html: `
634
+ (function() {
635
+ var editBtns = document.querySelectorAll('.eg-card-edit-btn');
636
+ var copyBtn = document.getElementById('eg-copy-changes-btn');
637
+
638
+ // Toggle editor — scope to clicked card's parent (each card appears in
639
+ // multiple views, so global querySelector would target the wrong one)
640
+ editBtns.forEach(function(btn) {
641
+ btn.addEventListener('click', function() {
642
+ var cardEl = btn.closest('.eg-board-card');
643
+ if (!cardEl) return;
644
+ var editor = cardEl.querySelector('.eg-card-editor');
645
+ if (!editor) return;
646
+ var isOpen = editor.style.display === 'block';
647
+ editor.style.display = isOpen ? 'none' : 'block';
648
+ btn.textContent = isOpen ? 'edit' : 'close';
649
+ });
650
+ });
651
+
652
+ // Sync edits across duplicate card instances (same card in Activity + Priority + Person)
653
+ function syncEdit(event) {
654
+ var input = event.target;
655
+ if (!input.classList) return;
656
+ var cls = input.className || '';
657
+ if (!cls.includes('eg-edit-')) return;
658
+ var cardId = input.dataset.cardId;
659
+ if (!cardId) return;
660
+ var className = cls.split(' ').find(function(c){return c.startsWith('eg-edit-');});
661
+ var selector = '.' + className + '[data-card-id="' + cardId + '"]';
662
+ document.querySelectorAll(selector).forEach(function(el) {
663
+ if (el !== input) el.value = input.value;
664
+ });
665
+ }
666
+ document.addEventListener('change', syncEdit);
667
+ document.addEventListener('input', syncEdit);
668
+
669
+ function collectChanges() {
670
+ var changes = [];
671
+ var seen = {};
672
+ document.querySelectorAll('.eg-board-card').forEach(function(card) {
673
+ var id = card.dataset.cardId;
674
+ if (seen[id]) return;
675
+ seen[id] = true;
676
+ var origStatus = card.dataset.originalStatus;
677
+ var origPriority = card.dataset.originalPriority;
678
+ var origOwners = card.dataset.originalOwners;
679
+ var statusEl = card.querySelector('.eg-edit-status');
680
+ var priorityEl = card.querySelector('.eg-edit-priority');
681
+ var ownersEl = card.querySelector('.eg-edit-owners');
682
+ if (!statusEl) return;
683
+ var newStatus = statusEl.value;
684
+ var newPriority = priorityEl.value;
685
+ var newOwners = ownersEl.value.split(',').map(function(s){return s.trim();}).filter(Boolean).join(',');
686
+ var cardChange = { id: id, changes: [] };
687
+ if (newStatus !== origStatus) cardChange.changes.push('status → ' + newStatus);
688
+ if (newPriority !== origPriority) cardChange.changes.push('priority → P' + newPriority);
689
+ if (newOwners !== origOwners) cardChange.changes.push('owners → [' + newOwners + ']');
690
+ if (cardChange.changes.length > 0) changes.push(cardChange);
691
+ });
692
+ return changes;
693
+ }
694
+
695
+ function updateCopyBtn() {
696
+ var changes = collectChanges();
697
+ if (changes.length === 0) {
698
+ copyBtn.style.display = 'none';
699
+ return;
700
+ }
701
+ copyBtn.style.display = 'block';
702
+ var total = changes.reduce(function(n, c) { return n + c.changes.length; }, 0);
703
+ copyBtn.textContent = 'Copy ' + total + ' change' + (total === 1 ? '' : 's');
704
+ }
705
+ document.addEventListener('change', updateCopyBtn);
706
+ document.addEventListener('input', updateCopyBtn);
707
+
708
+ copyBtn.addEventListener('click', function() {
709
+ var changes = collectChanges();
710
+ var lines = ['Apply these board changes:', ''];
711
+ changes.forEach(function(c) {
712
+ lines.push('- ' + c.id + ':');
713
+ c.changes.forEach(function(ch) { lines.push(' ' + ch); });
714
+ });
715
+ var text = lines.join('\\n');
716
+ navigator.clipboard.writeText(text).then(function() {
717
+ var original = copyBtn.textContent;
718
+ copyBtn.textContent = '✓ Copied — paste in Claude';
719
+ copyBtn.style.background = '#2e7d32';
720
+ setTimeout(function() {
721
+ copyBtn.textContent = original;
722
+ copyBtn.style.background = 'var(--terracotta)';
723
+ }, 2000);
724
+ });
725
+ });
726
+ })();
727
+ `,
728
+ },
729
+ })
730
+ );
731
+
449
732
  // Footer
450
733
  sections.push(
451
734
  h(ArtifactFooter, {
@@ -6,6 +6,7 @@ import {
6
6
  } from '../components.js';
7
7
  import { renderMarkdownLite } from '../markdown.js';
8
8
  import { fonts } from '../tokens.js';
9
+ import { renderSubgraph } from './subgraph.js';
9
10
 
10
11
  const h = React.createElement;
11
12
 
@@ -87,6 +88,16 @@ export function handoffTemplate(handoff) {
87
88
  );
88
89
  }
89
90
 
91
+ // Subgraph — handoff's neighborhood in the knowledge graph.
92
+ // Rendered as static SVG; renderSubgraph returns null if there's nothing to show.
93
+ const subgraphNode = renderSubgraph({
94
+ subgraph: handoff.subgraph,
95
+ handoffTopic: handoff.title,
96
+ });
97
+ if (subgraphNode) {
98
+ sections.push(React.cloneElement(subgraphNode, { key: 'subgraph' }));
99
+ }
100
+
90
101
  // Summary / Briefing
91
102
  if (handoff.summary) {
92
103
  sections.push(
@@ -0,0 +1,452 @@
1
+ // Handoff subgraph → inline static SVG
2
+ //
3
+ // Shows the graph-only context around a handoff — things the header doesn't
4
+ // already say. Author/recipients/project are in the metadata row above this.
5
+ //
6
+ // Central spine is VERTICAL so the SVG fits comfortably in the ~720px
7
+ // artifact column without scaling down and losing legibility:
8
+ //
9
+ // [prior session]
10
+ // by Oz
11
+ // ↓ implements
12
+ // ┌──────────────┐
13
+ // │ THIS HANDOFF │
14
+ // │ Topic │
15
+ // └──────────────┘
16
+ // ↓ picked up
17
+ // [later session]
18
+ // by Ali
19
+ //
20
+ // PRODUCED
21
+ // [PR #603 · egregore] [harvest · …] [decision · …]
22
+ //
23
+ // PART OF (shown only when quests exist — rare)
24
+ // [quest · Launch Priorities] [quest · Activity Wall]
25
+ //
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.
30
+ import React from 'react';
31
+ import { fonts } from '../tokens.js';
32
+
33
+ const h = React.createElement;
34
+
35
+ const CHAR_W_SANS_12 = 7.0;
36
+ const CHAR_W_SANS_13 = 7.6;
37
+ const CHAR_W_MONO_10 = 6.0;
38
+ const CHAR_W_MONO_9 = 5.4;
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;
57
+
58
+ export function renderSubgraph({ subgraph, handoffTopic }) {
59
+ if (!subgraph) return null;
60
+
61
+ const {
62
+ continues = [],
63
+ implementsHandoff = [],
64
+ implementedBy = [],
65
+ quests = [],
66
+ artifacts = [],
67
+ prs = [],
68
+ } = subgraph;
69
+
70
+ 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;
107
+
108
+ const laterText = laterNode ? truncate(laterNode.topic || laterNode.id || '', LABEL_TRUNCATE) : '';
109
+ const laterW = laterNode ? pillWidth(laterText) : 0;
110
+
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
+ );
124
+
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);
131
+
132
+ const contentW = Math.max(lineageW, questsMaxRowW, outputsMaxRowW, 420);
133
+ const VIEW_W = contentW + SIDE_PAD * 2;
134
+ const CX = VIEW_W / 2;
135
+
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
+ }
174
+
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,
184
+ }));
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)',
210
+ }));
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',
216
+ }));
217
+ cy += VERT_EDGE;
218
+
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',
224
+ }));
225
+ if (laterNode.author) {
226
+ children.push(subLabel({
227
+ key: 'later-sub', x: CX, y: y + PILL_H + 12,
228
+ text: `by ${laterNode.author}`,
229
+ }));
230
+ }
231
+ cy += PILL_H + SUB_LINE_H;
232
+ }
233
+
234
+ cy += ZONE_GAP;
235
+ }
236
+
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;
264
+ }
265
+
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,
286
+ fontFamily: fonts.mono, fontSize: 10,
287
+ fill: 'var(--muted)',
288
+ }, `+${questOverflow} more`));
289
+ }
290
+ cy += CHIP_H + 8;
291
+ });
292
+ }
293
+
294
+ const VIEW_H = cy + 10;
295
+
296
+ return h('div', {
297
+ style: {
298
+ marginBottom: '2rem',
299
+ padding: '1rem 0.5rem 0.75rem',
300
+ borderBottom: '1px solid var(--hairline)',
301
+ },
302
+ },
303
+ h('div', {
304
+ style: {
305
+ fontFamily: fonts.mono,
306
+ fontSize: '11px',
307
+ textTransform: 'uppercase',
308
+ letterSpacing: '0.08em',
309
+ color: 'var(--muted)',
310
+ marginBottom: '0.75rem',
311
+ paddingLeft: '0.25rem',
312
+ },
313
+ }, 'Subgraph'),
314
+ h('svg', {
315
+ viewBox: `0 0 ${VIEW_W} ${VIEW_H}`,
316
+ width: '100%',
317
+ height: 'auto',
318
+ preserveAspectRatio: 'xMidYMid meet',
319
+ style: { display: 'block', maxHeight: '520px' },
320
+ role: 'img',
321
+ '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
+ ),
336
+ );
337
+ }
338
+
339
+ // ── helpers ────────────────────────────────────────────────────────
340
+
341
+ function pillWidth(label) {
342
+ return clamp(textW(label, CHAR_W_SANS_12) + PILL_PAD_X, PILL_MIN_W, PILL_MAX_W);
343
+ }
344
+
345
+ function chipWidth(label) {
346
+ return Math.max(96, textW(label, CHAR_W_MONO_10) + 20);
347
+ }
348
+
349
+ function textW(label, perChar) {
350
+ return Math.ceil((label || '').length * perChar);
351
+ }
352
+
353
+ function truncate(s, max) {
354
+ if (!s) return '';
355
+ if (s.length <= max) return s;
356
+ return s.slice(0, max - 1) + '…';
357
+ }
358
+
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)' };
387
+
388
+ return h('g', { key },
389
+ 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,
393
+ }),
394
+ h('text', {
395
+ x: x + width / 2,
396
+ y: y + PILL_H / 2 + 4,
397
+ textAnchor: 'middle',
398
+ fontFamily: fonts.sans, fontSize: 13, fontWeight: 500,
399
+ fill: styles.text,
400
+ }, label),
401
+ );
402
+ }
403
+
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 },
412
+ h('rect', {
413
+ x, y: y - CHIP_H + 4, width, height: CHIP_H,
414
+ rx: 5, ry: 5,
415
+ fill: styles.fill,
416
+ }),
417
+ h('text', {
418
+ x: x + 10, y: y - CHIP_H / 2 + 8,
419
+ fontFamily: fonts.mono, fontSize: 11, fontWeight: 500,
420
+ fill: styles.text,
421
+ }, 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);
432
+ }
433
+
434
+ function edgeLabel({ key, x, y, text, anchor = 'start' }) {
435
+ return h('text', {
436
+ key, x, y,
437
+ textAnchor: anchor,
438
+ fontFamily: fonts.mono, fontSize: 10,
439
+ fill: 'var(--muted)',
440
+ letterSpacing: '0.04em',
441
+ }, text);
442
+ }
443
+
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);
452
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "egregore-artifacts",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Generate branded HTML artifacts from Egregore data",
5
5
  "type": "module",
6
6
  "license": "MIT",