+
{state === 'copied' ? 'Copied' : state === 'failed' ? 'Copy failed' : 'Copy'}
{#if state === 'failed'}
Could not copy the {name}; select and copy it manually.
{/if}
-
diff --git a/ui/src/components/CopyValue/index.js b/ui/src/components/CopyValue/index.js
new file mode 100644
index 0000000..bf88580
--- /dev/null
+++ b/ui/src/components/CopyValue/index.js
@@ -0,0 +1 @@
+export { default } from './CopyValue.svelte';
diff --git a/ui/src/components/DecisionComposer/DecisionComposer.module.css b/ui/src/components/DecisionComposer/DecisionComposer.module.css
new file mode 100644
index 0000000..2948fb2
--- /dev/null
+++ b/ui/src/components/DecisionComposer/DecisionComposer.module.css
@@ -0,0 +1,64 @@
+.composer {
+ display: contents;
+}
+
+.composer fieldset {
+ display: grid;
+ gap: 0;
+ border: 1px solid var(--border);
+ border-radius: 11px;
+ padding: 6px 0;
+ margin: 0 0 12px;
+ min-width: 0;
+}
+
+.composer fieldset label {
+ display: grid;
+ grid-template-columns: 20px minmax(0,1fr);
+ align-items: center;
+ gap: 2px 12px;
+ padding: 12px 16px;
+ margin: 0;
+ cursor: pointer;
+ min-height: 66px;
+}
+
+.composer fieldset label:hover {
+ background: var(--subtle);
+}
+
+.composer fieldset label.selected {
+ background: var(--accent-soft);
+}
+
+.composer fieldset input {
+ grid-row: 1 / span 2;
+ width: 18px;
+ height: 18px;
+ padding: 0;
+ margin: 0;
+ accent-color: var(--accent);
+}
+
+.decisionLabel {
+ font-weight: 600;
+}
+
+.decisionHint {
+ grid-column: 2;
+ font-size: 12px;
+ color: var(--muted);
+}
+
+.composer button[type=submit] {
+ width: 100%;
+ margin-top: 8px;
+ color: var(--surface);
+ background: var(--text);
+ border-color: var(--text);
+ font-weight: 700;
+}
+
+.composer button[type=submit]:disabled {
+ opacity: .45;
+}
diff --git a/ui/src/components/DecisionComposer/DecisionComposer.svelte b/ui/src/components/DecisionComposer/DecisionComposer.svelte
new file mode 100644
index 0000000..f9b71d0
--- /dev/null
+++ b/ui/src/components/DecisionComposer/DecisionComposer.svelte
@@ -0,0 +1,141 @@
+
+
+
diff --git a/ui/src/components/DecisionComposer/index.js b/ui/src/components/DecisionComposer/index.js
new file mode 100644
index 0000000..a0c6c7b
--- /dev/null
+++ b/ui/src/components/DecisionComposer/index.js
@@ -0,0 +1 @@
+export { default } from './DecisionComposer.svelte';
diff --git a/ui/src/components/DecisionStatus/DecisionStatus.module.css b/ui/src/components/DecisionStatus/DecisionStatus.module.css
new file mode 100644
index 0000000..ce157ef
--- /dev/null
+++ b/ui/src/components/DecisionStatus/DecisionStatus.module.css
@@ -0,0 +1,9 @@
+.title {
+ font-size: 20px;
+ margin-bottom: 10px;
+ letter-spacing: -.4px;
+}
+
+.status {
+ margin-bottom: 20px;
+}
diff --git a/ui/src/components/DecisionStatus/DecisionStatus.svelte b/ui/src/components/DecisionStatus/DecisionStatus.svelte
new file mode 100644
index 0000000..62105ba
--- /dev/null
+++ b/ui/src/components/DecisionStatus/DecisionStatus.svelte
@@ -0,0 +1,26 @@
+
+
+{title}
+Status: {statusLine()}
diff --git a/ui/src/components/DecisionStatus/index.js b/ui/src/components/DecisionStatus/index.js
new file mode 100644
index 0000000..4574547
--- /dev/null
+++ b/ui/src/components/DecisionStatus/index.js
@@ -0,0 +1 @@
+export { default } from './DecisionStatus.svelte';
diff --git a/ui/src/components/DecisionTrail/DecisionTrail.module.css b/ui/src/components/DecisionTrail/DecisionTrail.module.css
new file mode 100644
index 0000000..187f78a
--- /dev/null
+++ b/ui/src/components/DecisionTrail/DecisionTrail.module.css
@@ -0,0 +1,51 @@
+.historyHeading {
+ margin: 24px 0 12px;
+ padding-top: 20px;
+ border-top: 1px solid var(--border);
+ text-transform: uppercase;
+ letter-spacing: .07em;
+ font-size: 11px;
+ color: var(--muted);
+}
+
+.emptyHistory {
+ color: var(--muted);
+ font-size: 13px;
+ margin-bottom: 0;
+}
+
+.trail {
+ padding: 0;
+ list-style: none;
+}
+
+.trail li {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 8px;
+ border-top: 1px solid var(--border);
+ padding: 12px 0;
+ font-size: 13px;
+}
+
+.trail time {
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.trail .text {
+ flex-basis: 100%;
+ overflow-wrap: anywhere;
+}
+
+.trail .until {
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.trail button {
+ min-height: 32px;
+ padding: 4px 8px;
+ font-size: 12px;
+}
diff --git a/ui/src/components/DecisionTrail/DecisionTrail.svelte b/ui/src/components/DecisionTrail/DecisionTrail.svelte
new file mode 100644
index 0000000..d9a06e9
--- /dev/null
+++ b/ui/src/components/DecisionTrail/DecisionTrail.svelte
@@ -0,0 +1,63 @@
+
+
+{#if card}Recorded decisions {/if}
+{#if trail.length === 0}
+ No decisions recorded.
+{:else}
+
+ {#each trail as d (d.decision_id)}
+
+ {d.kind.replace('_', ' ')}
+ {#if d.undone}undone {/if}
+ {#if d.text}{d.text} {/if}
+ {#if d.until}until {d.until} {/if}
+ {new Date(d.created_at).toLocaleString()}
+ {#if !d.undone && d.kind !== 'undo'}
+ onUndo(d.decision_id)}
+ disabled={undoPending !== null}
+ >
+ {undoPending === d.decision_id ? 'Undoing…' : 'Undo'}
+
+ {/if}
+
+ {/each}
+
+ {#if !exhausted}
+
+ {paging ? 'Loading…' : 'Show older decisions'}
+
+ {/if}
+ {#if showNewer}
+ Show newer decisions
+ {/if}
+ {#if note !== null}
+ {note}
+ {/if}
+{/if}
+{#if listError !== null}
+ {listError}
+{/if}
diff --git a/ui/src/components/DecisionTrail/index.js b/ui/src/components/DecisionTrail/index.js
new file mode 100644
index 0000000..b23db20
--- /dev/null
+++ b/ui/src/components/DecisionTrail/index.js
@@ -0,0 +1 @@
+export { default } from './DecisionTrail.svelte';
diff --git a/ui/src/components/DeviceList/DeviceList.svelte b/ui/src/components/DeviceList/DeviceList.svelte
new file mode 100644
index 0000000..337c2e5
--- /dev/null
+++ b/ui/src/components/DeviceList/DeviceList.svelte
@@ -0,0 +1,44 @@
+
+
+{#if stale}
+ Connection recovered — this list may miss new reports.
+{/if}
+{#if devices.length === 0}
+ No reported subjects retained.
+{:else}
+
+ {#each devices as dev (dev.profile_id)}
+
+ {/each}
+
+{/if}
+{#if error !== null}
+ Page failed ({error}) — earlier rows kept.
+{/if}
+{#if capped}
+ List capped at {cap} rows; use the API for the full set.
+{:else if !exhausted}
+ Load more
+{:else}
+ End of retained list.
+{/if}
diff --git a/ui/src/components/DeviceList/index.js b/ui/src/components/DeviceList/index.js
new file mode 100644
index 0000000..7e0ca6e
--- /dev/null
+++ b/ui/src/components/DeviceList/index.js
@@ -0,0 +1 @@
+export { default } from './DeviceList.svelte';
diff --git a/ui/src/components/DeviceProfile/DeviceProfile.svelte b/ui/src/components/DeviceProfile/DeviceProfile.svelte
new file mode 100644
index 0000000..8d91e9e
--- /dev/null
+++ b/ui/src/components/DeviceProfile/DeviceProfile.svelte
@@ -0,0 +1,35 @@
+
+
+{profile.profile_id} — {classLabel(profile.class)}
+{#if profile.class === 'legacy'}
+ Earlier bridge-inferred association; not used for current identity.
+{:else if profile.class === 'address' || profile.class === 'event'}
+ This page describes {profile.class === 'address' ? 'address context' : 'unresolved events'}, not a device.
+{/if}
+
+ Source scope
+ {profile.source_scope}
+ Upstream reference
+ {profile.reported_id}
+ Reported by
+ {profile.reported_source} (confidence: {profile.reported_confidence || 'unknown'})
+ Association
+ {profile.association_status}
+ {#if profile.operator_label !== null && profile.operator_label !== undefined}
+ Your label
+ {profile.operator_label}
+ {/if}
+ First report
+
+ Last report
+
+
diff --git a/ui/src/components/DeviceProfile/index.js b/ui/src/components/DeviceProfile/index.js
new file mode 100644
index 0000000..d97ed8d
--- /dev/null
+++ b/ui/src/components/DeviceProfile/index.js
@@ -0,0 +1 @@
+export { default } from './DeviceProfile.svelte';
diff --git a/ui/src/components/DeviceRow/DeviceRow.svelte b/ui/src/components/DeviceRow/DeviceRow.svelte
new file mode 100644
index 0000000..370f41b
--- /dev/null
+++ b/ui/src/components/DeviceRow/DeviceRow.svelte
@@ -0,0 +1,17 @@
+
+
+
+
+ ▣
+ {dev.operator_label || dev.reported_id || 'Unlabelled subject'} {dev.profile_id}{classLabel(dev.class)}
+ Last report
+ →
+
+
diff --git a/ui/src/components/DeviceRow/index.js b/ui/src/components/DeviceRow/index.js
new file mode 100644
index 0000000..e798bf5
--- /dev/null
+++ b/ui/src/components/DeviceRow/index.js
@@ -0,0 +1 @@
+export { default } from './DeviceRow.svelte';
diff --git a/ui/src/lib/DigestBar.svelte b/ui/src/components/DigestBar/DigestBar.svelte
similarity index 95%
rename from ui/src/lib/DigestBar.svelte
rename to ui/src/components/DigestBar/DigestBar.svelte
index ec73b99..9c264ae 100644
--- a/ui/src/lib/DigestBar.svelte
+++ b/ui/src/components/DigestBar/DigestBar.svelte
@@ -1,5 +1,5 @@
+
+
+ Summary
+
+
+ Coverage {health.coverage}
+
Fallback briefings · 1h {health.fallback_1h.used} of {health.fallback_1h.total}
+
Awaiting operator {health.parked_groups}
+
Forward errors · 1h {health.forward_errors_1h}
+
Ingest queue {health.queue_depth} of {health.queue_cap}
+
+ {#if (health.coverage_reasons ?? []).length > 0}
+
+ {#each health.coverage_reasons as reason (reason)}
+ {reason}
+ {/each}
+
+ {/if}
+ {#if latestRunNeedsAttention(health.purge.last)}
+
+ Latest purge run {health.purge.last.status}
+ ( ){health.purge.last.error ? `: ${health.purge.last.error}` : ''}.
+
+ {/if}
+ {#if refreshError !== null}
+ Refresh failed ({refreshError}) — showing retained data.
+ {/if}
+
diff --git a/ui/src/components/HealthSummary/index.js b/ui/src/components/HealthSummary/index.js
new file mode 100644
index 0000000..095e05b
--- /dev/null
+++ b/ui/src/components/HealthSummary/index.js
@@ -0,0 +1 @@
+export { default } from './HealthSummary.svelte';
diff --git a/ui/src/components/HistoryList/HistoryList.svelte b/ui/src/components/HistoryList/HistoryList.svelte
new file mode 100644
index 0000000..9b20179
--- /dev/null
+++ b/ui/src/components/HistoryList/HistoryList.svelte
@@ -0,0 +1,45 @@
+
+
+
+ History
+ {#if observations.length === 0}
+ No retained reports in this window.
+ {:else}
+
+ {#each observations as obs (obs.observation_id)}
+
+ {obs.event_id} ({obs.view_role}) —
+ ,
+ {obs.ports_total} ports{obs.ports_truncated ? ' (truncated)' : ''}
+
+ {/each}
+
+ {/if}
+ {#if historyError !== null}
+ Older page failed ({historyError}).
+ {/if}
+ {#if capped}
+ History capped at {cap} rows; use the API for the full set.
+ {:else if !exhausted}
+ Load more reports
+ {:else}
+ End of retained history. Retention policy lives on the Health screen.
+ {/if}
+
diff --git a/ui/src/components/HistoryList/index.js b/ui/src/components/HistoryList/index.js
new file mode 100644
index 0000000..1d4f70c
--- /dev/null
+++ b/ui/src/components/HistoryList/index.js
@@ -0,0 +1 @@
+export { default } from './HistoryList.svelte';
diff --git a/ui/src/components/IdentityGrid/IdentityGrid.svelte b/ui/src/components/IdentityGrid/IdentityGrid.svelte
new file mode 100644
index 0000000..c20691c
--- /dev/null
+++ b/ui/src/components/IdentityGrid/IdentityGrid.svelte
@@ -0,0 +1,30 @@
+
+
+
+ {#if identity !== null && identityComplete}
+
Reported host {identity.hostname ?? 'Not reported'}
+
IP address {identity.ip ?? 'Not reported'}{#if identity.ip !== null} {/if}
+
MAC address {identity.mac ?? 'Not reported'}{#if identity.mac !== null} {/if}
+ {:else}
+ Reported identities See the individual report views below{identityComplete ? 'Grouped changes can describe different host views.' : 'Only part of the retained evidence is shown.'}
+ {/if}
+
Reported scan target {primaryEvent?.target || (eventCount > 1 ? 'Shown per report below' : 'Not reported')}{#if primaryEvent?.target_truncated} (truncated) {/if}
+
{range?.partial || referencedTruncated ? 'Available report times' : eventCount > 1 ? 'Report time range' : 'Report time'} {#if range !== null}{range.local} {:else}Not reported{/if}
+
Analysis source {sourceLabel(source)}
+
diff --git a/ui/src/components/IdentityGrid/index.js b/ui/src/components/IdentityGrid/index.js
new file mode 100644
index 0000000..f894b98
--- /dev/null
+++ b/ui/src/components/IdentityGrid/index.js
@@ -0,0 +1 @@
+export { default } from './IdentityGrid.svelte';
diff --git a/ui/src/components/IncidentEvidence/IncidentEvidence.svelte b/ui/src/components/IncidentEvidence/IncidentEvidence.svelte
new file mode 100644
index 0000000..242f8ed
--- /dev/null
+++ b/ui/src/components/IncidentEvidence/IncidentEvidence.svelte
@@ -0,0 +1,40 @@
+
+
+
+ Evidence
+ {countLine(incident)}
+ {#if incident.qualified_refs < incident.member_count}
+ Some members share identity evidence; only distinct qualified references count toward the burst threshold.
+ {/if}
+ {#if truncated}
+ More members exist than shown.
+ {/if}
+ {#if members.length === 0}
+ No member evidence retained.
+ {:else}
+
+ {#each members as m (m.briefing_id)}
+
+ {m.briefing_id}
+ {m.priority}
+
+ {m.refs.length} qualified reference{m.refs.length === 1 ? '' : 's'}
+
+ {/each}
+
+ {/if}
+
diff --git a/ui/src/components/IncidentEvidence/index.js b/ui/src/components/IncidentEvidence/index.js
new file mode 100644
index 0000000..a6292cd
--- /dev/null
+++ b/ui/src/components/IncidentEvidence/index.js
@@ -0,0 +1 @@
+export { default } from './IncidentEvidence.svelte';
diff --git a/ui/src/components/IncidentHeader/IncidentHeader.svelte b/ui/src/components/IncidentHeader/IncidentHeader.svelte
new file mode 100644
index 0000000..6845d15
--- /dev/null
+++ b/ui/src/components/IncidentHeader/IncidentHeader.svelte
@@ -0,0 +1,25 @@
+
+
+{KIND_LABEL[incident.kind] ?? incident.kind}
+
+ {incident.status}
+ Floor: {incident.floor}
+ {#if incident.status === 'closed' && incident.closed_at}
+ Closed
+ {:else}
+ Open since
+ {/if}
+
diff --git a/ui/src/components/IncidentHeader/index.js b/ui/src/components/IncidentHeader/index.js
new file mode 100644
index 0000000..df254c3
--- /dev/null
+++ b/ui/src/components/IncidentHeader/index.js
@@ -0,0 +1 @@
+export { default } from './IncidentHeader.svelte';
diff --git a/ui/src/components/IncidentScope/IncidentScope.svelte b/ui/src/components/IncidentScope/IncidentScope.svelte
new file mode 100644
index 0000000..81e4ea7
--- /dev/null
+++ b/ui/src/components/IncidentScope/IncidentScope.svelte
@@ -0,0 +1,14 @@
+
+
+
+ Scope
+ Source: {incident.source_scope}
+ Target: {incident.target}
+ Window start: · Last member:
+
diff --git a/ui/src/components/IncidentScope/index.js b/ui/src/components/IncidentScope/index.js
new file mode 100644
index 0000000..906cc1f
--- /dev/null
+++ b/ui/src/components/IncidentScope/index.js
@@ -0,0 +1 @@
+export { default } from './IncidentScope.svelte';
diff --git a/ui/src/components/IncidentSummary/IncidentSummary.svelte b/ui/src/components/IncidentSummary/IncidentSummary.svelte
new file mode 100644
index 0000000..21e5a8c
--- /dev/null
+++ b/ui/src/components/IncidentSummary/IncidentSummary.svelte
@@ -0,0 +1,22 @@
+
+
+
+ Summary
+ Summary status: {summary.status}
+ {#if summary.summary}{summary.summary}
{/if}
+ {#if summary.hypothesis}Hypothesis: {summary.hypothesis}
{/if}
+ {#if summary.confidence}Display confidence: {summary.confidence}
{/if}
+ Priority floor: {summary.floor}. Resolved priority: {summary.final_priority}{summary.suggestion ? ` (suggested ${summary.suggestion}${summary.clamped ? ', clamped' : ''})` : ' (no model raise)'}
+ {#if summary.reason}Priority explanation: {summary.reason}
{/if}
+ Evidence coverage: {summary.sampled} sampled member reports; {summary.omitted} omitted. The sample may not represent every member.
+ {#if summary.citations.length}
+ Cited reports:
+
+ {/if}
+ {#if summary.error}{summary.error}
{/if}
+
diff --git a/ui/src/components/IncidentSummary/index.js b/ui/src/components/IncidentSummary/index.js
new file mode 100644
index 0000000..4d71241
--- /dev/null
+++ b/ui/src/components/IncidentSummary/index.js
@@ -0,0 +1 @@
+export { default } from './IncidentSummary.svelte';
diff --git a/ui/src/components/InvestigatorNotes/InvestigatorNotes.svelte b/ui/src/components/InvestigatorNotes/InvestigatorNotes.svelte
new file mode 100644
index 0000000..a881478
--- /dev/null
+++ b/ui/src/components/InvestigatorNotes/InvestigatorNotes.svelte
@@ -0,0 +1,42 @@
+
+
+
+ {source === 'agent' ? 'Investigator notes' : 'Analysis and retained context'}
+ {#if likelyExplanation}Possible explanation: {likelyExplanation}
{/if}
+ {#if confidence}Explanation confidence (model self-assessment): {confidence}
{/if}
+ {#if limited}Investigation has limits. Some lookups could not establish retained context. Missing lookup results do not prove this host has no history.
{/if}
+ {context}
+
+ {#if evidenceRefs?.length}
+ Cited evidence
+ {#each evidenceRefs as ref, i (`${ref.event_id ?? ref.tool_step}:${i}`)}
+ onJump(ref.event_id ? eventKey(ref.event_id) : stepKey(ref.tool_step))}>{ref.event_id ? `Event ${ref.event_id}` : `Tool step ${ref.tool_step}`}
+ {/each}
+
+ {/if}
+ {#if profiles.length > 0}
+ Linked reported context {#each profiles as link (`${link.profile_id}:${link.role}`)}Open reported context {link.profile_id} ({link.role}) {/each}
+ {:else}No profile links persisted for this briefing.
{/if}
+ {#if profilesTruncated}More profile links exist than shown.
{/if}
+
diff --git a/ui/src/components/InvestigatorNotes/index.js b/ui/src/components/InvestigatorNotes/index.js
new file mode 100644
index 0000000..9546152
--- /dev/null
+++ b/ui/src/components/InvestigatorNotes/index.js
@@ -0,0 +1 @@
+export { default } from './InvestigatorNotes.svelte';
diff --git a/ui/src/components/LatestEvidence/LatestEvidence.svelte b/ui/src/components/LatestEvidence/LatestEvidence.svelte
new file mode 100644
index 0000000..ac6c55a
--- /dev/null
+++ b/ui/src/components/LatestEvidence/LatestEvidence.svelte
@@ -0,0 +1,30 @@
+
+
+
+ Latest evidence
+ {#if latest === null}
+ No retained observation.
+ {:else}
+ Reported ({latest.view_role}).
+
+ {#each Object.entries(latest.attributes ?? {}) as [key, value] (key)}
+ {key}
+ {JSON.stringify(value)}
+ {/each}
+
+ {#if (latest.ports ?? []).length > 0}
+
+ {#each latest.ports as port (`${port.proto}/${port.port}`)}
+ {port.proto}/{port.port} {port.state}{port.service ? ` (${port.service})` : ''}
+ {/each}
+
+ {/if}
+ Snapshot covers {latest.ports_total} ports{latest.ports_truncated ? ' (truncated)' : ''}.
+ {/if}
+
diff --git a/ui/src/components/LatestEvidence/index.js b/ui/src/components/LatestEvidence/index.js
new file mode 100644
index 0000000..8c1e945
--- /dev/null
+++ b/ui/src/components/LatestEvidence/index.js
@@ -0,0 +1 @@
+export { default } from './LatestEvidence.svelte';
diff --git a/ui/src/components/LookupReceipts/LookupReceipts.svelte b/ui/src/components/LookupReceipts/LookupReceipts.svelte
new file mode 100644
index 0000000..2aa7a0b
--- /dev/null
+++ b/ui/src/components/LookupReceipts/LookupReceipts.svelte
@@ -0,0 +1,16 @@
+
+
+{#if receipts?.length}
+ What the investigator could establish
+
+ {#each receipts as receipt, i (i)}
+ • {receipt.summary || 'No interpreted result is available. Open the tool step to inspect the saved response.'}
{#each receipt.steps as step (step)} onJump(stepKey(step))}>View step {step} {/each}
+ {/each}
+
+{/if}
diff --git a/ui/src/components/LookupReceipts/index.js b/ui/src/components/LookupReceipts/index.js
new file mode 100644
index 0000000..2f26e19
--- /dev/null
+++ b/ui/src/components/LookupReceipts/index.js
@@ -0,0 +1 @@
+export { default } from './LookupReceipts.svelte';
diff --git a/ui/src/components/MemberUndoReview/MemberUndoReview.svelte b/ui/src/components/MemberUndoReview/MemberUndoReview.svelte
new file mode 100644
index 0000000..a970bcf
--- /dev/null
+++ b/ui/src/components/MemberUndoReview/MemberUndoReview.svelte
@@ -0,0 +1,23 @@
+
+
+{memberUndoNote(review)}
+{#if review.members.length > 0}
+
+ {#each review.members as m (m.decision_id)}
+ {m.subject_id} — {m.kind.replace('_', ' ')}
+ {/each}
+
+{/if}
+{#if review.next}
+
+ {paging ? 'Loading…' : error ? 'Retry remaining member decisions' : 'Show remaining member decisions'}
+
+{/if}
+{#if error !== null}{error} {/if}
diff --git a/ui/src/components/MemberUndoReview/index.js b/ui/src/components/MemberUndoReview/index.js
new file mode 100644
index 0000000..f64fe7b
--- /dev/null
+++ b/ui/src/components/MemberUndoReview/index.js
@@ -0,0 +1 @@
+export { default } from './MemberUndoReview.svelte';
diff --git a/ui/src/components/Notice/Notice.svelte b/ui/src/components/Notice/Notice.svelte
new file mode 100644
index 0000000..9a53abc
--- /dev/null
+++ b/ui/src/components/Notice/Notice.svelte
@@ -0,0 +1,14 @@
+
+
+
+ {@render children()}
+ {#if actionLabel !== null}
+ {actionLabel}
+ {/if}
+
diff --git a/ui/src/components/Notice/index.js b/ui/src/components/Notice/index.js
new file mode 100644
index 0000000..78f2761
--- /dev/null
+++ b/ui/src/components/Notice/index.js
@@ -0,0 +1 @@
+export { default } from './Notice.svelte';
diff --git a/ui/src/components/Pill/Pill.module.css b/ui/src/components/Pill/Pill.module.css
new file mode 100644
index 0000000..791b1f0
--- /dev/null
+++ b/ui/src/components/Pill/Pill.module.css
@@ -0,0 +1,64 @@
+.pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ padding: 2px 9px;
+ background: var(--subtle);
+ font-size: 12px;
+ color: var(--muted);
+ vertical-align: middle;
+}
+
+.pill.large {
+ padding: 5px 11px;
+}
+
+.pill[data-state=ok],
+.priority[data-priority=low] {
+ color: var(--success);
+ background: var(--success-bg);
+ border-color: var(--success-border);
+}
+
+.pill[data-state=degraded],
+.priority[data-priority=medium] {
+ color: var(--warning);
+ background: var(--warning-bg);
+ border-color: var(--warning-border);
+}
+
+.pill[data-state=blind],
+.priority[data-priority=high],
+.priority[data-priority=critical] {
+ color: var(--danger);
+ background: var(--danger-bg);
+ border-color: var(--danger-border);
+}
+
+.priority {
+ text-transform: uppercase;
+ border-radius: 5px;
+ font-weight: 650;
+ letter-spacing: .03em;
+}
+
+.pill[data-review=unreviewed],
+.pill[data-review=snooze] {
+ color: var(--warning);
+ background: var(--warning-bg);
+ border-color: var(--warning-border);
+}
+
+.pill[data-review=expected] {
+ color: var(--success);
+ background: var(--success-bg);
+ border-color: var(--success-border);
+}
+
+.pill[data-review=not_expected] {
+ color: var(--danger);
+ background: var(--danger-bg);
+ border-color: var(--danger-border);
+}
diff --git a/ui/src/components/Pill/Pill.svelte b/ui/src/components/Pill/Pill.svelte
new file mode 100644
index 0000000..f3c6b19
--- /dev/null
+++ b/ui/src/components/Pill/Pill.svelte
@@ -0,0 +1,10 @@
+
+
+{@render children()}
diff --git a/ui/src/components/Pill/index.js b/ui/src/components/Pill/index.js
new file mode 100644
index 0000000..f93ed98
--- /dev/null
+++ b/ui/src/components/Pill/index.js
@@ -0,0 +1 @@
+export { default } from './Pill.svelte';
diff --git a/ui/src/components/PipelineDetails/PipelineDetails.svelte b/ui/src/components/PipelineDetails/PipelineDetails.svelte
new file mode 100644
index 0000000..c5de091
--- /dev/null
+++ b/ui/src/components/PipelineDetails/PipelineDetails.svelte
@@ -0,0 +1,31 @@
+
+
+
+ Pipeline (process lifetime)
+
+ Started
+
+ Ingest accepted
+ {process.ingest_accepted}
+ Ingest duplicates
+ {process.ingest_duplicates}
+ Ingest rejected
+ {process.ingest_rejected}
+ Ingest saturated
+ {process.ingest_saturated}
+ Provider calls
+ {process.provider_calls}
+ Provider fallbacks
+ {process.provider_fallbacks}
+ Redaction blocked
+ {process.redaction_blocked}
+ Writer saturated
+ {process.writer_saturated}
+
+
diff --git a/ui/src/components/PipelineDetails/index.js b/ui/src/components/PipelineDetails/index.js
new file mode 100644
index 0000000..6983583
--- /dev/null
+++ b/ui/src/components/PipelineDetails/index.js
@@ -0,0 +1 @@
+export { default } from './PipelineDetails.svelte';
diff --git a/ui/src/components/PriorityExplanation/PriorityExplanation.svelte b/ui/src/components/PriorityExplanation/PriorityExplanation.svelte
new file mode 100644
index 0000000..2c9740c
--- /dev/null
+++ b/ui/src/components/PriorityExplanation/PriorityExplanation.svelte
@@ -0,0 +1,32 @@
+
+
+
+ Why this priority
+ {priorityReason}
+ Final priority: {priority} {#if baselineRule}
{baselineRule}{/if}
+
+ How the priority was computed
+
+ Baseline floor {baseline}
+ Model suggestion {modelSuggestion ?? 'No model suggestion — baseline stands'}
+ Final priority {priority}
+
+ {#if clamped}Suggestion adjusted to respect the baseline floor and one-step raise limit.
{/if}
+
+ {#if fallbackUsed}Deterministic fallback text: the model path did not produce this briefing.
{/if}
+
diff --git a/ui/src/components/PriorityExplanation/index.js b/ui/src/components/PriorityExplanation/index.js
new file mode 100644
index 0000000..f460ff4
--- /dev/null
+++ b/ui/src/components/PriorityExplanation/index.js
@@ -0,0 +1 @@
+export { default } from './PriorityExplanation.svelte';
diff --git a/ui/src/components/PublisherDetails/PublisherDetails.svelte b/ui/src/components/PublisherDetails/PublisherDetails.svelte
new file mode 100644
index 0000000..44f584f
--- /dev/null
+++ b/ui/src/components/PublisherDetails/PublisherDetails.svelte
@@ -0,0 +1,17 @@
+
+
+
+ Publisher
+
+ Posted
+ {publisher.posted}
+ Dropped
+ {publisher.dropped}
+ Failed
+ {publisher.failed}
+
+
diff --git a/ui/src/components/PublisherDetails/index.js b/ui/src/components/PublisherDetails/index.js
new file mode 100644
index 0000000..f6e0828
--- /dev/null
+++ b/ui/src/components/PublisherDetails/index.js
@@ -0,0 +1 @@
+export { default } from './PublisherDetails.svelte';
diff --git a/ui/src/components/PurgeDetails/PurgeDetails.svelte b/ui/src/components/PurgeDetails/PurgeDetails.svelte
new file mode 100644
index 0000000..b553e9a
--- /dev/null
+++ b/ui/src/components/PurgeDetails/PurgeDetails.svelte
@@ -0,0 +1,34 @@
+
+
+
+ Purge
+
+ {#each Object.entries(purge.totals ?? {}) as [table, count] (table)}
+ {table}
+ {count}
+ {/each}
+
+ {#if retention}
+
+ Configured retention: {days(retention.reports_seconds)} for reports{#if retention.subjects_seconds !== undefined}, {days(retention.subjects_seconds)} for subjects{/if};
+ policy only — it never proves a per-profile boundary. Ask the API for per-profile history.
+
+ {/if}
+ {#if purge.last_success}
+ Latest fully successful purge: .
+ {:else}
+ No fully successful purge recorded yet.
+ {/if}
+
diff --git a/ui/src/components/PurgeDetails/index.js b/ui/src/components/PurgeDetails/index.js
new file mode 100644
index 0000000..5b91e89
--- /dev/null
+++ b/ui/src/components/PurgeDetails/index.js
@@ -0,0 +1 @@
+export { default } from './PurgeDetails.svelte';
diff --git a/ui/src/components/RecommendedCheck/RecommendedCheck.svelte b/ui/src/components/RecommendedCheck/RecommendedCheck.svelte
new file mode 100644
index 0000000..8cfdb36
--- /dev/null
+++ b/ui/src/components/RecommendedCheck/RecommendedCheck.svelte
@@ -0,0 +1,19 @@
+
+
+
+ ↗ Recommended check Use this check to decide whether further investigation is needed.
+ {check}
+ {#if identity !== null && identityComplete && (identity.ip !== null || identity.mac !== null)}
+ Copy for your investigation
+ {#if identity.ip !== null}{identity.ip} {/if}
+ {#if identity.mac !== null}{identity.mac} {/if}
+
+ {/if}
+
diff --git a/ui/src/components/RecommendedCheck/index.js b/ui/src/components/RecommendedCheck/index.js
new file mode 100644
index 0000000..0dbb119
--- /dev/null
+++ b/ui/src/components/RecommendedCheck/index.js
@@ -0,0 +1 @@
+export { default } from './RecommendedCheck.svelte';
diff --git a/ui/src/components/ReportView/ReportView.module.css b/ui/src/components/ReportView/ReportView.module.css
new file mode 100644
index 0000000..4b15291
--- /dev/null
+++ b/ui/src/components/ReportView/ReportView.module.css
@@ -0,0 +1,95 @@
+.card {
+ border: 1px solid var(--border);
+ border-radius: 11px;
+ padding: 0 16px;
+ margin: 12px 0 0;
+ background: var(--surface);
+ overflow-wrap: anywhere;
+}
+
+.card summary {
+ padding: 16px 0;
+}
+
+.heading {
+ display: inline-flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+ max-width: 100%;
+ vertical-align: top;
+}
+
+.reportId {
+ font-weight: 600;
+}
+
+.reportTime {
+ display: block;
+ font-size: 12px;
+ font-weight: 400;
+ color: var(--muted);
+ margin: 7px 0 0 16px;
+}
+
+.body {
+ padding: 16px 0 6px;
+ border-top: 1px solid var(--border);
+}
+
+.view {
+ border-top: 1px solid var(--border);
+ padding: 16px 0 0;
+ margin: 16px 0;
+}
+
+.view h4 {
+ font-size: 14px;
+}
+
+.identity {
+ display: grid;
+ grid-template-columns: repeat(auto-fit,minmax(min(100%,180px),1fr));
+ gap: 14px;
+ margin: 0 0 16px;
+}
+
+.identity dt {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: .05em;
+ margin-bottom: 4px;
+}
+
+.identity dd {
+ margin: 0;
+}
+
+.confidence {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.attributes {
+ padding: 0;
+ border: 0;
+}
+
+.attributes summary {
+ font-size: 12px;
+ color: var(--accent);
+}
+
+.profileChip {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 5px 12px;
+ border: 1px solid var(--border);
+ background: var(--accent-soft);
+ border-radius: 9px;
+ padding: 10px 12px;
+ text-decoration: none;
+ font-size: 13px;
+}
diff --git a/ui/src/components/ReportView/ReportView.svelte b/ui/src/components/ReportView/ReportView.svelte
new file mode 100644
index 0000000..592b433
--- /dev/null
+++ b/ui/src/components/ReportView/ReportView.svelte
@@ -0,0 +1,47 @@
+
+
+
+
+ {ref.event_id} {ref.event_type}
+ Reported
+
+
+
Reported scan target: {ref.target || 'Not reported'} {#if ref.target_truncated} (truncated){/if} Received
+ {#if views.length === 0}
+
No retained observation for this event. Its reference is retained, but a saved scanner view is unavailable.
+ {:else}
+ {#each views as view (`${view.event_id}:${view.view_role}`)}
+ {@const attrs = view.attributes ?? {}}
+
+ {view.view_role === 'previous' ? 'Before the reported change' : view.view_role === 'current' ? 'After the reported change' : 'Reported host'}
+
+
Hostname {value(attrs.hostname)}
+
IP address {value(attrs.ip)}
+
MAC address {value(attrs.mac)}
+ {#if attrs.vendor}
Reported vendor {attrs.vendor} {/if}
+ {#if attrs.os}
Reported OS {attrs.os} {/if}
+ {#if attrs.device}
Reported device {attrs.device} {/if}
+
+ Scanner-reported identity confidence: {attrs.identity_confidence || 'unknown'} Identity source: {attrs.identity_source || 'unknown'}
+ {randomizedLabel(attrs.randomized)}
+ {#if attrs.attributes_truncated}Some reported fields were truncated at ingest.
{/if}
+ Saved report view · technical attributes {JSON.stringify(attrs, null, 2)}
+ Snapshot covers {view.ports_total} ports{view.ports_truncated ? ' (truncated)' : ''}.
+ {#if (view.ports ?? []).length > 0}
+ {#each view.ports as port (`${port.proto}/${port.port}`)}{port.proto}/{port.port} {port.state}{port.service ? ` (${port.service})` : ''} {/each}
+ {/if}
+ {#if view.profile_id}
+ Open reported context → {classLabel(view.reference_class)} · {profileLinks.find((link) => link.profile_id === view.profile_id)?.role ?? view.view_role}
+ {:else}No linked profile is available for this saved view.
{/if}
+
+ {/each}
+ {/if}
+
+
diff --git a/ui/src/components/ReportView/index.js b/ui/src/components/ReportView/index.js
new file mode 100644
index 0000000..3a4fa63
--- /dev/null
+++ b/ui/src/components/ReportView/index.js
@@ -0,0 +1 @@
+export { default } from './ReportView.svelte';
diff --git a/ui/src/components/ScannerReports/ScannerReports.svelte b/ui/src/components/ScannerReports/ScannerReports.svelte
new file mode 100644
index 0000000..5b27418
--- /dev/null
+++ b/ui/src/components/ScannerReports/ScannerReports.svelte
@@ -0,0 +1,23 @@
+
+
+
+ Scanner snapshot{events.length === 1 ? '' : 's'} Saved at report time
+ Open a report to see what the scanner supplied, with its linked context.
+ {#if events.length === 0}No referenced events retained.
+ {:else}{#each events as ref (ref.event_id)} {/each}{/if}
+ {#if referencedTruncated}More referenced events exist than shown.
{/if}
+ {#if observationsTruncated}More observations exist than shown; some saved views are omitted.
{/if}
+
diff --git a/ui/src/components/ScannerReports/index.js b/ui/src/components/ScannerReports/index.js
new file mode 100644
index 0000000..33eab3d
--- /dev/null
+++ b/ui/src/components/ScannerReports/index.js
@@ -0,0 +1 @@
+export { default } from './ScannerReports.svelte';
diff --git a/ui/src/components/ShortcutsHelp/ShortcutsHelp.module.css b/ui/src/components/ShortcutsHelp/ShortcutsHelp.module.css
new file mode 100644
index 0000000..d73ac1b
--- /dev/null
+++ b/ui/src/components/ShortcutsHelp/ShortcutsHelp.module.css
@@ -0,0 +1,7 @@
+.help {
+ margin: 16px 28px;
+ padding: 20px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+}
diff --git a/ui/src/components/ShortcutsHelp/ShortcutsHelp.svelte b/ui/src/components/ShortcutsHelp/ShortcutsHelp.svelte
new file mode 100644
index 0000000..29534a6
--- /dev/null
+++ b/ui/src/components/ShortcutsHelp/ShortcutsHelp.svelte
@@ -0,0 +1,25 @@
+
+
+
+ Keyboard shortcuts
+
+ {#each globalKeys as binding (binding.key)}
+ {binding.key} — {binding.label}
+ {/each}
+ g then h — Home
+ g then d — Reported hosts
+ {#each briefingKeys as binding (binding.key)}
+ {binding.key} — on a briefing: {binding.label}, then confirm
+ {/each}
+
+ Shortcuts stay silent while typing. Briefing keys preset the composer; nothing submits on its own.
+
diff --git a/ui/src/components/ShortcutsHelp/index.js b/ui/src/components/ShortcutsHelp/index.js
new file mode 100644
index 0000000..4081759
--- /dev/null
+++ b/ui/src/components/ShortcutsHelp/index.js
@@ -0,0 +1 @@
+export { default } from './ShortcutsHelp.svelte';
diff --git a/ui/src/components/SkipLink/SkipLink.module.css b/ui/src/components/SkipLink/SkipLink.module.css
new file mode 100644
index 0000000..bf4c8f4
--- /dev/null
+++ b/ui/src/components/SkipLink/SkipLink.module.css
@@ -0,0 +1,12 @@
+.skip {
+ position: fixed;
+ left: 16px;
+ top: -100px;
+ z-index: 10;
+ padding: 12px;
+ background: var(--surface);
+}
+
+.skip:focus {
+ top: 12px;
+}
diff --git a/ui/src/components/SkipLink/SkipLink.svelte b/ui/src/components/SkipLink/SkipLink.svelte
new file mode 100644
index 0000000..20fbdd4
--- /dev/null
+++ b/ui/src/components/SkipLink/SkipLink.svelte
@@ -0,0 +1,8 @@
+
+
+ { event.preventDefault(); document.getElementById('content')?.focus(); }}>Skip to content
diff --git a/ui/src/components/SkipLink/index.js b/ui/src/components/SkipLink/index.js
new file mode 100644
index 0000000..add57cc
--- /dev/null
+++ b/ui/src/components/SkipLink/index.js
@@ -0,0 +1 @@
+export { default } from './SkipLink.svelte';
diff --git a/ui/src/components/SnapshotNote/SnapshotNote.svelte b/ui/src/components/SnapshotNote/SnapshotNote.svelte
new file mode 100644
index 0000000..121a887
--- /dev/null
+++ b/ui/src/components/SnapshotNote/SnapshotNote.svelte
@@ -0,0 +1,3 @@
+
+i Reported at the time shown. Network conditions may have changed since this report.
diff --git a/ui/src/components/SnapshotNote/index.js b/ui/src/components/SnapshotNote/index.js
new file mode 100644
index 0000000..7d99db1
--- /dev/null
+++ b/ui/src/components/SnapshotNote/index.js
@@ -0,0 +1 @@
+export { default } from './SnapshotNote.svelte';
diff --git a/ui/src/components/SourceCard/SourceCard.svelte b/ui/src/components/SourceCard/SourceCard.svelte
new file mode 100644
index 0000000..414c7f3
--- /dev/null
+++ b/ui/src/components/SourceCard/SourceCard.svelte
@@ -0,0 +1,50 @@
+
+
+
+ {source.scope} — {source.status}
+
+ Liveness
+ {source.liveness}{source.liveness === 'unknown' ? ' (no heartbeat capability; silence is not absence)' : ''}
+ Identity namespace
+ {source.identity_namespace || 'unknown — stable identity contract unavailable'}
+ Identity capability
+ {source.capabilities || 'not reported — heartbeat is not identity proof'}
+ Heartbeat
+
+ {source.heartbeat_enabled ? 'enabled (delivery opt-in, not identity proof)' : 'disabled'}
+ {#if source.last_heartbeat_at}— last at {/if}
+
+ {#if source.heartbeat}
+ Last completed scan
+
+ Scan result
+ {source.heartbeat.status}, {source.heartbeat.issue_count} {source.heartbeat.issue_count === 1 ? 'issue' : 'issues'}, {source.heartbeat.duration_ms} ms
+ Reported host records
+ {source.heartbeat.hosts_seen} — scan records, not a current inventory or device count
+ Signing clock offset
+ {source.heartbeat.clock_skew_seconds} seconds (includes delivery delay)
+ {/if}
+ {#if source.last_report_at}
+ Last report
+
+ {/if}
+ {#if source.delivery_status}
+ Delivery
+ {source.delivery_status}
+ {/if}
+
+ {#if (source.reasons ?? []).length > 0}
+
+ {#each source.reasons as reason (reason)}
+ {reason}
+ {/each}
+
+ {/if}
+
diff --git a/ui/src/components/SourceCard/index.js b/ui/src/components/SourceCard/index.js
new file mode 100644
index 0000000..e92eb36
--- /dev/null
+++ b/ui/src/components/SourceCard/index.js
@@ -0,0 +1 @@
+export { default } from './SourceCard.svelte';
diff --git a/ui/src/lib/SourceCoverage.svelte b/ui/src/components/SourceCoverage/SourceCoverage.svelte
similarity index 88%
rename from ui/src/lib/SourceCoverage.svelte
rename to ui/src/components/SourceCoverage/SourceCoverage.svelte
index 93e2a86..6ce3cc8 100644
--- a/ui/src/lib/SourceCoverage.svelte
+++ b/ui/src/components/SourceCoverage/SourceCoverage.svelte
@@ -1,4 +1,5 @@
+
+
+ Sources ({sources.length})
+ {#if sources.length === 0}
+ No sources registered.
+ {:else}
+ {#each sources as source (source.scope)}
+
+ {/each}
+ {/if}
+
diff --git a/ui/src/components/SourceList/index.js b/ui/src/components/SourceList/index.js
new file mode 100644
index 0000000..e328c96
--- /dev/null
+++ b/ui/src/components/SourceList/index.js
@@ -0,0 +1 @@
+export { default } from './SourceList.svelte';
diff --git a/ui/src/components/TechnicalDetails/TechnicalDetails.svelte b/ui/src/components/TechnicalDetails/TechnicalDetails.svelte
new file mode 100644
index 0000000..51f533c
--- /dev/null
+++ b/ui/src/components/TechnicalDetails/TechnicalDetails.svelte
@@ -0,0 +1,36 @@
+
+
+
+ Technical details Tool calls, accounting and provenance
+ Executed tool steps
+ {#if traces?.length}
{#each traces as step (step.step)} {/each} {:else}
No agent trace is available for this briefing.
{/if}
+ {#if inputTokens != null || outputTokens != null || costUsd != null}
+
Model accounting {inputTokens ?? 'Unknown'} input tokens · {outputTokens ?? 'Unknown'} output tokens · {costUsd == null ? 'Cost unknown' : `$${costUsd}`}
+ {/if}
+
Provenance
+ Briefing {briefingId} Group {groupId}
+ Grouping version {groupingVersion} Sequence {seq}
+ Created
+ Grouping anchor {host}
+
+
+
diff --git a/ui/src/components/TechnicalDetails/index.js b/ui/src/components/TechnicalDetails/index.js
new file mode 100644
index 0000000..86ad10d
--- /dev/null
+++ b/ui/src/components/TechnicalDetails/index.js
@@ -0,0 +1 @@
+export { default } from './TechnicalDetails.svelte';
diff --git a/ui/src/components/TimeStamp/TimeStamp.svelte b/ui/src/components/TimeStamp/TimeStamp.svelte
new file mode 100644
index 0000000..2f93042
--- /dev/null
+++ b/ui/src/components/TimeStamp/TimeStamp.svelte
@@ -0,0 +1,10 @@
+
+
+{at.local}
diff --git a/ui/src/components/TimeStamp/index.js b/ui/src/components/TimeStamp/index.js
new file mode 100644
index 0000000..a0074d4
--- /dev/null
+++ b/ui/src/components/TimeStamp/index.js
@@ -0,0 +1 @@
+export { default } from './TimeStamp.svelte';
diff --git a/ui/src/components/TraceStep/TraceStep.module.css b/ui/src/components/TraceStep/TraceStep.module.css
new file mode 100644
index 0000000..68a0cbb
--- /dev/null
+++ b/ui/src/components/TraceStep/TraceStep.module.css
@@ -0,0 +1,15 @@
+.step {
+ list-style: none;
+ border-top: 1px solid var(--border);
+ padding: 12px 0;
+}
+
+.toolName {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.step p {
+ margin: 8px 0;
+}
diff --git a/ui/src/lib/TraceStep.svelte b/ui/src/components/TraceStep/TraceStep.svelte
similarity index 71%
rename from ui/src/lib/TraceStep.svelte
rename to ui/src/components/TraceStep/TraceStep.svelte
index 90ff04a..e406d8b 100644
--- a/ui/src/lib/TraceStep.svelte
+++ b/ui/src/components/TraceStep/TraceStep.svelte
@@ -1,4 +1,6 @@
-
- {step.tool}
- {#if step.outcome}{step.outcome.replaceAll('_', ' ')} {/if}
+
+ {step.tool}
+ {#if step.outcome}{step.outcome.replaceAll('_', ' ')} {/if}
step {step.step} · {step.status} · {step.duration_ms} ms
{headline}
@@ -31,18 +33,3 @@
-
diff --git a/ui/src/components/TraceStep/index.js b/ui/src/components/TraceStep/index.js
new file mode 100644
index 0000000..52e3e71
--- /dev/null
+++ b/ui/src/components/TraceStep/index.js
@@ -0,0 +1 @@
+export { default } from './TraceStep.svelte';
diff --git a/ui/src/components/VerdictHeader/VerdictHeader.module.css b/ui/src/components/VerdictHeader/VerdictHeader.module.css
new file mode 100644
index 0000000..eebdec5
--- /dev/null
+++ b/ui/src/components/VerdictHeader/VerdictHeader.module.css
@@ -0,0 +1,8 @@
+.list {
+ list-style: none;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ padding: 0;
+ margin: 0 0 12px;
+}
diff --git a/ui/src/lib/VerdictHeader.svelte b/ui/src/components/VerdictHeader/VerdictHeader.svelte
similarity index 79%
rename from ui/src/lib/VerdictHeader.svelte
rename to ui/src/components/VerdictHeader/VerdictHeader.svelte
index 1cf10a4..4d5620b 100644
--- a/ui/src/lib/VerdictHeader.svelte
+++ b/ui/src/components/VerdictHeader/VerdictHeader.svelte
@@ -1,5 +1,7 @@
+
+{#if notice !== null && shown === null}
+
+ Briefing unavailable
+ The bridge did not return this briefing ({notice}).
+ Retry
+
+{:else if shown === null}
+ Loading briefing…
+{:else}
+
+ {#if layout === 'page'}← Back to Home
{/if}
+ {#if notice !== null}
+ The saved view could not be refreshed. Your last loaded report is still shown.
+ {/if}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
0}
+ context={shown.context}
+ receipts={receipts}
+ evidenceRefs={shown.evidence_refs}
+ profiles={shown.profiles}
+ profilesTruncated={shown.profiles_truncated}
+ onJump={jumpTo}
+ />
+
+
+
+
+{/if}
diff --git a/ui/src/containers/BriefingContainer/index.js b/ui/src/containers/BriefingContainer/index.js
new file mode 100644
index 0000000..3365d9d
--- /dev/null
+++ b/ui/src/containers/BriefingContainer/index.js
@@ -0,0 +1 @@
+export { default } from './BriefingContainer.svelte';
diff --git a/ui/src/containers/DecisionsContainer/DecisionsContainer.module.css b/ui/src/containers/DecisionsContainer/DecisionsContainer.module.css
new file mode 100644
index 0000000..f710152
--- /dev/null
+++ b/ui/src/containers/DecisionsContainer/DecisionsContainer.module.css
@@ -0,0 +1,7 @@
+.card {
+ padding: 26px;
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ overflow-wrap: anywhere;
+}
diff --git a/ui/src/containers/DecisionsContainer/DecisionsContainer.svelte b/ui/src/containers/DecisionsContainer/DecisionsContainer.svelte
new file mode 100644
index 0000000..93f773a
--- /dev/null
+++ b/ui/src/containers/DecisionsContainer/DecisionsContainer.svelte
@@ -0,0 +1,293 @@
+
+
+
+
+
+ {#if memberReview !== null}
+
+ {/if}
+ 0}
+ {note}
+ {listError}
+ {paging}
+ {undoPending}
+ {card}
+ onUndo={undo}
+ onOlder={older}
+ onNewer={newer}
+ />
+
diff --git a/ui/src/containers/DecisionsContainer/index.js b/ui/src/containers/DecisionsContainer/index.js
new file mode 100644
index 0000000..8598db5
--- /dev/null
+++ b/ui/src/containers/DecisionsContainer/index.js
@@ -0,0 +1 @@
+export { default } from './DecisionsContainer.svelte';
diff --git a/ui/src/containers/DeviceContainer/DeviceContainer.svelte b/ui/src/containers/DeviceContainer/DeviceContainer.svelte
new file mode 100644
index 0000000..79874f0
--- /dev/null
+++ b/ui/src/containers/DeviceContainer/DeviceContainer.svelte
@@ -0,0 +1,127 @@
+
+
+{#if $error !== null && profile === null}
+
+ Reported host unavailable
+ The bridge did not return this profile ({$error}).
+ Back to reported hosts Retry
+
+{:else if profile === null}
+ Loading reported host…
+{:else}
+
+ Back to reported hosts
+ {#if stale}
+ Connection recovered — this page may miss new reports.
+ {/if}
+ {#if notice !== null}
+ Refresh failed ({notice}) — showing retained data.
+ {/if}
+
+
+
+
+
+{/if}
diff --git a/ui/src/containers/DeviceContainer/index.js b/ui/src/containers/DeviceContainer/index.js
new file mode 100644
index 0000000..de9b3a1
--- /dev/null
+++ b/ui/src/containers/DeviceContainer/index.js
@@ -0,0 +1 @@
+export { default } from './DeviceContainer.svelte';
diff --git a/ui/src/containers/DevicesContainer/DevicesContainer.svelte b/ui/src/containers/DevicesContainer/DevicesContainer.svelte
new file mode 100644
index 0000000..82210f3
--- /dev/null
+++ b/ui/src/containers/DevicesContainer/DevicesContainer.svelte
@@ -0,0 +1,105 @@
+
+
+
+ Reported hosts
+ Scanner-reported subjects. Classes distinguish device references, address context, unresolved events and legacy profiles.
+ {#if loading}
+ Loading reported hosts…
+ {:else if $error !== null && empty}
+ The bridge did not return reported hosts ({$error}).
+ Retry
+ {:else}
+
+ {/if}
+
diff --git a/ui/src/containers/DevicesContainer/index.js b/ui/src/containers/DevicesContainer/index.js
new file mode 100644
index 0000000..2412d16
--- /dev/null
+++ b/ui/src/containers/DevicesContainer/index.js
@@ -0,0 +1 @@
+export { default } from './DevicesContainer.svelte';
diff --git a/ui/src/containers/HealthContainer/HealthContainer.svelte b/ui/src/containers/HealthContainer/HealthContainer.svelte
new file mode 100644
index 0000000..60af62e
--- /dev/null
+++ b/ui/src/containers/HealthContainer/HealthContainer.svelte
@@ -0,0 +1,78 @@
+
+
+
+ Health
+ Source coverage, processing, and retention. Liveness describes the source, not host presence.
+ {#if $error !== null && shown === null}
+ The bridge did not return health ({$error}).
+ resource.refresh()}>Retry
+ {:else if shown === null}
+ Loading health…
+ {:else}
+ resource.refresh()} />
+
+
+
+ {#if shown.publisher}
+
+ {/if}
+ {/if}
+
diff --git a/ui/src/containers/HealthContainer/index.js b/ui/src/containers/HealthContainer/index.js
new file mode 100644
index 0000000..57056b5
--- /dev/null
+++ b/ui/src/containers/HealthContainer/index.js
@@ -0,0 +1 @@
+export { default } from './HealthContainer.svelte';
diff --git a/ui/src/lib/Home.svelte b/ui/src/containers/HomeContainer/HomeContainer.svelte
similarity index 69%
rename from ui/src/lib/Home.svelte
rename to ui/src/containers/HomeContainer/HomeContainer.svelte
index 8774a32..f09ae41 100644
--- a/ui/src/lib/Home.svelte
+++ b/ui/src/containers/HomeContainer/HomeContainer.svelte
@@ -1,19 +1,24 @@
+
+Back to overview
+
+{#if notice !== null}
+ Refresh failed: {notice}
+{/if}
+{#if shown === null}
+ Loading incident…
+{:else}
+ {@const inc = shown.incident}
+
+
+
+ {#if shown.summary}
+
+ {/if}
+
+
+{/if}
diff --git a/ui/src/containers/IncidentContainer/index.js b/ui/src/containers/IncidentContainer/index.js
new file mode 100644
index 0000000..b06203d
--- /dev/null
+++ b/ui/src/containers/IncidentContainer/index.js
@@ -0,0 +1 @@
+export { default } from './IncidentContainer.svelte';
diff --git a/ui/src/containers/IncidentMembersContainer/IncidentMembersContainer.svelte b/ui/src/containers/IncidentMembersContainer/IncidentMembersContainer.svelte
new file mode 100644
index 0000000..7388fbd
--- /dev/null
+++ b/ui/src/containers/IncidentMembersContainer/IncidentMembersContainer.svelte
@@ -0,0 +1,109 @@
+
+
+{#if expanded}
+ {#if loading && members === undefined}
+ Loading members…
+ {:else if loadError !== undefined}
+ {loadError}
+ {:else if members !== undefined}
+
+ {#each members as m (m.briefing_id)}
+
+ {m.briefing_id}
+ {m.priority}
+
+ {/each}
+
+ {/if}
+{/if}
diff --git a/ui/src/containers/IncidentMembersContainer/index.js b/ui/src/containers/IncidentMembersContainer/index.js
new file mode 100644
index 0000000..a316e8e
--- /dev/null
+++ b/ui/src/containers/IncidentMembersContainer/index.js
@@ -0,0 +1 @@
+export { default } from './IncidentMembersContainer.svelte';
diff --git a/ui/src/lib/AttentionList.svelte b/ui/src/lib/AttentionList.svelte
deleted file mode 100644
index 5d1c3f1..0000000
--- a/ui/src/lib/AttentionList.svelte
+++ /dev/null
@@ -1,121 +0,0 @@
-
-
-
- Needs attention ({items.length})
- {#if items.length === 0}
- Nothing flagged. New briefings appear here when the bridge reports them.
- {:else}
-
- {#each items as item (item.id)}
-
- {#snippet row()}
- {item.priority}
- {item.kind === 'incident' ? 'Incident · ' : ''}{item.kind === 'briefing' ? (titles[item.id] ?? reviewTitle(item.change_kind)) : changeLabel(item.change_kind)} {item.id}
- {item.status} {new Date(item.created_at).toLocaleString()}
- {/snippet}
- {#if item.kind === 'briefing' || item.kind === 'incident'}
- {#if onselect}
- onselect(item)}>{@render row()}
- {:else}
- {@render row()}
- {/if}
- {:else}
- {@render row()}
- {/if}
- {#if item.kind === 'incident'}
- toggleMembers(item.id)}
- >
- {expanded[item.id] ? 'Hide members' : expanding[item.id] ? 'Loading members…' : 'Show members'}
-
- {#if expandError[item.id] !== undefined}
- {expandError[item.id]}
- {/if}
- {#if (expanded[item.id] ?? false) && memberCache[item.id] !== undefined}
-
- {#each memberCache[item.id] as m (m.briefing_id)}
-
- {m.briefing_id}
- {m.priority}
-
- {/each}
-
- {/if}
- {/if}
-
- {/each}
-
- {/if}
-
diff --git a/ui/src/lib/DecisionPanel.svelte b/ui/src/lib/DecisionPanel.svelte
deleted file mode 100644
index 624bba4..0000000
--- a/ui/src/lib/DecisionPanel.svelte
+++ /dev/null
@@ -1,445 +0,0 @@
-
-
-
- {requireChoice ? 'Your decision' : 'Operator decisions'}
- Status: {statusLine()}
-
-
-
- Choose a decision
- {#each kinds as k (k)}
-
- (chosen = k)} />
- {k === 'not_expected' ? 'Not expected' : k.charAt(0).toUpperCase() + k.slice(1)}
- {#if requireChoice}{({ expected: 'no further action', not_expected: 'investigate', snooze: 'hide until later', note: 'annotation only' })[k]} {/if}
-
- {/each}
-
- {kind === null ? 'Choose a verdict to record it.' : (kindHelp[kind] ?? KIND_HELP[kind] ?? '')}
- {#if memberScope}
-
-
- Also apply to the {memberCount} member briefing{memberCount === 1 ? '' : 's'} (snapshot now; later joiners stay untouched)
-
- {/if}
- {#if outcomeNote !== null}
- {outcomeNote}
- {/if}
- {#if kind === 'snooze'}
-
- Until (empty keeps the server default)
-
-
- {/if}
- {#if needsText}
-
- {kind === 'label' ? 'Label' : 'Note'}
-
-
- {/if}
- {#if formError !== null}
- {formError}
- {/if}
-
- {pending ? 'Recording…' : 'Record decision'}
-
-
-
- {#if memberReview !== null}
- {memberUndoNote(memberReview)}
- {#if memberReview.members.length > 0}
-
- {#each memberReview.members as m (m.decision_id)}
- {m.subject_id} — {m.kind.replace('_', ' ')}
- {/each}
-
- {/if}
- {#if memberReview.next}
-
- {memberPaging ? 'Loading…' : memberError ? 'Retry remaining member decisions' : 'Show remaining member decisions'}
-
- {/if}
- {#if memberError !== null}{memberError}
{/if}
- {/if}
-
- {#if requireChoice}Recorded decisions {/if}
- {#if trail.length === 0}
- No decisions recorded.
- {:else}
-
- {#each trail as d (d.decision_id)}
-
- {d.kind.replace('_', ' ')}
- {#if d.undone}undone {/if}
- {#if d.text}{d.text} {/if}
- {#if d.until}until {d.until} {/if}
- {new Date(d.created_at).toLocaleString()}
- {#if !d.undone && d.kind !== 'undo'}
- undo(d.decision_id)}
- disabled={undoPending !== null}
- >
- {undoPending === d.decision_id ? 'Undoing…' : 'Undo'}
-
- {/if}
-
- {/each}
-
- {#if !exhausted}
-
- {paging ? 'Loading…' : 'Show older decisions'}
-
- {/if}
- {#if extraKey === headKey && extra.length > 0}
- Show newer decisions
- {/if}
- {#if note !== null}
- {note}
- {/if}
- {/if}
- {#if listError !== null}
- {listError}
- {/if}
-
-
-
diff --git a/ui/src/lib/Detail.svelte b/ui/src/lib/Detail.svelte
deleted file mode 100644
index f2deeb9..0000000
--- a/ui/src/lib/Detail.svelte
+++ /dev/null
@@ -1,353 +0,0 @@
-
-
-{#if error !== null && shown === null}
-
- Briefing unavailable
- The bridge did not return this briefing ({error}).
- Retry
-
-{:else if shown === null}
- Loading briefing…
-{:else}
-
- {#if !embedded}← Back to Home
{/if}
- {#if error !== null}
- The saved view could not be refreshed. Your last loaded report is still shown. Retry refresh
- {/if}
-
-
-
-
-
{title}
- {shown.priority} priority
-
- {shown.what_changed}
-
- {#if identity !== null && identityComplete}
-
Reported host {identity.hostname ?? 'Not reported'}
-
IP address {identity.ip ?? 'Not reported'}{#if identity.ip !== null} {/if}
-
MAC address {identity.mac ?? 'Not reported'}{#if identity.mac !== null} {/if}
- {:else}
- Reported identities See the individual report views below{identityComplete ? 'Grouped changes can describe different host views.' : 'Only part of the retained evidence is shown.'}
- {/if}
-
Reported scan target {primaryEvent?.target || (shown.referenced_events.length > 1 ? 'Shown per report below' : 'Not reported')}{#if primaryEvent?.target_truncated} (truncated) {/if}
-
{range?.partial || shown.referenced_truncated ? 'Available report times' : shown.referenced_events.length > 1 ? 'Report time range' : 'Report time'} {#if range !== null}{range.local} {:else}Not reported{/if}
-
Analysis source {sourceLabel(shown.source)}
-
-
- {shown.referenced_events.length}{shown.referenced_truncated ? '+' : ''} source report{shown.referenced_events.length === 1 ? '' : 's'}
- {#if primaryEvent}
- jumpTo(`event:${primaryEvent.event_id}`)}>View scanner snapshot →
- {:else if shown.referenced_events.length > 0}
- jumpTo('reports')}>View saved reports →
- {/if}
-
-
-
-
i Reported at the time shown. Network conditions may have changed since this report.
-
-
- ↗ Recommended check Use this check to decide whether further investigation is needed.
- {shown.recommended_check}
- {#if identity !== null && identityComplete && (identity.ip !== null || identity.mac !== null)}
- Copy for your investigation
- {#if identity.ip !== null}{identity.ip} {/if}
- {#if identity.mac !== null}{identity.mac} {/if}
-
- {/if}
-
-
-
-
-
- Why this priority
- {shown.priority_reason}
- Final priority: {shown.priority} {#if shown.baseline_rule}{shown.baseline_rule}{/if}
-
- How the priority was computed
-
- Baseline floor {shown.baseline}
- Model suggestion {shown.model_suggestion ?? 'No model suggestion — baseline stands'}
- Final priority {shown.priority}
-
- {#if shown.clamped}Suggestion adjusted to respect the baseline floor and one-step raise limit.
{/if}
-
- {#if shown.fallback_used}Deterministic fallback text: the model path did not produce this briefing.
{/if}
-
-
-
-
Scanner snapshot{shown.referenced_events.length === 1 ? '' : 's'} Saved at report time
- Open a report to see what the scanner supplied, with its linked context.
- {#if shown.referenced_events.length === 0}No referenced events retained.
- {:else}{#each shown.referenced_events as ref (ref.event_id)} {/each}{/if}
- {#if shown.referenced_truncated}More referenced events exist than shown.
{/if}
- {#if shown.observations_truncated}More observations exist than shown; some saved views are omitted.
{/if}
-
-
-
- {shown.source === 'agent' ? 'Investigator notes' : 'Analysis and retained context'}
- {#if shown.likely_explanation}Possible explanation: {shown.likely_explanation}
{/if}
- {#if shown.confidence}Explanation confidence (model self-assessment): {shown.confidence}
{/if}
- {#if lookupLimits.length > 0}Investigation has limits. Some lookups could not establish retained context. Missing lookup results do not prove this host has no history.
{/if}
- {shown.context}
- {#if shown.traces?.length}
- What the investigator could establish
-
- {#each lookupReceipts as receipt, i (i)}
- • {receipt.summary || 'No interpreted result is available. Open the tool step to inspect the saved response.'}
{#each receipt.steps as step (step)} jumpTo(`step:${step}`)}>View step {step} {/each}
- {/each}
-
- {/if}
- {#if shown.evidence_refs?.length}
- Cited evidence
- {#each shown.evidence_refs as ref, i (`${ref.event_id ?? ref.tool_step}:${i}`)}
- jumpTo(ref.event_id ? `event:${ref.event_id}` : `step:${ref.tool_step}`)}>{ref.event_id ? `Event ${ref.event_id}` : `Tool step ${ref.tool_step}`}
- {/each}
-
- {/if}
- {#if shown.profiles.length > 0}
- Linked reported context {#each shown.profiles as link (`${link.profile_id}:${link.role}`)}Open reported context {link.profile_id} ({link.role}) {/each}
- {:else}No profile links persisted for this briefing.
{/if}
- {#if shown.profiles_truncated}More profile links exist than shown.
{/if}
-
-
-
- Technical details Tool calls, accounting and provenance
- Executed tool steps
- {#if shown.traces?.length}
{#each shown.traces as step (step.step)} {/each} {:else}
No agent trace is available for this briefing.
{/if}
- {#if shown.input_tokens != null || shown.output_tokens != null || shown.cost_usd != null}
-
Model accounting {shown.input_tokens ?? 'Unknown'} input tokens · {shown.output_tokens ?? 'Unknown'} output tokens · {shown.cost_usd == null ? 'Cost unknown' : `$${shown.cost_usd}`}
- {/if}
-
Provenance
- Briefing {shown.briefing_id} Group {shown.group_id}
- Grouping version {shown.grouping_version} Sequence {shown.seq}
- Created {created?.local}
- Grouping anchor {shown.host}
-
-
-
-
-
-{/if}
-
-
diff --git a/ui/src/lib/Device.svelte b/ui/src/lib/Device.svelte
deleted file mode 100644
index afc786d..0000000
--- a/ui/src/lib/Device.svelte
+++ /dev/null
@@ -1,215 +0,0 @@
-
-
-{#if error !== null && profile === null}
-
- Reported host unavailable
- The bridge did not return this profile ({error}).
- Back to reported hosts Retry
-
-{:else if profile === null}
- Loading reported host…
-{:else}
- {@const latestTime = profile.latest === null ? null : displayTime(profile.latest.reported_at)}
-
- Back to reported hosts
- {#if stale}
- Connection recovered — this page may miss new reports. Reload
- {/if}
- {#if refreshError !== null}
- Refresh failed ({refreshError}) — showing retained data. Retry
- {/if}
- {profile.profile_id} — {classLabel(profile.class)}
- {#if profile.class === 'legacy'}
- Earlier bridge-inferred association; not used for current identity.
- {:else if profile.class === 'address' || profile.class === 'event'}
- This page describes {profile.class === 'address' ? 'address context' : 'unresolved events'}, not a device.
- {/if}
-
- Source scope
- {profile.source_scope}
- Upstream reference
- {profile.reported_id}
- Reported by
- {profile.reported_source} (confidence: {profile.reported_confidence || 'unknown'})
- Association
- {profile.association_status}
- {#if profile.operator_label !== null && profile.operator_label !== undefined}
- Your label
- {profile.operator_label}
- {/if}
- First report
- {first?.local}
- Last report
- {last?.local}
-
-
-
-
-
- Latest evidence
- {#if profile.latest === null}
- No retained observation.
- {:else}
- Reported {latestTime?.local} ({profile.latest.view_role}).
-
- {#each Object.entries(profile.latest.attributes ?? {}) as [key, value] (key)}
- {key}
- {JSON.stringify(value)}
- {/each}
-
- {#if (profile.latest.ports ?? []).length > 0}
-
- {#each profile.latest.ports as port (`${port.proto}/${port.port}`)}
- {port.proto}/{port.port} {port.state}{port.service ? ` (${port.service})` : ''}
- {/each}
-
- {/if}
- Snapshot covers {profile.latest.ports_total} ports{profile.latest.ports_truncated ? ' (truncated)' : ''}.
- {/if}
-
-
-
- History
- {#if history.length === 0}
- No retained reports in this window.
- {:else}
-
- {#each history as obs (obs.observation_id)}
- {@const at = displayTime(obs.reported_at)}
-
- {obs.event_id} ({obs.view_role}) —
- {at.local} ,
- {obs.ports_total} ports{obs.ports_truncated ? ' (truncated)' : ''}
-
- {/each}
-
- {/if}
- {#if historyError !== null}
- Older page failed ({historyError}). Retry
- {/if}
- {#if historyCapped}
- History capped at {HISTORY_CAP} rows; use the API for the full set.
- {:else if !historyExhausted}
- Load more reports
- {:else}
- End of retained history. Retention policy lives on the Health screen.
- {/if}
-
-
-{/if}
diff --git a/ui/src/lib/Devices.svelte b/ui/src/lib/Devices.svelte
deleted file mode 100644
index 1f2344d..0000000
--- a/ui/src/lib/Devices.svelte
+++ /dev/null
@@ -1,146 +0,0 @@
-
-
-
- Reported hosts
- Scanner-reported subjects. Classes distinguish device references, address context, unresolved events and legacy profiles.
- {#if loading && devices.length === 0}
- Loading reported hosts…
- {:else if error !== null && devices.length === 0}
- The bridge did not return reported hosts ({error}).
- loadMore()}>Retry
- {:else}
- {#if stale}
- Connection recovered — this list may miss new reports. Reload
- {/if}
- {#if devices.length === 0}
- No reported subjects retained.
- {:else}
-
- {/if}
- {#if error !== null}
- Page failed ({error}) — earlier rows kept. loadMore(resetting)}>Retry
- {/if}
- {#if capped}
- List capped at {ROW_CAP} rows; use the API for the full set.
- {:else if !exhausted}
- loadMore()}>Load more
- {:else}
- End of retained list.
- {/if}
- {/if}
-
diff --git a/ui/src/lib/Health.svelte b/ui/src/lib/Health.svelte
deleted file mode 100644
index 30b5b02..0000000
--- a/ui/src/lib/Health.svelte
+++ /dev/null
@@ -1,225 +0,0 @@
-
-
-
- Health
- Source coverage, processing, and retention. Liveness describes the source, not host presence.
- {#if error !== null && shown === null}
- The bridge did not return health ({error}).
- Retry
- {:else if shown === null}
- Loading health…
- {:else}
-
- Summary
-
-
Coverage {shown.coverage}
-
Fallback briefings · 1h {shown.fallback_1h.used} of {shown.fallback_1h.total}
-
Awaiting operator {shown.parked_groups}
-
Forward errors · 1h {shown.forward_errors_1h}
-
Ingest queue {shown.queue_depth} of {shown.queue_cap}
-
- {#if (shown.coverage_reasons ?? []).length > 0}
-
- {#each shown.coverage_reasons as reason (reason)}
- {reason}
- {/each}
-
- {/if}
- {#if latestRunNeedsAttention(shown.purge.last)}
- {@const finished = displayTime(shown.purge.last.finished_at ?? shown.purge.last.started_at)}
-
- Latest purge run {shown.purge.last.status}
- ({finished.local}){shown.purge.last.error ? `: ${shown.purge.last.error}` : ''}.
-
- {/if}
- {#if refreshError !== null}
- Refresh failed ({refreshError}) — showing retained data. Retry
- {/if}
-
-
-
- Sources ({shown.sources.length})
- {#if shown.sources.length === 0}
- No sources registered.
- {:else}
- {#each shown.sources as src (src.scope)}
-
- {src.scope} — {src.status}
-
- Liveness
- {src.liveness}{src.liveness === 'unknown' ? ' (no heartbeat capability; silence is not absence)' : ''}
- Identity namespace
- {src.identity_namespace || 'unknown — stable identity contract unavailable'}
- Identity capability
- {src.capabilities || 'not reported — heartbeat is not identity proof'}
- Heartbeat
-
- {src.heartbeat_enabled ? 'enabled (delivery opt-in, not identity proof)' : 'disabled'}
- {#if src.last_heartbeat_at}— last at {displayTime(src.last_heartbeat_at).local}{/if}
-
- {#if src.heartbeat}
- {@const scan = displayTime(src.heartbeat.completed_at)}
- Last completed scan
- {scan.local}
- Scan result
- {src.heartbeat.status}, {src.heartbeat.issue_count} {src.heartbeat.issue_count === 1 ? 'issue' : 'issues'}, {src.heartbeat.duration_ms} ms
- Reported host records
- {src.heartbeat.hosts_seen} — scan records, not a current inventory or device count
- Signing clock offset
- {src.heartbeat.clock_skew_seconds} seconds (includes delivery delay)
- {/if}
- {#if src.last_report_at}
- {@const at = displayTime(src.last_report_at)}
- Last report
- {at.local}
- {/if}
- {#if src.delivery_status}
- Delivery
- {src.delivery_status}
- {/if}
-
- {#if (src.reasons ?? []).length > 0}
-
- {#each src.reasons as reason (reason)}
- {reason}
- {/each}
-
- {/if}
-
- {/each}
- {/if}
-
-
-
- Pipeline (process lifetime)
-
- Started
- {displayTime(shown.process.started_at).local}
- Ingest accepted
- {shown.process.ingest_accepted}
- Ingest duplicates
- {shown.process.ingest_duplicates}
- Ingest rejected
- {shown.process.ingest_rejected}
- Ingest saturated
- {shown.process.ingest_saturated}
- Provider calls
- {shown.process.provider_calls}
- Provider fallbacks
- {shown.process.provider_fallbacks}
- Redaction blocked
- {shown.process.redaction_blocked}
- Writer saturated
- {shown.process.writer_saturated}
-
-
-
-
- Purge
-
- {#each Object.entries(shown.purge.totals ?? {}) as [table, count] (table)}
- {table}
- {count}
- {/each}
-
- {#if shown.retention}
-
- Configured retention: {days(shown.retention.reports_seconds)} for reports{#if shown.retention.subjects_seconds !== undefined}, {days(shown.retention.subjects_seconds)} for subjects{/if};
- policy only — it never proves a per-profile boundary. Ask the API for per-profile history.
-
- {/if}
- {#if shown.purge.last_success}
- {@const ok = displayTime(shown.purge.last_success.finished_at ?? shown.purge.last_success.started_at)}
- Latest fully successful purge: {ok.local}.
- {:else}
- No fully successful purge recorded yet.
- {/if}
-
-
- {#if shown.publisher}
-
- Publisher
-
- Posted
- {shown.publisher.posted}
- Dropped
- {shown.publisher.dropped}
- Failed
- {shown.publisher.failed}
-
-
- {/if}
- {/if}
-
diff --git a/ui/src/lib/Incident.svelte b/ui/src/lib/Incident.svelte
deleted file mode 100644
index de0785f..0000000
--- a/ui/src/lib/Incident.svelte
+++ /dev/null
@@ -1,170 +0,0 @@
-
-
-Back to overview
-
-{#if error !== null}
- Refresh failed: {error} Retry
-{/if}
-{#if shown === null}
- Loading incident…
-{:else}
- {@const inc = shown.incident}
- {@const opened = displayTime(inc.opened_at)}
- {@const closed = displayTime(inc.closed_at)}
- {@const start = displayTime(inc.window_start)}
- {@const last = displayTime(inc.last_member_at)}
- {KIND_LABEL[inc.kind] ?? inc.kind}
-
- {inc.status}
- Floor: {inc.floor}
- {#if inc.status === 'closed' && inc.closed_at}
- Closed {closed.local}
- {:else}
- Open since {opened.local}
- {/if}
-
-
-
- Scope
- Source: {inc.source_scope}
- Target: {inc.target}
- Window start: {start.local} · Last member: {last.local}
-
-
-
- Evidence
- {countLine(inc)}
- {#if inc.qualified_refs < inc.member_count}
- Some members share identity evidence; only distinct qualified references count toward the burst threshold.
- {/if}
- {#if shown.truncated}
- More members exist than shown.
- {/if}
- {#if shown.members.length === 0}
- No member evidence retained.
- {:else}
-
- {#each shown.members as m (m.briefing_id)}
- {@const reported = displayTime(m.created_at)}
-
- {m.briefing_id}
- {m.priority}
- {reported.local}
- {m.refs.length} qualified reference{m.refs.length === 1 ? '' : 's'}
-
- {/each}
-
- {/if}
-
-
- {#if shown.summary}
- {@const summary = shown.summary}
-
- Summary
- Summary status: {summary.status}
- {#if summary.summary}{summary.summary}
{/if}
- {#if summary.hypothesis}Hypothesis: {summary.hypothesis}
{/if}
- {#if summary.confidence}Display confidence: {summary.confidence}
{/if}
- Priority floor: {summary.floor}. Resolved priority: {summary.final_priority}{summary.suggestion ? ` (suggested ${summary.suggestion}${summary.clamped ? ', clamped' : ''})` : ' (no model raise)'}
- {#if summary.reason}Priority explanation: {summary.reason}
{/if}
- Evidence coverage: {summary.sampled} sampled member reports; {summary.omitted} omitted. The sample may not represent every member.
- {#if summary.citations.length}
- Cited reports:
-
- {/if}
- {#if summary.error}{summary.error}
{/if}
-
- {/if}
-
-
-{/if}
diff --git a/ui/src/lib/ReportView.svelte b/ui/src/lib/ReportView.svelte
deleted file mode 100644
index 86052d4..0000000
--- a/ui/src/lib/ReportView.svelte
+++ /dev/null
@@ -1,65 +0,0 @@
-
-
-
-
- {ref.event_id} {ref.event_type}
- Reported {reported.local}
-
-
-
Reported scan target: {ref.target || 'Not reported'} {#if ref.target_truncated} (truncated){/if} Received {received.local}
- {#if views.length === 0}
-
No retained observation for this event. Its reference is retained, but a saved scanner view is unavailable.
- {:else}
- {#each views as view (`${view.event_id}:${view.view_role}`)}
- {@const attrs = view.attributes ?? {}}
-
- {view.view_role === 'previous' ? 'Before the reported change' : view.view_role === 'current' ? 'After the reported change' : 'Reported host'}
-
-
Hostname {value(attrs.hostname)}
-
IP address {value(attrs.ip)}
-
MAC address {value(attrs.mac)}
- {#if attrs.vendor}
Reported vendor {attrs.vendor} {/if}
- {#if attrs.os}
Reported OS {attrs.os} {/if}
- {#if attrs.device}
Reported device {attrs.device} {/if}
-
- Scanner-reported identity confidence: {attrs.identity_confidence || 'unknown'} Identity source: {attrs.identity_source || 'unknown'}
- {randomizedLabel(attrs.randomized)}
- {#if attrs.attributes_truncated}Some reported fields were truncated at ingest.
{/if}
- Saved report view · technical attributes {JSON.stringify(attrs, null, 2)}
- Snapshot covers {view.ports_total} ports{view.ports_truncated ? ' (truncated)' : ''}.
- {#if (view.ports ?? []).length > 0}
- {#each view.ports as port (`${port.proto}/${port.port}`)}{port.proto}/{port.port} {port.state}{port.service ? ` (${port.service})` : ''} {/each}
- {/if}
- {#if view.profile_id}
- Open reported context → {classLabel(view.reference_class)} · {profileLinks.find((link) => link.profile_id === view.profile_id)?.role ?? view.view_role}
- {:else}No linked profile is available for this saved view.
{/if}
-
- {/each}
- {/if}
-
-
-
-
diff --git a/ui/src/lib/briefingView.js b/ui/src/lib/briefingView.js
new file mode 100644
index 0000000..a2b88ee
--- /dev/null
+++ b/ui/src/lib/briefingView.js
@@ -0,0 +1,54 @@
+// Pure briefing dossier derivations (Designv9 §8.2). No markup, no
+// fetch: BriefingContainer calls these, and scripts/characterize.mjs
+// covers them in plain node.
+
+/**
+ * The priority comparison opens when the reader needs it: equal
+ * baseline, suggestion and final stay collapsed; a difference or a
+ * clamp opens the detail.
+ * @param {{clamped?: boolean, baseline?: string, priority?: string, model_suggestion?: string|null}|null} shown
+ */
+export function priorityDiffers(shown) {
+ return shown !== null &&
+ (shown.clamped || shown.baseline !== shown.priority ||
+ (shown.model_suggestion ?? shown.baseline) !== shown.priority);
+}
+
+/**
+ * Lookup steps that could not establish retained context: unknown
+ * subjects, blocked or failed lookups stay explicit limitations.
+ * @param {Array<{outcome?: string}>} traces
+ */
+export function lookupLimits(traces) {
+ return (traces ?? []).filter((step) =>
+ ['unknown_subject', 'blocked', 'error', 'unavailable', 'truncated'].includes(step.outcome));
+}
+
+/**
+ * Group tool steps into lookup receipts by their summary, preserving
+ * first-seen order.
+ * @param {Array<{summary?: string, step?: number}>} traces
+ */
+export function lookupReceipts(traces) {
+ const groups = [];
+ for (const step of traces ?? []) {
+ const key = step.summary || `step:${step.step}`;
+ let group = groups.find((receipt) => receipt.key === key);
+ if (!group) {
+ group = { key, summary: step.summary, steps: [] };
+ groups.push(group);
+ }
+ group.steps.push(step.step);
+ }
+ return groups;
+}
+
+/**
+ * The single-view identity grid renders only when every observation
+ * and event is retained; a truncated dossier falls back to per-report
+ * views with an explicit notice.
+ * @param {{observations_truncated?: boolean, referenced_truncated?: boolean}|null} shown
+ */
+export function identityComplete(shown) {
+ return shown !== null && !shown.observations_truncated && !shown.referenced_truncated;
+}
diff --git a/ui/src/lib/context.js b/ui/src/lib/context.js
new file mode 100644
index 0000000..ebe897a
--- /dev/null
+++ b/ui/src/lib/context.js
@@ -0,0 +1,34 @@
+// Svelte context glue for the single event stream. The store object
+// itself is framework-free (`./stream.js`); this file only publishes
+// it to the component tree. Call `setConnection` once in App setup,
+// `getConnection` in any screen — never a module-level singleton, so
+// prerender stays connection-free.
+
+import { setContext, getContext } from 'svelte';
+
+const KEY = Symbol('watcherbridge.connection');
+
+/** @param {ReturnType} stream */
+export function setConnection(stream) {
+ setContext(KEY, stream);
+}
+
+/** @returns {ReturnType} */
+export function getConnection() {
+ return getContext(KEY);
+}
+
+/**
+ * The connection, or null when rendered outside App (SSR harness,
+ * standalone screens). Screens use this to read connection state and
+ * the incident revision from context instead of prop drilling; the
+ * null path keeps direct renders working without a provider.
+ * @returns {ReturnType|null}
+ */
+export function getConnectionOrNull() {
+ try {
+ return getConnection();
+ } catch {
+ return null;
+ }
+}
diff --git a/ui/src/lib/evidence.js b/ui/src/lib/evidence.js
new file mode 100644
index 0000000..13a1786
--- /dev/null
+++ b/ui/src/lib/evidence.js
@@ -0,0 +1,17 @@
+// Evidence navigation keys: the contract between dossier sections
+// and revealEvidence (../evidenceNavigation.js). Sections tag targets
+// with data-evidence keys; buttons jump by key without changing the
+// hash route. Keys are `event:`, `step:` or the
+// literal reports key for the scanner-reports section.
+
+export const REPORTS_KEY = 'reports';
+
+/** @param {string} eventId */
+export function eventKey(eventId) {
+ return `event:${eventId}`;
+}
+
+/** @param {number|string} step */
+export function stepKey(step) {
+ return `step:${step}`;
+}
diff --git a/ui/src/lib/incidentEvents.js b/ui/src/lib/incidentEvents.js
deleted file mode 100644
index 87dc473..0000000
--- a/ui/src/lib/incidentEvents.js
+++ /dev/null
@@ -1,17 +0,0 @@
-// Incident updates have no replay cursor. An opened connection and a
-// resync invalidate local incident state just like a live update does.
-export function forwardIncidentEvents(source, target, refreshOverview) {
- const refresh = () => {
- target.dispatchEvent(new Event('watcherbridge:incident'));
- refreshOverview();
- };
- const events = ['open', 'incident', 'resync'];
- for (const event of events) {
- source.addEventListener(event, refresh);
- }
- return () => {
- for (const event of events) {
- source.removeEventListener(event, refresh);
- }
- };
-}
diff --git a/ui/src/lib/latestOnly.js b/ui/src/lib/latestOnly.js
new file mode 100644
index 0000000..7d2607a
--- /dev/null
+++ b/ui/src/lib/latestOnly.js
@@ -0,0 +1,26 @@
+// Latest-wins guard for one async operation. Each operation (reload,
+// load-more, decision page, …) owns an instance, so intents never
+// cancel each other — the flaw BUG-1 pins in screens that share a
+// single guard. Stream-frame fencing lives in lib/stream.js, not here.
+
+/**
+ * @returns {{start: () => number, current: (token: number) => boolean, invalidate: () => void}}
+ */
+export function latestOnly() {
+ let gen = 0;
+ return {
+ // Call when starting work; keep the token for completion.
+ start() {
+ gen += 1;
+ return gen;
+ },
+ // True only when no newer work started (or invalidated) since.
+ current(token) {
+ return token === gen;
+ },
+ // Void every in-flight token: unmounts, route changes, resets.
+ invalidate() {
+ gen += 1;
+ }
+ };
+}
diff --git a/ui/src/lib/pager.js b/ui/src/lib/pager.js
new file mode 100644
index 0000000..0dcedae
--- /dev/null
+++ b/ui/src/lib/pager.js
@@ -0,0 +1,92 @@
+// Cursor pager with merge-by-key, a row cap and per-operation fencing.
+// resetGen orders reloads; pageGen invalidates appends. A reload
+// landing after a page start still applies (the BUG-1 case), but a
+// committed reset retires every older append, so a late page cannot
+// reopen an exhausted list. Only a successful commit clears the error
+// slot and voids pending appends: a failed reset changes nothing, so
+// a pending append against the still-valid cursor lands normally.
+//
+// Unlike createResource, the pager seeds from the mount-time snapshot:
+// cursor and merge live here, so the container passes `initial` once and
+// never rewrites it. A reset retires the snapshot by replacing rows.
+// `initial` also accepts a thunk returning the snapshot: .svelte callers
+// must read the prop inside a closure, or the compiler's init-capture
+// rule treats it as a stale local capture.
+
+import { writable, derived, get } from 'svelte/store';
+import { nextPageRows } from '@/lib/paging.js';
+
+/**
+ * @template T
+ * @param {{fetchPage: (cursor: string) => Promise<{rows: T[], nextAfter: string}>, keyOf: (row: T) => string|number, cap: number, initial?: {rows: T[], nextAfter: string}|(() => ({rows: T[], nextAfter: string}|null))|null}} options
+ */
+export function createPager({ fetchPage, keyOf, cap, initial = null }) {
+ const snap = typeof initial === 'function' ? initial() : initial;
+ const rows = writable(snap ? [...snap.rows] : []);
+ const cursor = writable(snap ? (snap.nextAfter ?? '') : '');
+ const started = writable(snap !== null);
+ const failure = writable(/** @type {{message: string, reset: boolean}|null} */ (null));
+ const error = derived(failure, ($failure) => $failure?.message ?? null);
+ const loadingMore = writable(false);
+ const exhausted = derived([cursor, started], ([$cursor, $started]) => $started && $cursor === '');
+ const capped = derived([rows, cursor], ([$rows, $cursor]) => $cursor !== '' && $rows.length >= cap);
+ let resetGen = 0;
+ let pageGen = 0;
+
+ async function loadMore(reset = false) {
+ const my = reset ? (resetGen += 1) : (pageGen += 1);
+ const gen = () => (reset ? resetGen : pageGen);
+ failure.set(null);
+ loadingMore.set(true);
+ try {
+ const page = await fetchPage(reset ? '' : get(cursor));
+ if (my !== gen()) {
+ return 'stale';
+ }
+ const base = reset ? [] : get(rows);
+ const fresh = nextPageRows(base, page.rows, keyOf, cap);
+ rows.set(reset ? fresh : [...base, ...fresh]);
+ cursor.set(page.nextAfter ?? '');
+ started.set(true);
+ if (reset) {
+ // A committed reset owns the list now: clear the error slot
+ // (a previous page failure refers to the old state) and retire
+ // every append requested against an older cursor, so a late
+ // page cannot reopen an exhausted list. Appends requested after
+ // this commit use the new cursor and are unaffected.
+ failure.set(null);
+ pageGen += 1;
+ }
+ return 'applied';
+ } catch (err) {
+ if (my !== gen()) {
+ return 'stale';
+ }
+ failure.set({ message: err instanceof Error ? err.message : String(err), reset });
+ return 'failed';
+ } finally {
+ if (my === gen()) {
+ loadingMore.set(false);
+ }
+ }
+ }
+
+ function reset() {
+ return loadMore(true);
+ }
+
+ // Replace every row outright (profile reload with embedded history):
+ // voids in-flight pages, clears the error and restarts from the new
+ // cursor. Failures never reach here — only applied data replaces.
+ function replace(nextRows, nextAfter = '') {
+ resetGen += 1;
+ pageGen += 1;
+ failure.set(null);
+ loadingMore.set(false);
+ rows.set([...nextRows]);
+ cursor.set(nextAfter ?? '');
+ started.set(true);
+ }
+
+ return { rows, cursor, started, exhausted, capped, failure, error, loadingMore, loadMore, reset, replace };
+}
diff --git a/ui/src/lib/payload.js b/ui/src/lib/payload.js
new file mode 100644
index 0000000..b6969fb
--- /dev/null
+++ b/ui/src/lib/payload.js
@@ -0,0 +1,36 @@
+// Decision submission payloads (Designv9 §5.1). The container owns
+// the post; this pure step shapes and validates the draft so plain
+// node covers the error paths the harness cannot mount: a bad snooze
+// date never reaches the network, and text/member rules are explicit.
+
+/**
+ * @param {{kind: string, untilText?: string, text?: string, includeMembers?: boolean}} draft
+ * @param {{subjectKind: string, subjectId: string, requestId: string, memberScope?: boolean}} ctx
+ * @returns {{payload?: Record, error?: string}}
+ */
+export function buildDecisionPayload(draft, ctx) {
+ if (draft.kind === 'snooze' && (draft.untilText ?? '') !== '') {
+ const at = new Date(draft.untilText);
+ if (Number.isNaN(at.getTime())) {
+ return { error: 'snooze time is not a date' };
+ }
+ return { payload: { ...base(ctx, draft), until: at.toISOString() } };
+ }
+ const payload = base(ctx, draft);
+ if (draft.kind === 'note' || draft.kind === 'label') {
+ payload.text = draft.text ?? '';
+ }
+ if (ctx.memberScope) {
+ payload.includeMembers = draft.includeMembers ?? false;
+ }
+ return { payload };
+}
+
+function base(ctx, draft) {
+ return {
+ subjectKind: ctx.subjectKind,
+ subjectId: ctx.subjectId,
+ kind: draft.kind,
+ requestId: ctx.requestId
+ };
+}
diff --git a/ui/src/lib/reconnect.js b/ui/src/lib/reconnect.js
new file mode 100644
index 0000000..5315b0a
--- /dev/null
+++ b/ui/src/lib/reconnect.js
@@ -0,0 +1,11 @@
+// Reconnect-edge decision: refresh when the stream comes (back) live
+// and there is rendered content — fetched data or a mounted snapshot.
+// A snapshot is renderable state, not a reason to stay stale. Pure so
+// it is unit-tested; the container owns the wasLive tracking.
+
+/**
+ * @param {{live: boolean, wasLive: boolean, data: unknown, initial: unknown}} state
+ */
+export function shouldRefreshOnReconnect({ live, wasLive, data, initial }) {
+ return live && !wasLive && (data ?? initial) !== null;
+}
diff --git a/ui/src/lib/registry.js b/ui/src/lib/registry.js
new file mode 100644
index 0000000..942e072
--- /dev/null
+++ b/ui/src/lib/registry.js
@@ -0,0 +1,50 @@
+// Scoped keyboard shortcut registry. Screens register their keys with
+// a human label; the help panel is generated from the same table, so
+// documented and working shortcuts cannot drift apart. Dispatch stays
+// silent while typing and never claims modifier combos. Multi-key
+// sequences (`g h`, `g d`) stay in the App shell — they are navigation,
+// not screen actions.
+
+import { typingTarget } from '@/lib/roving.js';
+
+export function createShortcutRegistry() {
+ // scope -> key -> { label, handler }
+ const bindings = new Map();
+
+ /**
+ * @param {string} scope e.g. 'global', 'briefing'
+ * @param {string} key single-key value, e.g. 'j', '?', 'e'
+ * @param {string} label help-panel wording
+ * @param {(ev: KeyboardEvent) => void} handler
+ */
+ function register(scope, key, label, handler) {
+ if (!bindings.has(scope)) {
+ bindings.set(scope, new Map());
+ }
+ bindings.get(scope).set(key, { label, handler });
+ }
+
+ /**
+ * @param {string} scope
+ * @param {KeyboardEvent} ev
+ * @returns {boolean} true when a binding handled the event
+ */
+ function dispatch(scope, ev) {
+ if (typingTarget(ev) || ev.metaKey || ev.ctrlKey || ev.altKey) {
+ return false;
+ }
+ const binding = bindings.get(scope)?.get(ev.key);
+ if (!binding) {
+ return false;
+ }
+ binding.handler(ev);
+ return true;
+ }
+
+ /** @param {string} scope */
+ function describe(scope) {
+ return [...(bindings.get(scope)?.entries() ?? [])].map(([key, binding]) => ({ key, label: binding.label }));
+ }
+
+ return { register, dispatch, describe };
+}
diff --git a/ui/src/lib/resource.js b/ui/src/lib/resource.js
new file mode 100644
index 0000000..c00bb50
--- /dev/null
+++ b/ui/src/lib/resource.js
@@ -0,0 +1,82 @@
+// Single-item async resource with snapshot support. Replaces the
+// initial/retired plumbing in screens: a snapshot fills `data` until
+// (or instead of) a fetch, a failed refresh keeps data with a notice,
+// and only a data-less failure becomes an error. One guard per
+// resource; concurrent refreshes are latest-wins.
+//
+// Plain JS over svelte/store (no runes, no .svelte.js): the stores run
+// uncompiled, so scripts/characterize.mjs covers this in plain node
+// with no test runner. Components subscribe with `$data` as usual.
+
+import { writable, get } from 'svelte/store';
+
+/**
+ * Fetch-owned data with a failed-refresh notice. The mount-time
+ * snapshot (if any) stays a container concern: containers render
+ * `data ?? snapshot`, so the resource never captures props at init.
+ * @template T
+ * @param {() => Promise} fetcher
+ */
+export function createResource(fetcher) {
+ const data = writable(null);
+ const error = writable(null);
+ const refreshError = writable(null);
+ const loading = writable(false);
+ const guard = { gen: 0 };
+
+ async function refresh() {
+ const my = (guard.gen += 1);
+ refreshError.set(null);
+ if (get(data) === null) {
+ error.set(null);
+ loading.set(true);
+ }
+ try {
+ const fresh = await fetcher();
+ if (my !== guard.gen) {
+ return 'stale';
+ }
+ data.set(fresh);
+ error.set(null);
+ refreshError.set(null);
+ return 'applied';
+ } catch (err) {
+ if (my !== guard.gen) {
+ return 'stale';
+ }
+ const message = err instanceof Error ? err.message : String(err);
+ if (get(data) === null) {
+ error.set(message);
+ } else {
+ refreshError.set(message);
+ }
+ return 'failed';
+ } finally {
+ if (my === guard.gen) {
+ loading.set(false);
+ }
+ }
+ }
+
+ function invalidate() {
+ guard.gen += 1;
+ loading.set(false);
+ }
+
+ return { data, error, refreshError, loading, refresh, invalidate };
+}
+
+/**
+ * Which failure (if any) is visible next to rendered content. A
+ * data-less failure renders as the error state, never a notice; a
+ * snapshot counts as rendered content, so its failures must surface
+ * too — otherwise a failed refresh on a snapshot screen is silent.
+ * @param {{data: unknown, initial: unknown, error: string|null, refreshError: string|null}} state
+ * @returns {string|null} notice text, or null when nothing may show
+ */
+export function failureNotice({ data = null, initial = null, error = null, refreshError = null } = {}) {
+ if ((data ?? initial) === null) {
+ return null;
+ }
+ return refreshError ?? (data === null ? error : null);
+}
diff --git a/ui/src/lib/routes.js b/ui/src/lib/routes.js
new file mode 100644
index 0000000..45d4659
--- /dev/null
+++ b/ui/src/lib/routes.js
@@ -0,0 +1,41 @@
+// Hash route table. Single source of truth for route names, patterns
+// and document titles — replaces App's inline parseRoute and title
+// map. Matching behavior is unchanged: first match wins, briefing and
+// incident IDs are decoded, anything unrecognized is home.
+
+const routes = [
+ { name: 'briefing', pattern: /^#\/briefing\/(.+)$/ },
+ { name: 'incident', pattern: /^#\/incidents\/(.+)$/ },
+ { name: 'device', pattern: /^#\/devices\/(.+)$/ },
+ { name: 'devices', pattern: /^#\/devices\/?$/ },
+ { name: 'health', pattern: /^#\/health$/ }
+];
+
+const titles = {
+ home: 'Review overview',
+ devices: 'Reported hosts',
+ device: 'Reported host detail',
+ briefing: 'Briefing',
+ incident: 'Incident',
+ health: 'Health'
+};
+
+/**
+ * @param {string|null|undefined} hash
+ * @returns {{name: string, id?: string}}
+ */
+export function parseRoute(hash) {
+ const h = hash || '#/';
+ for (const route of routes) {
+ const m = h.match(route.pattern);
+ if (m) {
+ return m[1] === undefined ? { name: route.name } : { name: route.name, id: decodeURIComponent(m[1]) };
+ }
+ }
+ return { name: 'home' };
+}
+
+/** @param {string} name */
+export function routeTitle(name) {
+ return titles[name] ?? titles.home;
+}
diff --git a/ui/src/lib/stream.js b/ui/src/lib/stream.js
new file mode 100644
index 0000000..73a7aa8
--- /dev/null
+++ b/ui/src/lib/stream.js
@@ -0,0 +1,133 @@
+// SSE connection lifecycle for GET /events. Transport only: connection
+// state, overview frames and an incident revision counter. Parsing
+// failures degrade to the next frame; refresh ownership stays with the
+// screens, which subscribe to `incidentRevision` for refetch/markStale.
+// No module singletons: App creates one instance and shares it through
+// `./context.js`, so prerender never connects.
+//
+// `open`/`incident`/`resync` all mean "incident state may have
+// changed". Screens subscribe to `incidentRevision` from context, so
+// no window-event bus remains.
+//
+// Reconnects are native while the source is still connecting: on a
+// mid-stream drop the source stays open and the browser retries with
+// Last-Event-ID, so the server resumes bounded replay instead of
+// restarting the stream. A rejected handshake (non-200, e.g. the 503
+// subscriber cap) moves the source to CLOSED, where native retry is
+// dead — that path gets an explicit reconnect after 3s. Closing and
+// recreating a live source would drop the cursor (a fresh EventSource
+// sends no Last-Event-ID).
+
+import { writable } from 'svelte/store';
+
+export const INCIDENT_KINDS = ['open', 'incident', 'resync'];
+
+/**
+ * @param {object} [options]
+ * @param {string} [options.url] stream URL
+ * @param {number} [options.retryMs] explicit reconnect delay after a rejected handshake (CLOSED)
+ * @param {(url: string) => EventSource} [options.createSource] transport factory (fake in tests)
+ * @param {(fn: () => void, ms: number) => unknown} [options.schedule] retry scheduler (manual in tests)
+ * @param {(t: unknown) => void} [options.cancelSchedule]
+ * @param {(overview: unknown) => void} [options.onOverview] valid overview frames only
+ * @param {(kind: string) => void} [options.onIncident] every open/incident/resync frame
+ */
+export function createEventStream({
+ url = '/events',
+ retryMs = 3000,
+ createSource = (u) => new EventSource(u),
+ schedule = (fn, ms) => setTimeout(fn, ms),
+ cancelSchedule = (t) => clearTimeout(t),
+ onOverview = () => {},
+ onIncident = () => {}
+} = {}) {
+ const connection = writable('connecting');
+ const incidentRevision = writable(0);
+ let source = null;
+ let timer = null;
+ // Generation fences late callbacks after disconnect/reconnect, the
+ // same stale-continuation rule screens apply to fetches.
+ let gen = 0;
+
+ function cleanup() {
+ gen += 1;
+ if (timer !== null) {
+ cancelSchedule(timer);
+ timer = null;
+ }
+ if (source !== null) {
+ source.onopen = null;
+ source.onmessage = null;
+ source.onerror = null;
+ for (const kind of [...INCIDENT_KINDS, 'overview']) {
+ source.removeEventListener?.(kind, listeners[kind]);
+ }
+ source.close();
+ source = null;
+ }
+ }
+
+ // Named listeners so cleanup removes exactly what connect added.
+ const listeners = {};
+ for (const kind of INCIDENT_KINDS) {
+ listeners[kind] = () => {
+ incidentRevision.update((n) => n + 1);
+ onIncident(kind);
+ };
+ }
+ listeners.overview = (/** @type {MessageEvent} */ ev) => {
+ try {
+ onOverview(JSON.parse(ev.data));
+ } catch {
+ // Malformed frame degrades to the next refresh, never a crash.
+ }
+ };
+
+ function connect() {
+ cleanup();
+ const my = gen;
+ connection.set('connecting');
+ const next = createSource(url);
+ source = next;
+ next.onopen = () => {
+ if (my === gen) {
+ connection.set('live');
+ }
+ };
+ for (const kind of [...INCIDENT_KINDS, 'overview']) {
+ next.addEventListener(kind, listeners[kind]);
+ }
+ next.onerror = () => {
+ if (my !== gen) {
+ return;
+ }
+ if (next.readyState === 2 /* CLOSED: handshake rejected, e.g. the 503 cap */) {
+ // Native retry is dead on a closed source, so reconnect
+ // explicitly after the specified delay. The fresh handshake
+ // carries no Last-Event-ID; the server answers that with a
+ // fresh snapshot and overview, and the client loads state as
+ // on first load.
+ cleanup();
+ connection.set('retrying');
+ const mine = gen;
+ timer = schedule(() => {
+ if (mine === gen) {
+ connect();
+ }
+ }, retryMs);
+ return;
+ }
+ // Still connecting (mid-stream drop): leave the source open so
+ // the browser's native retry carries Last-Event-ID and the
+ // server resumes instead of restarting. The next onopen flips
+ // back to live; disconnect() still closes explicitly.
+ connection.set('retrying');
+ };
+ }
+
+ function disconnect() {
+ cleanup();
+ }
+
+ return { connection, incidentRevision, connect, disconnect };
+}
diff --git a/ui/src/main.js b/ui/src/main.js
index 2d09e34..9f19042 100644
--- a/ui/src/main.js
+++ b/ui/src/main.js
@@ -1,6 +1,10 @@
import { mount } from 'svelte';
import App from './App.svelte';
-import './style.css';
+import './styles/tokens.css';
+import './styles/base.css';
+import './styles/utilities.css';
+import './styles/dossier.css';
+import './styles/shell.css';
// Step 22 entry: mounts the shell only. Hash routes and screens land
// in Steps 23-24; the entry shape stays stable so they slot in here.
diff --git a/ui/src/style.css b/ui/src/styles/base.css
similarity index 78%
rename from ui/src/style.css
rename to ui/src/styles/base.css
index 24aefed..1c244b7 100644
--- a/ui/src/style.css
+++ b/ui/src/styles/base.css
@@ -1,27 +1,6 @@
+/* Global element defaults. Component classes move out in Phases 3-5; see inventory in the Phase 1 report. */
/* Shared local-only viewer styles; no remote fonts or inline assets. */
-:root {
- font: 14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
- color: var(--text);
- background: var(--canvas);
- --canvas: #f4f5f7;
- --surface: #fff;
- --subtle: #f8f9fb;
- --text: #14171c;
- --muted: #535d6b;
- --border: #d9dde3;
- --accent: #1b4f9c;
- --accent-soft: #e8eef7;
- --success: #225b38;
- --success-bg: #e8f3ec;
- --success-border: #9ec4aa;
- --warning: #6b4700;
- --warning-bg: #fff4d6;
- --warning-border: #d9b94f;
- --danger: #9e302c;
- --danger-bg: #fcecea;
- --danger-border: #dea49d;
- color-scheme: light dark;
-}
+
* {
box-sizing: border-box;
}
@@ -145,39 +124,8 @@ time {
color: var(--muted);
font-size: 13px;
}
-.pill {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- border: 1px solid var(--border);
- border-radius: 999px;
- padding: 2px 9px;
- background: var(--subtle);
- font-size: 12px;
- color: var(--muted);
- vertical-align: middle;
-}
-.pill[data-state=ok],.priority[data-priority=low] {
- color: var(--success);
- background: var(--success-bg);
- border-color: var(--success-border);
-}
-.pill[data-state=degraded],.priority[data-priority=medium] {
- color: var(--warning);
- background: var(--warning-bg);
- border-color: var(--warning-border);
-}
-.pill[data-state=blind],.priority[data-priority=high],.priority[data-priority=critical] {
- color: var(--danger);
- background: var(--danger-bg);
- border-color: var(--danger-border);
-}
-.priority {
- text-transform: uppercase;
- border-radius: 5px;
- font-weight: 650;
- letter-spacing: .03em;
-}
+/* Pill badges live in components/Pill (Pill.module.css owns the base,
+ priority and data-* variants). */
.conn {
margin: 0 0 0 auto;
display: flex;
@@ -250,14 +198,7 @@ time {
color: var(--surface);
font-weight: 650;
}
-.pills {
- list-style: none;
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- padding: 0;
- margin: 0 0 12px;
-}
+/* Verdict pills live in components/VerdictHeader (VerdictHeader.module.css). */
.context {
font-size: 12px;
color: var(--muted);
@@ -410,6 +351,9 @@ time {
font-weight: 400;
color: var(--muted);
}
+/* TRANSITIONAL: .sources transfers to components/SourceCard when its
+ last global consumer migrates — find them with:
+ grep -rn 'class="[^"]*\bsources\b' src. New screens use their own module. */
.sources {
list-style: none;
padding: 0;
@@ -578,26 +522,9 @@ details>section {
padding: 16px 0;
border-top: 1px solid var(--border);
}
-.health-metrics {
- display: grid;
- grid-template-columns: repeat(5,minmax(0,1fr));
- gap: 12px;
- margin-bottom: 0;
-}
-.health-metrics>div {
- background: var(--subtle);
- border: 1px solid var(--border);
- border-radius: 8px;
- padding: 16px;
-}
-.health-metrics dd {
- font-size: 26px;
- margin-top: 10px;
-}
-.health-metrics small {
- font-size: 13px;
- color: var(--muted);
-}
+/* .health-metrics moved to components/HealthSummary/HealthSummary.module.css
+ (sole consumer). The `health-metrics` hook class stays on the element so
+ `dl:not(.digest,…)` below keeps excluding it. */
.stepper {
display: flex;
flex-wrap: wrap;
@@ -631,22 +558,9 @@ label {
label input:not([type=checkbox],[type=radio]),label select,label textarea {
margin-top: 4px;
}
-.trail {
- list-style: none;
- padding: 0;
-}
-.trail li {
- border-top: 1px solid var(--border);
- padding: 12px 0;
- font-size: 13px;
-}
-.trail .text {
- overflow-wrap: anywhere;
-}
-.trail .until {
- color: var(--muted);
- font-size: 12px;
-}
+/* The decision trail moved to components/DecisionTrail with its own
+ module (transitional global rules removed once the last global
+ consumer migrated). */
@media(max-width:1050px) {
.review-workspace {
grid-template-columns: minmax(0,1fr);
@@ -658,9 +572,6 @@ label input:not([type=checkbox],[type=radio]),label select,label textarea {
.detail-pane {
position: static;
}
- .health-metrics {
- grid-template-columns: repeat(3,minmax(0,1fr));
- }
}
@media(max-width:640px) {
.briefing .stepper {
@@ -713,50 +624,8 @@ label input:not([type=checkbox],[type=radio]),label select,label textarea {
dd {
margin-bottom: 10px;
}
- .health-metrics {
- grid-template-columns: repeat(2,minmax(0,1fr));
- }
-}
-@media(prefers-color-scheme:dark) {
- :root {
- --canvas: #15191f;
- --surface: #1e242d;
- --subtle: #252c36;
- --text: #edf0f5;
- --muted: #b4bfce;
- --border: #414b5a;
- --accent: #9dc2ff;
- --accent-soft: #293c58;
- --success: #abd7b8;
- --success-bg: #213b2b;
- --success-border: #4e7a5d;
- --warning: #f1d38e;
- --warning-bg: #3d321e;
- --warning-border: #8c7237;
- --danger: #f3b3ad;
- --danger-bg: #422b2b;
- --danger-border: #9e625b;
- }
-}
-@media(prefers-reduced-motion:reduce) {
- *,*::before,*::after {
- animation: none!important;
- transition: none!important;
- scroll-behavior: auto!important;
- }
}
-.sr-only {
- position: absolute;
- width: 1px;
- height: 1px;
- padding: 0;
- margin: -1px;
- overflow: hidden;
- clip-path: inset(50%);
- white-space: nowrap;
- border: 0;
-}
.review-workspace[data-empty='true'] {
grid-template-columns: minmax(0, 1fr);
diff --git a/ui/src/styles/dossier.css b/ui/src/styles/dossier.css
new file mode 100644
index 0000000..8cc3da0
--- /dev/null
+++ b/ui/src/styles/dossier.css
@@ -0,0 +1,80 @@
+/* Briefing dossier page shell (plan step 34). Moved verbatim from the
+ old Detail screen's scoped