Files
eranos/src/index.css
T
Chad Curtis 34c9ab7492 Frame composer, sidebar, and feed/comments as panels with brand-edged surfaces
- Wrap composer + comments / composer + feed in a rounded muted panel
  with continuous border-primary rails.
- Composer and campaign sidebar use a near-white surface with a
  brand-orange border; dark mode swaps to a near-black warm surface.
- Per-article borders and backgrounds retinted via scoped selectors
  so NoteCard stays untouched globally.
- Add hideCommentContext, authorBadge to NoteCard and
  renderAuthorBadge, leafCardClassName to ThreadedReplyList; move the
  Campaigner badge into the author row.
- Light-mode tokens: --card to 0 0% 98%, --muted to 30 12% 94% so
  cards and muted regions read as real surface steps.
- Drop the unused campaignsDetail.commentCount locale key everywhere.
2026-05-24 20:29:57 -05:00

785 lines
23 KiB
CSS

@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
/*
* Agora's built-in color tokens. These are hardcoded and ship with the
* bundle — there is no runtime injection path, no remote theme loader,
* no localStorage-driven CSS-variable mutation. The only thing that
* changes at runtime is the `.dark` class on <html>, set by
* AppProvider based on the resolved theme preference.
*
* If you want to recolor the app, edit these values directly.
*/
:root {
--radius: 0.75rem;
/* Mobile chrome dimensions — referenced by layout utility classes below */
--top-bar-height: 2.5rem; /* MobileTopBar h-10 = 40px */
--bottom-nav-height: 2.75rem; /* MobileBottomNav h-11 = 44px */
/* ── Light theme tokens ───────────────────────────────────────────── */
--background: 0 0% 100%;
--foreground: 222 47% 11%;
/* Card surfaces sit one subtle step above the page. The 2%
luminance drop is barely perceptible in isolation but gives
every card real elevation against the pure-white background
without needing borders or shadows. */
--card: 0 0% 98%;
--card-foreground: 222 47% 11%;
--popover: 0 0% 100%;
--popover-foreground: 222 47% 11%;
--primary: 24 100% 50%;
--primary-foreground: 0 0% 100%;
--secondary: 0 0% 96%;
--secondary-foreground: 222 47% 11%;
/* Muted surfaces (input fields, skeleton fills, comment-region
wraps, etc.) are a genuine luminance step away from background
so they read as a distinct surface in light mode. A very faint
warm hue ties them to the brand without coloring the page. */
--muted: 30 12% 94%;
--muted-foreground: 222 17% 46%;
--accent: 0 0% 92%;
--accent-foreground: 222 47% 11%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 24 50% 82%;
--input: 24 50% 82%;
--ring: 24 100% 50%;
--success: 142 72% 29%;
--success-foreground: 138 60% 94%;
}
.dark {
--background: 0 0% 10%;
--foreground: 0 0% 95%;
--card: 0 0% 12%;
--card-foreground: 0 0% 95%;
--popover: 0 0% 12%;
--popover-foreground: 0 0% 95%;
--primary: 24 100% 50%;
--primary-foreground: 0 0% 100%;
--secondary: 0 0% 18%;
--secondary-foreground: 0 0% 95%;
--muted: 0 0% 18%;
--muted-foreground: 0 0% 65%;
--accent: 0 0% 24%;
--accent-foreground: 0 0% 95%;
--destructive: 0 72% 51%;
--destructive-foreground: 0 0% 95%;
--border: 24 40% 30%;
--input: 24 40% 30%;
--ring: 24 100% 50%;
--success: 142 60% 22%;
--success-foreground: 138 60% 94%;
}
}
@layer base {
* {
@apply border-border;
scrollbar-width: none;
}
*::-webkit-scrollbar {
display: none;
}
body {
@apply bg-background text-foreground;
}
}
/* ── Han display-font overrides for Chinese (zh, zh-Hant, zh-TW, zh-HK) ────
Bebas Neue (the `.font-display` family) ships only Latin glyphs, so
Chinese hero text falls back to system fonts and loses its industrial
display character. Swap in Noto Sans TC weight 900 for any element
using `.font-display` while the page language is a Chinese variant.
Tailwind's `italic`, `uppercase`, and `tracking-*` utilities are
applied directly to those hero elements; we reverse them here because
Han text doesn't have a meaningful italic form, never uppercases, and
wants tighter (not wider) letter-spacing. The `:lang()` selector has
higher specificity (0,1,1) than a single utility class (0,1,0) so
these rules win without `!important`.
`-webkit-text-stroke` is neutralized because the hero h1 fattens its
weight with a 0.022em stroke (Bebas Neue only ships weight 400); at
Noto Sans TC weight 900 the stroke trick muddies CJK strokes. */
:root[lang|="zh"] .font-display {
font-family: 'Noto Sans TC', 'PingFang TC', 'PingFang SC', 'Microsoft JhengHei', 'Microsoft YaHei', 'Heiti TC', sans-serif;
font-weight: 900;
font-style: normal;
letter-spacing: 0;
text-transform: none;
-webkit-text-stroke: 0;
}
@layer utilities {
/* ── Safe-area inset utilities ────────────────────────────────────────────
Use var(--safe-area-inset-*, …) as the outer wrapper so that
Capacitor's SystemBars plugin (which injects --safe-area-inset-* CSS
variables on Android) takes precedence when available. The inner
env(safe-area-inset-*, 0px) is the standard fallback for iOS / web. */
.safe-area-top {
padding-top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
}
.safe-area-bottom {
padding-bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
}
.safe-area-inset-top {
top: var(--safe-area-inset-top, env(safe-area-inset-top, 0px));
}
.safe-area-inset-bottom {
bottom: var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px));
}
.overflow-wrap-anywhere {
overflow-wrap: anywhere;
}
/* FAB bottom offset: no bottom nav, only clear safe area on native shells. */
.bottom-fab {
bottom: calc(2rem + var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)));
}
/* Position above mobile bottom nav + safe area + arc overhang (28px) */
.bottom-mobile-nav {
bottom: calc(var(--bottom-nav-height) + 28px + var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)));
}
/* Bottom overscroll padding for the center column:
clears the mobile bottom nav + safe area + generous extra space
so content can be scrolled well past the bottom bar */
.pb-overscroll {
padding-bottom: calc(10vh + var(--bottom-nav-height) + var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)));
}
@media (min-width: 900px) {
.pb-overscroll {
padding-bottom: 10vh;
}
}
/* Mobile top bar height + safe area inset for sticky elements */
.top-mobile-bar {
top: calc(var(--top-bar-height) + var(--safe-area-inset-top, env(safe-area-inset-top, 0px)));
}
/* New-posts pill: just below the SubHeaderBar on both mobile and desktop */
.new-posts-pill {
top: calc(var(--top-bar-height) + var(--safe-area-inset-top, env(safe-area-inset-top, 0px)) + 3.5rem);
}
@media (min-width: 900px) {
.new-posts-pill {
top: 4rem;
}
}
/* Slide the sub-header bar out of view together with the mobile top bar.
Must clear its own height (100%) + top bar + safe area + arc overhang (20px). */
@media (max-width: 899px) {
.nav-hidden-slide {
transform: translateY(calc(-100% - var(--top-bar-height) - 20px - var(--safe-area-inset-top, env(safe-area-inset-top, 0px))));
}
}
/* Negative margin to pull content area up behind the mobile top bar (only when it's visible) */
@media (max-width: 899px) {
.-mt-mobile-bar {
margin-top: calc(-1 * var(--top-bar-height) - var(--safe-area-inset-top, env(safe-area-inset-top, 0px)));
padding-top: calc(var(--top-bar-height) + var(--safe-area-inset-top, env(safe-area-inset-top, 0px)));
}
}
/* AI chat height on mobile: full viewport minus top bar, extends behind bottom nav.
Padding-bottom keeps input above the nav. */
.ai-chat-height {
height: calc(100dvh - var(--top-bar-height) - var(--safe-area-inset-top, env(safe-area-inset-top, 0px)));
padding-bottom: calc(var(--bottom-nav-height) + var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)));
}
@media (min-width: 900px) {
.ai-chat-height {
padding-bottom: 0;
}
}
/* Live stream page height on mobile: full viewport minus top bar, bottom nav, and safe-area insets */
.livestream-height {
height: calc(100dvh - var(--top-bar-height) - var(--bottom-nav-height) - var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)));
max-height: calc(100dvh - var(--top-bar-height) - var(--bottom-nav-height) - var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)));
}
/* Vine feed slide height: full viewport on mobile (top bar + bottom nav are
both hidden for an immersive experience). Tab bar floats over video. */
.vine-slide-height {
height: 100dvh;
}
/* World map height: full viewport minus mobile chrome (top bar, bottom nav,
safe-area insets). On desktop where neither chrome is present, fills the
full viewport. The bottom nav is preserved on mobile (it's primary nav)
and the discovery drawer floats above it. */
.world-map-height {
height: calc(100dvh - var(--top-bar-height) - var(--safe-area-inset-top, env(safe-area-inset-top, 0px)) - var(--bottom-nav-height) - var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)));
}
@media (min-width: 900px) {
.world-map-height {
height: 100dvh;
}
}
/* Anchor world-page floating overlays (the discovery launcher button and
the docked desktop drawer slot) above the mobile bottom nav so the
bottom-nav stays usable. On desktop there's no bottom nav, so they
anchor to the viewport bottom. */
.world-drawer-anchor {
bottom: 0;
}
@media (max-width: 899px) {
.world-drawer-anchor {
bottom: calc(var(--bottom-nav-height) + var(--safe-area-inset-bottom, env(safe-area-inset-bottom, 0px)));
}
}
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* 3-burst ping: pulses 3 times then stops */
@keyframes ping-3 {
0% { transform: scale(1); opacity: 0.6; }
11% { transform: scale(2); opacity: 0; }
22% { transform: scale(1); opacity: 0; }
33% { transform: scale(1); opacity: 0.6; }
44% { transform: scale(2); opacity: 0; }
55% { transform: scale(1); opacity: 0; }
66% { transform: scale(1); opacity: 0.6; }
77% { transform: scale(2); opacity: 0; }
100% { transform: scale(1); opacity: 0; }
}
.animate-ping-3 {
animation: ping-3 4s ease-out forwards;
}
/* Eyeball blink animation - eyelid sweeps down then back up */
@keyframes eyelid-blink {
0% { transform: translateY(-100%); }
50% { transform: translateY(0%); }
100% { transform: translateY(-100%); }
}
.animate-eyelid-blink {
animation: eyelid-blink 0.5s cubic-bezier(0.4, 0, 0.6, 1) forwards;
}
/* Landing hero staggered fade-in */
@keyframes landing-hero-fade-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.landing-hero-fade {
opacity: 0;
animation: landing-hero-fade-in 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes nudge-cancel-fade-in {
from { opacity: 0; }
to { opacity: 0.6; }
}
/* ── NIP-62 Request to Vanish ───────────────────────────────────────────────── */
/* Animated caution stripes — red/dark diagonal bands that scroll */
.vanish-stripes {
background: repeating-linear-gradient(
-45deg,
hsl(0 72% 51% / 0.7),
hsl(0 72% 51% / 0.7) 10px,
hsl(0 72% 51% / 0.25) 10px,
hsl(0 72% 51% / 0.25) 20px
);
background-size: 28.28px 28.28px;
animation: vanish-stripe-scroll 1.5s linear infinite;
}
:is(.dark) .vanish-stripes {
background: repeating-linear-gradient(
-45deg,
hsl(0 72% 51% / 0.6),
hsl(0 72% 51% / 0.6) 10px,
hsl(0 72% 51% / 0.15) 10px,
hsl(0 72% 51% / 0.15) 20px
);
background-size: 28.28px 28.28px;
animation: vanish-stripe-scroll 1.5s linear infinite;
}
@keyframes vanish-stripe-scroll {
from { background-position: 0 0; }
to { background-position: 28.28px 0; }
}
/* Glitch text effect on the /// symbol */
.vanish-glitch-text {
position: relative;
animation: vanish-glitch 3s ease-in-out infinite;
}
@keyframes vanish-glitch {
0%, 90%, 100% { opacity: 1; transform: translate(0); }
92% { opacity: 0.8; transform: translate(-2px, 1px); }
94% { opacity: 0.6; transform: translate(2px, -1px); }
96% { opacity: 0.9; transform: translate(-1px, -1px); }
98% { opacity: 1; transform: translate(1px, 0); }
}
/* Ring pulse animation for the icon */
.vanish-ring-pulse {
animation: vanish-ring 3s ease-out infinite;
}
.vanish-ring-pulse-delayed {
animation: vanish-ring 3s ease-out 1.5s infinite;
}
@keyframes vanish-ring {
0% { transform: scale(1); opacity: 0.4; }
50% { transform: scale(1.15); opacity: 0; }
100% { transform: scale(1); opacity: 0; }
}
/* Slow blink for the status dot */
.vanish-blink {
animation: vanish-blink-kf 2s ease-in-out infinite;
}
@keyframes vanish-blink-kf {
0%, 40%, 100% { opacity: 1; }
50%, 90% { opacity: 0.2; }
}
/* ── Wii Mail envelope grid ─────────────────────────────────────────────────── */
/* Staggered entrance: envelopes pop in one by one.
Keep the animation short and avoid large rotations so the tap target
doesn't shift while the user is already trying to interact. */
@keyframes envelope-entrance {
0% { opacity: 0; transform: scale(0.85) translateY(12px); }
70% { opacity: 1; transform: scale(1.02) translateY(-1px); }
100% { opacity: 1; transform: scale(1) translateY(0); }
}
.envelope-card {
animation: envelope-entrance 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) both;
animation-delay: var(--entrance-delay, 0ms);
transform-origin: center bottom;
cursor: pointer;
}
/* Hover wobble — playful Wii-style oscillation */
@keyframes envelope-wobble {
0% { transform: rotate(0deg); }
15% { transform: rotate(-5deg); }
30% { transform: rotate(4deg); }
45% { transform: rotate(-3deg); }
60% { transform: rotate(2deg); }
75% { transform: rotate(-1deg); }
100% { transform: rotate(0deg); }
}
.envelope-body {
transition: transform 0.15s ease;
}
/* Wobble only on true pointer hover — skip on touch where :hover sticks
and the rotation shifts the tap target, making repeated taps miss. */
@media (hover: hover) and (pointer: fine) {
.envelope-card:hover .envelope-body,
.envelope-card:focus-visible .envelope-body {
animation: envelope-wobble 0.5s ease-in-out;
}
}
.envelope-card:active .envelope-body {
transform: scale(0.95);
animation: none;
}
/* Envelope skeleton shimmer */
@keyframes envelope-skeleton-shimmer {
0% { opacity: 0.4; }
50% { opacity: 0.7; }
100% { opacity: 0.4; }
}
.envelope-skeleton {
animation: envelope-skeleton-shimmer 1.5s ease-in-out infinite;
}
/* Reduced motion: disable envelope animations */
@media (prefers-reduced-motion: reduce) {
.envelope-card {
animation: none;
opacity: 1;
}
.envelope-card:hover .envelope-body,
.envelope-card:focus-visible .envelope-body {
animation: none;
}
}
/* Drawn SVG stickers — ensure inline SVGs fill their container */
[data-sticker] svg,
.sticker-svg-wrap svg {
width: 100%;
height: 100%;
display: block;
}
/* Letter send animation keyframes */
@keyframes letter-send-fade-up {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes letter-send-avatar-in {
0% { transform: scale(0); opacity: 0; }
50% { transform: scale(1.15); opacity: 1; }
70% { transform: scale(0.92); }
85% { transform: scale(1.04); }
100% { transform: scale(1); opacity: 1; }
}
@keyframes letter-send-burst {
0% { transform: translate(0, 0) rotate(0deg) scale(0); opacity: 0; }
15% { opacity: 1; transform: translate(0, 0) rotate(0deg) scale(1); }
60% { opacity: 0.8; transform: translate(var(--burst-tx), var(--burst-ty)) rotate(var(--burst-rot)) scale(1); }
100% { opacity: 0; transform: translate(var(--burst-tx), var(--burst-ty)) rotate(var(--burst-rot)) scale(0.4); }
}
/* Reduced motion: disable all vanish animations */
@media (prefers-reduced-motion: reduce) {
.vanish-stripes {
animation: none;
}
.vanish-glitch-text {
animation: none;
}
.vanish-ring-pulse,
.vanish-ring-pulse-delayed {
animation: none;
opacity: 0;
}
.vanish-blink {
animation: none;
}
}
/* ─── Floating Music Notes ───────────────────────────────────────────────────── */
@keyframes float-up {
0% {
opacity: 0;
transform: translateY(0) scale(0.8);
}
15% {
opacity: 1;
transform: translateY(-10px) scale(1);
}
85% {
opacity: 0.8;
transform: translateY(-60px) scale(1);
}
100% {
opacity: 0;
transform: translateY(-80px) scale(0.6);
}
}
.animate-float-up {
animation: float-up 3s ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.animate-float-up {
animation: none !important;
opacity: 0 !important;
}
}
/* ── Milkdown Editor Styles ─────────────────────────────────────────────────── */
.milkdown-editor .milkdown {
@apply outline-none;
}
.milkdown-editor .editor {
@apply outline-none min-h-[400px] p-3;
font-size: 1.125rem;
line-height: 1.75;
}
.milkdown-editor .ProseMirror {
@apply outline-none min-h-[400px];
}
.milkdown-editor .ProseMirror:focus {
@apply outline-none;
}
/* Headings */
.milkdown-editor h1 {
@apply text-3xl font-bold mt-6 mb-4;
}
.milkdown-editor h2 {
@apply text-2xl font-semibold mt-5 mb-3;
}
.milkdown-editor h3 {
@apply text-xl font-semibold mt-4 mb-2;
}
.milkdown-editor h4 {
@apply text-lg font-medium mt-3 mb-2;
}
/* Inline styles */
.milkdown-editor strong {
@apply font-bold;
}
.milkdown-editor em {
@apply italic;
}
.milkdown-editor del {
@apply line-through text-muted-foreground;
}
.milkdown-editor code {
@apply bg-muted px-1.5 py-0.5 rounded text-sm font-mono;
}
/* Block elements */
.milkdown-editor p {
@apply my-1.5;
}
.milkdown-editor blockquote {
@apply border-l-4 border-primary/40 pl-4 my-4 italic text-muted-foreground;
}
.milkdown-editor pre {
@apply bg-muted rounded-lg p-4 my-4 overflow-x-auto;
}
.milkdown-editor pre code {
@apply bg-transparent p-0 font-mono;
font-size: 0.875rem;
}
/* Lists */
.milkdown-editor ul {
@apply list-disc list-inside my-3 space-y-1;
}
.milkdown-editor ol {
@apply list-decimal list-inside my-3 space-y-1;
}
.milkdown-editor li {
@apply pl-1;
}
.milkdown-editor li p {
@apply inline my-0;
}
/* Task lists (GFM) */
.milkdown-editor ul.task-list {
@apply list-none pl-0;
}
.milkdown-editor li.task-list-item {
@apply flex items-start gap-2 pl-0;
}
.milkdown-editor li.task-list-item input[type="checkbox"] {
@apply mt-1.5 h-4 w-4 rounded border-border;
}
/* Links */
.milkdown-editor a {
@apply text-primary underline underline-offset-2 hover:text-primary/80 transition-colors;
}
/* Horizontal rule */
.milkdown-editor hr {
@apply my-6 border-border;
}
/* Images */
.milkdown-editor img {
@apply max-w-full h-auto rounded-lg my-4;
}
/* Tables (GFM) */
.milkdown-editor table {
@apply w-full border-collapse my-4;
}
.milkdown-editor th,
.milkdown-editor td {
@apply border border-border px-3 py-2 text-left;
}
.milkdown-editor th {
@apply bg-muted font-semibold;
}
.milkdown-editor tr:nth-child(even) {
@apply bg-muted/30;
}
/* Upload placeholder */
.milkdown-upload-placeholder {
@apply inline-flex items-center gap-2 px-3 py-1.5 bg-muted/50 rounded-md text-sm text-muted-foreground;
}
.milkdown-upload-placeholder::before {
content: '';
@apply w-4 h-4 border-2 border-muted-foreground/30 border-t-primary rounded-full animate-spin;
}
/* Placeholder — only when unfocused AND content is empty. */
.milkdown-editor .ProseMirror:not(:focus):not(.has-content) > p:first-child::before {
@apply text-muted-foreground pointer-events-none float-left h-0;
content: var(--ph, '');
}
/* Milkdown content area */
.milkdown-editor .milkdown-content .ProseMirror {
@apply min-h-[350px];
}
/* Room navigation arrow nudge — subtle horizontal pulse */
@keyframes room-arrow-nudge-left {
0%, 100% { transform: translateX(0); }
50% { transform: translateX(-3px); }
}
@keyframes room-arrow-nudge-right {
0%, 100% { transform: translateX(0); }
50% { transform: translateX(3px); }
}
/* ── Overstimulation block overlay ─────────────────────────────────────────── */
@keyframes overstim-vignette-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes overstim-vignette-out {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes overstim-shockwave {
0% { transform: scale(0.1); opacity: 1; }
60% { opacity: 0.6; }
100% { transform: scale(2.2); opacity: 0; }
}
@keyframes vomit-fall {
0% { transform: translate(-50%, -100%) translate(0, 0) scale(1); opacity: 1; }
100% { transform: translate(-50%, -100%) translate(var(--vomit-dx), var(--vomit-dy)) scale(1); opacity: 1; }
}
/* Override global scrollbar-hiding for elements that need a visible thin scrollbar */
.scrollbar-thin {
scrollbar-width: thin;
}
.scrollbar-thin::-webkit-scrollbar {
display: block;
height: 4px;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background: hsl(var(--muted-foreground) / 0.3);
border-radius: 9999px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
/* Slow Ken-Burns-style pan for full-bleed hero images. The image is
oversized (scale 1.12) so the translation never reveals the edge.
Used by HeroBanner on Communities/Actions/Guide pages. */
@keyframes heroPanRight {
0% { transform: scale(1.12) translateX(-1.5%); }
50% { transform: scale(1.12) translateX(1.5%); }
100% { transform: scale(1.12) translateX(-1.5%); }
}
@keyframes heroPanLeft {
0% { transform: scale(1.12) translateX(1.5%); }
50% { transform: scale(1.12) translateX(-1.5%); }
100% { transform: scale(1.12) translateX(1.5%); }
}
.hero-pan-right { animation: heroPanRight 28s ease-in-out infinite; }
.hero-pan-left { animation: heroPanLeft 28s ease-in-out infinite; }
/* Lightning-arc flow on the home hero. Each path is dashed and the
dash offset animates by exactly one full dash period, so the loop
point is visually identical to the start point — no snap, no
visible restart. Dash sizes are in screen pixels because the arcs
use `vector-effect: non-scaling-stroke`. */
@keyframes heroArcFlow {
0% { stroke-dashoffset: 0; }
100% { stroke-dashoffset: -16; }
}
.hero-arc-flow {
stroke-dasharray: 6 10;
animation: heroArcFlow 2.4s linear infinite;
}
/* City-node pulse used on the home hero. The dot itself doesn't move;
we just breathe the halo's opacity so the location reads as a live
beacon. Offsets are staggered per-node from the JSX so the field
pulses organically rather than in lockstep. */
@keyframes heroNodePulse {
0% { opacity: 0.35; transform: scale(0.85); }
50% { opacity: 0.95; transform: scale(1.15); }
100% { opacity: 0.35; transform: scale(0.85); }
}
.hero-node-pulse {
transform-origin: center;
transform-box: fill-box;
animation: heroNodePulse 3.2s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.hero-pan-right, .hero-pan-left { animation: none; }
.hero-arc-flow { animation: none; stroke-dasharray: none; }
.hero-node-pulse { animation: none; opacity: 0.7; }
}