From 76a0b0b6361aa91de0d86bf62ac7a13b26b8d4a2 Mon Sep 17 00:00:00 2001 From: m17hr1l Date: Mon, 25 May 2026 19:02:22 +0200 Subject: [PATCH] =?UTF-8?q?stage-31:=20Newsline=20polish=20=E2=80=94=20fea?= =?UTF-8?q?tured=20case,=20time=20buckets,=20severity=20accents,=20generat?= =?UTF-8?q?ed=20visuals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The home page goes from a flat event stream to something that reads like a news blog: - Featured-case hero card at the top, picked as the highest-severity case (CRITICAL > HIGH, tie-break recency) from the last 7 days. Wide, with a procedurally generated SVG hero behind a gradient overlay that carries title + severity + TLP + feed + ingest time. - Recent activity is now grouped under Today / Yesterday / Earlier this week / Older bucket headers. - Each item gets a left-border severity accent (red CRITICAL, amber HIGH, muted MEDIUM/LOW) so the page is scannable at a glance. Images: new cockpit/case_visuals.py generates SVGs from case data — zero external image gen, zero curated assets. Every visual is deterministic from case_id (so a case keeps its identity across sessions) and themed to its severity: - case_hero_svg() — 880x220 hero with severity radial glow, a faint scan grid, a particle constellation with auto-connecting lines, HUD corner brackets, and the case id whispered in the bottom-right. - case_glyph_svg() — small mirror-symmetric identicon (5-grid), severity-colored, shown beside each case news item in place of an emoji icon. Two case_ids → two distinct glyphs; same id → same glyph. 7 news tests pass; visual sanity print confirms hero is deterministic and uses the right severity accent. Co-Authored-By: Claude Opus 4.7 --- src/psyc/cockpit/app.py | 15 ++- src/psyc/cockpit/case_visuals.py | 166 +++++++++++++++++++++++++++ src/psyc/cockpit/static/cockpit.css | 60 ++++++++++ src/psyc/cockpit/templates/home.html | 65 ++++++++--- src/psyc/lines/news.py | 48 ++++++++ tests/test_news.py | 29 +++++ 6 files changed, 363 insertions(+), 20 deletions(-) create mode 100644 src/psyc/cockpit/case_visuals.py 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 %} -
  1. -
    {{ i.icon }}
    -
    -
    - {{ i.headline }} - {% if i.severity %}{{ i.severity }}{% endif %} + + {% for b in buckets %} +

    {{ b.label }} {{ b.items|length }}

    +
      + {% for i in b.items %} +
    1. +
      + {% 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 }}
      -
      - - {% if i.case_id %} · {{ i.case_id }}{% endif %} +
      +
      + {{ i.headline }} + {% if i.severity %}{{ i.severity }}{% endif %} +
      +
      {{ i.body }}
      +
      + + {% if i.case_id %} · {{ i.case_id }}{% endif %} +
      -
      -
    2. - {% endfor %} -
    +
  2. + {% endfor %} +
+ {% endfor %}