egregore-artifacts 0.4.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.
@@ -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
@@ -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.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Generate branded HTML artifacts from Egregore data",
5
5
  "type": "module",
6
6
  "license": "MIT",