diff --git a/src/psyc/cockpit/app.py b/src/psyc/cockpit/app.py
index 7bded93..57e0ce4 100644
--- a/src/psyc/cockpit/app.py
+++ b/src/psyc/cockpit/app.py
@@ -12,7 +12,7 @@ from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
from psyc import db, log
-from psyc.cockpit import adminauth, docker_view, inference, journey as journey_view
+from psyc.cockpit import adminauth, case_visuals, docker_view, inference, journey as journey_view
from psyc.lines import courier as courier_line
from psyc.lines import ledger as ledger_line
from psyc.lines import lookup as lookup_line
@@ -42,13 +42,24 @@ def _admin_ok(request: Request) -> bool:
@app.get("/", response_class=HTMLResponse)
def index(request: Request) -> HTMLResponse:
+ items = news_line.recent_items(limit=40)
+ featured = news_line.featured_case()
+ case_index: dict = {}
+ for i in items:
+ if i.kind == "case" and i.case_id and i.case_id not in case_index:
+ got = db.get_case(i.case_id)
+ if not isinstance(got, Err):
+ case_index[i.case_id] = case_visuals.case_glyph_svg(got.value)
return TEMPLATES.TemplateResponse(
request,
"home.html",
{
"kpis": news_line.kpis(),
- "items": news_line.recent_items(limit=24),
+ "buckets": news_line.bucket_items(items),
"feeds": news_line.feed_health(),
+ "featured": featured,
+ "featured_hero": case_visuals.case_hero_svg(featured) if featured else "",
+ "case_glyphs": case_index,
},
)
diff --git a/src/psyc/cockpit/case_visuals.py b/src/psyc/cockpit/case_visuals.py
new file mode 100644
index 0000000..c86c2bd
--- /dev/null
+++ b/src/psyc/cockpit/case_visuals.py
@@ -0,0 +1,166 @@
+"""Procedural SVG visuals derived from case data.
+
+Zero external image-gen, zero curated assets — every visual is generated
+server-side from the case_id (deterministic per case) and severity. Cyber-HUD
+aesthetic, theme-coordinated with cockpit.css.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import math
+from typing import Optional
+
+from psyc.models import Case, Severity
+
+
+_SEV_ACCENT = {
+ Severity.CRITICAL: "#f87171",
+ Severity.HIGH: "#fbbf24",
+ Severity.MEDIUM: "#1ec8ff",
+ Severity.LOW: "#7d8597",
+}
+_DEFAULT_ACCENT = "#1ec8ff"
+
+
+def _seed(case_id: str) -> int:
+ """Stable 32-bit int seed from a string — deterministic per case."""
+ return int.from_bytes(hashlib.sha256(case_id.encode()).digest()[:4], "big")
+
+
+def _prng(seed: int):
+ """Mulberry32 — small fast deterministic PRNG. Yields floats in [0,1)."""
+ state = [seed & 0xFFFFFFFF]
+ def nxt():
+ state[0] = (state[0] + 0x6D2B79F5) & 0xFFFFFFFF
+ t = state[0]
+ t = ((t ^ (t >> 15)) * (t | 1)) & 0xFFFFFFFF
+ t ^= (t + ((t ^ (t >> 7)) * (t | 61))) & 0xFFFFFFFF
+ return ((t ^ (t >> 14)) & 0xFFFFFFFF) / 4294967296.0
+ return nxt
+
+
+def _accent(case: Optional[Case]) -> str:
+ if case and case.classification.severity:
+ return _SEV_ACCENT.get(case.classification.severity, _DEFAULT_ACCENT)
+ return _DEFAULT_ACCENT
+
+
+# ---------- hero SVG (featured card) -----------------------------------
+
+def case_hero_svg(case: Case, width: int = 880, height: int = 220) -> str:
+ """Wide SVG for the featured-case hero. Particle constellation + severity glow."""
+ seed = _seed(case.case_id)
+ rng = _prng(seed)
+ accent = _accent(case)
+ sid = f"h{seed:x}"
+
+ parts = []
+ parts.append(
+ f''
+ f''
+ f''
+ f''
+ f''
+ f''
+ f''
+ f''
+ f''
+ f''
+ f''
+ f''
+ f''
+ f''
+ )
+ parts.append(f'')
+ parts.append(f'')
+ parts.append(f'')
+
+ # Faint scan grid
+ for x in range(0, width, 44):
+ parts.append(f'')
+ for y in range(0, height, 44):
+ parts.append(f'')
+
+ # Particle field
+ n = 40
+ pts = [(rng() * width, rng() * height, 1 + rng() * 3, 0.3 + rng() * 0.55) for _ in range(n)]
+
+ # Connect close particles
+ for i in range(n):
+ ax, ay, *_ = pts[i]
+ for j in range(i + 1, n):
+ bx, by, *_ = pts[j]
+ d = math.hypot(bx - ax, by - ay)
+ if d < 90:
+ op = (1 - d / 90) * 0.20
+ parts.append(
+ f''
+ )
+
+ for x, y, s, op in pts:
+ if rng() < 0.7:
+ parts.append(f'')
+ else:
+ parts.append(
+ f''
+ )
+
+ # HUD corner brackets
+ for cx, cy, dx, dy in [
+ (12, 12, 1, 1), (width - 12, 12, -1, 1),
+ (12, height - 12, 1, -1), (width - 12, height - 12, -1, -1),
+ ]:
+ parts.append(
+ f''
+ )
+
+ # Ornamental case id, very faint
+ parts.append(
+ f''
+ f'{case.case_id}'
+ )
+
+ return (
+ f'"
+ )
+
+
+# ---------- glyph SVG (news list items) ---------------------------------
+
+def case_glyph_svg(case: Case, size: int = 36) -> str:
+ """A small identicon-like SVG glyph for a case — symmetric, severity-colored."""
+ seed = _seed(case.case_id)
+ rng = _prng(seed)
+ accent = _accent(case)
+ grid = 5
+ cell = size / grid
+ parts = [f'']
+ pad = 3
+ # mirror-symmetric pattern
+ for cy in range(grid):
+ for cx in range((grid + 1) // 2):
+ if rng() < 0.55:
+ op = 0.45 + rng() * 0.45
+ x = cx * cell + pad / 2
+ y = cy * cell + pad / 2
+ w = cell - pad
+ h = cell - pad
+ parts.append(f'')
+ # mirror
+ mx = (grid - 1 - cx) * cell + pad / 2
+ if cx != grid - 1 - cx:
+ parts.append(f'')
+ return (
+ f'"
+ )
diff --git a/src/psyc/cockpit/static/cockpit.css b/src/psyc/cockpit/static/cockpit.css
index 601dee3..ffdfff7 100644
--- a/src/psyc/cockpit/static/cockpit.css
+++ b/src/psyc/cockpit/static/cockpit.css
@@ -867,3 +867,63 @@ body.wide .net-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr
.model-status { display: none; }
.brand-icon { height: 32px; width: 100px; }
}
+
+/* ── featured case hero ────────────────────────────────────── */
+.featured {
+ position: relative; margin: 0 0 18px;
+ border: 1px solid var(--border); border-radius: 14px; overflow: hidden;
+ background: var(--panel-2);
+ box-shadow: 0 6px 30px rgba(0,0,0,0.45);
+ transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s;
+}
+.featured.sev-critical { border-color: var(--red); box-shadow: 0 6px 30px rgba(248,113,113,0.18); }
+.featured.sev-high { border-color: var(--amber); box-shadow: 0 6px 30px rgba(251,191,36,0.16); }
+.featured:hover { transform: translateY(-1px); box-shadow: 0 10px 36px rgba(30,200,255,0.18); }
+.featured-link { display: block; color: inherit; text-decoration: none; position: relative; }
+.featured-link:hover { text-decoration: none; }
+.featured-hero { position: relative; aspect-ratio: 880 / 220; max-height: 240px; overflow: hidden; }
+.featured-hero svg { width: 100%; height: 100%; display: block; }
+.featured-overlay {
+ position: absolute; left: 0; right: 0; bottom: 0;
+ padding: 18px 22px 18px;
+ background: linear-gradient(180deg, transparent, rgba(15,17,21,0.85) 55%);
+}
+.featured-tag {
+ font-family: var(--font-display); font-size: 10px; letter-spacing: 0.22em;
+ color: var(--accent); text-transform: uppercase; opacity: 0.85;
+ text-shadow: 0 0 12px var(--accent-glow);
+}
+.featured.sev-critical .featured-tag { color: var(--red); text-shadow: 0 0 12px rgba(248,113,113,0.45); }
+.featured.sev-high .featured-tag { color: var(--amber); text-shadow: 0 0 12px rgba(251,191,36,0.45); }
+.featured-title {
+ margin: 6px 0 8px; font-size: clamp(18px, 2.4vw, 24px); color: #eaf6ff;
+ text-shadow: 0 0 18px rgba(0,0,0,0.6);
+}
+.featured-meta { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; font-size: 12px; }
+.featured-meta .muted { color: var(--muted); font-family: ui-monospace, Menlo, monospace; font-size: 11px; }
+@media (max-width: 720px) {
+ .featured-hero { aspect-ratio: 16 / 9; }
+ .featured-overlay { padding: 12px 14px; }
+}
+
+/* ── time-bucket headers ───────────────────────────────────── */
+.bucket-head {
+ display: flex; align-items: baseline; gap: 10px;
+ font-family: var(--font-display); font-size: 13px;
+ letter-spacing: 0.12em; text-transform: uppercase;
+ color: var(--muted);
+ margin: 18px 0 6px; padding-bottom: 4px;
+ border-bottom: 1px dashed var(--border);
+}
+.bucket-head:first-of-type { margin-top: 12px; }
+.bucket-count { font-family: ui-monospace, Menlo, monospace; color: var(--accent); font-size: 12px; letter-spacing: 0; }
+
+/* ── per-item severity accent + glyph ──────────────────────── */
+.news-item { border-left: 3px solid transparent; padding-left: 10px; transition: background 0.12s; }
+.news-item:hover { background: rgba(30,200,255,0.04); }
+.news-item.sev-critical { border-left-color: var(--red); }
+.news-item.sev-high { border-left-color: var(--amber); }
+.news-item.sev-medium { border-left-color: #fde68a; }
+.news-item.sev-low { border-left-color: var(--muted); }
+.news-item .news-icon { background: transparent; border: none; padding: 0; }
+.news-item .news-icon .case-glyph-svg { display: block; border: 1px solid var(--border); border-radius: 7px; }
diff --git a/src/psyc/cockpit/templates/home.html b/src/psyc/cockpit/templates/home.html
index cdaab39..3835d41 100644
--- a/src/psyc/cockpit/templates/home.html
+++ b/src/psyc/cockpit/templates/home.html
@@ -37,34 +37,63 @@
+{% if featured %}
+
+{% endif %}
+
Recent activity
- {{ items|length }} items
+ {{ buckets|sum(attribute='items')|length }} items
Live feed of what psyc has detected and what it has done about it.
- {% if not items %}
+
+ {% if not buckets %}
Nothing recent yet — start with psyc fetch-all.
{% endif %}
-
- {% for i in items %}
- -
-
{{ i.icon }}
-
-
-
{{ i.headline }}
- {% if i.severity %}
{{ i.severity }}{% endif %}
+
+ {% for b in buckets %}
+
{{ b.label }} {{ b.items|length }}
+
+ {% for i in b.items %}
+ -
+
+ {% if i.kind == 'case' and i.case_id and case_glyphs.get(i.case_id) %}
+ {{ case_glyphs[i.case_id]|safe }}
+ {% else %}
+ {{ i.icon }}
+ {% endif %}
- {{ i.body }}
-
-
- {% endfor %}
-
+
+ {% endfor %}
+
+ {% endfor %}