diff --git a/src/psyc/cockpit/case_visuals.py b/src/psyc/cockpit/case_visuals.py
index c86c2bd..2b53697 100644
--- a/src/psyc/cockpit/case_visuals.py
+++ b/src/psyc/cockpit/case_visuals.py
@@ -82,9 +82,20 @@ def case_hero_svg(case: Case, width: int = 880, height: int = 220) -> str:
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)]
+ # Particle field — each particle gets a CSS animation-delay so it blinks
+ # exactly when the sweep column passes over its x-position. Sweep cycle is
+ # 12s left-to-right; arrival time at x = 12 * (px*100 + 20) / 140 sec.
+ n = 42
+ pts = []
+ for _ in range(n):
+ x = rng() * width
+ y = rng() * height
+ s = 1.2 + rng() * 3
+ op = 0.3 + rng() * 0.55
+ # negative delay so the cycle is already at the right phase when the
+ # page loads (otherwise every particle would flash in unison at t=0).
+ delay = -round(12.0 * ((x / width) * 100 + 20) / 140, 2)
+ pts.append((x, y, s, op, delay))
# Connect close particles
for i in range(n):
@@ -99,13 +110,18 @@ def case_hero_svg(case: Case, width: int = 880, height: int = 220) -> str:
f'stroke="{accent}" stroke-width="0.6" stroke-opacity="{op:.2f}"/>'
)
- for x, y, s, op in pts:
+ for x, y, s, op, d in pts:
+ style = f'animation-delay: {d}s;'
if rng() < 0.7:
- parts.append(f'')
+ parts.append(
+ f''
+ )
else:
parts.append(
- f''
+ f''
)
# HUD corner brackets
diff --git a/src/psyc/cockpit/static/cockpit.css b/src/psyc/cockpit/static/cockpit.css
index 05d58a4..c097b83 100644
--- a/src/psyc/cockpit/static/cockpit.css
+++ b/src/psyc/cockpit/static/cockpit.css
@@ -904,11 +904,12 @@ body.wide .net-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr
will-change: color, background-position;
}
.featured-grid::after {
- /* a soft glowing column that sweeps across the hero */
+ /* glowing column that sweeps left-to-right; non-alternating so the
+ particle pulse delays line up the same way every cycle */
content: ""; position: absolute; inset: 0;
- background: radial-gradient(220px 100% at 0% 50%, currentColor 0%, transparent 65%);
+ background: radial-gradient(180px 100% at 0% 50%, currentColor 0%, transparent 60%);
opacity: 0.55; mix-blend-mode: screen;
- animation: hero-sweep 12s ease-in-out infinite alternate;
+ animation: hero-sweep 12s linear infinite;
}
@keyframes hero-hue-cycle {
0%, 100% { color: #1ec8ff; } /* cyan */
@@ -922,8 +923,26 @@ body.wide .net-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr
to { background-position: 37px 37px; }
}
@keyframes hero-sweep {
- 0% { transform: translateX(-20%); }
- 100% { transform: translateX(120%); }
+ 0% { transform: translateX(-20%); opacity: 0.55; }
+ 85% { transform: translateX(120%); opacity: 0.55; }
+ 90% { opacity: 0; }
+ 99% { transform: translateX(-20%); opacity: 0; }
+ 100% { transform: translateX(-20%); opacity: 0.55; }
+}
+
+/* Particles in the hero SVG blink when the sweep column passes over them.
+ Each particle has animation-delay = -arrival-time (negative), set inline
+ by case_visuals so the cycle phase matches its x-position from t=0. */
+.featured-hero .hero-particle {
+ animation: hero-particle-pulse 12s linear infinite;
+ transform-box: fill-box; transform-origin: center;
+}
+@keyframes hero-particle-pulse {
+ /* peak at 0% = the moment the sweep arrives; quick decay to baseline */
+ 0% { fill-opacity: 1; filter: drop-shadow(0 0 5px currentColor); }
+ 8% { fill-opacity: 0.35; filter: none; }
+ 92% { fill-opacity: 0.35; }
+ 100% { fill-opacity: 1; }
}
/* case-specific particles layered on top of the grid */
@@ -1095,3 +1114,47 @@ body.wide .net-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr
@media (prefers-reduced-motion: reduce) {
.news-item, .news-arrow, .featured-arrow, .featured-cta, .case-glyph-svg, .featured-section-pulse { transition: none; animation: none; }
}
+
+/* ── featured card: radar emblem + stat chips ──────────────── */
+.featured-radar {
+ position: absolute; top: 16px; right: 18px;
+ width: 130px; height: 130px;
+ color: #1ec8ff;
+ pointer-events: none;
+ opacity: 0.62;
+ animation: hero-hue-cycle 18s ease-in-out infinite;
+ filter: drop-shadow(0 0 12px currentColor);
+}
+.radar-sweep-arm {
+ transform-origin: 50px 50px;
+ animation: radar-spin 4.5s linear infinite;
+}
+@keyframes radar-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
+@media (max-width: 720px) {
+ .featured-radar { width: 78px; height: 78px; top: 10px; right: 10px; opacity: 0.45; }
+}
+@media (prefers-reduced-motion: reduce) {
+ .radar-sweep-arm, .featured-radar { animation: none; }
+}
+
+.featured-stats {
+ display: flex; flex-wrap: wrap; gap: 6px;
+ margin: 8px 0 10px;
+}
+.stat-chip {
+ display: inline-flex; align-items: center; gap: 4px;
+ padding: 3px 9px;
+ font-family: ui-monospace, Menlo, monospace; font-size: 11px;
+ background: rgba(15,17,21,0.55);
+ border: 1px solid rgba(125,133,151,0.35);
+ border-radius: 4px;
+ color: var(--text);
+ backdrop-filter: blur(2px);
+ transition: border-color 0.18s, color 0.18s, background 0.18s;
+}
+.stat-chip:hover { border-color: var(--accent); color: var(--accent); }
+.stat-incident { border-color: rgba(30,200,255,0.45); color: var(--accent); }
+.stat-iocs { border-color: rgba(74,222,128,0.4); color: var(--green); }
+.stat-country { border-color: rgba(251,191,36,0.4); color: var(--amber); }
+.stat-confidence { border-color: rgba(167,139,250,0.4); color: #c4b5fd; }
+.stat-family { border-color: rgba(248,113,113,0.4); color: var(--red); }
diff --git a/src/psyc/cockpit/templates/home.html b/src/psyc/cockpit/templates/home.html
index b08e129..9e87006 100644
--- a/src/psyc/cockpit/templates/home.html
+++ b/src/psyc/cockpit/templates/home.html
@@ -55,8 +55,44 @@
{{ featured_hero|safe }}
+
{{ featured.summary }}
+
+ {% if featured.classification.incident_type %}
+ ⌧ {{ featured.classification.incident_type.value }}
+ {% endif %}
+ {% set obs = featured.observables %}
+ {% set total_iocs = (obs.urls|length) + (obs.domains|length) + (obs.ips|length) + (obs.hashes|length) + (obs.cves|length) %}
+ {% if total_iocs %}
+ ⛛ {{ total_iocs }} IOCs · {{ obs.urls|length }}U/{{ obs.domains|length }}D/{{ obs.ips|length }}I/{{ obs.hashes|length }}H/{{ obs.cves|length }}C
+ {% endif %}
+ {% if featured.victim.country %}
+ 📍 {{ featured.victim.country }}
+ {% endif %}
+ {% if featured.confidence and featured.confidence.level %}
+ ⊞ {{ featured.confidence.level }} confidence
+ {% endif %}
+ {% if featured.source_metadata.malware %}
+ ⌬ {{ featured.source_metadata.malware }}
+ {% endif %}
+
Open case →