* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

/* The browser's default [hidden] { display: none } lives in the
   user-agent stylesheet, which always loses to an author rule that sets
   display — even one with lower specificity, like `.detail-speak {
   display: inline-flex }`. Without this, toggling the `hidden` attribute
   on an element that also has its own display value does nothing
   visible. */
[hidden] {
    display: none !important;
}

/* Visible only for the pointer/keyboard interactions that actually
   warrant an indicator — a mouse click doesn't need a ring around
   whatever it just clicked, but Tab-ing through the page does. Applied
   to every custom interactive element (hub-node/sub-node/mascot/toggles/
   detail-panel buttons); native ones already get a sane default. */
:focus-visible {
    outline: 3px solid var(--accent);
    outline-offset: 3px;
    border-radius: 4px;
}

/* Skip-to-content link: invisible until it receives keyboard focus, then
   pinned to the top-left corner so a keyboard user can jump straight past
   the nav without having to tab through it every single page load. */
.skip-link {
    position: absolute;
    top: -3rem;
    left: 1rem;
    z-index: 2000;
    padding: 0.6rem 1.1rem;
    background: var(--accent);
    color: #fff;
    border-radius: 8px;
    font-size: 0.9rem;
    font-weight: 600;
    text-decoration: none;
    transition: top 0.2s ease;
}

.skip-link:focus {
    top: 1rem;
}

:root {
    --primary: #000;
    --accent: #115e59;
    --accent-soft: #e6f5f4;
    --bg: #fff;
    --surface: #fff;
    --text: #1a1a1a;
    --text-muted: #666;
    --border: #e0e0e0;
    --glow: rgba(17, 94, 89, 0.35);
    --job-accent: #b45309;
    --job-accent-soft: #fef3c7;
    --planet-grad: radial-gradient(circle at 32% 26%, #6fc9bf, #115e59 55%, #073330 100%);
    --moon-grad: radial-gradient(circle at 32% 26%, #f7f7f8, #b9bcc3 55%, #83868f 100%);
    /* .footer's own rendered height (padding + one line of text + border) —
       kept in one place so every page reserving space for the fixed footer
       (see .timeline-page, .hero-zoom-layer) uses the same number. */
    --footer-h: 3.75rem;
}

/* Light theme is always the default (see script.js) — dark is only
   ever applied via the explicit toggle, never from system preference. */
:root[data-theme="dark"] {
    --accent: #0d9488;
    --accent-soft: rgba(13, 148, 136, 0.2);
    --bg: #101014;
    --surface: #1a1a22;
    --text: #f2f2f5;
    --text-muted: #a3a3ad;
    --border: rgba(255, 255, 255, 0.12);
    --glow: rgba(13, 148, 136, 0.4);
    --job-accent: #fbbf24;
    --job-accent-soft: rgba(251, 191, 36, 0.18);
    --planet-grad: radial-gradient(circle at 32% 26%, #7fe6d6, #0d9488 55%, #04332c 100%);
    --moon-grad: radial-gradient(circle at 32% 26%, #d7dce6, #8b93a3 55%, #464d5e 100%);
}

html {
    scroll-behavior: smooth;
}

/* During the warp jump, #page-zoom-layer scales up to 4x via a CSS
   transform (see script.js) — transforms don't reflow layout, but the
   visually-enlarged content still grows the document's scrollable
   overflow unless something clips it, which showed up as the whole
   screen shifting/jittering mid-jump as a scrollbar came and went.
   Toggled on <html> for the jump's duration only. */
html.warping {
    overflow: hidden;
}

html, body {
    height: 100%;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
    background: var(--bg);
    color: var(--text);
    line-height: 1.6;
    transition: background 0.25s ease, color 0.25s ease;
    display: flex;
    flex-direction: column;
    min-height: 100vh;
    min-height: 100dvh;
}

.navbar {
    flex-shrink: 0;
}

.container {
    max-width: 1000px;
    margin: 0 auto;
    padding: 0 20px;
    flex-shrink: 0;
}

/* Navigation */
.navbar {
    position: sticky;
    top: 0;
    z-index: 1000;
    background: color-mix(in srgb, var(--bg) 85%, transparent);
    backdrop-filter: blur(8px);
    border-bottom: 1px solid var(--border);
    padding: 1.5rem 0;
}

.nav-content {
    padding: 0 clamp(1.5rem, 5vw, 4rem);
    display: flex;
    justify-content: space-between;
    align-items: center;
}

.logo {
    font-size: 1.3rem;
    font-weight: 800;
    letter-spacing: 1px;
    text-decoration: none;
    color: var(--text);
}

.logo .dot {
    color: var(--accent);
}

.nav-menu-wrap {
    position: relative;
}

.nav-toggle {
    display: flex;
    flex-direction: column;
    justify-content: center;
    gap: 5px;
    width: 38px;
    height: 38px;
    padding: 0;
    background: none;
    border: 1px solid var(--border);
    border-radius: 8px;
    cursor: pointer;
}

.nav-toggle-bar {
    display: block;
    width: 18px;
    height: 2px;
    margin: 0 auto;
    background: var(--text);
    transition: transform 0.25s ease, opacity 0.25s ease;
}

.nav-toggle[aria-expanded="true"] .nav-toggle-bar:nth-child(1) {
    transform: translateY(7px) rotate(45deg);
}

.nav-toggle[aria-expanded="true"] .nav-toggle-bar:nth-child(2) {
    opacity: 0;
}

.nav-toggle[aria-expanded="true"] .nav-toggle-bar:nth-child(3) {
    transform: translateY(-7px) rotate(-45deg);
}

.nav-menu {
    position: absolute;
    top: calc(100% + 0.75rem);
    right: 0;
    width: min(260px, 90vw);
    list-style: none;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 12px;
    box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12);
    padding: 0.5rem;
    opacity: 0;
    transform: translateY(-8px);
    pointer-events: none;
    transition: opacity 0.2s ease, transform 0.2s ease;
}

.nav-menu.open {
    opacity: 1;
    transform: translateY(0);
    pointer-events: auto;
}

.nav-menu li {
    display: block;
}

.nav-menu a,
.nav-menu-soon {
    display: block;
    padding: 0.6rem 0.75rem;
    border-radius: 8px;
    color: var(--text-muted);
    text-decoration: none;
    font-size: 0.9rem;
    font-weight: 500;
    transition: background 0.2s ease, color 0.2s ease;
}

.nav-menu a:hover,
.nav-menu a.active {
    background: var(--accent-soft);
    color: var(--text);
}

.nav-menu-divider {
    height: 1px;
    background: var(--border);
    margin: 0.35rem 0.25rem;
}

.nav-news {
    padding: 0.5rem 0.75rem 0.25rem;
}

.nav-news-title {
    font-size: 0.7rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--text-muted);
    opacity: 0.7;
    margin-bottom: 0.35rem;
}

.nav-news-list {
    list-style: none;
    display: flex;
    flex-direction: column;
    gap: 0.15rem;
}

.nav-news-list li {
    display: block;
}

.nav-news-list a,
.nav-news-list span {
    display: block;
    padding: 0.35rem 0.5rem;
    border-radius: 8px;
    color: var(--text-muted);
    text-decoration: none;
    font-size: 0.8rem;
    line-height: 1.3;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    transition: background 0.2s ease, color 0.2s ease;
}

.nav-news-list a:hover {
    background: var(--accent-soft);
    color: var(--text);
}

/* A transitory line, not a real news item — reads visually as "still
   waiting" rather than as a dead link (it isn't one: no href at all). */
.nav-news-list .nav-news-loading {
    font-style: italic;
    opacity: 0.85;
    cursor: default;
}

/* Job listings stand out from the rest of the feed — a distinct accent
   (not the site's own teal, which already means "interactive/hover" here)
   so they're spottable at a glance among general news. */
.nav-news-list .nav-news-job {
    background: var(--job-accent-soft);
    color: var(--job-accent);
    font-weight: 600;
}

.nav-news-list a.nav-news-job:hover {
    background: var(--job-accent-soft);
    filter: brightness(0.95);
    color: var(--job-accent);
}

.nav-menu-soon {
    color: var(--text-muted);
    cursor: default;
}

.nav-menu-soon small {
    opacity: 0.7;
}

.nav-menu-action {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    width: 100%;
    padding: 0.6rem 0.75rem;
    border-radius: 8px;
    background: none;
    border: none;
    color: var(--text-muted);
    font-family: inherit;
    font-size: 0.9rem;
    font-weight: 500;
    text-align: left;
    cursor: pointer;
    transition: background 0.2s ease, color 0.2s ease;
}

.nav-menu-action:hover {
    background: var(--accent-soft);
    color: var(--text);
}

/* Hero Section */
.hero {
    display: grid;
    grid-template-columns: 5fr 6fr;
    align-items: stretch;
    flex: 1 1 auto;
    min-height: 0;
}

.hero-text {
    display: flex;
    flex-direction: column;
    justify-content: center;
    gap: clamp(0.35rem, 2.2vh, 1.25rem);
    padding: clamp(0.5rem, 3.5vh, 2rem) clamp(1.5rem, 5vw, 4rem);
    text-align: left;
    min-height: 0;
    /* Sized in vh below so this never has to scroll — a scrollbar here
       would show up as an ugly bar right at the canvas edge. */
    overflow: hidden;
}

.hero-visual {
    position: relative;
    overflow: hidden;
    background: linear-gradient(160deg, var(--accent-soft), var(--bg) 65%);
    min-height: 0;
}

/* Plain positioning wrapper for the canvas + graph — no longer scales
   itself during the warp jump (see #page-zoom-layer, which now scales
   the whole page instead). */
.hero-zoom-layer {
    position: absolute;
    inset: 0;
}

/* The whole-page wrapper the warp jump scales (see the comment on it in
   index.html). Mirrors body's own flex column + min-height so the
   elements now nested one level deeper (.navbar, .hero, .container) keep
   behaving exactly as they did as direct children of body — in
   particular .hero's flex:1 1 auto/min-height:0 (below) relies on its
   parent being a flex container to shrink-to-fit the viewport and let
   .hero-text's own overflow-y:auto absorb extra content instead of the
   whole page growing a scrollbar. display:contents would dodge that but
   also remove this element's own box, making it untransformable and
   defeating the point of the wrapper; script.js sets transform-origin
   inline right before each jump starts. */
#page-zoom-layer {
    display: flex;
    flex-direction: column;
    min-height: 100vh;
    min-height: 100dvh;
    /* Reserves room for the fixed footer at the very bottom of the hero,
       so it never covers the Social hub-node (positioned low in the hero)
       or the footer's own .container — see .footer. */
    padding-bottom: var(--footer-h);
}

#network-canvas {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    transition: opacity 0.3s ease, filter 0.3s ease;
}

#network-canvas.dimmed {
    opacity: 0.35;
    filter: blur(3px);
}

#warp-overlay {
    position: fixed;
    inset: 0;
    width: 100vw;
    height: 100vh;
    z-index: 3000;
    pointer-events: none;
}

/* Warp jump arrival: a brief white-to-page fade on a project page, but
   only when it was actually reached via the warp jump — the .warp-arrival
   class is set by that page's own inline head script, from a one-shot
   sessionStorage flag script.js leaves right before navigating (see
   markWarpArrival() in script.js). A direct visit or a reload never sets
   the class, so this rule never fires for those. */
html.warp-arrival body::before {
    content: '';
    position: fixed;
    inset: 0;
    z-index: 9999;
    background: #fff;
    pointer-events: none;
    animation: warp-arrival-fade 0.6s ease forwards;
}

@keyframes warp-arrival-fade {
    from { opacity: 1; }
    to { opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
    html.warp-arrival body::before {
        animation: none;
        opacity: 0;
    }
}

/* Project-card arrival: a project page reached by clicking its card on
   projects/index.html slides in from the right instead of just appearing —
   same one-shot sessionStorage-flag pattern as .warp-arrival above (see
   markProjectArrival() in projects/index.html and each project page's own
   head script), so a direct visit or reload never triggers it. */
html.project-arrival .timeline-page {
    animation: project-arrival-slide 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
}

@keyframes project-arrival-slide {
    from { transform: translateX(48px); opacity: 0; }
    to { transform: translateX(0); opacity: 1; }
}

@media (prefers-reduced-motion: reduce) {
    html.project-arrival .timeline-page {
        animation: none;
    }
}

.warp-trigger {
    position: absolute;
    right: 14px;
    bottom: 14px;
    z-index: 1;
    width: 26px;
    height: 26px;
    display: flex;
    align-items: center;
    justify-content: center;
    border-radius: 50%;
    border: 1.5px solid var(--accent);
    background: var(--surface);
    font-size: 0.7rem;
    line-height: 1;
    cursor: pointer;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
    transition: transform 0.15s ease, box-shadow 0.15s ease;
}

.warp-trigger:hover {
    transform: scale(1.08);
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
}

.warp-trigger.warping {
    animation: warp-pulse 0.5s ease-in-out infinite;
    cursor: default;
}

@keyframes warp-pulse {
    0%, 100% { transform: scale(1); }
    50% { transform: scale(1.15); }
}

@media (prefers-reduced-motion: reduce) {
    .warp-trigger.warping {
        animation: none;
    }
}

.name {
    font-size: clamp(1.6rem, 5vh, 3.25rem);
    font-weight: 800;
    line-height: 1.1;
    letter-spacing: -1.5px;
}

.name .dot {
    color: var(--accent);
}

.tagline {
    display: flex;
    align-items: center;
    justify-content: flex-start;
    gap: clamp(0.3rem, 1vh, 0.75rem);
    flex-wrap: wrap;
    font-size: clamp(0.7rem, 1.8vh, 1rem);
    font-weight: 600;
}

.role {
    background: var(--accent-soft);
    padding: clamp(0.2rem, 1vh, 0.5rem) clamp(0.5rem, 2vh, 1rem);
    border-radius: 50px;
    border: 1px solid transparent;
    color: var(--text);
    transition: all 0.25s ease;
}

.role:hover {
    border-color: var(--accent);
    box-shadow: 0 0 0 4px var(--glow);
    transform: translateY(-2px);
}

.description {
    text-align: left;
}

.description p {
    font-size: clamp(0.72rem, 1.8vh, 1rem);
    color: var(--text-muted);
    line-height: 1.4;
    margin-bottom: clamp(0.2rem, 1vh, 0.75rem);
}

.description p:last-child {
    margin-bottom: 0;
}

/* Moses effect: each word nudges sideways as the mascot passes near it,
   then eases back — purely visual (transform), text/layout untouched. */
.description .word {
    display: inline-block;
    transition: transform 0.15s ease-out;
}

.cta-buttons {
    display: flex;
    gap: 1rem;
    justify-content: flex-start;
    margin-top: 0;
}

.btn {
    padding: clamp(0.4rem, 1.8vh, 0.85rem) clamp(1.5rem, 4vw, 2.25rem);
    border-radius: 50px;
    text-decoration: none;
    font-weight: 600;
    transition: all 0.3s ease;
    border: 2px solid transparent;
    cursor: pointer;
    display: inline-block;
    text-align: center;
    font-size: clamp(0.8rem, 1.8vh, 0.95rem);
}

.btn-primary {
    background: var(--accent);
    color: #fff;
    border: 2px solid var(--accent);
    box-shadow: 0 8px 24px var(--glow);
}

.btn-primary:hover {
    transform: translateY(-2px);
    box-shadow: 0 12px 28px var(--glow);
}

/* Mascot: a small animated node that roams the whole page on its own —
   fixed to the viewport (not confined to the hero canvas), draggable,
   and it can be "popped" by mashing clicks on it. */
.mascot {
    position: fixed;
    left: 50%;
    top: 45%;
    /* The click-inflate grows the mascot via actual width/height (below),
       not transform: scale() — a scaled transform reuses the element's
       already-rasterized layer and stretches that bitmap, which looks
       grainy at anything beyond a small factor. Real width/height forces
       a fresh, crisp render at every size. Squash/rotate stay as transform
       since those are quick, small, transient effects (bounce impacts). */
    --mascot-base: 38px;
    width: calc(var(--mascot-base) * var(--mascot-scale, 1));
    height: calc(var(--mascot-base) * var(--mascot-scale, 1));
    transform: translate(-50%, -50%)
        translateY(var(--mascot-rest-bounce, 0px))
        rotate(var(--mascot-rotate, 0deg))
        scale(var(--mascot-squash-x, 1), var(--mascot-squash-y, 1))
        translate(var(--warp-suck-x, 0px), var(--warp-suck-y, 0px))
        scale(var(--warp-suck-scale, 1));
    display: flex;
    align-items: center;
    justify-content: center;
    gap: calc(7px * var(--mascot-scale, 1));
    padding: 0;
    border: none;
    border-radius: 50%;
    /* Split-off clones get their own vivid, toy-like color — the original
       "dot" stays on the site's brand teal. */
    background: var(--mascot-color, var(--accent));
    box-shadow: 0 4px 10px var(--glow);
    cursor: grab;
    /* Above the sticky navbar and the detail panel/backdrop, always. */
    z-index: 1200;
    touch-action: none;
    transition: left 0.6s cubic-bezier(0.34, 1.56, 0.64, 1), top 0.6s cubic-bezier(0.34, 1.56, 0.64, 1), transform 0.18s ease-out, width 0.25s ease-out, height 0.25s ease-out;
}

.mascot.dragging {
    cursor: grabbing;
    /* A soft inset pressure mark while held, on top of the squash/stretch
       driven from JS — reinforces the "gripping something rubbery" feel. */
    box-shadow: 0 4px 10px var(--glow), inset 0 0 9px rgba(0, 0, 0, 0.3);
    transition: transform 0.18s ease-out;
}

/* While the throw physics loop drives left/top every frame, the hop's
   0.6s elastic transition must not also be fighting those updates — it
   would lag/smooth every frame behind a half-finished easing, turning a
   crisp bounce into a mushy, delayed one. Position moves instantly here;
   only the squash/rotate feel (transform) still eases. */
.mascot.thrown {
    transition: transform 0.18s ease-out;
}

.mascot-eye {
    width: calc(7px * var(--mascot-scale, 1));
    height: calc(7px * var(--mascot-scale, 1));
    border-radius: 50%;
    background: #fff;
    /* Eased so falling asleep closes them rather than snapping them shut. */
    transition: height 0.3s ease, border-radius 0.3s ease;
}

/* A small "I have something to say" indicator — appears once dot has a
   news item ready, before it shows up on its own; clicking dot while it's
   up reveals it right away instead of waiting. */
.mascot-news-badge {
    position: absolute;
    top: -6px;
    right: -6px;
    width: 16px;
    height: 16px;
    border-radius: 50%;
    background: #fff;
    box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
    font-size: 9px;
    line-height: 16px;
    text-align: center;
    animation: mascot-news-badge-pop 0.3s ease;
}

@keyframes mascot-news-badge-pop {
    0% { transform: scale(0); }
    70% { transform: scale(1.25); }
    100% { transform: scale(1); }
}

@media (prefers-reduced-motion: reduce) {
    .mascot-news-badge {
        animation: none;
    }
}

.mascot.hopping {
    animation: mascot-hop 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
}

@keyframes mascot-hop {
    0% { transform: translate(-50%, -50%) scale(1, 1); }
    30% { transform: translate(-50%, calc(-50% - 16px)) scale(0.9, 1.15); }
    60% { transform: translate(-50%, calc(-50% - 16px)) scale(1.1, 0.9); }
    100% { transform: translate(-50%, -50%) scale(1, 1); }
}

.mascot.excited {
    animation: mascot-spin 0.5s ease;
}

@keyframes mascot-spin {
    0% { transform: translate(-50%, -50%) rotate(0deg) scale(1); }
    50% { transform: translate(-50%, -50%) rotate(180deg) scale(1.3); }
    100% { transform: translate(-50%, -50%) rotate(360deg) scale(1); }
}

.mascot.dropped {
    animation: mascot-land 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}

@keyframes mascot-land {
    0% { transform: translate(-50%, -50%) scale(1, 1); }
    40% { transform: translate(-50%, -50%) scale(1.35, 0.7); }
    70% { transform: translate(-50%, -50%) scale(0.9, 1.1); }
    100% { transform: translate(-50%, -50%) scale(1, 1); }
}

/* Left alone long enough, dot nods off: eyes shut, a slow breath, and the
   odd "z" drifting away. The breathing rides the standalone `scale`
   property so it composes with .mascot's own transform — which JS drives
   for squash, rotation and the resting bounce — instead of overwriting it. */
.mascot.sleeping {
    animation: mascot-breathe 3.6s ease-in-out infinite;
}

@keyframes mascot-breathe {
    0%, 100% { scale: 1; }
    50% { scale: 1.06; }
}

.mascot.sleeping .mascot-eye {
    height: calc(2px * var(--mascot-scale, 1));
    border-radius: 2px;
}

/* Absolutely positioned on purpose: .mascot is a flex row of two eyes, and
   an in-flow ::after would become a third flex item and push them apart. */
.mascot.sleeping::after {
    content: 'z';
    position: absolute;
    top: -4px;
    right: -2px;
    font-size: calc(11px * var(--mascot-scale, 1));
    font-weight: 700;
    line-height: 1;
    color: var(--mascot-color, var(--accent));
    opacity: 0;
    pointer-events: none;
    animation: mascot-zzz 3.6s ease-out infinite;
}

@keyframes mascot-zzz {
    0% { opacity: 0; translate: 0 0; }
    20% { opacity: 0.9; }
    100% { opacity: 0; translate: 7px -20px; }
}

/* A stretch on the way back up, so waking reads as waking and not as a
   glitch that simply reopened its eyes. */
.mascot.waking {
    animation: mascot-wake 0.46s cubic-bezier(0.34, 1.6, 0.64, 1);
}

@keyframes mascot-wake {
    0% { scale: 1.06; }
    45% { scale: 0.93 1.13; }
    100% { scale: 1; }
}

.mascot.popping {
    animation: mascot-pop 0.4s ease forwards;
}

/* Trembles while it grows red with rage — the growth itself (width/height
   via --mascot-scale) is already handled by .mascot's own transition; this
   is just the shake layered on top. */
.mascot.angry {
    animation: mascot-angry 0.5s ease-in-out;
}

@keyframes mascot-angry {
    0%, 100% { transform: translate(-50%, -50%) rotate(0deg); }
    20% { transform: translate(-53%, -50%) rotate(-8deg); }
    40% { transform: translate(-47%, -50%) rotate(8deg); }
    60% { transform: translate(-53%, -50%) rotate(-6deg); }
    80% { transform: translate(-47%, -50%) rotate(6deg); }
}

@keyframes mascot-pop {
    0% { transform: translate(-50%, -50%) scale(1); opacity: 1; }
    35% { transform: translate(-50%, -50%) scale(2.2); opacity: 1; }
    100% { transform: translate(-50%, -50%) scale(0); opacity: 0; }
}

.mascot-shockwave {
    position: fixed;
    left: 0;
    top: 0;
    width: 20px;
    height: 20px;
    margin: -10px 0 0 -10px;
    border-radius: 50%;
    border: 3px solid var(--accent);
    pointer-events: none;
    z-index: 1198;
    animation: mascot-shockwave 0.45s ease-out forwards;
}

@keyframes mascot-shockwave {
    0% { transform: scale(0.5); opacity: 0.8; border-width: 4px; }
    100% { transform: scale(7); opacity: 0; border-width: 1px; }
}

/* Bigger variant for the rage explosion — same ring, reads across the
   whole page instead of just around dot. */
.mascot-shockwave.big {
    animation: mascot-shockwave-big 0.7s ease-out forwards;
}

@keyframes mascot-shockwave-big {
    0% { transform: scale(0.5); opacity: 0.9; border-width: 8px; }
    100% { transform: scale(var(--shockwave-scale, 50)); opacity: 0; border-width: 1px; }
}

.mascot.recovering {
    /* Longer than most of the mascot's other transient effects on
       purpose — a balloon actually filling up needs to read as a
       gradual swell, not a snap-into-place blip. */
    animation: mascot-recover 1.1s cubic-bezier(0.22, 0.61, 0.36, 1);
}

@keyframes mascot-recover {
    0% { transform: translate(-50%, -50%) scale(0.05, 0.05); opacity: 0; }
    10% { opacity: 1; }
    35% { transform: translate(-50%, -50%) scale(0.55, 0.45); }
    55% { transform: translate(-50%, -50%) scale(0.8, 0.9); }
    75% { transform: translate(-50%, -50%) scale(1.08, 0.95); }
    88% { transform: translate(-50%, -50%) scale(0.96, 1.04); }
    100% { transform: translate(-50%, -50%) scale(1, 1); }
}

.mascot-particle {
    position: fixed;
    width: 5px;
    height: 5px;
    border-radius: 50%;
    background: var(--accent);
    pointer-events: none;
    z-index: 1199;
    animation: mascot-particle 0.5s ease-out forwards;
}

@keyframes mascot-particle {
    0% { transform: translate(0, 0) scale(1); opacity: 1; }
    100% { transform: translate(var(--px), var(--py)) scale(0); opacity: 0; }
}

/* Knocked by the mascot mid-throw: spins a whole number of turns (always
   landing back at its starting orientation) and settles. Uses the
   standalone `rotate` property rather than `transform` so it composes
   safely with whatever transform the element already has for its own
   positioning (e.g. .hub-node/.sub-node's translate(-50%, -50%)) instead of
   overwriting it. Purely visual — content/handlers are untouched. */
.page-hit {
    animation: page-hit-spin 0.6s cubic-bezier(0.34, 1.56, 0.64, 1);
}

@keyframes page-hit-spin {
    0% { rotate: 0deg; }
    100% { rotate: var(--impact-turns, 360deg); }
}

/* The mascot's pop sends a blast through anything standing nearby —
   falls over, holds for a beat, then rights itself back up. Same
   standalone `rotate` property as .page-hit, but this one doesn't return
   in a full turn: it topples and recovers, a different feel for a
   different trigger. */
.page-tipped {
    animation: page-tip 1.15s cubic-bezier(0.36, 0, 0.66, 0);
}

@keyframes page-tip {
    0% { rotate: 0deg; }
    18% { rotate: var(--tip-angle, 70deg); }
    70% { rotate: var(--tip-angle, 70deg); }
    100% { rotate: 0deg; }
}

/* Panels that clip their overflow are held open while the collapse plays:
   without this, anything falling out of .hero-visual or .hero-text is cut
   off in mid-air at the panel's edge instead of reaching the floor. Put
   back the moment everything is standing again. */
.page-collapse-open {
    overflow: visible !important;
}

/* The rage collapse: everything on screen falls to the floor of its own
   box, bounces once, lies there for a beat, then gets back up. The
   getting-back-up is not a nicety — the graph nodes and the nav ARE the
   site's navigation, so a page that stayed on the ground would read as a
   bug rather than as a joke.

   Per-keyframe timing functions do the physics: accelerating on the way
   down (that's gravity), sharp on the landing, and a slight overshoot on
   the way back up, as if each element pushed itself upright. `translate`
   and `rotate` are the standalone properties, so this composes with the
   `transform` the graph nodes are positioned with instead of fighting it. */
.page-collapsed {
    animation: page-collapse var(--fall-dur, 2000ms) var(--fall-delay, 0ms) both;
}

@keyframes page-collapse {
    0% {
        translate: 0 0;
        rotate: 0deg;
        animation-timing-function: cubic-bezier(0.55, 0, 1, 0.45); /* falling */
    }
    22% {
        translate: var(--fall-x, 0px) var(--fall-y, 0px);
        rotate: var(--fall-angle, 70deg);
        animation-timing-function: cubic-bezier(0.3, 0, 0.5, 1); /* the bounce up */
    }
    29% {
        translate: var(--fall-x, 0px) calc(var(--fall-y, 0px) - 14px);
        rotate: var(--fall-angle, 70deg);
        animation-timing-function: cubic-bezier(0.5, 0, 1, 0.5); /* and down again */
    }
    36%, 74% {
        translate: var(--fall-x, 0px) var(--fall-y, 0px);
        rotate: var(--fall-angle, 70deg);
        animation-timing-function: cubic-bezier(0.34, 1.3, 0.64, 1); /* pushing itself back up */
    }
    100% {
        translate: 0 0;
        rotate: 0deg;
    }
}

/* Nothing above happens for a visitor who asked for less motion — the rage
   sequence bails out before this in script.js, but the guard is repeated
   here so the class can never animate on its own. */
@media (prefers-reduced-motion: reduce) {
    .page-collapsed {
        animation: none;
    }
}

.mascot-bubble {
    position: fixed;
    /* Base "float centered above dot" placement, plus a small pixel
       correction (JS, via showBubble) that pulls it back on-screen when
       dot is near an edge/corner — never half clipped off the viewport. */
    transform: translate(calc(-50% + var(--bubble-nudge-x, 0px)), calc(-160% + var(--bubble-nudge-y, 0px)));
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 10px;
    padding: 0.65rem 1rem;
    font-size: 0.9rem;
    font-weight: 600;
    line-height: 1.4;
    color: var(--text);
    /* News items can run to a full sentence (a commit message, a Wikipedia
       blurb) — wrap instead of the one-liner nowrap, capped so it never
       spans the whole viewport. */
    white-space: normal;
    max-width: min(320px, 80vw);
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
    z-index: 1201;
    pointer-events: none;
}

/* A news bubble carries a link to its source — auto pointer-events (only
   this state, so a plain one-liner never blocks clicks on what's behind
   it) plus an underline so the affordance is obvious before the click. */
.mascot-bubble.clickable {
    pointer-events: auto;
    cursor: pointer;
    text-decoration: underline;
    text-decoration-color: var(--accent);
    text-underline-offset: 3px;
}

.mascot-bubble.clickable:hover {
    border-color: var(--accent);
}

/* Hub/sub-node chips overlaid on the hero canvas, fixed relative to the
   panel (they do not pan — only the animated background moves). */
.hub-overlay {
    position: absolute;
    inset: 0;
    z-index: 2;
    pointer-events: none;
}

.graph-lines {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    overflow: visible;
    pointer-events: none;
}

.graph-lines line {
    stroke: var(--accent);
    stroke-width: 1;
    opacity: 0.45;
}

.graph-lines line.underline {
    stroke: var(--text-muted);
    stroke-width: 1;
    opacity: 0.6;
}

/* Hub-node = "planet", sub-node = "moon": a shaded sphere (.node-sphere) with
   plain text beside it — no pill background/border. The label gets a
   soft background-colored halo (text-shadow) so a graph line or the
   animated hero canvas passing behind it doesn't visually cut through
   the letters. */
.hub-node,
.sub-node {
    position: absolute;
    left: 0;
    top: 0;
    display: flex;
    align-items: center;
    gap: 0.4rem;
    transform: translate(-50%, -50%);
    background: none;
    border: none;
    padding: 0.5rem;
    cursor: pointer;
    font-family: inherit;
    text-decoration: none;
    pointer-events: auto;
}

.node-sphere {
    border-radius: 50%;
    flex-shrink: 0;
    box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.08) inset, 0 4px 16px var(--glow);
    transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.3s ease;
}

.hub-node .node-sphere {
    width: clamp(24px, 3.2vw, 32px);
    height: clamp(24px, 3.2vw, 32px);
    background: var(--planet-grad);
}

.node-label {
    font-size: 0.85rem;
    font-weight: 700;
    color: var(--text);
    white-space: nowrap;
    text-shadow: 0 0 3px var(--bg), 0 0 3px var(--bg), 0 0 5px var(--bg), 0 0 5px var(--bg);
}

.hub-node:hover .node-sphere,
.hub-node.active .node-sphere {
    transform: scale(1.15);
    box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.1) inset, 0 6px 26px var(--glow), 0 0 0 6px var(--glow);
}

.sub-node {
    display: none;
    opacity: 0;
    z-index: 1;
    transition: opacity 0.35s ease, transform 0.25s ease;
}

.sub-node .node-sphere {
    width: clamp(11px, 1.6vw, 15px);
    height: clamp(11px, 1.6vw, 15px);
    background: var(--moon-grad);
    box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06) inset, 0 2px 8px rgba(0, 0, 0, 0.2);
}

.sub-node .node-label {
    font-size: 0.75rem;
    font-weight: 600;
    color: var(--text-muted);
}

.sub-node.visible {
    display: flex;
}

.sub-node.label-left {
    flex-direction: row-reverse;
}

.sub-node.animate-in {
    opacity: 1;
}

.sub-node:hover .node-sphere {
    transform: scale(1.15);
}

.detail-backdrop {
    position: fixed;
    inset: 0;
    z-index: 999;
    background: rgba(0, 0, 0, 0.25);
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.3s ease;
}

.detail-backdrop.visible {
    opacity: 1;
    pointer-events: auto;
}

.detail-panel {
    position: fixed;
    left: 50%;
    bottom: 1.5rem;
    z-index: 1000;
    width: calc(100% - 2.5rem);
    max-width: 560px;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 16px;
    padding: 2rem;
    display: flex;
    gap: 1.25rem;
    align-items: flex-start;
    box-shadow: 0 20px 50px rgba(0, 0, 0, 0.2);
    opacity: 0;
    transform: translate(-50%, 20px);
    transition: opacity 0.3s ease, transform 0.3s ease;
    pointer-events: none;
    max-height: min(60vh, 420px);
    overflow-y: auto;
}

.detail-panel.visible {
    opacity: 1;
    transform: translate(-50%, 0);
    pointer-events: auto;
}

.detail-icon {
    font-size: 2rem;
    line-height: 1;
}

/* "Dot World"'s icon: a little cluster of dot-colored circles next to the
   globe — slightly uneven sizes/positions on purpose, a loose triangle
   rather than a neat, machine-perfect grid. Each one gets its own tiny
   pair of white eyes (a background-image, not extra DOM nodes — the
   gradient positions are percentages of each circle's own box, so they
   scale automatically with it), the same detail that makes the real
   "dot" mascot read as a face and not just a colored ball. */
.icon-dot-trio {
    position: relative;
    display: inline-block;
    width: 1.4em;
    height: 1.2em;
    vertical-align: middle;
    margin-left: 0.15em;
}

.icon-dot-trio span {
    position: absolute;
    border-radius: 50%;
    background-color: var(--accent);
    background-image:
        radial-gradient(circle at 35% 42%, #fff 0 16%, transparent 17%),
        radial-gradient(circle at 65% 42%, #fff 0 16%, transparent 17%);
}

.icon-dot-trio span:nth-child(1) {
    width: 0.56em;
    height: 0.56em;
    top: 0;
    left: 0.38em;
}

.icon-dot-trio span:nth-child(2) {
    width: 0.44em;
    height: 0.44em;
    top: 0.62em;
    left: 0;
}

.icon-dot-trio span:nth-child(3) {
    width: 0.48em;
    height: 0.48em;
    top: 0.54em;
    left: 0.86em;
}

/* "Triple Triad"'s icon: a hand-built card instead of the generic joker
   emoji, which says "playing cards" but nothing about this game. The layout
   is the one Final Fantasy VIII actually prints: the four ranks in a cross
   in the top-left corner, the element slot top-right, and artwork filling
   the rest — a stylized blade rather than a specific card's creature. Pure
   CSS in em units, so the whole thing scales with whatever font-size
   .detail-icon carries. */
.icon-tt-card {
    position: relative;
    display: inline-block;
    width: 1.62em;
    height: 2.16em;
    vertical-align: middle;
    border: 0.075em solid var(--accent);
    border-radius: 0.2em;
    background: var(--accent-soft);
    color: var(--accent);
}

/* The little box in the top-right corner: on a real card that slot carries
   the element symbol. It sits inside the outline (no negative offsets), so
   the card's own border stays an unbroken rectangle. */
.icon-tt-card::before {
    content: "";
    position: absolute;
    top: 0;
    right: 0;
    width: 0.34em;
    height: 0.34em;
    background: var(--surface);
    border-left: 0.075em solid var(--accent);
    border-bottom: 0.075em solid var(--accent);
    border-bottom-left-radius: 0.1em;
    border-top-right-radius: 0.13em;
}

/* The rank cross, tucked in the top-left corner the way the game lays it
   out — not spread across the whole card, which would leave nowhere for the
   artwork to go. */
.icon-tt-ranks {
    position: absolute;
    top: 0.08em;
    left: 0.05em;
    width: 0.8em;
    height: 0.7em;
}

.icon-tt-rank {
    position: absolute;
    font-size: 0.3em;
    font-weight: 700;
    line-height: 1;
    color: var(--accent);
}

.icon-tt-rank.tt-top {
    top: 0;
    left: 50%;
    transform: translateX(-50%);
}

.icon-tt-rank.tt-left {
    left: 0;
    top: 50%;
    transform: translateY(-50%);
}

.icon-tt-rank.tt-right {
    right: 0;
    top: 50%;
    transform: translateY(-50%);
}

.icon-tt-rank.tt-bottom {
    bottom: 0;
    left: 50%;
    transform: translateX(-50%);
}

/* A stylized blade — long straight edge, squared cross-guard, short grip —
   drawn as a 5x11 pixel grid of inline <rect>s with crispEdges, so every
   pixel stays a hard-edged square at any zoom instead of blurring the way
   a scaled bitmap would. */
.icon-tt-art {
    position: absolute;
    bottom: 0.12em;
    left: 50%;
    transform: translateX(-50%);
    width: 0.5em;
    height: auto;
    display: block;
}

.detail-body h2 {
    font-size: 1.1rem;
    margin-bottom: 0.5rem;
}

.detail-body p {
    color: var(--text-muted);
    font-size: 0.95rem;
    line-height: 1.7;
}

.inline-link {
    color: var(--accent);
    text-decoration: underline;
    text-underline-offset: 2px;
}

.inline-link:hover {
    text-decoration-thickness: 2px;
}

.detail-actions {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.6rem;
    margin-top: 1rem;
}

.detail-links {
    display: flex;
    flex-wrap: wrap;
    gap: 0.6rem;
}

.detail-link {
    display: inline-flex;
    align-items: center;
    gap: 0.35rem;
    padding: 0.45rem 0.95rem;
    border-radius: 50px;
    border: 1px solid var(--accent);
    color: var(--accent);
    text-decoration: none;
    font-size: 0.85rem;
    font-weight: 600;
    transition: background 0.2s ease, color 0.2s ease;
}

.detail-link:hover {
    background: var(--accent);
    color: #fff;
}

.detail-speak {
    display: inline-flex;
    align-items: center;
    gap: 0.35rem;
    padding: 0.45rem 0.95rem;
    border-radius: 50px;
    border: 1px solid var(--border);
    background: none;
    color: var(--text);
    font-family: inherit;
    font-size: 0.85rem;
    font-weight: 600;
    text-decoration: none;
    cursor: pointer;
    transition: background 0.2s ease, border-color 0.2s ease;
}

.detail-speak:hover {
    border-color: var(--accent);
    background: var(--accent-soft);
}

.detail-speak.speaking {
    border-color: var(--accent);
    color: var(--accent);
}

.detail-close {
    position: absolute;
    top: 0.85rem;
    right: 0.85rem;
    background: none;
    border: none;
    font-size: 1rem;
    color: var(--text-muted);
    cursor: pointer;
    padding: 0.25rem;
    line-height: 1;
}

.detail-close:hover {
    color: var(--text);
}

/* Footer — fixed to the bottom of the viewport on every page, always
   visible while scrolling rather than trailing the page's content. Needs
   its own opaque background since content now scrolls underneath it, and
   every page reserves --footer-h of bottom space (see .timeline-page,
   .hero-zoom-layer) so it never covers the last bit of real content. */
.footer {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 500;
    padding: 1.5rem 0;
    text-align: center;
    background: var(--bg);
    border-top: 1px solid var(--border);
    color: var(--text-muted);
    font-size: 0.9rem;
}

.footer a {
    color: var(--accent);
    text-decoration: none;
    font-weight: 600;
}

.footer a:hover {
    text-decoration: underline;
}

/* Respect reduced motion: no scroll fade-in */
/* Responsive */
@media (max-width: 768px) {
    /* Stacked instead of side-by-side, but still fit to the viewport with
       no scroll — same no-scroll intent as the desktop split-screen, just
       a column instead of a row. Every size below is vh-clamped against
       the actual remaining height (viewport minus navbar minus footer)
       instead of a fixed rem value, so it holds on anything from an
       iPhone SE up to a big-screen phone in portrait. */
    .hero {
        display: flex;
        flex-direction: column;
        flex: 1 1 auto;
        min-height: 0;
    }

    .navbar {
        padding: 0.6rem 0;
    }

    .logo {
        font-size: 1.1rem;
    }

    .nav-toggle {
        width: 32px;
        height: 32px;
    }

    .hero-visual {
        order: -1;
        flex: 0 0 auto;
        height: clamp(160px, 38vh, 340px);
    }

    .hero-text {
        flex: 1 1 auto;
        min-height: 0;
        /* The clamp()s below fit comfortably down to ~700px of viewport
           height, but a genuinely short phone (iPhone SE, 568px) still
           overflows even at every floor value — an internal scroll here
           is a much better fallback than silently clipping the end of the
           bio with no way to reach it. */
        overflow-y: auto;
        justify-content: center;
        gap: clamp(0.3rem, 1.6vh, 0.9rem);
        padding: clamp(0.6rem, 2.5vh, 1.25rem) 1.25rem;
        text-align: center;
        align-items: center;
    }

    .tagline,
    .cta-buttons {
        justify-content: center;
    }

    .description {
        text-align: left;
    }

    .name {
        font-size: clamp(1.6rem, 7vh, 2.4rem);
    }

    .tagline {
        font-size: clamp(0.62rem, 2.6vh, 0.85rem);
        gap: clamp(0.3rem, 1.2vh, 0.6rem);
    }

    .role {
        padding: clamp(0.25rem, 1vh, 0.4rem) clamp(0.5rem, 2.5vw, 0.8rem);
    }

    .description p {
        font-size: clamp(0.68rem, 2.2vh, 0.85rem);
        line-height: 1.4;
        margin-bottom: clamp(0.2rem, 0.8vh, 0.5rem);
    }

    .cta-buttons {
        margin-top: 0;
    }

    .btn {
        font-size: clamp(0.72rem, 2vh, 0.9rem);
        padding: clamp(0.4rem, 1.6vh, 0.7rem) clamp(1.25rem, 5vw, 2rem);
    }

    .footer {
        padding: clamp(0.6rem, 2vh, 1.25rem) 0;
        margin-top: clamp(0.5rem, 1.5vh, 1.5rem);
    }

    /* Same interactive graph as desktop, but padding/label/dot size scale
       with the available vertical space instead of a fixed value — the
       hero-visual panel is much shorter here than on desktop, so chips need
       their own vh-clamped sizing like the rest of this block. */
    .hub-node {
        gap: clamp(0.3rem, 1vh, 0.4rem);
    }

    .sub-node {
        gap: clamp(0.2rem, 0.7vh, 0.3rem);
    }

    .hub-node .node-sphere {
        width: clamp(20px, 6vh, 28px);
        height: clamp(20px, 6vh, 28px);
    }

    .sub-node .node-sphere {
        width: clamp(7px, 2.4vh, 10px);
        height: clamp(7px, 2.4vh, 10px);
    }

    .hub-node .node-label {
        font-size: clamp(0.78rem, 2.3vh, 0.92rem);
    }

    .sub-node .node-label {
        font-size: clamp(0.72rem, 2vh, 0.82rem);
    }
}

@media (max-width: 480px) {
    .name {
        font-size: clamp(1.5rem, 6.5vh, 2rem);
    }

    .tagline {
        font-size: clamp(0.6rem, 2.4vh, 0.85rem);
        gap: clamp(0.25rem, 1vh, 0.5rem);
    }
}

/* Phone in landscape: often wider than the 768px breakpoint above (so the
   navbar keeps its bulky desktop padding) while the viewport height is just
   as short as portrait mobile — compress it the same way, keyed off height
   instead of width. */
@media (max-height: 480px) and (orientation: landscape) {
    .navbar {
        padding: 0.6rem 0;
    }

    .logo {
        font-size: 1.1rem;
    }

    .nav-toggle {
        width: 32px;
        height: 32px;
    }
}

/* Same short-landscape case as above, but only once the viewport is wide
   enough to stay on the desktop side-by-side grid (narrower phones already
   fall into the max-width:768px stacked layout above and are unaffected
   here). The grid costs .hero-text zero vertical budget for .hero-visual
   (they sit side by side, not stacked), so it's worth keeping rather than
   switching to the mobile column layout — but its own clamp() floors were
   tuned for a tall desktop window, not a ~390px-tall phone turned sideways,
   so they need their own, tighter floors here. */
@media (max-height: 480px) and (orientation: landscape) and (min-width: 769px) {
    .hero-text {
        gap: clamp(0.2rem, 1.2vh, 0.6rem);
        padding: clamp(0.3rem, 2vh, 1rem) clamp(1.25rem, 4vw, 2.5rem);
    }

    .name {
        font-size: clamp(1.3rem, 4.5vh, 2rem);
    }

    .tagline {
        font-size: clamp(0.62rem, 1.6vh, 0.8rem);
        gap: clamp(0.25rem, 0.8vh, 0.5rem);
    }

    .role {
        padding: clamp(0.15rem, 0.8vh, 0.35rem) clamp(0.4rem, 1.6vh, 0.7rem);
    }

    .description p {
        font-size: clamp(0.62rem, 1.5vh, 0.8rem);
        margin-bottom: clamp(0.15rem, 0.6vh, 0.35rem);
    }

    /* The second paragraph is supplementary — dropping it buys back real
       margin at the worst case (390px tall) instead of shipping floors
       that fit with zero slack against bilingual (IT/EN) text length. */
    .description p:last-child {
        display: none;
    }

    .cta-buttons {
        gap: clamp(0.4rem, 1.5vh, 1rem);
    }

    .btn {
        padding: clamp(0.3rem, 1.4vh, 0.7rem) clamp(1.25rem, 3.5vw, 2rem);
        font-size: clamp(0.68rem, 1.6vh, 0.85rem);
    }

    .footer {
        padding: clamp(0.3rem, 1.2vh, 0.75rem) 0;
        margin-top: clamp(0.25rem, 1vh, 0.75rem);
        font-size: clamp(0.65rem, 1.6vh, 0.85rem);
    }

    .mascot {
        --mascot-base: 26px;
    }

    .hub-node .node-sphere {
        width: clamp(16px, 5vh, 22px);
        height: clamp(16px, 5vh, 22px);
    }

    .sub-node .node-sphere {
        width: clamp(8px, 2.6vh, 11px);
        height: clamp(8px, 2.6vh, 11px);
    }

    .hub-node .node-label {
        font-size: clamp(0.72rem, 2vh, 0.85rem);
    }

    .sub-node .node-label {
        font-size: clamp(0.65rem, 1.7vh, 0.75rem);
    }
}

/* Evolution timeline page (evolution.html) — a standalone vertical history
   of every merged PR, grouped into eras. Reuses the same theme tokens as
   the rest of the site so light/dark just works. Dynamic on purpose: each
   era pops/fades into place the first time it's scrolled into view, and an
   accent-colored fill grows behind it as you read down the page, instead
   of the whole thing sitting there fully drawn from the start. */
.timeline-page {
    /* Bottom padding reserves room for the fixed footer (see .footer) so
       the page's own last line of content is never hidden under it. */
    padding: 3rem 1.5rem calc(5rem + var(--footer-h));
    /* body is already a flex column with min-height:100vh (see body{} above)
       — .footer used to rely on this to sit at the bottom of short pages,
       but now that it's position:fixed this just keeps short pages from
       looking oddly cramped above the (always-visible) footer bar. */
    flex: 1 0 auto;
}

.timeline-intro {
    text-align: center;
    max-width: 640px;
    margin: 0 auto 3rem;
}

.timeline-intro h1 {
    font-size: 2rem;
    margin-bottom: 0.75rem;
}

.timeline-intro p {
    color: var(--text-muted);
    line-height: 1.7;
}

/* The project pages (cerebro/dot-world/triple-triad) read as short
   technical documents: overview, diagram, then a fact sheet, the flow
   written out step by step, and the decisions behind it. Text sections are
   held to a reading width, deliberately narrower than the diagram above
   them, which stays as wide as it needs to be. */
.doc-sections {
    max-width: 720px;
    margin: 3rem auto 0;
    display: flex;
    flex-direction: column;
    gap: 2.5rem;
}

.doc-section h2 {
    font-size: 1.15rem;
    margin-bottom: 0.9rem;
    padding-bottom: 0.5rem;
    border-bottom: 1px solid var(--border);
}

/* The fact sheet: label/value pairs in two columns, the labels quiet and
   the values carrying the weight. Rows a project has no documented answer
   for are simply left out of that page's markup rather than shown empty —
   the sheet is variable-length, not a table with holes. */
.doc-facts {
    display: grid;
    grid-template-columns: minmax(7rem, 12rem) 1fr;
    gap: 0.55rem 1.25rem;
    margin: 0;
}

.doc-facts dt {
    font-size: 0.78rem;
    font-weight: 600;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--text-muted);
    padding-top: 0.15em;
}

.doc-facts dd {
    margin: 0;
    font-size: 0.95rem;
    line-height: 1.6;
    color: var(--text);
}

/* The flow in words — the diagram above said the same thing in a picture,
   but on a phone that picture needs a tap and a pinch to read. */
.doc-steps {
    margin: 0;
    padding-left: 1.3rem;
    display: flex;
    flex-direction: column;
    gap: 0.6rem;
}

.doc-steps li {
    font-size: 0.95rem;
    line-height: 1.6;
    padding-left: 0.3rem;
}

.doc-steps li::marker {
    color: var(--accent);
    font-weight: 700;
}

/* The step's (or decision's) own name, kept as a separate element from
   its description: these pages swap languages with textContent, so any
   markup nested inside a translated node would be stripped on the first
   toggle. Two sibling nodes, two data-it attributes, nothing nested. */
.doc-step-name {
    font-weight: 600;
}

/* Same dot marker as the timeline's entries, so a bulleted list looks the
   same wherever it appears on the site. */
.doc-list {
    list-style: none;
    display: flex;
    flex-direction: column;
    gap: 0.7rem;
}

.doc-list li {
    position: relative;
    padding-left: 1.1rem;
    font-size: 0.95rem;
    line-height: 1.6;
    color: var(--text);
}

.doc-list li::before {
    content: '';
    position: absolute;
    left: 0;
    top: 0.6em;
    width: 6px;
    height: 6px;
    border-radius: 50%;
    background: var(--accent);
}

/* Thesis-style references at the foot of a project page: a numbered
   entry, the full URL on its own line (never truncated — a reference you
   can't copy isn't much of a reference), and a note saying why this
   particular source is here. The <a> is a sibling of the translated
   <span>s, never nested inside one: these pages swap languages with
   textContent, which would strip a link living inside a translated node. */
.doc-refs {
    margin: 0;
    padding-left: 1.3rem;
    display: flex;
    flex-direction: column;
    gap: 1rem;
}

.doc-refs li {
    padding-left: 0.3rem;
    scroll-margin-top: 5rem;
}

.doc-refs li::marker {
    color: var(--accent);
    font-weight: 700;
}

.doc-ref-cite {
    display: block;
    font-size: 0.95rem;
    line-height: 1.6;
}

.doc-ref-link {
    display: block;
    margin-top: 0.2rem;
    font-size: 0.85rem;
    color: var(--accent);
    text-decoration: underline;
    text-underline-offset: 2px;
    /* Long URLs are the classic source of horizontal scroll on a phone. */
    overflow-wrap: anywhere;
}

.doc-ref-note {
    display: block;
    margin-top: 0.4rem;
    font-size: 0.9rem;
    line-height: 1.6;
    color: var(--text-muted);
}

/* The [1] marker next to the claim it backs up. */
.doc-ref-mark {
    color: var(--accent);
    font-size: 0.8em;
    font-weight: 600;
    text-decoration: none;
    vertical-align: super;
}

.doc-ref-mark:hover {
    text-decoration: underline;
}

@media (max-width: 560px) {
    /* Two columns stop working once the labels have less room than their
       own words need: below this width the label sits above its value. */
    .doc-facts {
        grid-template-columns: 1fr;
        gap: 0.15rem;
    }

    .doc-facts dd {
        margin-bottom: 0.7rem;
    }
}

/* A symmetric card grid, shared by the Projects and News overview pages
   (projects/index.html, news/index.html) in place of .doc-sections —
   those read as a document, these read as a set of equal-weight entries
   to scan and pick from. auto-fit + minmax lets it reflow from 1 to 3
   columns without a breakpoint of its own. */
.card-grid {
    max-width: 960px;
    margin: 3rem auto 0;
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
    gap: 1.25rem;
}

.info-card {
    position: relative;
    display: flex;
    flex-direction: column;
    gap: 0.6rem;
    padding: 1.6rem 1.5rem 1.5rem;
    background: linear-gradient(160deg, var(--accent-soft), var(--surface) 55%);
    border: 1px solid var(--border);
    border-radius: 14px;
    overflow: hidden;
    text-decoration: none;
    color: inherit;
    transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
}

/* A thin accent rule along the top edge, in the same family as the
   node-sphere/pill accents used everywhere else — the cheapest way to make
   a plain bordered box read as "branded" instead of generic. */
.info-card::before {
    content: '';
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    height: 3px;
    background: var(--accent);
    opacity: 0.55;
    transition: opacity 0.15s ease;
}

a.info-card:hover,
a.info-card:focus-visible {
    transform: translateY(-3px);
    border-color: var(--accent);
    box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12);
}

a.info-card:hover::before,
a.info-card:focus-visible::before {
    opacity: 1;
}

.info-card-icon {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    align-self: flex-start;
    padding: 0.4em;
    font-size: 1.8rem;
    line-height: 1;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 10px;
}

.info-card-title {
    display: flex;
    align-items: center;
    gap: 0.4em;
    font-size: 1.05rem;
    font-weight: 700;
    color: var(--text);
}

/* A "go read it" arrow, tucked out of view until hover/focus nudges it
   in — the same affordance cue as .btn-primary elsewhere, just quieter
   since a whole card is the click target here, not a small button. */
.info-card-title::after {
    content: '→';
    color: var(--accent);
    opacity: 0;
    transform: translateX(-4px);
    transition: opacity 0.15s ease, transform 0.15s ease;
}

a.info-card:hover .info-card-title::after,
a.info-card:focus-visible .info-card-title::after {
    opacity: 1;
    transform: translateX(0);
}

.info-card-date {
    font-size: 0.78rem;
    font-weight: 600;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--accent);
}

/* Clamped instead of truncated with an ellipsis mid-word — 3 lines keeps
   every card the same height ("simmetriche") regardless of how long each
   project's or entry's excerpt happens to be. */
.info-card-excerpt {
    font-size: 0.92rem;
    line-height: 1.6;
    color: var(--text-muted);
    display: -webkit-box;
    -webkit-line-clamp: 3;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

.card-grid-empty {
    grid-column: 1 / -1;
    text-align: center;
    padding: 2.5rem 1rem;
    color: var(--text-muted);
    font-size: 0.95rem;
}

/* dot Dodge mini-game: canvas + score HUD + game-over overlay. */
.game-hud {
    display: flex;
    justify-content: center;
    gap: 1.5rem;
    margin: 0 0 0.75rem;
    font-variant-numeric: tabular-nums;
    font-weight: 600;
    color: var(--text);
}
.game-hud span {
    color: var(--text-muted);
    font-weight: 400;
    margin-right: 0.35em;
}
/* Same 480px column as the stat bar above and the controls below — the arena is
   never wider than that, and never narrower than the room actually has, so nothing
   here shrinks the arena on its own to squeeze inside a short window. */
.game-canvas-wrap {
    position: relative;
    width: 100%;
    max-width: 480px;
    margin: 0 auto;
    border-radius: 16px;
    overflow: hidden;
    border: 1px solid var(--border);
    box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12);
    /* A double click used to select the arena and the text around it, which swallowed
       the next inputs. Nothing in here is meant to be selectable. */
    user-select: none;
    -webkit-user-select: none;
    -webkit-tap-highlight-color: transparent;
}
.game-canvas-wrap canvas {
    display: block;
    width: 100%;
    aspect-ratio: 3 / 4;
    touch-action: none;
    cursor: pointer;
}
.game-overlay {
    position: absolute;
    inset: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 0.75rem;
    background: rgba(0, 0, 0, 0.55);
    color: #fff;
    text-align: center;
    padding: 1.5rem;
}
.game-overlay h2 {
    margin: 0;
    font-size: 1.3rem;
}
.game-overlay p {
    margin: 0;
    color: rgba(255, 255, 255, 0.8);
}
/* Ember Keep: the level-up cards and the sound toggle, both sitting over the arena
   in the same .game-overlay family as the end screen. */
.game-upgrade {
    gap: 0.7rem;
    padding: 1rem 0.75rem;
}
.game-upgrade h2 {
    font-size: 1.05rem;
}
.game-upgrade-cards {
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
    width: 100%;
    max-width: 320px;
}
.game-upgrade-card {
    display: grid;
    grid-template-columns: 1.9rem 1fr;
    grid-template-rows: auto auto;
    gap: 0.1rem 0.6rem;
    align-items: center;
    width: 100%;
    padding: 0.55rem 0.7rem;
    border: 1px solid rgba(255, 255, 255, 0.28);
    border-radius: 12px;
    background: rgba(255, 255, 255, 0.08);
    color: #fff;
    font: inherit;
    text-align: left;
    cursor: pointer;
    transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
}
.game-upgrade-card:hover,
.game-upgrade-card:focus-visible {
    background: rgba(255, 255, 255, 0.16);
    border-color: var(--accent);
    transform: translateY(-1px);
}
.game-upgrade-icon {
    grid-row: 1 / span 2;
    font-size: 1.4rem;
    line-height: 1;
    text-align: center;
}
.game-upgrade-card b {
    font-size: 0.85rem;
}
.game-upgrade-desc {
    font-size: 0.75rem;
    line-height: 1.3;
    color: rgba(255, 255, 255, 0.78);
}
.game-upgrade-hint {
    margin: 0;
    font-size: 0.72rem;
    color: rgba(255, 255, 255, 0.6);
}
/* Comandi sotto l'arena, non sopra: stick e bottoni coprivano la fascia bassa dove
   i mostri spingono il giocatore. Nessun pannello attorno — sono distanziati come
   quando stavano ancorati agli angoli della canvas: movimento a sinistra, azioni a
   destra, stessa larghezza di 480px della barra di stato sopra. */
.game-controls {
    max-width: 480px;
    margin: 1.1rem auto 0;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 1rem;
    user-select: none;
    -webkit-user-select: none;
}
.game-controls-actions {
    display: flex;
    align-items: center;
    gap: 0.75rem;
}
.game-stick {
    position: relative;
    flex: 0 0 auto;
    width: 104px;
    height: 104px;
    border-radius: 50%;
    border: 1px solid var(--border);
    background: var(--accent-soft);
    touch-action: none;
    cursor: grab;
}
.game-stick.is-held {
    cursor: grabbing;
    border-color: var(--accent);
}
.game-stick-knob {
    position: absolute;
    top: 50%;
    left: 50%;
    width: 44px;
    height: 44px;
    margin: -22px 0 0 -22px;
    border-radius: 50%;
    background: var(--accent);
    opacity: 0.85;
    pointer-events: none;
}

.game-mute {
    position: absolute;
    top: 0.6rem;
    right: 0.6rem;
    width: 34px;
    height: 34px;
    display: grid;
    place-items: center;
    padding: 0;
    border: 1px solid var(--border);
    border-radius: 50%;
    background: var(--surface);
    color: var(--text);
    font-size: 0.95rem;
    line-height: 1;
    cursor: pointer;
    opacity: 0.75;
    transition: opacity 0.15s ease;
}
.game-mute:hover,
.game-mute:focus-visible {
    opacity: 1;
}

/* Ember Keep: legend under the arena. The sprites are painted by the game module
   itself (drawArenaIcon), so they cannot drift from what walks into the fight. */
.game-legend {
    list-style: none;
    max-width: 480px;
    margin: 1rem auto 0;
    padding: 0;
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
    gap: 0.55rem 1.1rem;
}
.game-legend-item {
    display: flex;
    align-items: center;
    gap: 0.6rem;
}
.game-legend-sprite {
    flex: 0 0 auto;
    width: 44px;
    height: 44px;
    image-rendering: pixelated;
}
.game-legend-text {
    display: flex;
    flex-direction: column;
    gap: 0.1rem;
    min-width: 0;
}
.game-legend-text b {
    font-size: 0.8rem;
    color: var(--text);
}
.game-legend-text span {
    font-size: 0.75rem;
    line-height: 1.35;
    color: var(--text-muted);
}

.game-instructions {
    max-width: 480px;
    margin: 0.75rem auto 0;
    text-align: center;
    color: var(--text-muted);
    font-size: 0.85rem;
}
/* The credits sit under the section heading now, and that heading already carries
   the rule: a second border here would draw two lines a few pixels apart. */
.game-credits {
    margin: 0;
    color: var(--text-muted);
    font-size: 0.8rem;
    line-height: 1.5;
}
/* The heading that introduces the game. The rule goes ABOVE the title, not under it:
   its job is to close the memory piece and open the arena, so the title already belongs
   to what comes after it. Held to the arena's own column so the line starts and ends
   where the canvas does. */
.ember-heading {
    max-width: 480px;
    margin: 2.5rem auto 1.4rem;
    padding-top: 1.5rem;
    border-top: 1px solid var(--border);
    gap: 0;
}
.ember-heading .doc-section h2 {
    margin-bottom: 0.7rem;
    padding-bottom: 0;
    border-bottom: none;
}
/* Title and credits sit centred over the arena, like the instructions under it: the
   rule spans the column, the text does not hang off its left edge. */
.ember-heading .doc-section {
    text-align: center;
}
.game-credits a {
    color: var(--accent);
}
/* Ember Keep: HP/XP status bars + on-canvas touch attack buttons,
   an extension of the same game-hud/game-overlay family above. */
.game-statbar {
    display: flex;
    flex-direction: column;
    gap: 0.35rem;
    max-width: 480px;
    margin: 0 auto 0.75rem;
}
.game-statbar-row {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    font-size: 0.85rem;
    color: var(--text-muted);
}
.game-statbar-row span:first-child {
    flex-shrink: 0;
}
.game-statbar-track {
    flex: 1;
    height: 10px;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: 6px;
    overflow: hidden;
}
.game-statbar-fill {
    height: 100%;
    background: var(--accent);
    transition: width 0.2s ease;
}
.game-statbar-fill.hp {
    background: #c0392b;
}
.game-touch-btn {
    --cd: 0;
    position: relative;
    width: 64px;
    height: 64px;
    padding: 0;
    border-radius: 50%;
    border: 2px solid var(--border);
    background: var(--bg);
    color: var(--text);
    font-size: 1.7rem;
    line-height: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    touch-action: none;
    user-select: none;
    -webkit-user-select: none;
    -webkit-tap-highlight-color: transparent;
}
.game-touch-btn-ult {
    width: 76px;
    height: 76px;
    font-size: 2rem;
    border-color: rgba(249, 202, 36, 0.7);
}
.game-touch-btn:active {
    background: rgba(0, 0, 0, 0.7);
    transform: scale(0.94);
}
/* Cooldown: dim the button and sweep a ring around it as --cd goes 1 -> 0. */
.game-touch-btn::after {
    content: '';
    position: absolute;
    inset: -2px;
    border-radius: 50%;
    background: conic-gradient(rgba(255, 255, 255, 0.55) calc(var(--cd) * 360deg), transparent 0);
    -webkit-mask: radial-gradient(circle, transparent calc(50% - 4px), #000 calc(50% - 3px));
    mask: radial-gradient(circle, transparent calc(50% - 4px), #000 calc(50% - 3px));
    opacity: 0;
    pointer-events: none;
}
.game-touch-btn.is-cooling {
    opacity: 0.55;
}
.game-touch-btn.is-cooling::after {
    opacity: 1;
}

/* A third overlay in the .game-overlay family (alongside the end screen and the
   upgrade cards) — shown on top of the canvas, not in place of it. The canvas
   itself stays visible and interactive in portrait; this only appears once the
   player actually tries to play (see the pointerdown/keydown interceptors in
   index.html), and hides itself again the moment the phone is rotated. */
.game-rotate-prompt {
    gap: 0.6rem;
    color: var(--text-muted);
}
.game-rotate-icon {
    font-size: 2.6rem;
    line-height: 1;
    animation: game-rotate-icon-tilt 1.8s ease-in-out infinite;
}
@keyframes game-rotate-icon-tilt {
    0%, 100% { transform: rotate(0deg); }
    50% { transform: rotate(-90deg); }
}
@media (prefers-reduced-motion: reduce) {
    .game-rotate-icon {
        animation: none;
    }
}

/* Portrait phone: touch controls don't fit a narrow, tall arena the way the
   landscape one does, so this is no longer a valid way to play — but the
   canvas itself stays put, showing its own "tap or press Space" idle prompt
   same as always. It's the attempt to actually play (see index.html) that
   surfaces .game-rotate-prompt over it. Same 480px "phone" threshold the rest
   of the site already uses (e.g. line ~1560), so a portrait tablet (well past
   480px wide) is unaffected and keeps playing normally, same as today. */
@media (orientation: portrait) and (max-width: 480px) {
    .game-controls {
        display: none;
    }
}

/* Rotate the phone, get the fullscreen use case back — without the Fullscreen API
   this site tried and dropped (it squeezed the arena and cut the buttons off). This
   is CSS only: it turns on and off with the physical orientation, so it can never get
   stuck the way a JS-toggled class could. The max-height keeps a landscape tablet out
   of it (an iPad's shortest side is still well past 480px) — the same breakpoint the
   hero already uses for its own landscape-phone case above — via .game-play, the one
   wrapper that holds the stat bar, the arena and the controls together — the stat bar
   being outside the old fullscreen container is exactly what used to get cut off, so
   this time it rides along inside the same fixed panel. */
@media (orientation: landscape) and (max-height: 480px) {
    body:has(.game-play) {
        overflow: hidden;
    }
    /* A thin full-width strip on top (the HP/Level bars are short and wide, not tall
       and narrow — a column would cramp them) with the arena and the controls filling
       the rest of the height side by side underneath. */
    .game-play {
        position: fixed;
        inset: 0;
        /* Above the sticky nav (1000) and the fixed footer (500): this panel has to
           cover the whole page, not sit behind either of them. */
        z-index: 1100;
        display: flex;
        flex-direction: column;
        gap: 0.15rem;
        padding: 0.2rem 0.35rem;
        background: var(--bg);
        /* A phone with its browser chrome still showing can be well under 350px tall
           in landscape — shorter than this panel was first tuned against. The deck's
           own sizing below keeps it fitting in that case too, but this is the backstop
           that keeps a genuinely tighter phone from spilling controls past the edge of
           the panel instead of just quietly clipping them. */
        overflow: hidden;
    }
    .game-play .game-statbar {
        flex: 0 0 auto;
        max-width: none;
        margin: 0;
        gap: 0.1rem;
    }
    .game-play .game-statbar-row {
        font-size: 0.7rem;
        gap: 0.3rem;
    }
    .game-play .game-statbar-track {
        height: 6px;
    }
    /* Fullscreen means the canvas and the controls together claim the whole
       landscape screen — not a small arena floating in the middle of empty
       gutters. The stick sits at the true bottom-left corner (thumb rests there
       naturally when a phone is held in two hands) and the buttons stack in a
       column at the true right edge (reachable by the other thumb), so no
       control needs a one-handed reach across the screen. The arena fills
       everything in between, top to bottom — .game-controls goes display:contents
       so its own two children (the stick, the button column) become grid items
       in their own right instead of one block that would still sit to only one
       side. */
    .game-play .game-stage {
        display: grid;
        grid-template-columns: auto 1fr auto;
        /* Without this, the row has no definite height of its own to give — it sizes
           to whatever its items already are, so the arena's height:100% below had
           nothing real to resolve against and fell back to a small default instead of
           actually filling the row. This is what gives it one to stretch into. */
        grid-template-rows: minmax(0, 1fr);
        align-items: stretch;
        flex: 1 1 auto;
        min-height: 0;
        gap: 0.35rem;
    }
    .game-play .game-controls {
        display: contents;
    }
    /* A portrait 3:4 arena inside a wide, short phone screen can only ever be a
       narrow strip with empty gutters either side — that's geometry, not spacing,
       and no amount of padding fixes it. So here the arena isn't kept at 3:4 at
       all: it fills the entire middle grid column, whatever shape that turns out
       to be. ember-arena-game.js has no fixed-shape assumptions (spawn, movement
       clamps and knockback all work off W/H independently) and reflows its live
       entities when this shape changes underneath it — see resize() there. */
    .game-play .game-canvas-wrap {
        grid-column: 2;
        grid-row: 1;
        justify-self: stretch;
        align-self: stretch;
        max-width: none;
        width: 100%;
        height: 100%;
    }
    .game-play .game-canvas-wrap canvas {
        width: 100%;
        height: 100%;
        aspect-ratio: auto;
    }
    .game-play .game-stick {
        grid-column: 1;
        grid-row: 1;
        /* Bottom-left, not centred on the edge: a two-handed grip rests the left
           thumb low, and centring it vertically just left empty air above and
           below it that the arena couldn't use either. A little lift off the very
           bottom edge keeps the thumb's resting spot clear of the panel's corner. */
        align-self: end;
        margin-bottom: 1rem;
    }
    .game-play .game-controls-actions {
        grid-column: 3;
        grid-row: 1;
        align-self: center;
        flex-direction: column;
        gap: 0.7rem;
    }
    /* Sized up again for accessibility (bigger touch targets, easier to read and
       hit accurately) — the full-shape arena still leaves enough room next to it,
       even at the tightest tested phone height (~280px real), to go bigger still. */
    .game-play .game-stick {
        width: 116px;
        height: 116px;
    }
    .game-play .game-stick-knob {
        width: 46px;
        height: 46px;
        margin: -23px 0 0 -23px;
    }
    .game-play .game-touch-btn {
        width: 74px;
        height: 74px;
        font-size: 1.6rem;
    }
    .game-play .game-touch-btn-ult {
        width: 86px;
        height: 86px;
        font-size: 1.9rem;
    }
}

.timeline-wrap {
    position: relative;
    max-width: 720px;
    margin: 0 auto;
}

/* Cerebro Engine's diagram, shown full-page on cerebro.html — deliberately
   wider than the site's usual reading-width container (it's a technical
   diagram, not prose): up to 1400px on desktop. Always fits the screen
   width, never cropped — a tap opens it full-screen instead (see
   .diagram-zoom-overlay below), since the SVG's own baked-in text would
   turn to mush at a typical phone's width otherwise. The SVG already
   draws its own white card (see cerebro-diagram.svg), so this just
   centers/constrains it, adding depth via shadow. */
.diagram-page {
    max-width: 1400px;
    margin: 0 auto;
}

/* Wraps the diagram img in a real button — a plain <img> is never
   reachable by keyboard, so a Tab + Enter/Space visitor couldn't open the
   zoomed view before this existed. */
.diagram-trigger {
    display: block;
    width: 100%;
    max-width: 1400px;
    margin: 0 auto;
    padding: 0;
    border: none;
    background: none;
    cursor: zoom-in;
}

.diagram-page img {
    display: block;
    width: 100%;
    max-width: 1400px;
    height: auto;
    border-radius: 18px;
    box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
}

/* Full-screen tap-to-zoom view — a plain scrollable container so the
   browser's native pinch-zoom/pan does the actual work (the SVG stays
   crisp at any zoom level); this just gets it out of the small inline
   size and onto a dedicated dark backdrop that reads clearly regardless
   of site theme. */
.diagram-zoom-overlay {
    position: fixed;
    inset: 0;
    z-index: 2000;
    overflow: auto;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 1.5rem;
    background: rgba(0, 0, 0, 0.85);
}

.diagram-zoom-overlay img {
    display: block;
    width: 100%;
    max-width: 1100px;
    height: auto;
    border-radius: 18px;
    cursor: zoom-out;
}

.diagram-zoom-close {
    position: fixed;
    top: 1.25rem;
    right: 1.5rem;
    background: none;
    border: none;
    font-size: 1.5rem;
    color: #fff;
    cursor: pointer;
    padding: 0.25rem;
    line-height: 1;
    z-index: 2001;
}

.diagram-zoom-close:hover {
    color: var(--accent);
}

/* Two stacked lines behind the markers: a faint full-height track, and an
   accent-colored fill that grows with scroll position (height set via JS)
   — the timeline visibly "grows" as you read down the page. */
.timeline-track,
.timeline-progress {
    position: absolute;
    top: 0;
    left: 27px;
    width: 2px;
}

.timeline-track {
    bottom: 0;
    background: var(--border);
}

.timeline-progress {
    height: 0%;
    background: var(--accent);
    transition: height 0.1s linear;
}

.timeline {
    list-style: none;
    position: relative;
}

.timeline-era {
    position: relative;
    display: flex;
    gap: 1.25rem;
    margin-bottom: 2.5rem;
}

.timeline-era:last-child {
    margin-bottom: 0;
}

.timeline-era-marker {
    position: relative;
    z-index: 1;
    flex: 0 0 auto;
    width: 56px;
    height: 56px;
    border-radius: 50%;
    background: var(--surface);
    border: 1px solid var(--border);
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 1.5rem;
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
    opacity: 0;
    transform: scale(0.5);
    transition: opacity 0.4s ease, transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}

.timeline-era.in-view .timeline-era-marker {
    opacity: 1;
    transform: scale(1);
}

.timeline-era-content {
    flex: 1;
    min-width: 0;
    padding-top: 0.5rem;
    opacity: 0;
    transform: translateY(20px);
    transition: opacity 0.5s ease 0.08s, transform 0.5s ease 0.08s;
}

.timeline-era.in-view .timeline-era-content {
    opacity: 1;
    transform: translateY(0);
}

/* Real dates, taken from the repo's own git history — a timeline without
   them is just a list. Deliberately quiet: it labels the era, it doesn't
   compete with its title. */
.timeline-era-date {
    display: block;
    font-size: 0.78rem;
    font-weight: 600;
    letter-spacing: 0.04em;
    color: var(--text-muted);
    margin-bottom: 0.2rem;
}

.timeline-era-content h2 {
    font-size: 1.2rem;
    margin-bottom: 0.35rem;
}

.timeline-era-desc {
    color: var(--text-muted);
    font-size: 0.95rem;
    line-height: 1.6;
    margin-bottom: 0.9rem;
}

.timeline-entries {
    list-style: none;
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
}

.timeline-entries li {
    position: relative;
    padding-left: 1.1rem;
    font-size: 0.9rem;
    color: var(--text);
    line-height: 1.5;
}

.timeline-entries li::before {
    content: '';
    position: absolute;
    left: 0;
    top: 0.55em;
    width: 6px;
    height: 6px;
    border-radius: 50%;
    background: var(--accent);
}

/* The line that closes the timeline: it says the story is finished, not
   abandoned. Deliberately quiet — no marker, no track, and outside
   .timeline-wrap entirely, so the vertical track still stops at the last
   era exactly as it did before. Indented to line up with the eras' text
   rather than with their markers. */
.timeline-end {
    max-width: 720px;
    margin: 2.5rem auto 0;
    padding-left: 4.75rem; /* 56px marker + the era's 1.25rem gap */
    color: var(--text-muted);
    font-size: 0.9rem;
    line-height: 1.7;
}

@media (max-width: 600px) {
    .timeline-track,
    .timeline-progress {
        left: 21px;
    }

    .timeline-era-marker {
        width: 42px;
        height: 42px;
        font-size: 1.2rem;
    }

    .timeline-era {
        gap: 0.85rem;
    }

    .timeline-end {
        padding-left: 3.48rem; /* 42px marker + 0.85rem gap */
    }
}
