hex-audit 0.5.3 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +22 -1
  2. package/dashboard/.next/BUILD_ID +1 -1
  3. package/dashboard/.next/app-build-manifest.json +30 -22
  4. package/dashboard/.next/app-path-routes-manifest.json +1 -1
  5. package/dashboard/.next/build-manifest.json +4 -4
  6. package/dashboard/.next/next-server.js.nft.json +1 -1
  7. package/dashboard/.next/prerender-manifest.js +1 -1
  8. package/dashboard/.next/prerender-manifest.json +1 -1
  9. package/dashboard/.next/required-server-files.json +1 -1
  10. package/dashboard/.next/routes-manifest.json +1 -1
  11. package/dashboard/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  12. package/dashboard/.next/server/app/_not-found.html +1 -1
  13. package/dashboard/.next/server/app/_not-found.rsc +4 -4
  14. package/dashboard/.next/server/app/access/page_client-reference-manifest.js +1 -1
  15. package/dashboard/.next/server/app/api/notes/done/route.js +1 -0
  16. package/dashboard/.next/server/app/api/notes/done/route.js.nft.json +1 -0
  17. package/dashboard/.next/server/app/api/notes/lead/route.js +1 -0
  18. package/dashboard/.next/server/app/api/notes/lead/route.js.nft.json +1 -0
  19. package/dashboard/.next/server/app/api/notes/question/route.js +1 -0
  20. package/dashboard/.next/server/app/api/notes/question/route.js.nft.json +1 -0
  21. package/dashboard/.next/server/app/api/notes/record/route.js +9 -0
  22. package/dashboard/.next/server/app/api/notes/record/route.js.nft.json +1 -0
  23. package/dashboard/.next/server/app/api/notes/route.js +3 -0
  24. package/dashboard/.next/server/app/api/notes/route.js.nft.json +1 -0
  25. package/dashboard/.next/server/app/api/progress/route.js +1 -1
  26. package/dashboard/.next/server/app/calls/page_client-reference-manifest.js +1 -1
  27. package/dashboard/.next/server/app/conformance/page_client-reference-manifest.js +1 -1
  28. package/dashboard/.next/server/app/diagram/page_client-reference-manifest.js +1 -1
  29. package/dashboard/.next/server/app/diagram.html +1 -1
  30. package/dashboard/.next/server/app/diagram.rsc +4 -4
  31. package/dashboard/.next/server/app/flows/page_client-reference-manifest.js +1 -1
  32. package/dashboard/.next/server/app/flows.html +1 -1
  33. package/dashboard/.next/server/app/flows.rsc +4 -4
  34. package/dashboard/.next/server/app/functions/page_client-reference-manifest.js +1 -1
  35. package/dashboard/.next/server/app/issues/page.js +4 -4
  36. package/dashboard/.next/server/app/issues/page_client-reference-manifest.js +1 -1
  37. package/dashboard/.next/server/app/notes/page.js +22 -0
  38. package/dashboard/.next/server/app/notes/page.js.nft.json +1 -0
  39. package/dashboard/.next/server/app/notes/page_client-reference-manifest.js +1 -0
  40. package/dashboard/.next/server/app/overleaf/page_client-reference-manifest.js +1 -1
  41. package/dashboard/.next/server/app/page.js +2 -2
  42. package/dashboard/.next/server/app/page_client-reference-manifest.js +1 -1
  43. package/dashboard/.next/server/app/progress/page.js +1 -1
  44. package/dashboard/.next/server/app/progress/page_client-reference-manifest.js +1 -1
  45. package/dashboard/.next/server/app/stats/page_client-reference-manifest.js +1 -1
  46. package/dashboard/.next/server/app-paths-manifest.json +15 -9
  47. package/dashboard/.next/server/chunks/530.js +1 -1
  48. package/dashboard/.next/server/chunks/622.js +3 -0
  49. package/dashboard/.next/server/functions-config-manifest.json +1 -1
  50. package/dashboard/.next/server/middleware-build-manifest.js +1 -1
  51. package/dashboard/.next/server/pages/404.html +1 -1
  52. package/dashboard/.next/server/pages/500.html +1 -1
  53. package/dashboard/.next/server/server-reference-manifest.js +1 -1
  54. package/dashboard/.next/server/server-reference-manifest.json +1 -1
  55. package/dashboard/.next/static/chunks/app/issues/{page-abb062e4cf118340.js → page-72089090c04e96ee.js} +1 -1
  56. package/dashboard/.next/static/chunks/app/{layout-2c5383c11b1fbd97.js → layout-a95b696089e278c3.js} +1 -1
  57. package/dashboard/.next/static/chunks/app/notes/page-17af94c67ba79c00.js +1 -0
  58. package/dashboard/.next/static/chunks/app/page-24bac51dfe007c66.js +1 -0
  59. package/dashboard/.next/static/chunks/app/progress/page-427fb18da3afb061.js +1 -0
  60. package/dashboard/.next/static/chunks/{main-8f1e837090aef5cf.js → main-9a811c95265dbd11.js} +1 -1
  61. package/dashboard/.next/static/css/4a38d8f6f6b7a68f.css +3 -0
  62. package/dashboard/app/api/notes/done/route.ts +26 -0
  63. package/dashboard/app/api/notes/lead/route.ts +40 -0
  64. package/dashboard/app/api/notes/question/route.ts +30 -0
  65. package/dashboard/app/api/notes/record/route.ts +60 -0
  66. package/dashboard/app/api/notes/route.ts +25 -0
  67. package/dashboard/app/notes/NotesClient.tsx +701 -0
  68. package/dashboard/app/notes/page.tsx +47 -0
  69. package/dashboard/app/progress/ProgressClient.tsx +29 -8
  70. package/dashboard/app/progress/page.tsx +2 -0
  71. package/dashboard/components/DashboardLayout.tsx +22 -1
  72. package/dashboard/components/MarkdownRenderer.tsx +31 -0
  73. package/dashboard/next.config.mjs +11 -0
  74. package/dashboard/whisper-server.py +85 -0
  75. package/dist/commands/dashboard.d.ts.map +1 -1
  76. package/dist/commands/dashboard.js +23 -1
  77. package/dist/commands/dashboard.js.map +1 -1
  78. package/dist/commands/note.d.ts.map +1 -1
  79. package/dist/commands/note.js +55 -2
  80. package/dist/commands/note.js.map +1 -1
  81. package/dist/core/notes.d.ts +68 -0
  82. package/dist/core/notes.d.ts.map +1 -1
  83. package/dist/core/notes.js +167 -0
  84. package/dist/core/notes.js.map +1 -1
  85. package/dist/core/review.d.ts +14 -0
  86. package/dist/core/review.d.ts.map +1 -0
  87. package/dist/core/review.js +80 -0
  88. package/dist/core/review.js.map +1 -0
  89. package/dist/core/whisper.d.ts +8 -0
  90. package/dist/core/whisper.d.ts.map +1 -1
  91. package/dist/core/whisper.js +55 -0
  92. package/dist/core/whisper.js.map +1 -1
  93. package/dist/index.js +2 -0
  94. package/dist/index.js.map +1 -1
  95. package/dist/skills/diane.md +65 -36
  96. package/dist/skills/ingest-aa-report.md +4 -4
  97. package/package.json +2 -1
  98. package/dashboard/.next/static/chunks/app/page-1226422e170d8895.js +0 -1
  99. package/dashboard/.next/static/chunks/app/progress/page-f07ac39d1d27b480.js +0 -1
  100. package/dashboard/.next/static/css/691f4e9edab008c8.css +0 -3
  101. /package/dashboard/.next/static/{8MjKGPuSZw4OIpcHY4G0W → ZvCvC4cMbL3GR8kloB9OJ}/_buildManifest.js +0 -0
  102. /package/dashboard/.next/static/{8MjKGPuSZw4OIpcHY4G0W → ZvCvC4cMbL3GR8kloB9OJ}/_ssgManifest.js +0 -0
@@ -0,0 +1,701 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useMemo, useRef, useState } from 'react';
4
+ import { useRouter } from 'next/navigation';
5
+ import { MarkdownRenderer, InlineMarkdown } from '@/components/MarkdownRenderer';
6
+ import type { ContractNote, FnEntry, Question, Lead } from '../../../src/core/notes';
7
+
8
+ interface NotesClientProps {
9
+ general: string;
10
+ contractNotes: ContractNote[];
11
+ contractOptions: string[];
12
+ activeContract: string | null;
13
+ unprocessedCount: number;
14
+ }
15
+
16
+ type RecState = 'idle' | 'recording' | 'transcribing';
17
+ const OTHER = '__other__';
18
+ const GENERAL = 'general';
19
+
20
+ function fmt(sec: number): string {
21
+ const m = Math.floor(sec / 60);
22
+ const s = sec % 60;
23
+ return `${m}:${String(s).padStart(2, '0')}`;
24
+ }
25
+
26
+ export function NotesClient({
27
+ general,
28
+ contractNotes,
29
+ contractOptions,
30
+ activeContract,
31
+ unprocessedCount,
32
+ }: NotesClientProps) {
33
+ const router = useRouter();
34
+
35
+ // ── Left rail: General + each contract note ──
36
+ const railItems = useMemo(
37
+ () => [
38
+ { key: GENERAL, label: 'General' },
39
+ ...contractNotes.map((n) => ({ key: n.contract, label: n.contract })),
40
+ ],
41
+ [contractNotes],
42
+ );
43
+ const initialKey =
44
+ activeContract && railItems.some((d) => d.key === activeContract) ? activeContract : GENERAL;
45
+ const [selectedKey, setSelectedKey] = useState<string>(initialKey);
46
+ const selectedNote = contractNotes.find((n) => n.contract === selectedKey) ?? null;
47
+
48
+ // ── Recording ──
49
+ const [recState, setRecState] = useState<RecState>('idle');
50
+ const [recordContract, setRecordContract] = useState<string>(
51
+ activeContract ?? contractOptions[0] ?? OTHER,
52
+ );
53
+ const [otherName, setOtherName] = useState('');
54
+ const [elapsed, setElapsed] = useState(0);
55
+ const [statusMsg, setStatusMsg] = useState<string | null>(null);
56
+
57
+ const recorderRef = useRef<MediaRecorder | null>(null);
58
+ const chunksRef = useRef<Blob[]>([]);
59
+ const streamRef = useRef<MediaStream | null>(null);
60
+ const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
61
+
62
+ const effectiveContract = (recordContract === OTHER ? otherName : recordContract).trim();
63
+
64
+ const upload = useCallback(async () => {
65
+ setRecState('transcribing');
66
+ setStatusMsg('Transcribing locally…');
67
+ try {
68
+ const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
69
+ const fd = new FormData();
70
+ fd.append('audio', blob, 'narration.webm');
71
+ fd.append('contract', effectiveContract || 'general');
72
+ const res = await fetch('/api/notes/record', { method: 'POST', body: fd });
73
+ const data = await res.json().catch(() => ({}));
74
+ if (res.status === 422 && data.whisperMissing) {
75
+ setStatusMsg(
76
+ `Audio saved (${data.audioSaved}) but no local Whisper engine is installed. ${data.error}`,
77
+ );
78
+ } else if (!res.ok) {
79
+ setStatusMsg(data.error ?? 'Recording failed');
80
+ } else {
81
+ setStatusMsg(`Session saved for ${data.contract}. Run /diane in Claude Code to process it.`);
82
+ router.refresh();
83
+ }
84
+ } catch (err) {
85
+ setStatusMsg(err instanceof Error ? err.message : 'Recording failed');
86
+ } finally {
87
+ setRecState('idle');
88
+ }
89
+ }, [effectiveContract, router]);
90
+
91
+ const startRecording = useCallback(async () => {
92
+ setStatusMsg(null);
93
+ try {
94
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
95
+ streamRef.current = stream;
96
+ chunksRef.current = [];
97
+ const mr = new MediaRecorder(stream);
98
+ mr.ondataavailable = (e) => {
99
+ if (e.data.size > 0) chunksRef.current.push(e.data);
100
+ };
101
+ mr.onstop = () => {
102
+ streamRef.current?.getTracks().forEach((t) => t.stop());
103
+ if (timerRef.current) clearInterval(timerRef.current);
104
+ void upload();
105
+ };
106
+ recorderRef.current = mr;
107
+ mr.start();
108
+ setElapsed(0);
109
+ timerRef.current = setInterval(() => setElapsed((e) => e + 1), 1000);
110
+ setRecState('recording');
111
+ } catch {
112
+ setStatusMsg('Could not access the microphone. Grant permission and try again.');
113
+ }
114
+ }, [upload]);
115
+
116
+ const stopRecording = useCallback(() => {
117
+ recorderRef.current?.stop();
118
+ }, []);
119
+
120
+ const canRecord = recState === 'idle' && effectiveContract.length > 0;
121
+
122
+ return (
123
+ <div className="space-y-sp-5">
124
+ {/* ── Recorder bar ── */}
125
+ <div className="rounded-lg border border-border-subtle bg-surface-1 p-4">
126
+ <div className="flex flex-wrap items-center gap-3">
127
+ <span className="text-heading font-medium text-text-primary">Record narration</span>
128
+
129
+ <select
130
+ value={recordContract}
131
+ onChange={(e) => setRecordContract(e.target.value)}
132
+ disabled={recState !== 'idle'}
133
+ className="rounded-md border border-border-default bg-surface-2 px-3 py-1.5 text-body text-text-primary focus:outline-none focus:ring-2 focus:ring-accent disabled:opacity-50"
134
+ >
135
+ {contractOptions.map((c) => (
136
+ <option key={c} value={c}>
137
+ {c}
138
+ </option>
139
+ ))}
140
+ <option value={OTHER}>Other…</option>
141
+ </select>
142
+
143
+ {recordContract === OTHER && (
144
+ <input
145
+ type="text"
146
+ placeholder="Contract name"
147
+ value={otherName}
148
+ onChange={(e) => setOtherName(e.target.value)}
149
+ disabled={recState !== 'idle'}
150
+ className="rounded-md border border-border-default bg-surface-2 px-3 py-1.5 text-body text-text-primary focus:outline-none focus:ring-2 focus:ring-accent disabled:opacity-50"
151
+ />
152
+ )}
153
+
154
+ {recState === 'recording' ? (
155
+ <button
156
+ type="button"
157
+ onClick={stopRecording}
158
+ className="flex items-center gap-2 rounded-md bg-severity-critical px-3 py-1.5 text-body font-medium text-surface-0"
159
+ >
160
+ <span className="h-2.5 w-2.5 rounded-sm bg-surface-0" />
161
+ Stop · {fmt(elapsed)}
162
+ </button>
163
+ ) : (
164
+ <button
165
+ type="button"
166
+ onClick={startRecording}
167
+ disabled={!canRecord}
168
+ className="flex items-center gap-2 rounded-md bg-accent px-3 py-1.5 text-body font-medium text-surface-0 transition-opacity duration-200 hover:opacity-90 disabled:opacity-40"
169
+ >
170
+ <span className="h-2.5 w-2.5 rounded-full bg-surface-0" />
171
+ {recState === 'transcribing' ? 'Transcribing…' : 'Record'}
172
+ </button>
173
+ )}
174
+ </div>
175
+
176
+ {statusMsg && <p className="mt-3 text-caption text-text-secondary">{statusMsg}</p>}
177
+ {unprocessedCount > 0 && (
178
+ <p className="mt-2 text-caption text-accent">
179
+ {unprocessedCount} narration session{unprocessedCount > 1 ? 's' : ''} waiting — run{' '}
180
+ <code className="font-mono">/diane</code> in Claude Code to process.
181
+ </p>
182
+ )}
183
+ </div>
184
+
185
+ {/* ── Notes (rail + structured view) ── */}
186
+ <div className="flex gap-sp-5">
187
+ <nav className="w-44 shrink-0 space-y-0.5">
188
+ {railItems.map((d) => (
189
+ <button
190
+ key={d.key}
191
+ type="button"
192
+ onClick={() => setSelectedKey(d.key)}
193
+ className={`block w-full truncate rounded-md px-3 py-1.5 text-left text-body transition-colors duration-200 ${
194
+ d.key === selectedKey
195
+ ? 'bg-accent-subtle font-medium text-accent'
196
+ : 'text-text-secondary hover:bg-surface-2 hover:text-text-primary'
197
+ }`}
198
+ >
199
+ {d.label}
200
+ </button>
201
+ ))}
202
+ </nav>
203
+
204
+ <div className="min-w-0 flex-1">
205
+ {selectedKey === GENERAL ? (
206
+ <GeneralDoc body={general} />
207
+ ) : (
208
+ <ContractView
209
+ note={selectedNote}
210
+ contract={selectedKey}
211
+ onMutate={() => router.refresh()}
212
+ />
213
+ )}
214
+ </div>
215
+ </div>
216
+ </div>
217
+ );
218
+ }
219
+
220
+ // ─── General (freeform markdown) ────────────────────────────────────
221
+
222
+ function GeneralDoc({ body }: { body: string }) {
223
+ return (
224
+ <div className="rounded-lg border border-border-subtle bg-surface-1 p-5">
225
+ <div className="mb-3 text-heading font-medium text-text-primary">General</div>
226
+ {body.trim() ? (
227
+ <MarkdownRenderer content={body} />
228
+ ) : (
229
+ <p className="text-body text-text-tertiary">
230
+ No protocol-wide notes yet. Record a narration above against{' '}
231
+ <span className="font-medium">Other…</span> as <code className="font-mono">general</code> and
232
+ run <code className="font-mono">/diane</code>.
233
+ </p>
234
+ )}
235
+ </div>
236
+ );
237
+ }
238
+
239
+ // ─── Contract (structured view) ─────────────────────────────────────
240
+
241
+ const SECTIONS = [
242
+ { id: 'leads', label: 'Leads' },
243
+ { id: 'description', label: 'Description' },
244
+ { id: 'questions', label: 'Questions' },
245
+ ] as const;
246
+
247
+ function scrollToSection(id: string) {
248
+ document.getElementById(`note-${id}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
249
+ }
250
+
251
+ function ContractView({
252
+ note,
253
+ contract,
254
+ onMutate,
255
+ }: {
256
+ note: ContractNote | null;
257
+ contract: string;
258
+ onMutate: () => void;
259
+ }) {
260
+ const [busy, setBusy] = useState(false);
261
+
262
+ const post = useCallback(
263
+ async (url: string, body: unknown) => {
264
+ setBusy(true);
265
+ try {
266
+ const res = await fetch(url, {
267
+ method: 'POST',
268
+ headers: { 'Content-Type': 'application/json' },
269
+ body: JSON.stringify(body),
270
+ });
271
+ if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? 'request failed');
272
+ onMutate();
273
+ } catch {
274
+ /* surface via refresh; keep UI resilient */
275
+ } finally {
276
+ setBusy(false);
277
+ }
278
+ },
279
+ [onMutate],
280
+ );
281
+
282
+ const isEmpty =
283
+ !note ||
284
+ (note.leads.length === 0 &&
285
+ note.questions.length === 0 &&
286
+ note.description.purpose.length === 0 &&
287
+ note.description.inheritance.length === 0 &&
288
+ note.description.storage.length === 0 &&
289
+ note.description.roles.length === 0 &&
290
+ note.description.functions.length === 0);
291
+
292
+ return (
293
+ <div className="rounded-lg border border-border-subtle bg-surface-1 p-5">
294
+ {/* Title + mark-done + section nav */}
295
+ <div className="mb-4 flex flex-wrap items-center justify-between gap-3">
296
+ <div className="flex items-center gap-2">
297
+ <span className="text-heading font-medium text-text-primary">{contract}</span>
298
+ {note?.file && (
299
+ <span className="font-mono text-caption text-text-tertiary">{note.file}</span>
300
+ )}
301
+ </div>
302
+ <label className="flex cursor-pointer items-center gap-2 text-caption text-text-secondary">
303
+ <input
304
+ type="checkbox"
305
+ checked={Boolean(note?.marked_done)}
306
+ disabled={busy || !note}
307
+ onChange={(e) => post('/api/notes/done', { contract, done: e.target.checked })}
308
+ className="h-4 w-4 rounded border-border-emphasis bg-surface-1 accent-[var(--accent)]"
309
+ />
310
+ Mark done reading
311
+ </label>
312
+ </div>
313
+
314
+ {isEmpty ? (
315
+ <p className="text-body text-text-tertiary">
316
+ No notes yet — record a narration above and run <code className="font-mono">/diane</code>.
317
+ </p>
318
+ ) : (
319
+ <>
320
+ <nav className="mb-5 flex flex-wrap items-center gap-1 border-b border-border-subtle pb-3 text-caption">
321
+ {SECTIONS.map((s) => (
322
+ <button
323
+ key={s.id}
324
+ type="button"
325
+ onClick={() => scrollToSection(s.id)}
326
+ className="rounded-md px-2 py-1 text-text-secondary transition-colors duration-200 hover:bg-surface-2 hover:text-text-primary"
327
+ >
328
+ {s.label}
329
+ </button>
330
+ ))}
331
+ </nav>
332
+
333
+ <div className="space-y-6">
334
+ <LeadsSection
335
+ leads={note!.leads}
336
+ contract={contract}
337
+ busy={busy}
338
+ post={post}
339
+ />
340
+ <DescriptionSection note={note!} />
341
+ <QuestionsSection
342
+ questions={note!.questions}
343
+ contract={contract}
344
+ busy={busy}
345
+ post={post}
346
+ />
347
+ </div>
348
+ </>
349
+ )}
350
+ </div>
351
+ );
352
+ }
353
+
354
+ // ─── Leads ──────────────────────────────────────────────────────────
355
+
356
+ function LeadsSection({
357
+ leads,
358
+ contract,
359
+ busy,
360
+ post,
361
+ }: {
362
+ leads: Lead[];
363
+ contract: string;
364
+ busy: boolean;
365
+ post: (url: string, body: unknown) => Promise<void>;
366
+ }) {
367
+ const open = leads.filter((l) => l.status === 'open');
368
+ const closed = leads.filter((l) => l.status !== 'open');
369
+ const [showClosed, setShowClosed] = useState(false);
370
+
371
+ return (
372
+ <section id="note-leads">
373
+ <h3 className="mb-2 text-heading font-medium text-text-primary">Leads</h3>
374
+ {open.length === 0 ? (
375
+ <p className="text-body text-text-tertiary">No open leads.</p>
376
+ ) : (
377
+ <ul className="space-y-2">
378
+ {open.map((lead) => (
379
+ <li
380
+ key={lead.id}
381
+ className="flex items-start justify-between gap-3 rounded-md border border-border-subtle bg-surface-2 px-3 py-2"
382
+ >
383
+ <span className="min-w-0 flex-1 text-body text-text-primary">
384
+ <InlineMarkdown>{lead.text}</InlineMarkdown>
385
+ </span>
386
+ <button
387
+ type="button"
388
+ disabled={busy}
389
+ onClick={() =>
390
+ post('/api/notes/lead', { contract, id: lead.id, action: 'close', status: 'dismissed' })
391
+ }
392
+ className="shrink-0 rounded-md border border-border-default px-2.5 py-1 text-caption text-text-secondary transition-colors duration-200 hover:bg-surface-3 hover:text-text-primary disabled:opacity-50"
393
+ >
394
+ Close
395
+ </button>
396
+ </li>
397
+ ))}
398
+ </ul>
399
+ )}
400
+
401
+ {closed.length > 0 && (
402
+ <div className="mt-3">
403
+ <button
404
+ type="button"
405
+ onClick={() => setShowClosed((v) => !v)}
406
+ className="flex items-center gap-1.5 text-caption text-text-tertiary transition-colors duration-200 hover:text-text-secondary"
407
+ >
408
+ <svg
409
+ className={`h-3.5 w-3.5 transition-transform duration-200 ${showClosed ? 'rotate-90' : ''}`}
410
+ fill="none"
411
+ viewBox="0 0 24 24"
412
+ stroke="currentColor"
413
+ strokeWidth={2}
414
+ >
415
+ <path strokeLinecap="round" strokeLinejoin="round" d="m9 5 7 7-7 7" />
416
+ </svg>
417
+ Closed ({closed.length})
418
+ </button>
419
+ {showClosed && (
420
+ <ul className="mt-2 space-y-1.5">
421
+ {closed.map((lead) => (
422
+ <li
423
+ key={lead.id}
424
+ className="flex items-start justify-between gap-3 rounded-md px-3 py-1.5"
425
+ >
426
+ <span className="min-w-0 flex-1 text-caption text-text-tertiary">
427
+ <span className="line-through"><InlineMarkdown>{lead.text}</InlineMarkdown></span>
428
+ {lead.status === 'logged' && lead.ref && (
429
+ <span className="ml-2 font-mono text-text-secondary">→ {lead.ref}</span>
430
+ )}
431
+ {lead.status === 'dismissed' && (
432
+ <span className="ml-2 text-text-tertiary">dismissed</span>
433
+ )}
434
+ </span>
435
+ <button
436
+ type="button"
437
+ disabled={busy}
438
+ onClick={() => post('/api/notes/lead', { contract, id: lead.id, action: 'reopen' })}
439
+ className="shrink-0 rounded-md px-2 py-0.5 text-caption text-text-secondary transition-colors duration-200 hover:text-accent disabled:opacity-50"
440
+ >
441
+ Reopen
442
+ </button>
443
+ </li>
444
+ ))}
445
+ </ul>
446
+ )}
447
+ </div>
448
+ )}
449
+ </section>
450
+ );
451
+ }
452
+
453
+ // ─── Description ────────────────────────────────────────────────────
454
+
455
+ function BulletList({ label, items }: { label: string; items: string[] }) {
456
+ if (items.length === 0) return null;
457
+ return (
458
+ <div>
459
+ <h4 className="mb-1 text-body font-medium text-text-secondary">{label}</h4>
460
+ <ul className="list-disc space-y-0.5 pl-5 text-body text-text-primary">
461
+ {items.map((it, i) => (
462
+ <li key={i}>
463
+ <InlineMarkdown>{it}</InlineMarkdown>
464
+ </li>
465
+ ))}
466
+ </ul>
467
+ </div>
468
+ );
469
+ }
470
+
471
+ function DescriptionSection({ note }: { note: ContractNote }) {
472
+ const { purpose, inheritance, storage, roles, functions } = note.description;
473
+ const hasAny =
474
+ purpose.length > 0 ||
475
+ inheritance.length > 0 ||
476
+ storage.length > 0 ||
477
+ roles.length > 0 ||
478
+ functions.length > 0;
479
+
480
+ return (
481
+ <section id="note-description">
482
+ <h3 className="mb-3 text-heading font-medium text-text-primary">Description</h3>
483
+ {!hasAny ? (
484
+ <p className="text-body text-text-tertiary">No description recorded yet.</p>
485
+ ) : (
486
+ <div className="space-y-4">
487
+ <BulletList label="Purpose" items={purpose} />
488
+ <BulletList label="Inheritance" items={inheritance} />
489
+ <BulletList label="Storage" items={storage} />
490
+ <BulletList label="Roles & modifiers" items={roles} />
491
+ {functions.length > 0 && (
492
+ <div>
493
+ <h4 className="mb-1.5 text-body font-medium text-text-secondary">Functions</h4>
494
+ <div className="space-y-1.5">
495
+ {functions.map((fn) => (
496
+ <FunctionAccordion key={fn.id} fn={fn} />
497
+ ))}
498
+ </div>
499
+ </div>
500
+ )}
501
+ </div>
502
+ )}
503
+ </section>
504
+ );
505
+ }
506
+
507
+ function FunctionAccordion({ fn }: { fn: FnEntry }) {
508
+ const [open, setOpen] = useState(false);
509
+ const meta: [string, string | undefined][] = [
510
+ ['Purpose', fn.purpose],
511
+ ['Access', fn.access],
512
+ ['Effects', fn.effects],
513
+ ];
514
+ return (
515
+ <div className="rounded-md border border-border-subtle">
516
+ <button
517
+ type="button"
518
+ onClick={() => setOpen((v) => !v)}
519
+ className="flex w-full items-center gap-2 rounded-md bg-surface-2 px-3 py-2 text-left transition-colors duration-200 hover:bg-surface-3"
520
+ >
521
+ <svg
522
+ className={`h-4 w-4 shrink-0 text-text-tertiary transition-transform duration-200 ${open ? 'rotate-90' : ''}`}
523
+ fill="none"
524
+ viewBox="0 0 24 24"
525
+ stroke="currentColor"
526
+ strokeWidth={2}
527
+ >
528
+ <path strokeLinecap="round" strokeLinejoin="round" d="m9 5 7 7-7 7" />
529
+ </svg>
530
+ <span className="min-w-0 truncate font-mono text-body text-text-primary">{fn.sig}</span>
531
+ {fn.warnings.length > 0 && (
532
+ <span className="ml-auto shrink-0 text-caption text-[var(--high)]">
533
+ ⚠ {fn.warnings.length}
534
+ </span>
535
+ )}
536
+ </button>
537
+ {open && (
538
+ <div className="space-y-2 px-3 py-2.5">
539
+ {meta
540
+ .filter(([, v]) => v && v.trim())
541
+ .map(([label, v]) => (
542
+ <p key={label} className="text-body text-text-primary">
543
+ <span className="text-text-secondary">{label}: </span>
544
+ <InlineMarkdown>{v as string}</InlineMarkdown>
545
+ </p>
546
+ ))}
547
+ {fn.notes.length > 0 && (
548
+ <ul className="list-disc space-y-0.5 pl-5 text-body text-text-primary">
549
+ {fn.notes.map((n, i) => (
550
+ <li key={i}>
551
+ <InlineMarkdown>{n}</InlineMarkdown>
552
+ </li>
553
+ ))}
554
+ </ul>
555
+ )}
556
+ {fn.warnings.length > 0 && (
557
+ <ul className="space-y-1">
558
+ {fn.warnings.map((w, i) => (
559
+ <li
560
+ key={i}
561
+ className="flex items-start gap-1.5 rounded-md bg-[var(--high)]/10 px-2 py-1 text-body text-[var(--high)]"
562
+ >
563
+ <span aria-hidden>⚠</span>
564
+ <span className="min-w-0 flex-1">
565
+ <InlineMarkdown>{w}</InlineMarkdown>
566
+ </span>
567
+ </li>
568
+ ))}
569
+ </ul>
570
+ )}
571
+ </div>
572
+ )}
573
+ </div>
574
+ );
575
+ }
576
+
577
+ // ─── Questions ──────────────────────────────────────────────────────
578
+
579
+ function QuestionsSection({
580
+ questions,
581
+ contract,
582
+ busy,
583
+ post,
584
+ }: {
585
+ questions: Question[];
586
+ contract: string;
587
+ busy: boolean;
588
+ post: (url: string, body: unknown) => Promise<void>;
589
+ }) {
590
+ return (
591
+ <section id="note-questions">
592
+ <h3 className="mb-2 text-heading font-medium text-text-primary">Questions</h3>
593
+ {questions.length === 0 ? (
594
+ <p className="text-body text-text-tertiary">No questions recorded yet.</p>
595
+ ) : (
596
+ <div className="space-y-1.5">
597
+ {questions.map((q) => (
598
+ <QuestionAccordion
599
+ key={q.id}
600
+ question={q}
601
+ contract={contract}
602
+ busy={busy}
603
+ post={post}
604
+ />
605
+ ))}
606
+ </div>
607
+ )}
608
+ </section>
609
+ );
610
+ }
611
+
612
+ function QuestionAccordion({
613
+ question,
614
+ contract,
615
+ busy,
616
+ post,
617
+ }: {
618
+ question: Question;
619
+ contract: string;
620
+ busy: boolean;
621
+ post: (url: string, body: unknown) => Promise<void>;
622
+ }) {
623
+ const answered = Boolean(question.answer && question.answer.trim());
624
+ const [open, setOpen] = useState(false);
625
+ const [editing, setEditing] = useState(false);
626
+ const [draft, setDraft] = useState(question.answer ?? '');
627
+
628
+ return (
629
+ <div className="rounded-md border border-border-subtle">
630
+ <button
631
+ type="button"
632
+ onClick={() => setOpen((v) => !v)}
633
+ className="flex w-full items-center gap-2 rounded-md bg-surface-2 px-3 py-2 text-left transition-colors duration-200 hover:bg-surface-3"
634
+ >
635
+ <svg
636
+ className={`h-4 w-4 shrink-0 text-text-tertiary transition-transform duration-200 ${open ? 'rotate-90' : ''}`}
637
+ fill="none"
638
+ viewBox="0 0 24 24"
639
+ stroke="currentColor"
640
+ strokeWidth={2}
641
+ >
642
+ <path strokeLinecap="round" strokeLinejoin="round" d="m9 5 7 7-7 7" />
643
+ </svg>
644
+ <span className={`min-w-0 flex-1 text-body ${answered ? 'text-text-primary' : 'text-text-tertiary'}`}>
645
+ <InlineMarkdown>{question.q}</InlineMarkdown>
646
+ </span>
647
+ {!answered && <span className="shrink-0 text-caption text-text-tertiary">open</span>}
648
+ </button>
649
+ {open && (
650
+ <div className="space-y-2 px-3 py-2.5">
651
+ {editing ? (
652
+ <div className="space-y-2">
653
+ <textarea
654
+ value={draft}
655
+ onChange={(e) => setDraft(e.target.value)}
656
+ rows={3}
657
+ className="w-full resize-y rounded-md border border-border-default bg-surface-0 p-2 text-body text-text-primary focus:outline-none focus:ring-2 focus:ring-accent"
658
+ />
659
+ <div className="flex gap-2">
660
+ <button
661
+ type="button"
662
+ onClick={() => {
663
+ setEditing(false);
664
+ setDraft(question.answer ?? '');
665
+ }}
666
+ className="rounded-md px-2.5 py-1 text-caption text-text-secondary transition-colors duration-200 hover:text-text-primary"
667
+ >
668
+ Cancel
669
+ </button>
670
+ <button
671
+ type="button"
672
+ disabled={busy || !draft.trim()}
673
+ onClick={async () => {
674
+ await post('/api/notes/question', { contract, id: question.id, answer: draft.trim() });
675
+ setEditing(false);
676
+ }}
677
+ className="rounded-md bg-accent px-2.5 py-1 text-caption font-medium text-surface-0 transition-opacity duration-200 hover:opacity-90 disabled:opacity-50"
678
+ >
679
+ Save answer
680
+ </button>
681
+ </div>
682
+ </div>
683
+ ) : (
684
+ <div className="flex items-start justify-between gap-3">
685
+ <p className={`min-w-0 flex-1 text-body ${answered ? 'text-text-primary' : 'text-text-tertiary'}`}>
686
+ {answered ? <InlineMarkdown>{question.answer as string}</InlineMarkdown> : '— open'}
687
+ </p>
688
+ <button
689
+ type="button"
690
+ onClick={() => setEditing(true)}
691
+ className="shrink-0 rounded-md border border-border-default px-2.5 py-1 text-caption text-text-secondary transition-colors duration-200 hover:bg-surface-3 hover:text-text-primary"
692
+ >
693
+ {answered ? 'Edit' : 'Answer'}
694
+ </button>
695
+ </div>
696
+ )}
697
+ </div>
698
+ )}
699
+ </div>
700
+ );
701
+ }