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:
@@ -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
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user