From 838d90ffcb7eddbcc6252ba6fa11ce493d864b14 Mon Sep 17 00:00:00 2001 From: m17hr1l Date: Mon, 18 May 2026 20:41:45 +0200 Subject: [PATCH] =?UTF-8?q?stage-5:=20Worker=20Mesh=20=E2=80=94=20animated?= =?UTF-8?q?=20character-bot=20agents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/psyc/cockpit/journey.py | 15 +++ src/psyc/cockpit/static/cockpit.css | 116 ++++++++++++++++++++---- src/psyc/cockpit/templates/journey.html | 73 +++++++++++---- 3 files changed, 169 insertions(+), 35 deletions(-) diff --git a/src/psyc/cockpit/journey.py b/src/psyc/cockpit/journey.py index 4f786e8..814ac25 100644 --- a/src/psyc/cockpit/journey.py +++ b/src/psyc/cockpit/journey.py @@ -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 diff --git a/src/psyc/cockpit/static/cockpit.css b/src/psyc/cockpit/static/cockpit.css index 709d45b..36fd00e 100644 --- a/src/psyc/cockpit/static/cockpit.css +++ b/src/psyc/cockpit/static/cockpit.css @@ -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); } diff --git a/src/psyc/cockpit/templates/journey.html b/src/psyc/cockpit/templates/journey.html index ea28a3d..b163e55 100644 --- a/src/psyc/cockpit/templates/journey.html +++ b/src/psyc/cockpit/templates/journey.html @@ -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'] %} + + + +{% macro bot_svg(b) %} + +{% endmacro %} +
← {{ case.case_id }}
-

Case Journey

- +

Worker Mesh

+

{{ case.summary }}

-
    +
      + {% for b in beats %} -
    1. - -
      - {{ b.index }} · {{ b.line }} -

      {{ b.title }}{% if not b.occurred %} pending{% endif %}

      -

      {{ b.caption }}

      +
    2. +
      + + {{ bot_svg(b) }} + {{ b.bot }} +
      +
      +

      {{ b.purpose }}

      + {% if b.occurred %} +

      ▸ {{ b.action }}…

      +

      {{ b.caption }}

      + {% else %} +

      {{ b.caption }}

      + {% endif %} {% if b.facts %} -
        {% for f in b.facts %}
      • {{ f }}
      • {% endfor %}
      +
        {% for f in b.facts %}
      • {{ f }}
      • {% endfor %}
      {% endif %}
    3. @@ -26,11 +67,11 @@
{% endblock %}