stage-5: Worker Mesh — animated character-bot agents
Replaces the passive journey timeline with an active worker mesh: seven robot agents (Scout, Classifier, Mapper, Sealer, Router, Courier, Ledger), each with a geometric SVG body, glowing antenna + reactor core in its own accent colour, expressive awake/asleep faces, and an idle float. A case token travels the conduit; as it reaches each bot the bot wakes (activation ring + work-flash), performs its action, and speaks its real answer in a speech bubble. Asleep bots are steps that did not occur for this case. Replay button re-runs it. Every answer is real persisted data — the bots animate, they do not fake. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -20,10 +20,24 @@ from psyc.result import Err
|
||||
|
||||
_ACTED_OUTCOMES = {Outcome.SUBMITTED, Outcome.ACKNOWLEDGED, Outcome.ACTIONED}
|
||||
|
||||
# worker line -> (bot name, purpose, action verb)
|
||||
_BOT = {
|
||||
"Scoutline": ("Scout", "I sweep public feeds for fresh threat signals.", "scanning feeds"),
|
||||
"Classifyline": ("Classifier", "I judge severity, TLP and incident type.", "assessing severity"),
|
||||
"Mapline": ("Mapper", "I locate the host and its jurisdiction.", "locating host"),
|
||||
"Sealine": ("Sealer", "I encrypt evidence for authorized recipients.", "encrypting evidence"),
|
||||
"Routeline": ("Router", "I decide who is allowed to receive this.", "evaluating destinations"),
|
||||
"Courier": ("Courier", "I deliver the payload to each destination.", "delivering payload"),
|
||||
"Ledgerline": ("Ledger", "I record every action, immutably.", "writing the record"),
|
||||
}
|
||||
|
||||
|
||||
class Beat(BaseModel):
|
||||
index: int
|
||||
line: str
|
||||
bot: str = ""
|
||||
purpose: str = ""
|
||||
action: str = ""
|
||||
title: str
|
||||
occurred: bool
|
||||
caption: str
|
||||
@@ -43,6 +57,7 @@ def build_journey(case: Case) -> List[Beat]:
|
||||
]
|
||||
for i, beat in enumerate(beats, start=1):
|
||||
beat.index = i
|
||||
beat.bot, beat.purpose, beat.action = _BOT.get(beat.line, (beat.line, "", "working"))
|
||||
return beats
|
||||
|
||||
|
||||
|
||||
@@ -103,23 +103,101 @@ tr.sev-low .sev-badge { color: var(--muted); }
|
||||
.loss-bar { display: block; height: 100%; background: linear-gradient(90deg, var(--accent), var(--green)); }
|
||||
.loss-val { text-align: right; color: var(--text); }
|
||||
|
||||
/* Case Journey timeline (/cases/{id}/journey) */
|
||||
.journey { list-style: none; margin: 20px 0 0; padding: 0; position: relative; }
|
||||
.journey::before { content: ""; position: absolute; left: 11px; top: 8px; bottom: 22px; width: 2px; background: var(--border); }
|
||||
.beat { position: relative; padding: 0 0 18px 40px; }
|
||||
.journey.playing .beat { opacity: 0; transform: translateY(12px); animation: beat-in 0.5s ease forwards; animation-delay: calc(var(--i) * 0.55s); }
|
||||
@keyframes beat-in { to { opacity: 1; transform: none; } }
|
||||
.beat-node { position: absolute; left: 4px; top: 4px; width: 16px; height: 16px; border-radius: 50%; background: var(--panel-2); border: 2px solid var(--border); }
|
||||
.beat-done .beat-node { background: var(--accent); border-color: var(--accent); box-shadow: 0 0 8px var(--accent-glow); }
|
||||
.journey.playing .beat-done .beat-node { animation: node-pulse 0.6s ease; animation-delay: calc(var(--i) * 0.55s); }
|
||||
@keyframes node-pulse { 0% { transform: scale(0.4); } 60% { transform: scale(1.35); } 100% { transform: scale(1); } }
|
||||
.beat-card { background: var(--panel-2); border: 1px solid var(--border); border-radius: 6px; padding: 12px 14px; }
|
||||
.beat-skip .beat-card { opacity: 0.5; }
|
||||
.beat-line { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 1px; }
|
||||
.beat-card h2 { margin: 2px 0 6px; font-size: 15px; }
|
||||
.beat-pending { font-size: 11px; color: var(--muted); border: 1px solid var(--border); border-radius: 10px; padding: 1px 8px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.beat-caption { margin: 0; font-size: 13px; }
|
||||
.beat-facts { margin: 8px 0 0; padding-left: 16px; font-size: 12px; color: var(--muted); }
|
||||
.beat-facts li { margin: 2px 0; }
|
||||
.replay-btn { background: var(--panel-2); color: var(--accent); border: 1px solid var(--border); border-radius: 4px; padding: 4px 12px; font: inherit; font-size: 12px; cursor: pointer; }
|
||||
/* Worker Mesh — character bots (/cases/{id}/journey) */
|
||||
.mesh { list-style: none; margin: 22px 0 0; padding: 0; position: relative; }
|
||||
.mesh::before {
|
||||
content: ""; position: absolute; left: 47px; top: 36px; bottom: 70px; width: 3px;
|
||||
background: linear-gradient(180deg, transparent, var(--border) 7%, var(--border) 93%, transparent);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.case-token {
|
||||
position: absolute; left: 42px; top: 28px; width: 13px; height: 13px; border-radius: 50%;
|
||||
background: var(--accent); opacity: 0; z-index: 3;
|
||||
box-shadow: 0 0 10px 3px var(--accent), 0 0 24px 7px var(--accent-glow);
|
||||
}
|
||||
.mesh.playing .case-token { animation: token-travel 6.6s ease-in-out forwards; }
|
||||
@keyframes token-travel {
|
||||
0% { opacity: 0; top: 28px; }
|
||||
6% { opacity: 1; }
|
||||
94% { opacity: 1; }
|
||||
100% { opacity: 0; top: calc(100% - 78px); }
|
||||
}
|
||||
|
||||
.bot {
|
||||
position: relative; display: grid; grid-template-columns: 96px 1fr;
|
||||
gap: 20px; align-items: start; padding-bottom: 26px;
|
||||
}
|
||||
.mesh.playing .bot {
|
||||
opacity: 0; transform: translateY(16px);
|
||||
animation: bot-rise 0.55s cubic-bezier(0.2, 0.7, 0.3, 1) forwards;
|
||||
animation-delay: calc(var(--i) * 0.95s);
|
||||
}
|
||||
@keyframes bot-rise { to { opacity: 1; transform: none; } }
|
||||
|
||||
.bot-avatar { position: relative; display: flex; flex-direction: column; align-items: center; }
|
||||
.bot-svg { width: 96px; height: 116px; overflow: visible; }
|
||||
.bot-done .bot-svg { animation: idle-float 3.6s ease-in-out infinite; animation-delay: calc(var(--i) * 0.3s); }
|
||||
@keyframes idle-float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }
|
||||
.bot-name {
|
||||
margin-top: 2px; font-size: 12px; font-weight: 700; letter-spacing: 1px;
|
||||
text-transform: uppercase; color: var(--bot);
|
||||
}
|
||||
.bot-asleep .bot-name { color: var(--muted); }
|
||||
|
||||
.bot-head, .bot-body { fill: #11151d; stroke: var(--bot); stroke-width: 2.4; }
|
||||
.bot-visor { fill: #0a0d13; }
|
||||
.bot-stalk { stroke: var(--bot); stroke-width: 3; }
|
||||
.bot-arm, .bot-foot { fill: #1c2230; stroke: var(--bot); stroke-width: 2; }
|
||||
.bot-tip, .bot-eye.eye-awake, .bot-core { fill: var(--bot); }
|
||||
.bot-mouth { stroke: var(--bot); stroke-width: 3; stroke-linecap: round; fill: none; }
|
||||
.bot-eye.eye-sleep { stroke: var(--bot); stroke-width: 3; stroke-linecap: round; }
|
||||
.bot-num { fill: #0a0d13; font-size: 11px; font-weight: 700; }
|
||||
|
||||
.eye-sleep, .mouth-flat { display: none; }
|
||||
.bot-asleep .eye-awake, .bot-asleep .mouth-happy { display: none; }
|
||||
.bot-asleep .eye-sleep, .bot-asleep .mouth-flat { display: inline; }
|
||||
.bot-asleep .bot-svg { filter: grayscale(0.7) brightness(0.7); opacity: 0.55; }
|
||||
|
||||
.bot-done .bot-tip { animation: tip-pulse 1.8s ease-in-out infinite; }
|
||||
@keyframes tip-pulse { 0%, 100% { opacity: 0.5; } 50% { opacity: 1; } }
|
||||
.bot-done .bot-core { animation: core-pulse 2.4s ease-in-out infinite; }
|
||||
@keyframes core-pulse { 0%, 100% { opacity: 0.65; } 50% { opacity: 1; } }
|
||||
|
||||
.bot-ring {
|
||||
position: absolute; top: 44px; left: 50%; width: 64px; height: 64px; margin-left: -32px;
|
||||
border-radius: 50%; border: 2px solid var(--bot); opacity: 0;
|
||||
}
|
||||
.mesh.playing .bot-done .bot-ring {
|
||||
animation: ring-burst 1s ease-out; animation-delay: calc(var(--i) * 0.95s);
|
||||
}
|
||||
@keyframes ring-burst { 0% { opacity: 0.8; transform: scale(0.3); } 100% { opacity: 0; transform: scale(1.6); } }
|
||||
|
||||
.bot-bubble {
|
||||
position: relative; background: var(--panel-2); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 12px 16px; min-height: 72px;
|
||||
}
|
||||
.bot-bubble::before {
|
||||
content: ""; position: absolute; left: -9px; top: 26px; width: 16px; height: 16px;
|
||||
background: var(--panel-2); border-left: 1px solid var(--border); border-bottom: 1px solid var(--border);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.bot-done .bot-bubble { border-color: color-mix(in oklab, var(--bot) 45%, var(--border)); }
|
||||
.bot-purpose { margin: 0 0 6px; font-size: 12px; font-style: italic; color: var(--muted); }
|
||||
.bot-answer { margin: 0; font-size: 14px; color: var(--text); }
|
||||
.bot-done .bot-answer { color: var(--bot); font-weight: 600; }
|
||||
.bot-answer-skip { color: var(--muted); }
|
||||
.bot-working { margin: 0; font-size: 14px; font-weight: 600; color: var(--bot); position: absolute; opacity: 0; pointer-events: none; }
|
||||
.bot-facts { margin: 8px 0 0; padding-left: 16px; font-size: 12px; color: var(--muted); }
|
||||
.bot-facts li { margin: 2px 0; }
|
||||
|
||||
.mesh.playing .bot-done .bot-working { animation: work-flash 1.5s ease both; animation-delay: calc(var(--i) * 0.95s); }
|
||||
@keyframes work-flash { 0%, 12% { opacity: 0; } 22%, 60% { opacity: 1; } 74%, 100% { opacity: 0; } }
|
||||
.mesh.playing .bot-done .bot-answer,
|
||||
.mesh.playing .bot-done .bot-facts { animation: answer-rise 1.6s ease both; animation-delay: calc(var(--i) * 0.95s); }
|
||||
@keyframes answer-rise { 0%, 62% { opacity: 0; transform: translateY(4px); } 80%, 100% { opacity: 1; transform: none; } }
|
||||
|
||||
.replay-btn {
|
||||
background: var(--panel-2); color: var(--accent); border: 1px solid var(--border);
|
||||
border-radius: 4px; padding: 4px 12px; font: inherit; font-size: 12px; cursor: pointer;
|
||||
}
|
||||
.replay-btn:hover { border-color: var(--accent); }
|
||||
|
||||
@@ -1,24 +1,65 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ case.case_id }} — Journey — psyc{% endblock %}
|
||||
{% block title %}{{ case.case_id }} — Worker Mesh — psyc{% endblock %}
|
||||
{% block content %}
|
||||
{% set accents = ['#1ec8ff', '#2dd4bf', '#38bdf8', '#a78bfa', '#60a5fa', '#4ade80', '#fbbf24'] %}
|
||||
|
||||
<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs>
|
||||
<filter id="botglow" x="-90%" y="-90%" width="280%" height="280%">
|
||||
<feGaussianBlur stdDeviation="2.6" result="b"/>
|
||||
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs></svg>
|
||||
|
||||
{% macro bot_svg(b) %}
|
||||
<svg class="bot-svg" viewBox="0 0 96 116" aria-hidden="true">
|
||||
<line class="bot-stalk" x1="48" y1="7" x2="48" y2="19"/>
|
||||
<circle class="bot-tip" cx="48" cy="6" r="4.6" filter="url(#botglow)"/>
|
||||
<rect class="bot-arm" x="8" y="70" width="9" height="23" rx="4.5"/>
|
||||
<rect class="bot-arm" x="79" y="70" width="9" height="23" rx="4.5"/>
|
||||
<rect class="bot-head" x="16" y="18" width="64" height="46" rx="14"/>
|
||||
<rect class="bot-visor" x="24" y="28" width="48" height="22" rx="10"/>
|
||||
<circle class="bot-eye eye-awake" cx="38" cy="39" r="4.6" filter="url(#botglow)"/>
|
||||
<circle class="bot-eye eye-awake" cx="58" cy="39" r="4.6" filter="url(#botglow)"/>
|
||||
<line class="bot-eye eye-sleep" x1="32" y1="39" x2="44" y2="39"/>
|
||||
<line class="bot-eye eye-sleep" x1="52" y1="39" x2="64" y2="39"/>
|
||||
<path class="bot-mouth mouth-happy" d="M40 54 Q48 61 56 54" fill="none"/>
|
||||
<line class="bot-mouth mouth-flat" x1="41" y1="57" x2="55" y2="57"/>
|
||||
<rect class="bot-body" x="24" y="68" width="48" height="34" rx="11"/>
|
||||
<circle class="bot-core" cx="48" cy="85" r="8.5" filter="url(#botglow)"/>
|
||||
<text class="bot-num" x="48" y="89" text-anchor="middle">{{ b.index }}</text>
|
||||
<rect class="bot-foot" x="30" y="101" width="13" height="9" rx="3"/>
|
||||
<rect class="bot-foot" x="53" y="101" width="13" height="9" rx="3"/>
|
||||
</svg>
|
||||
{% endmacro %}
|
||||
|
||||
<section class="panel">
|
||||
<a class="back" href="/cases/{{ case.case_id }}">← {{ case.case_id }}</a>
|
||||
<div class="panel-head">
|
||||
<h1>Case Journey</h1>
|
||||
<button class="replay-btn" onclick="replayJourney()">▶ replay</button>
|
||||
<h1>Worker Mesh</h1>
|
||||
<button class="replay-btn" onclick="replayMesh()">▶ replay</button>
|
||||
</div>
|
||||
<p class="summary-lead">{{ case.summary }}</p>
|
||||
|
||||
<ol class="journey playing" id="journey">
|
||||
<ol class="mesh playing" id="mesh">
|
||||
<span class="case-token" aria-hidden="true"></span>
|
||||
{% for b in beats %}
|
||||
<li class="beat {{ 'beat-done' if b.occurred else 'beat-skip' }}" style="--i: {{ loop.index0 }}">
|
||||
<span class="beat-node"></span>
|
||||
<div class="beat-card">
|
||||
<span class="beat-line">{{ b.index }} · {{ b.line }}</span>
|
||||
<h2>{{ b.title }}{% if not b.occurred %} <span class="beat-pending">pending</span>{% endif %}</h2>
|
||||
<p class="beat-caption">{{ b.caption }}</p>
|
||||
<li class="bot {{ 'bot-done' if b.occurred else 'bot-asleep' }}"
|
||||
style="--i: {{ loop.index0 }}; --bot: {{ accents[loop.index0] }}">
|
||||
<div class="bot-avatar">
|
||||
<span class="bot-ring" aria-hidden="true"></span>
|
||||
{{ bot_svg(b) }}
|
||||
<span class="bot-name">{{ b.bot }}</span>
|
||||
</div>
|
||||
<div class="bot-bubble">
|
||||
<p class="bot-purpose">{{ b.purpose }}</p>
|
||||
{% if b.occurred %}
|
||||
<p class="bot-working">▸ {{ b.action }}…</p>
|
||||
<p class="bot-answer">{{ b.caption }}</p>
|
||||
{% else %}
|
||||
<p class="bot-answer bot-answer-skip">{{ b.caption }}</p>
|
||||
{% endif %}
|
||||
{% if b.facts %}
|
||||
<ul class="beat-facts">{% for f in b.facts %}<li>{{ f }}</li>{% endfor %}</ul>
|
||||
<ul class="bot-facts">{% for f in b.facts %}<li>{{ f }}</li>{% endfor %}</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
@@ -26,11 +67,11 @@
|
||||
</ol>
|
||||
</section>
|
||||
<script>
|
||||
function replayJourney() {
|
||||
const j = document.getElementById('journey');
|
||||
j.classList.remove('playing');
|
||||
void j.offsetWidth;
|
||||
j.classList.add('playing');
|
||||
function replayMesh() {
|
||||
const m = document.getElementById('mesh');
|
||||
m.classList.remove('playing');
|
||||
void m.offsetWidth;
|
||||
m.classList.add('playing');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user