diff --git a/README.md b/README.md index e238987..ce20f03 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,13 @@ live mode reports actual Ready pod counts from the authorized kubectl context. E failure includes its evidence and an exact remediation. The platform demo commands will not declare success unless this panel reports **Ready to present**. +Select **How it works** in Sentinel's header for the unified 30-second explanation. The +full-screen flow introduces Argus observation, the SOG shared model, Sentinel/OpenAI +decision support, human governance, and Phoenix recovery plus verification. It includes +a ready-to-speak judge narration, explicit autonomous/high-risk boundaries, and all four +evidence-provenance labels. The overlay is responsive and closes with its button, the +backdrop, or `Escape`. + ### Sentinel-only development Run the Sentinel Operations Graph service first, then: diff --git a/backend/src/main.py b/backend/src/main.py index 7816906..74773d4 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -166,8 +166,10 @@ def _correlated_incidents(timeline: list[dict]) -> list[dict]: proof_record = next((item for item in reversed(ordered) if isinstance(item.get("payload", {}).get("lifecycle"), list)), None) proof = None + proof_payload = {} if proof_record: payload = proof_record.get("payload", {}) + proof_payload = payload proof = { "lifecycle": payload.get("lifecycle", []), "metrics": payload.get("metrics", {}), @@ -175,6 +177,38 @@ def _correlated_incidents(timeline: list[dict]) -> list[dict]: "experiment_id": payload.get("scenario_id"), } entity = next((item.get("entity_name") for item in ordered if item.get("entity_name")), None) + entity_id = next((item.get("entity_id") for item in ordered if item.get("entity_id")), None) + argus_record = next((item for item in ordered if _source(item) == "argus"), {}) + phoenix_record = next((item for item in reversed(ordered) if _source(item) == "phoenix"), {}) + argus_payload = argus_record.get("payload", {}) + phoenix_payload = phoenix_record.get("payload", {}) + lifecycle = proof_payload.get("lifecycle", []) + + def stage_evidence(stage: str, fallback: str) -> str: + record = next((item for item in lifecycle if item.get("stage") == stage), None) + return str(record.get("evidence")) if record and record.get("evidence") else fallback + + detection = str(argus_record.get("summary") or _summary(argus_record) or "Detection detail not supplied") + recovery = str(phoenix_record.get("summary") or _summary(phoenix_record) or "Recovery detail not supplied") + resource = str(entity or entity_id or "Unmapped resource") + root_cause = (argus_payload.get("root_cause") or argus_payload.get("causal_chain") + or phoenix_payload.get("root_cause") or "Not established by the supplied evidence") + report = { + "executive_summary": f"{detection.rstrip('.')}. Phoenix reported: {recovery.rstrip('.')}.", + "detection": detection, + "affected_resource": resource, + "impact": str(argus_payload.get("impact") or + f"{severity.capitalize()} evidence was attached to {resource}; no wider impact is claimed without supporting evidence."), + "root_cause": str(root_cause), + "decision": stage_evidence("decision", "No explicit decision record was supplied"), + "governance": stage_evidence("human_approval", "Approval evidence was not supplied"), + "recovery": stage_evidence("recovery", recovery), + "verification": stage_evidence("verification", "Verification evidence was not supplied"), + "operator_next_step": str(phoenix_payload.get("operator_next_step") or + "Review the supporting evidence and keep the resource under observation."), + "evidence_source": str(proof_payload.get("evidence_source") or + " + ".join(sorted({str(item.get("provenance") or "observed") for item in ordered}))), + } incidents.append({ "incident_id": f"corr:{correlation_id}", "correlation_id": correlation_id, "title": f"Argus → Phoenix lifecycle{f' for {entity}' if entity else ''}", @@ -182,7 +216,7 @@ def _correlated_incidents(timeline: list[dict]) -> list[dict]: "started_at": ordered[0].get("timestamp"), "updated_at": ordered[-1].get("timestamp"), "sources": sorted(sources), "evidence_count": len(ordered), "timeline": ordered, "provenance": sorted({str(item.get("provenance") or "observed") for item in ordered}), - "proof": proof, + "proof": proof, "report": report, }) return sorted(incidents, key=lambda item: str(item.get("updated_at") or ""), reverse=True) diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py index 1b537b4..0c9e05f 100644 --- a/backend/tests/test_main.py +++ b/backend/tests/test_main.py @@ -36,6 +36,10 @@ def test_incident_requires_explicit_cross_agent_correlation(): assert incidents[0]["status"] == "resolved" assert incidents[0]["sources"] == ["argus", "phoenix"] assert [item["id"] for item in incidents[0]["timeline"]] == ["a", "p"] + assert incidents[0]["report"]["detection"] == "detected" + assert incidents[0]["report"]["recovery"] == "recovered" + assert incidents[0]["report"]["root_cause"] == "Not established by the supplied evidence" + assert "no wider impact is claimed" in incidents[0]["report"]["impact"] def test_incident_exposes_only_supplied_proof_stages_and_metrics(): @@ -45,6 +49,8 @@ def test_incident_exposes_only_supplied_proof_stages_and_metrics(): assert [item["stage"] for item in incident["proof"]["lifecycle"]] == ["healthy", "verification"] assert incident["proof"]["metrics"]["availability_percent"] == 99.8 assert incident["proof"]["experiment_id"] == "chaos-1" + assert incident["report"]["verification"] == "Verification evidence was not supplied" + assert incident["report"]["evidence_source"] == "Falco + HTTP probe" @pytest.mark.asyncio diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index c1a2bbc..8e40815 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react' import { - Activity, AlertTriangle, ArrowRight, BrainCircuit, CheckCircle2, ChevronRight, + Activity, AlertTriangle, ArrowRight, BookOpen, BrainCircuit, CheckCircle2, ChevronRight, CircleDot, Clock3, GitBranch, Layers3, Network, RefreshCw, Shield, Sparkles, Timer, UserCheck, X, Zap, } from 'lucide-react' @@ -27,6 +27,7 @@ type Incident = { started_at?: string; updated_at?: string; sources?: string[]; evidence_count?: number provenance?: string[]; timeline?: Timeline[] proof?: { lifecycle?: ProofStage[]; metrics?: Record; evidence_source?: string; experiment_id?: string } + report?: { executive_summary?: string; detection?: string; affected_resource?: string; impact?: string; root_cause?: string; decision?: string; governance?: string; recovery?: string; verification?: string; operator_next_step?: string; evidence_source?: string } } type ProofStage = { stage: string; timestamp?: string; source?: string; evidence?: string; status?: string } type Overview = { @@ -146,6 +147,22 @@ function LiveSignalFeed({ events, onSelect }: { events: Timeline[]; onSelect: (e } const proofStages = ['healthy', 'fault_injected', 'detection', 'decision', 'human_approval', 'recovery', 'verification'] +function IncidentReport({ incident }: { incident: Incident }) { + const report = incident.report + if (!report) return

Incident report unavailable

This correlation predates structured reporting. Supporting evidence remains available below.

+ const sections = [ + { label: 'What happened', value: report.detection, tone: 'argus' }, + { label: 'Affected resource', value: report.affected_resource, tone: 'sentinel' }, + { label: 'Impact assessment', value: report.impact, tone: 'high' }, + { label: 'Root cause', value: report.root_cause, tone: 'medium' }, + { label: 'Decision + containment', value: report.decision, tone: 'sentinel' }, + { label: 'Human governance', value: report.governance, tone: 'human' }, + { label: 'Recovery performed', value: report.recovery, tone: 'phoenix' }, + { label: 'Verification result', value: report.verification, tone: 'low' }, + ] + return
OPERATOR-READY REPORT

Incident understood and recovery accounted for

{(incident.provenance || ['observed']).map(label).join(' + ')}

{report.executive_summary}

{sections.map(section =>
{section.label}

{section.value || 'Not supplied by the available evidence'}

)}
OPERATOR FOLLOW-UP

{report.operator_next_step}

Evidence source · {report.evidence_source || 'not recorded'}
+} + function ResilienceProof({ incident, onSignal }: { incident: Incident; onSignal: (signal: Timeline) => void }) { const supplied = new Map((incident.proof?.lifecycle || []).map(item => [item.stage, item])) const start = incident.proof?.lifecycle?.find(item => item.timestamp)?.timestamp @@ -222,6 +239,23 @@ function ReadinessPanel({ readiness, expanded, onToggle, onRefresh }: { readines } +function HowItWorks({ onClose }: { onClose: () => void }) { + useEffect(() => { const close = (event: KeyboardEvent) => event.key === 'Escape' && onClose(); document.addEventListener('keydown', close); return () => document.removeEventListener('keydown', close) }, [onClose]) + const steps = [ + { number: '01', name: 'ARGUS', role: 'Detect + contain', icon: Shield, tone: 'argus', text: 'Kyverno guards admission while eBPF, Falco, and Cilium watch the kernel, runtime, and network—stopping threats close to where they start.', output: 'Early containment + evidence' }, + { number: '02', name: 'SOG', role: 'Connect', icon: GitBranch, tone: 'sentinel', text: 'The Sentinel Operations Graph connects services, dependencies, evidence, incidents, and trust.', output: 'Shared operational truth' }, + { number: '03', name: 'SENTINEL + OPENAI', role: 'Decide', icon: BrainCircuit, tone: 'sentinel', text: 'Sentinel explains what changed, ranks risk, and chooses what requires attention.', output: 'Evidence-grounded decision' }, + { number: '04', name: 'HUMAN GOVERNANCE', role: 'Authorize', icon: UserCheck, tone: 'human', text: 'Low-risk, proven actions can run autonomously. High-impact actions wait for explicit approval.', output: 'Accountable control' }, + { number: '05', name: 'PHOENIX', role: 'Break + recover', icon: Zap, tone: 'phoenix', text: 'Phoenix injects bounded faults before customers find them, recovers the service, and verifies that health is actually restored.', output: 'Proven resilience before impact' }, + ] + return
+
{steps.map(({ icon: Icon, ...step }, index) =>
{step.number}
{step.role}

{step.name}

{step.text}

{step.output}{index < steps.length - 1 && }
)}
+
Say this to a judge≈ 25 seconds at a natural pace
“Argus watches admission, the kernel, runtime, and network to stop threats close to where they start. Phoenix tests bounded failures before customers find them. The SOG connects both, and Sentinel uses OpenAI to explain what matters next. Safe recovery can run automatically; risky action waits for a human. Nothing is called fixed until recovery is verified.”
+
AUTONOMOUSDetection · correlation · diagnosis · bounded recovery · verification
HUMAN-GOVERNEDHigh blast radius · ambiguous evidence · destructive or unproven actions
EVERY RECORD SAYS WHAT IT ISLIVE OBSERVEDLIVE CHAOS MESHSYNTHETIC SIMULATORREPLAYED EVIDENCE
+
SOG means Sentinel Operations GraphFour protection layers. One governed evidence loop. Recovery must be proven.
+
+} + function MetricDetail({ metric, data, onSignal, onComponent, onIncident }: { metric: MetricKind; data: Overview | null; onSignal: (signal: Timeline) => void; onComponent: (component: Component) => void; onIncident: (incident: Incident) => void }) { const definitions: Record = { signals: { title: 'Evidence records', description: 'Every finding in the current Sentinel Operations Graph window, separated by exact provenance.' }, @@ -247,7 +281,7 @@ function DetailDrawer({ detail, data, onClose, onDetail }: { detail: Detail; dat } if (detail.kind === 'incident') { const incident = detail.incident - return
STATUS{incident.status || 'open'}SEVERITY{incident.severity || 'info'}SOURCES{(incident.sources || []).join(' + ') || 'unknown'}EVIDENCE{incident.evidence_count || incident.timeline?.length || 0}
onDetail({ kind: 'signal', signal })} /> + return
STATUS{incident.status || 'open'}SEVERITY{incident.severity || 'info'}SOURCES{(incident.sources || []).join(' + ') || 'unknown'}EVIDENCE{incident.evidence_count || incident.timeline?.length || 0}
onDetail({ kind: 'signal', signal })} /> } const signal = detail.kind === 'signal' ? detail.signal : undefined, component = detail.kind === 'component' ? detail.component : undefined const evidence = component?.evidence || (signal ? [signal] : []) @@ -266,6 +300,7 @@ export default function App() { const [brief, setBrief] = useState(''), [briefing, setBriefing] = useState(false), [detail, setDetail] = useState(null) const [previousRisk, setPreviousRisk] = useState(null), [, setClock] = useState(0) const [readiness, setReadiness] = useState(null), [showReadiness, setShowReadiness] = useState(true) + const [showHow, setShowHow] = useState(false) const load = async () => { setLoading(true); try { const response = await fetch(`${API}/overview`); if (!response.ok) throw Error(await response.text()); const next: Overview = await response.json(); setData(current => { if (current) setPreviousRisk(current.fleet_risk); return next }); setError('') } catch (caught: any) { setError(caught.message) } finally { setLoading(false) } } const loadReadiness = async () => { try { const response = await fetch(`${API}/readiness`); if (!response.ok) throw Error(await response.text()); setReadiness(await response.json()) } catch { setReadiness(null) } } useEffect(() => { load(); const refreshTimer = setInterval(load, 15000); const clockTimer = setInterval(() => setClock(value => value + 1), 1000); return () => { clearInterval(refreshTimer); clearInterval(clockTimer) } }, []) @@ -275,14 +310,14 @@ export default function App() { const selectTopology = (id: string) => { const component = componentsById.get(id); if (component) setDetail({ kind: 'component', component }) } const urgent = (data?.counts.critical || 0) + (data?.counts.high || 0) return
-

SENTINEL

OPENAI-NATIVE · SENTINEL OPERATIONS GRAPH

{error ? 'SOG DEGRADED' : data?.status === 'degraded' ? 'PARTIAL DATA' : 'SOG LIVE'}{data ? `refreshed ${age(data.generated_at)}` : 'connecting'}
+

SENTINEL

OPENAI-NATIVE · SENTINEL OPERATIONS GRAPH

{error ? 'SOG DEGRADED' : data?.status === 'degraded' ? 'PARTIAL DATA' : 'SOG LIVE'}{data ? `refreshed ${age(data.generated_at)}` : 'connecting'}
UNIFIED COMMAND CENTER

One fleet. Two specialist agents.
One accountable decision.

Sentinel combines Argus security evidence and Phoenix resilience outcomes inside the Sentinel Operations Graph (SOG)—the shared, live evidence layer that explains exactly what requires attention and why.

SOGSentinel Operations GraphOne connected model of services, dependencies, evidence, incidents, and trust.
{urgent ? `${urgent} urgent signals need review` : 'No urgent evidence in the current window'}Polling the Operations Graph every 15 secondsSOG evidence stream active
setDetail({ kind: 'component', component })} />
{error &&
Sentinel cannot reach the Sentinel Operations Graph (SOG) gateway. {error}
} setShowReadiness(value => !value)} onRefresh={loadReadiness} />
setDetail({ kind: 'metric', metric: 'signals' })} /> setDetail({ kind: 'metric', metric: 'urgent' })} /> setDetail({ kind: 'metric', metric: 'affected' })} /> setDetail({ kind: 'metric', metric: 'namespaces' })} /> setDetail({ kind: 'metric', metric: 'incidents' })} />
setDetail({ kind: 'source', source })} />
-
{data?.timeline.length || 0} records} /> setDetail({ kind: 'signal', signal })} />
+
{data?.timeline.length || 0} records
} /> setDetail({ kind: 'signal', signal })} />
{data?.counts.findings || 0} signals mapped} />
setDetail({ kind: 'component', component })} />
{data?.counts.entities || 0} nodes · {data?.counts.edges || 0} edges} />
{data?.counts.namespaces || 0} namespaces} />
@@ -290,6 +325,7 @@ export default function App() {
SENTINEL PLATFORMARGUS PHOENIX SOG · SENTINEL OPERATIONS GRAPH15s live refresh
{detail && setDetail(null)} onDetail={setDetail} />} + {showHow && setShowHow(false)} />} } diff --git a/dashboard/src/readability.css b/dashboard/src/readability.css index bd7d6b6..d709ddf 100644 --- a/dashboard/src/readability.css +++ b/dashboard/src/readability.css @@ -14,3 +14,406 @@ .incident-facts{display:grid;grid-template-columns:repeat(4,1fr);gap:7px;margin:15px 0}.incident-facts span{padding:11px;border:1px solid #27364d;border-radius:8px;background:#0c1522}.incident-facts small{display:block;color:#687a92;font:700 9px JetBrains Mono}.incident-facts b{display:block;margin-top:5px;color:#e3e9f2;font:700 12px JetBrains Mono;text-transform:uppercase}.timeline-help{color:#8191a6;font-size:11px;line-height:1.55}.lifecycle{position:relative;margin-top:11px}.lifecycle:before{content:"";position:absolute;left:12px;top:15px;bottom:15px;width:1px;background:#34445e}.lifecycle>button{position:relative;display:grid;width:100%;grid-template-columns:25px 1fr 13px;gap:9px;align-items:center;padding:12px 5px;border:0;border-bottom:1px solid #1e2c42;background:transparent;color:inherit;text-align:left;cursor:pointer}.lifecycle>button:hover{background:#111c2b}.lifecycle>button>i{z-index:1;width:10px;height:10px;justify-self:center;border:2px solid #0b1420;border-radius:50%;box-shadow:0 0 8px currentColor}.lifecycle>button>div{display:flex;min-width:0;flex-direction:column;gap:5px}.lifecycle header{display:flex;align-items:center;gap:9px}.lifecycle header span,.lifecycle header strong,.lifecycle time{font:700 9px JetBrains Mono;text-transform:uppercase}.lifecycle header strong{color:#8999af}.lifecycle time{margin-left:auto;color:#66778f;text-transform:none}.lifecycle>button b{color:#dbe3ef;font-size:12px}.lifecycle>button small{color:#73849a;font:500 10px JetBrains Mono;text-transform:capitalize}.lifecycle>button>svg{width:12px;color:#61738b}@media(max-width:520px){.incident-facts{grid-template-columns:1fr 1fr}} .proof-section{margin-top:18px}.proof-timeline{display:grid;grid-template-columns:repeat(7,minmax(105px,1fr));gap:7px;margin-top:14px;overflow-x:auto;padding-bottom:8px}.proof-stage{position:relative;min-height:142px;padding:13px 11px;border:1px solid #26364e;border-radius:9px;background:#0b1421}.proof-stage:not(:last-child):after{content:"→";position:absolute;right:-9px;top:20px;z-index:2;color:#697c98;font:700 13px JetBrains Mono}.proof-stage>span{display:grid;width:22px;height:22px;place-items:center;border-radius:50%;background:#55e6a518;color:#55e6a5;font:700 9px JetBrains Mono}.proof-stage>div{display:flex;flex-direction:column;gap:7px;margin-top:11px}.proof-stage b{color:#edf3fb;font-size:11px;text-transform:capitalize}.proof-stage small{min-height:32px;color:#91a1b6;font-size:9px;line-height:1.45}.proof-stage em{color:#667a94;font:600 8px JetBrains Mono;font-style:normal}.proof-stage>strong{position:absolute;bottom:11px;left:11px;color:#55e6a5;font:700 8px JetBrains Mono;text-transform:uppercase}.proof-stage.missing{border-style:dashed;opacity:.6}.proof-stage.missing>span{background:#71809816;color:#718098}.proof-stage.missing>strong{color:#718098}.proof-metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-top:10px}.proof-metrics>span{padding:12px;border:1px solid #283952;border-radius:8px;background:#0c1624}.proof-metrics small{display:block;color:#71849e;font:700 8px JetBrains Mono}.proof-metrics b{display:block;margin-top:7px;color:#e8eef7;font-size:12px}.supporting-evidence{margin-top:12px;border:1px solid #263750;border-radius:8px;padding:10px}.supporting-evidence summary{cursor:pointer;color:#91a4be;font:700 9px JetBrains Mono}.provenance{white-space:nowrap}.provenance.observed,.provenance-text.observed{color:#55e6a5!important}.provenance.live_chaos,.provenance-text.live_chaos{color:#4de7c7!important}.provenance.simulator,.provenance-text.simulator{color:#ffad55!important}.provenance.replayed,.provenance-text.replayed{color:#b68cff!important}.provenance.unknown,.provenance-text.unknown{color:#ff5874!important}@media(max-width:900px){.proof-timeline{grid-template-columns:repeat(7,140px)}.proof-metrics{grid-template-columns:1fr 1fr}} .readiness-panel{margin:0 0 14px;border:1px solid #2c4059;border-radius:12px;background:linear-gradient(110deg,#0c1725,#0b1320);overflow:hidden}.readiness-panel.ready{border-color:#55e6a552}.readiness-panel.blocked{border-color:#ff587466}.readiness-summary{display:grid;width:100%;grid-template-columns:38px 1fr auto;gap:13px;align-items:center;padding:15px 17px;border:0;background:transparent;color:inherit;text-align:left;cursor:pointer}.readiness-icon{display:grid;width:35px;height:35px;place-items:center;border:1px solid #55e6a55c;border-radius:9px;color:#55e6a5;background:#55e6a50e}.blocked .readiness-icon{border-color:#ff58745c;color:#ff5874;background:#ff58740e}.readiness-icon svg{width:18px}.readiness-summary>div{display:flex;flex-direction:column;gap:4px}.readiness-summary small{color:#7d91aa;font:700 8px JetBrains Mono;letter-spacing:.8px}.readiness-summary b{color:#e8eef7;font-size:15px}.readiness-summary p{color:#71849c;font-size:10px}.readiness-summary>em{display:flex;align-items:center;gap:7px;color:#8ea0b8;font:700 9px JetBrains Mono;font-style:normal;text-transform:uppercase}.readiness-summary>em svg{width:13px}.readiness-body{padding:0 17px 15px;border-top:1px solid #223249}.readiness-head{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:11px 0}.readiness-head p{color:#8294aa;font-size:10px}.readiness-head button{display:flex;align-items:center;gap:6px;padding:7px 10px;border:1px solid #34506a;border-radius:7px;background:#111e2d;color:#a9b8ca;font:700 9px JetBrains Mono;cursor:pointer}.readiness-head button svg{width:12px}.readiness-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:7px}.readiness-grid article{min-height:112px;padding:10px;border:1px solid #27384f;border-radius:8px;background:#09121e}.readiness-grid article.ready{border-color:#55e6a533}.readiness-grid article.not_ready,.readiness-grid article.not_configured{border-color:#ff587455}.readiness-grid article.not_applicable{opacity:.62}.readiness-grid header{display:flex;gap:7px;align-items:center}.readiness-grid header>span{color:#55e6a5}.readiness-grid .not_ready header>span,.readiness-grid .not_configured header>span{color:#ff5874}.readiness-grid header svg{width:13px}.readiness-grid header div{display:flex;flex-direction:column}.readiness-grid header b{color:#dce5f0;font-size:11px}.readiness-grid header small{color:#71839a;font:600 7px JetBrains Mono;text-transform:uppercase}.readiness-grid article p{margin-top:9px;color:#8394aa;font-size:9px;line-height:1.45;word-break:break-word}.readiness-grid article>em{display:block;margin-top:7px;color:#ffad55;font-size:8px;font-style:normal;line-height:1.4}.readiness-body>footer{display:flex;justify-content:space-between;margin-top:11px;color:#74869d;font:600 8px JetBrains Mono}.readiness-body>footer strong{color:#55e6a5}.blocked .readiness-body>footer strong{color:#ffad55}@media(max-width:1100px){.readiness-grid{grid-template-columns:repeat(3,1fr)}}@media(max-width:700px){.readiness-summary{grid-template-columns:35px 1fr}.readiness-summary>em{grid-column:2}.readiness-grid{grid-template-columns:1fr 1fr}} +.how-link{border:1px solid #b68cff44!important;background:#b68cff0e!important;color:#d2bcff!important;cursor:pointer}.how-wrap{position:fixed;inset:0;z-index:80;display:grid;place-items:center;padding:22px}.how-backdrop{position:absolute;inset:0;border:0;background:#030712e8;backdrop-filter:blur(12px)}.how-modal{position:relative;width:min(1500px,97vw);max-height:95vh;overflow:auto;border:1px solid #b68cff55;border-radius:17px;background:radial-gradient(circle at 50% -20%,#2b174b77,transparent 42%),linear-gradient(145deg,#0c1320,#080d17);box-shadow:0 30px 100px #000c;color:#dce5f0}.how-modal>header{display:flex;justify-content:space-between;gap:30px;padding:28px 32px 20px;border-bottom:1px solid #26344a}.how-modal>header span{color:#bfa3ff;font:700 9px JetBrains Mono;letter-spacing:1.8px}.how-modal>header h2{margin-top:7px;color:#f0f3fa;font:700 30px Space Grotesk}.how-modal>header p{margin-top:6px;color:#91a0b4;font-size:13px}.how-modal>header>button{display:grid;width:35px;height:35px;place-items:center;border:1px solid #33445d;border-radius:8px;background:#101927;color:#91a3ba;cursor:pointer}.how-modal>header>button svg{width:17px}.how-flow{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;padding:24px 30px}.how-step{--step:#b68cff;position:relative;min-height:245px;padding:16px;border:1px solid color-mix(in srgb,var(--step) 36%,#25344c);border-radius:12px;background:linear-gradient(160deg,color-mix(in srgb,var(--step) 8%,#0b1421),#09111c)}.how-step.argus{--step:#45d9ff}.how-step.phoenix{--step:#55e6a5}.how-step.human{--step:#ffd166}.how-step-top{display:flex;align-items:center;justify-content:space-between}.how-step-top>span{color:#71839c;font:700 9px JetBrains Mono}.how-step-top>i{display:grid;width:34px;height:34px;place-items:center;border:1px solid color-mix(in srgb,var(--step) 45%,transparent);border-radius:9px;background:color-mix(in srgb,var(--step) 9%,transparent);color:var(--step)}.how-step-top svg{width:17px}.how-step>small{display:block;margin-top:19px;color:var(--step);font:700 9px JetBrains Mono;text-transform:uppercase;letter-spacing:1px}.how-step h3{margin-top:5px;color:#edf2f9;font:700 15px Space Grotesk}.how-step p{margin-top:12px;color:#94a3b7;font-size:11px;line-height:1.62}.how-step>strong{position:absolute;right:15px;bottom:15px;left:15px;display:flex;align-items:center;gap:6px;padding-top:10px;border-top:1px solid #27364c;color:var(--step);font:600 9px JetBrains Mono}.how-step>strong svg{width:12px}.how-arrow{position:absolute;right:-19px;top:50%;z-index:3;width:24px;padding:4px;border-radius:50%;background:#111a29;color:#8295ae}.how-narration{display:grid;grid-template-columns:210px 1fr;gap:20px;margin:0 30px;padding:18px 20px;border:1px solid #b68cff45;border-radius:12px;background:#b68cff0a}.how-narration>div{display:flex;align-items:center;gap:11px;color:#c7aaff}.how-narration>div svg{width:22px}.how-narration>div span{display:flex;flex-direction:column}.how-narration b{font-size:12px}.how-narration small{margin-top:3px;color:#71829a;font:600 8px JetBrains Mono}.how-narration blockquote{color:#c9d3e1;font-size:13px;line-height:1.7}.how-truth{display:grid;grid-template-columns:.8fr 1fr 1.6fr;gap:10px;padding:14px 30px 24px}.how-truth>div{padding:12px 14px;border:1px solid #26364d;border-radius:9px;background:#09121e}.how-truth b{display:block;color:#8295ae;font:700 8px JetBrains Mono;letter-spacing:.7px}.how-truth>div>span{display:block;margin-top:6px;color:#aebaca;font-size:10px;line-height:1.45}.how-provenance>span{display:flex!important;flex-wrap:wrap;gap:5px}.how-provenance em{padding:4px 6px;border:1px solid currentColor;border-radius:5px;font:700 7px JetBrains Mono;font-style:normal}.how-provenance .observed{color:#55e6a5}.how-provenance .live_chaos{color:#4de7c7}.how-provenance .simulator{color:#ffad55}.how-provenance .replayed{color:#b68cff}.how-modal>footer{display:grid;grid-template-columns:1fr 1.7fr auto;gap:18px;align-items:center;padding:15px 30px;border-top:1px solid #26354b;background:#080e18}.how-modal>footer span{display:flex;align-items:center;gap:6px;color:#7d8fa7;font:600 8px JetBrains Mono}.how-modal>footer span svg{width:12px}.how-modal>footer strong{color:#d8c8ff;font-size:12px;text-align:center}.how-modal>footer button{display:flex;align-items:center;gap:7px;padding:9px 12px;border:1px solid #b68cff66;border-radius:8px;background:#b68cff15;color:#d5c1ff;font:700 9px JetBrains Mono;cursor:pointer}.how-modal>footer button svg{width:12px}@media(max-width:1050px){.how-flow{grid-template-columns:1fr 1fr}.how-step{min-height:210px}.how-arrow{display:none}.how-flow .how-step:last-child{grid-column:1/-1}.how-truth{grid-template-columns:1fr}.how-modal>footer{grid-template-columns:1fr}.how-modal>footer strong{text-align:left}}@media(max-width:650px){.how-wrap{padding:0}.how-modal{width:100%;height:100%;max-height:none;border-radius:0}.how-modal>header,.how-flow,.how-truth{padding-right:17px;padding-left:17px}.how-modal>header h2{font-size:23px}.how-flow{grid-template-columns:1fr}.how-flow .how-step:last-child{grid-column:auto}.how-narration{grid-template-columns:1fr;margin:0 17px}.how-modal>footer{padding:14px 17px}} + +/* Presentation overlay: prioritize legibility at recording and laptop sizes. */ +.how-modal { + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + scrollbar-gutter: stable; +} + +.how-modal > header { + min-height: 130px; + align-items: flex-start; + padding: 26px 32px 24px; +} + +.how-modal > header > div { + min-width: 0; +} + +.how-modal > header span { + display: block; + margin-bottom: 10px; + font-size: 10px; + line-height: 1.4; +} + +.how-modal > header h2 { + margin: 0; + font-family: Inter, ui-sans-serif, system-ui, sans-serif; + font-size: clamp(27px, 2vw, 34px); + line-height: 1.16; + letter-spacing: -.025em; +} + +.how-modal > header p { + margin-top: 10px; + font-size: 14px; + line-height: 1.55; +} + +.how-flow { + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 14px; +} + +.how-step { + min-width: 0; + min-height: 270px; + padding: 18px; +} + +.how-step > small { + font-size: 10px; + line-height: 1.35; +} + +.how-step h3 { + min-height: 42px; + margin-top: 7px; + font-family: Inter, ui-sans-serif, system-ui, sans-serif; + font-size: 16px; + line-height: 1.3; + overflow-wrap: anywhere; +} + +.how-step p { + margin-top: 8px; + color: #aeb9c9; + font-size: 12px; + line-height: 1.6; +} + +.how-step > strong { + right: 18px; + bottom: 16px; + left: 18px; + font-size: 9px; + line-height: 1.45; +} + +.how-narration { + grid-template-columns: minmax(180px, 220px) minmax(0, 1fr); + align-items: center; + padding: 20px 22px; +} + +.how-narration b { + font-size: 13px; +} + +.how-narration small { + font-size: 9px; + line-height: 1.45; +} + +.how-narration blockquote { + font-size: 14px; + line-height: 1.7; +} + +.how-truth b, +.how-modal > footer span { + font-size: 9px; + line-height: 1.4; +} + +.how-truth > div > span { + font-size: 11px; + line-height: 1.55; +} + +.how-provenance em { + padding: 5px 7px; + font-size: 8px; + line-height: 1.2; +} + +.how-modal > footer { + min-height: 72px; + padding-top: 16px; + padding-bottom: 16px; +} + +.how-modal > footer strong { + font-family: Inter, ui-sans-serif, system-ui, sans-serif; + font-size: 13px; + line-height: 1.4; +} + +@media (max-width: 1280px) { + .how-flow { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .how-flow .how-step:nth-child(4), + .how-flow .how-step:nth-child(5) { + min-height: 235px; + } + + .how-flow .how-step:nth-child(4) { + grid-column: 1 / 2; + } + + .how-flow .how-step:nth-child(5) { + grid-column: 2 / 4; + } + + .how-arrow { + display: none; + } +} + +@media (max-width: 760px) { + .how-modal > header { + min-height: 0; + padding-top: 22px; + } + + .how-flow, + .how-truth { + grid-template-columns: 1fr; + } + + .how-flow .how-step:nth-child(4), + .how-flow .how-step:nth-child(5) { + grid-column: auto; + } + + .how-step, + .how-flow .how-step:nth-child(4), + .how-flow .how-step:nth-child(5) { + min-height: 230px; + } + + .how-narration { + grid-template-columns: 1fr; + } +} + +.evidence-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; +} + +.incident-entry { + display: flex; + align-items: center; + gap: 8px; + padding: 9px 11px; + border: 1px solid #55e6a55c; + border-radius: 8px; + background: linear-gradient(135deg, #55e6a512, #101b28); + color: #7ff0bd; + cursor: pointer; + transition: border-color .15s ease, background .15s ease, transform .15s ease; +} + +.incident-entry:hover { + border-color: #55e6a5; + background: #55e6a51c; + transform: translateY(-1px); +} + +.incident-entry:focus-visible { + outline: 2px solid #8ff5c8; + outline-offset: 3px; +} + +.incident-entry > svg { + width: 14px; + flex: 0 0 auto; +} + +.incident-entry > svg:last-child { + width: 12px; + color: #5f9f82; +} + +.incident-entry > span { + display: flex; + align-items: baseline; + gap: 5px; + white-space: nowrap; + font: 700 9px JetBrains Mono, monospace; + letter-spacing: .7px; + text-transform: uppercase; +} + +.incident-entry b { + color: #e9fff5; + font-size: 13px; +} + +/* The app header is globally sticky and 70px tall. Reset those rules inside the modal. */ +.how-wrap { + display: flex; + align-items: flex-start; + justify-content: center; + overflow-y: auto; + padding: clamp(10px, 2.2vh, 22px); +} + +.how-backdrop { + position: fixed; +} + +.how-modal { + flex: 0 0 auto; + max-height: none; + overflow: visible; +} + +.how-modal > header { + position: relative; + top: auto; + z-index: auto; + height: auto; + min-height: 130px; + overflow: visible; + background: transparent; + backdrop-filter: none; +} + +.how-step { + min-height: 292px; + padding-bottom: 62px; +} + +@media (max-width: 700px) { + .evidence-actions { + align-items: flex-end; + flex-direction: column; + gap: 7px; + } + + .how-modal { + min-height: 100%; + } +} + +.incident-report { + margin: 18px 0 22px; + padding: 17px; + border: 1px solid #b68cff45; + border-radius: 11px; + background: linear-gradient(145deg, #b68cff0b, #0a1421 48%, #09111c); +} + +.incident-report.missing { + border-style: dashed; + color: #91a1b6; +} + +.report-title { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; +} + +.report-title > div > span, +.report-grid article > span, +.report-next span { + color: #8f7dbd; + font: 700 8px JetBrains Mono, monospace; + letter-spacing: .8px; +} + +.report-title h3 { + display: flex; + align-items: center; + gap: 7px; + margin-top: 6px; + color: #edf2f9; + font-size: 14px; +} + +.report-title h3 svg { + width: 15px; + color: #55e6a5; +} + +.report-executive { + margin: 14px 0; + padding: 13px 14px; + border-left: 3px solid #b68cff; + border-radius: 0 8px 8px 0; + background: #b68cff0c; + color: #d5ddea; + font-size: 12px; + line-height: 1.65; +} + +.report-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.report-grid article { + min-width: 0; + padding: 11px 12px; + border: 1px solid #27384f; + border-left: 2px solid #b68cff; + border-radius: 8px; + background: #09131f; +} + +.report-grid article.argus { border-left-color: #45d9ff; } +.report-grid article.phoenix, +.report-grid article.low { border-left-color: #55e6a5; } +.report-grid article.high { border-left-color: #ffad55; } +.report-grid article.medium, +.report-grid article.human { border-left-color: #ffd166; } + +.report-grid article p { + margin-top: 6px; + color: #b7c3d2; + font-size: 10px; + line-height: 1.55; + overflow-wrap: anywhere; +} + +.report-next { + display: flex; + gap: 10px; + margin-top: 9px; + padding: 12px; + border: 1px solid #55e6a53b; + border-radius: 8px; + background: #55e6a508; +} + +.report-next > svg { + width: 17px; + flex: 0 0 auto; + color: #55e6a5; +} + +.report-next p { + margin-top: 5px; + color: #c2ccda; + font-size: 10px; + line-height: 1.5; +} + +.report-next small { + display: block; + margin-top: 6px; + color: #71849d; + font: 600 8px JetBrains Mono, monospace; +} + +@media (max-width: 640px) { + .report-title { + flex-direction: column; + } + + .report-grid { + grid-template-columns: 1fr; + } +}