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.
- package/lib/parsers/board.js +34 -5
- package/lib/parsers/handoff.js +149 -5
- package/lib/templates/board.js +293 -10
- package/lib/templates/handoff.js +11 -0
- package/lib/templates/subgraph.js +452 -0
- package/package.json +1 -1
package/lib/parsers/board.js
CHANGED
|
@@ -25,7 +25,29 @@ export function parseBoard(input) {
|
|
|
25
25
|
|
|
26
26
|
const today = new Date().toISOString().split('T')[0];
|
|
27
27
|
|
|
28
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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 =
|
|
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,
|
package/lib/parsers/handoff.js
CHANGED
|
@@ -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
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
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(
|
|
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
|
package/lib/templates/board.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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, {
|
package/lib/templates/handoff.js
CHANGED
|
@@ -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
|
+
}
|