stage-31 polish: featured hero — particles sync with sweep, stat chips, radar emblem

1. Particles now blink in time with the scan column.
   Each <circle>/<rect> in the case hero SVG carries class="hero-particle"
   and a CSS animation-delay equal to -(arrival-time-of-sweep-at-its-x),
   so as the sweep marches left-to-right the particles light up in
   sequence — a real "the scanner just touched this point" effect. The
   sweep is now linear / non-alternating on a 12s cycle so the phase
   stays predictable. Drop-shadow + opacity flash at the peak.

2. Stat chips added under the featured title.
   Up to five compact chips showing incident type, total IOC count with
   per-type breakdown (U/D/I/H/C), victim country if mapped, confidence
   level, malware family. Each chip is color-coded.

3. Radar emblem in the top-right of the hero.
   Pure SVG: three concentric range rings, cross-hairs, a translucent
   sweep arm that spins every 4.5s, a center dot. The whole emblem
   inherits the cycling-hero hue cycle so it changes color with the
   grid. Smaller + dimmer on mobile; honors prefers-reduced-motion.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m17hr1l
2026-05-25 19:38:31 +02:00
parent 977c3670f3
commit 43c7c199c3
3 changed files with 127 additions and 12 deletions

View File

@@ -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'<line x1="0" y1="{y}" x2="{width}" y2="{y}" stroke="{accent}" stroke-opacity="0.04"/>')
# 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'<circle cx="{x:.1f}" cy="{y:.1f}" r="{s:.1f}" fill="{accent}" fill-opacity="{op:.2f}"/>')
parts.append(
f'<circle class="hero-particle" cx="{x:.1f}" cy="{y:.1f}" r="{s:.1f}" '
f'fill="{accent}" fill-opacity="{op:.2f}" style="{style}"/>'
)
else:
parts.append(
f'<rect x="{x - s:.1f}" y="{y - s:.1f}" width="{s * 2:.1f}" height="{s * 2:.1f}" '
f'fill="{accent}" fill-opacity="{op:.2f}" transform="rotate(45 {x:.1f} {y:.1f})"/>'
f'<rect class="hero-particle" x="{x - s:.1f}" y="{y - s:.1f}" '
f'width="{s * 2:.1f}" height="{s * 2:.1f}" fill="{accent}" fill-opacity="{op:.2f}" '
f'transform="rotate(45 {x:.1f} {y:.1f})" style="{style}"/>'
)
# HUD corner brackets

View File

@@ -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); }

View File

@@ -55,8 +55,44 @@
<div class="featured-hero">
<div class="featured-grid" aria-hidden="true"></div>
<div class="featured-particles" aria-hidden="true">{{ featured_hero|safe }}</div>
<svg class="featured-radar" viewBox="0 0 100 100" aria-hidden="true">
<defs>
<linearGradient id="radar-sweep" x1="0" y1="50" x2="50" y2="50" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="currentColor" stop-opacity="0"/>
<stop offset="100%" stop-color="currentColor" stop-opacity="0.55"/>
</linearGradient>
</defs>
<circle cx="50" cy="50" r="46" fill="none" stroke="currentColor" stroke-opacity="0.35" stroke-width="0.6"/>
<circle cx="50" cy="50" r="32" fill="none" stroke="currentColor" stroke-opacity="0.25" stroke-width="0.5"/>
<circle cx="50" cy="50" r="18" fill="none" stroke="currentColor" stroke-opacity="0.18" stroke-width="0.4"/>
<line x1="50" y1="4" x2="50" y2="96" stroke="currentColor" stroke-opacity="0.10" stroke-width="0.4"/>
<line x1="4" y1="50" x2="96" y2="50" stroke="currentColor" stroke-opacity="0.10" stroke-width="0.4"/>
<g class="radar-sweep-arm">
<path d="M50,50 L50,4 A46,46 0 0,1 96,50 Z" fill="url(#radar-sweep)"/>
</g>
<circle cx="50" cy="50" r="2.5" fill="currentColor" fill-opacity="0.85"/>
</svg>
<div class="featured-overlay">
<h3 class="featured-title">{{ featured.summary }}</h3>
<div class="featured-stats">
{% if featured.classification.incident_type %}
<span class="stat-chip stat-incident">⌧ {{ featured.classification.incident_type.value }}</span>
{% 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 %}
<span class="stat-chip stat-iocs" title="URLs/Domains/IPs/Hashes/CVEs">⛛ {{ total_iocs }} IOCs · {{ obs.urls|length }}U/{{ obs.domains|length }}D/{{ obs.ips|length }}I/{{ obs.hashes|length }}H/{{ obs.cves|length }}C</span>
{% endif %}
{% if featured.victim.country %}
<span class="stat-chip stat-country">📍 {{ featured.victim.country }}</span>
{% endif %}
{% if featured.confidence and featured.confidence.level %}
<span class="stat-chip stat-confidence">⊞ {{ featured.confidence.level }} confidence</span>
{% endif %}
{% if featured.source_metadata.malware %}
<span class="stat-chip stat-family">⌬ {{ featured.source_metadata.malware }}</span>
{% endif %}
</div>
<div class="featured-cta">Open case <span class="featured-arrow"></span></div>
</div>
</div>