/* ---------------------------------------------------------------------------
   Design tokens

   One typeface, three jobs. JetBrains Mono sets display, prose and the terminal
   voice alike: once the page became a TUI, a proportional heading was the only
   thing on it still pretending to be a document. The three tokens stay separate
   because size, weight and tracking still differ per job — the family is what
   they now agree on. IBM Plex Sans Thai backs all three, because the EN/TH
   toggle needs Thai glyphs and JetBrains Mono has none.

   Colour is split the same way. Green means live or interactive and nothing
   else; amber means an achievement was earned; structure is neutral. Every text
   token clears 4.5:1 on --bg on its own, so nothing needs an opacity multiplier
   to look right — that was what pushed the old footer copy below AA.
--------------------------------------------------------------------------- */
:root {
    /* Ground.
       Channels get their own token because rules further down need these
       colours at their own alpha. Without it, rgba() copies of the same three
       numbers spread through the file and "change the palette" stops being a
       one-line edit. */
    --bg-rgb:        8, 11, 8;
    --bg:            rgb(var(--bg-rgb));
    --surface:       #0e130e;
    --surface-2:     #131a13;

    /* Structure — neutral, so green stays meaningful */
    --border:        #232823;
    --border-strong: #333c33;

    /* Glass — for text that sits directly on the black hole raster rather than
       on a --surface card. The alpha does the contrast work and the blur does
       the calming; neither is enough alone, which is why they travel as a pair.
       Every text token below is measured against flat --bg, and the raster
       breaks that measurement wherever a dense glyph cluster drifts under small
       type — this is what puts the flat ground back. .navbar has carried the
       same idiom inline since it was written. */
    --glass:         rgba(var(--bg-rgb), 0.62);
    --glass-blur:    blur(14px) saturate(0.85);

    /* The same idiom at panel strength. The three big cards — hero, timeline,
       project — used to be opaque --surface, which meant the raster was only
       ever visible in the gutters: the one element the page is built around
       spent most of the scroll hidden behind the content sitting on top of it.
       These panels carry long-form text, so they sit denser than --glass, but
       they let the hole read through as a diffused wash instead of blocking it
       outright. */
    --glass-panel:   rgba(var(--bg-rgb), 0.80);

    /* Green — live / interactive only */
    --green-rgb:        34, 197, 94;
    --green-bright-rgb: 74, 222, 128;
    --green:         rgb(var(--green-rgb));
    --green-bright:  rgb(var(--green-bright-rgb));
    --green-dim:     rgba(var(--green-rgb), 0.10);
    --green-line:    rgba(var(--green-rgb), 0.25);
    --green-glow:    rgba(var(--green-rgb), 0.18);
    /* Ink for text sitting on a green fill — the one place green is a
       background rather than a foreground. */
    --on-green:      #06170c;

    /* Amber — achievement earned */
    --amber:         #ffb000;

    /* Contribution heatmap — a 5-step ramp of --green rather than a new hue, so
       the grid still reads as "green means activity" like everything else. */
    --contrib-0:     var(--surface-2);
    --contrib-1:     rgba(var(--green-rgb), 0.22);
    --contrib-2:     rgba(var(--green-rgb), 0.45);
    --contrib-3:     rgba(var(--green-rgb), 0.70);
    --contrib-4:     var(--green);

    /* Text — 15.5:1 / 7.4:1 / 5.6:1 on --bg */
    --text-pri:      #dde6dd;
    --text-sec:      #93a393;
    --text-muted:    #7f8c7f;

    /* Type */
    --font-display: 'JetBrains Mono', 'IBM Plex Sans Thai', ui-monospace, monospace;
    --font-body:    'JetBrains Mono', 'IBM Plex Sans Thai', ui-monospace, monospace;
    --font-mono:    'JetBrains Mono', 'IBM Plex Sans Thai', ui-monospace, monospace;

    --fs-display: clamp(2.75rem, 6vw, 5.25rem);
    --fs-h2:      clamp(1.85rem, 3.6vw, 3.15rem);
    /* Card headings sit between h2 and lead. */
    --fs-card:    clamp(1.25rem, 2vw, 1.75rem);
    /* Lead carries the hero's own body copy — the hero reads at a larger size
       than the sections below it, which is why it needs a step of its own. */
    --fs-lead:    clamp(1.1rem, 1.8vw, 1.5rem);
    --fs-h3:      clamp(1.1rem, 1.6vw, 1.35rem);
    --fs-body:    clamp(1rem, 1.25vw, 1.15rem);
    --fs-small:   clamp(0.9rem, 1.1vw, 1.02rem);
    --fs-micro:   clamp(0.8rem, 0.95vw, 0.9rem);

    /* Space — 4px base */
    --sp-1: 4px;
    --sp-2: 8px;
    --sp-3: 12px;
    --sp-4: 16px;
    --sp-5: 24px;
    --sp-6: 32px;
    --sp-7: 48px;
    --sp-8: 64px;
    --sp-9: 96px;

    /* Radius. A character grid cannot draw a curve, so every box on the page is
       square and these two are 0 — they stay as tokens rather than being deleted
       because "how round is a box" is still one decision, made in one place.
       The two exceptions are shapes, not boxes: --r-pill for the terminal
       trigger and the avatar's own 50%. */
    --r-sm:   0;
    --r-lg:   0;
    --r-pill: 999px;

    /* Scrollbar. The page rail is wide enough to carry a lit core between two
       --bg gutters; panels get a narrower one so an inner scroller never
       out-shouts the page's own bar. */
    --scrollbar-w:      12px;
    --scrollbar-w-thin: 8px;
    --scrollbar-inset:  3px;

    /* Layout spine. Every section on the page resolves its left edge from these
       two numbers and nothing else, so the hero card, the project grid and the
       timeline start on the same pixel. They used to each carry their own
       max-width and padding and landed on three different edges (75 / 48 / 91
       at 1440), which is what made the page read as assembled rather than
       designed. Change the spine here, the whole page moves together. */
    --gutter:      var(--sp-7);
    --content-max: 1280px;
    /* What a full-bleed section needs to be so its *content box* is
       --content-max wide. Sections use this; inner panels use --content-max. */
    --section-max: calc(var(--content-max) + 2 * var(--gutter));
}

*, *::before, *::after {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}
/* Form controls don't inherit type by default, so any button that never named a
   family fell through to the UA font — .avatar-toggle, .term-close and
   .timeline-lightbox-close were all rendering in Arial, which is not a face
   this page uses anywhere. Every control that *does* name a family sets it on a
   class, so those still win on specificity. */
button, input, select, textarea {
    font-family: inherit;
    font-size: inherit;
    line-height: inherit;
    color: inherit;
}
:focus-visible {
    outline: 2px solid var(--green);
    outline-offset: 2px;
}
html {
    scroll-behavior: smooth;
}
/* Selecting text on a terminal inverts it into the foreground colour. Green is
   already the page's "live" channel, so a selection reads as the same idea. */
::selection {
    background: var(--green);
    color: var(--on-green);
}
/* Scanlines. The one artifact that says CRT rather than "dark website", and the
   layer the z-index note further down has always referred to. Held to a 3px
   period at low alpha: enough to read as phosphor texture at a glance, not
   enough to band the body copy. It stops below the fixed chrome — the navbar
   and the terminal panel are the interface, not the screen it's drawn on.

   Removed outright when the reader has asked for more contrast, since this is
   decoration that costs a little of it. */
body::after {
    content: '';
    position: fixed;
    inset: 0;
    z-index: 5;
    pointer-events: none;
    background: repeating-linear-gradient(
        to bottom,
        transparent 0px,
        transparent 2px,
        rgba(0, 0, 0, 0.10) 2px,
        rgba(0, 0, 0, 0.10) 3px);
}
@media (prefers-contrast: more) {
    body::after { display: none; }
}

/* Scrollbar ------------------------------------------------------------------
   The last piece of chrome on this page that still read as a dark website. A
   terminal's scroll rail is a column of the character grid - a hatched track
   with a solid block riding in it - so that is what this draws. The hatch is a
   1-on-4 gradient rather than glyphs: the same trick, and near enough the same
   rhythm, as the scanlines in body::after.

   Green means live everywhere else here, so the thumb is only green while you
   are actually scrolling; at rest it sits at --border-strong with the rest of
   the structure. The resting half of that is here. The live half cannot be:
   Chromium never matches a class on the originating element for the *viewport*
   scrollbar (html.is-scrolling::-webkit-scrollbar-thumb simply never applies),
   and var() inside a scrollbar pseudo-element resolves one restyle late, so
   neither of the two ways of expressing "now it is green" in this file works.
   What does work is toggling a stylesheet's media attribute, so the live rules
   live in the #railLive block in index.html and index.js flips it. */
::-webkit-scrollbar {
    width: var(--scrollbar-w);
    height: var(--scrollbar-w);
}
/* Arrowless. The buttons are the one part of a native bar with no counterpart
   in a TUI, and they cost the track a chunk at each end. */
::-webkit-scrollbar-button {
    display: none;
}
::-webkit-scrollbar-track {
    background-color: var(--bg);
    background-image: repeating-linear-gradient(
        to bottom,
        var(--border) 0 1px,
        transparent 1px 4px);
}
::-webkit-scrollbar-thumb {
    background-color: var(--border-strong);
    /* The --bg border is what makes the thumb read as a block sitting *in* a
       gutter rather than a bar filling it. A solid colour rather than
       `transparent` plus background-clip, which webkit paints through. */
    border: var(--scrollbar-inset) solid var(--bg);
    border-radius: var(--r-sm);
    transition: background-color 0.25s ease;
}
::-webkit-scrollbar-thumb:hover {
    background-color: var(--green-bright);
}
::-webkit-scrollbar-corner {
    background: var(--bg);
}

/* Firefox has no ::-webkit-scrollbar, so it gets the idea rather than the
   drawing: no hatch, no caps, but the same idle-grey-to-live-green swap.

   The @supports gate is not politeness, it is required. Chromium hands any
   scroller that declares scrollbar-width or scrollbar-color to its *standard*
   scrollbar painter and drops every ::-webkit-scrollbar rule for that element —
   so the two ways of styling a bar are mutually exclusive, and declaring both
   silently costs you the custom one. This file used to declare both on html,
   .term-output and .contrib-scroll, which is why the old rail rendered as a
   default 15px bar in Chrome no matter what the pseudo-elements said. */
@supports not selector(::-webkit-scrollbar) {
    html {
        scrollbar-width: auto;
        scrollbar-color: var(--border-strong) var(--bg);
    }
    html.is-scrolling {
        scrollbar-color: var(--green) var(--bg);
    }
    .term-output,
    .contrib-scroll {
        scrollbar-width: thin;
        scrollbar-color: var(--border-strong) transparent;
    }
}
/* The hatch is texture, and texture is the first thing to go when a reader has
   asked for contrast - the same bargain body::after makes just above. */
@media (prefers-contrast: more) {
    ::-webkit-scrollbar-track {
        background-image: none;
    }
    ::-webkit-scrollbar-thumb {
        background-color: var(--green-bright);
    }
}

/* Scroll readout. The rail says where you are; this says it in the one idiom a
   terminal has for it, a ruler percentage. Parked at the vertical middle of the
   right edge rather than riding the thumb: the top and bottom of that edge
   already belong to .navbar and .term-widget, and a travelling chip would slide
   under both at the ends of the scroll. */
.scroll-readout {
    position: fixed;
    top: 50%;
    right: calc(var(--scrollbar-w) + var(--sp-3));
    z-index: 90;
    transform: translateY(-50%);
    min-width: 5ch;
    padding: var(--sp-1) var(--sp-2);
    background: var(--glass);
    backdrop-filter: var(--glass-blur);
    border: 1px solid var(--border);
    border-radius: var(--r-sm);
    font-family: var(--font-mono);
    font-size: var(--fs-micro);
    line-height: 1.4;
    letter-spacing: 0.08em;
    text-align: right;
    color: var(--text-muted);
    pointer-events: none;
    opacity: 0;
    transition: opacity 0.3s ease, color 0.25s ease;
}
/* The sign is CSS so the script only ever writes an integer. */
.scroll-readout::after {
    content: '%';
}
html.is-scrolling .scroll-readout {
    opacity: 1;
    color: var(--green);
}
/* Nothing to report on a viewport tall enough to hold the whole page. */
html:not(.is-scrollable) .scroll-readout {
    display: none;
}

body {
    background: var(--bg);
    color: var(--text-pri);
    font-family: var(--font-body);
    font-size: var(--fs-body);
    line-height: 1.6;
    background-image:
        radial-gradient(ellipse 80% 40% at 50% 0%, rgba(var(--green-rgb),0.04) 0%, transparent 60%);
    overflow-x: hidden;
}
/* Scanlines sit above page content but beneath the navbar, toast, lightbox and
   terminal — at the old z-index of 9999 the texture was laid over every dialog. */
body::after {
    content: '';
    position: fixed;
    inset: 0;
    pointer-events: none;
    z-index: 5;
    background: repeating-linear-gradient(
        to bottom,
        transparent 0px,
        transparent 2px,
        rgba(0, 0, 0, 0.4) 3px
    );
    opacity: 0.05;
}

/* TUI frame -----------------------------------------------------------------
   A terminal has no curves: it draws a box out of the rule itself, and titles
   that box by breaking the rule and dropping a label into the gap —

       ┌─ ~/projects ─────────────────┐

   Every panel here already carried the label it needs (~/projects, the filter
   path, ~/contributions), so those are lifted onto the border rather than
   invented, and the panels with no heading of their own get one from content.
   The green hairline these panels used to wear is retired with it: the notch
   marks the top edge now, and two markers on one edge read as noise.

   Squaring the corners is what lets the old `overflow: hidden` go — it was
   clipping that hairline and the corners of the images inside, and a label that
   straddles the top rule survives neither. */
.about-inner,
.section-header,
.filter-bar,
.contrib-block,
.project-card,
.timeline {
    position: relative;
    border-radius: 0;
}
/* Half in, half out, painting --bg across the line so the rule is genuinely
   broken rather than merely covered. */
.about-inner::before,
.section-header .section-tag,
.filter-bar .filter-label,
.contrib-block .contrib-title,
.project-card::before,
.timeline::before {
    position: absolute;
    top: 0;
    left: var(--sp-5);
    /* Above the panel's own content: .project-img-wrap is positioned and comes
       later in the DOM, so without this the card's label paints behind its
       screenshot. */
    z-index: 1;
    transform: translateY(-50%);
    margin: 0;
    padding: 0 var(--sp-2);
    background: var(--bg);
    font-family: var(--font-mono);
    font-size: var(--fs-micro);
    font-weight: 400;
    line-height: 1.4;
    letter-spacing: 0.1em;
    color: var(--text-muted);
    white-space: nowrap;
}
.about-inner::before  { content: 'guest@freyr:~'; }
.project-card::before { content: attr(data-label); }
/* The timeline titles itself with whichever track is on show. */
.timeline[data-mode="education"]::before  { content: '~/background/education'; }
.timeline[data-mode="experience"]::before { content: '~/background/experience'; }

/* Nav ---------------------------------------------------------------------- */
.navbar {
    position: fixed;
    top: var(--sp-6);
    right: var(--sp-7);
    z-index: 100;
    display: flex;
    gap: var(--sp-1);
    background: rgba(var(--bg-rgb), 0.85);
    border: 1px solid var(--border);
    border-radius: var(--r-sm);
    padding: var(--sp-2) var(--sp-3);
    backdrop-filter: blur(10px);
}
.nav-link {
    display: inline-flex;
    align-items: center;
    font-family: var(--font-mono);
    font-size: var(--fs-body);
    font-weight: 500;
    letter-spacing: 0.06em;
    color: var(--text-sec);
    text-decoration: none;
    padding: var(--sp-2) var(--sp-4);
    border-radius: var(--r-sm);
    transition: color 0.2s, background 0.2s;
}
.nav-link::before,
.nav-link::after {
    color: var(--green);
    opacity: 0;
    transition: opacity 0.2s ease, transform 0.2s ease;
}
.nav-link::before {
    content: '[';
    margin-right: 0.3em;
    transform: translateX(5px);
}
.nav-link::after {
    content: ']';
    margin-left: 0.3em;
    transform: translateX(-5px);
}
.nav-link:hover,
.nav-link.active {
    color: var(--green);
    background: var(--green-dim);
}
.nav-link:hover::before,
.nav-link:hover::after,
.nav-link.active::before,
.nav-link.active::after {
    opacity: 1;
    transform: translateX(0);
}

/* Hero --------------------------------------------------------------------- */
.about-section {
    min-height: 100dvh;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: var(--sp-9) var(--gutter) var(--sp-8);
}
.about-inner {
    display: flex;
    align-items: center;
    gap: var(--sp-8);
    max-width: var(--content-max);
    width: 100%;
    animation: fadeUp 0.7s ease both;
    background: var(--glass-panel);
    -webkit-backdrop-filter: var(--glass-blur);
    backdrop-filter: var(--glass-blur);
    border: 1px solid var(--border);
    padding: var(--sp-8) var(--sp-8);
}
/* One variable drives the photo, the ASCII grid and the breakpoints, so the two
   renderings can never drift out of step. The cats are Braille grids of two
   different sizes (ASCII_CAT_1/2 in index.js) — see .ascii-face for how each is
   fitted to this same box. */
.avatar-wrap {
    --avatar-size: 280px;
    position: relative;
    flex-shrink: 0;
}
.avatar-toggle {
    display: block;
    position: relative;
    padding: 0;
    border: none;
    background: none;
    border-radius: 50%;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
}
.myFaceImage {
    width: var(--avatar-size);
    height: var(--avatar-size);
    border-radius: 50%;
    object-fit: cover;
    display: block;
    filter: grayscale(15%) brightness(0.9);
    position: relative;
    z-index: 1;
    cursor: inherit;
    transition: transform 0.4s ease, filter 0.4s ease, opacity 0.35s ease;
}
.avatar-toggle:hover .myFaceImage {
    transform: scale(1.04);
    filter: grayscale(0%) brightness(1);
}
.ascii-face {
    position: absolute;
    inset: 0;
    z-index: 1;
    margin: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    /* JetBrains Mono ships no Braille glyphs (Google Fonts serves no subset
       covering U+2800-28FF), so the cat would fall through to whatever the OS
       happens to have — different per platform, and misaligned. Noto Sans
       Symbols 2 goes first: its Braille subset is 5.6KB and every glyph in it
       advances a uniform 0.7em, which is what holds the columns square. */
    font-family: 'Noto Sans Symbols 2', var(--font-mono);
    /* --ascii-w (cols x 0.7) and --ascii-rows are set from the art itself in
       index.js, so the two cats' different grids both resolve to --avatar-size.
       The fallbacks match CAT_1 and only matter before the first paint. */
    --ascii-w: 63;
    --ascii-rows: 45;
    font-size: calc(var(--avatar-size) / var(--ascii-w));
    line-height: calc(var(--avatar-size) / var(--ascii-rows));
    letter-spacing: 0;
    white-space: pre;
    color: var(--green);
    border-radius: 50%;
    overflow: hidden;
    user-select: none;
    opacity: 0;
    pointer-events: none;
    transition: transform 0.4s ease, opacity 0.35s ease;
}
.avatar-toggle:hover .ascii-face {
    transform: scale(1.04);
}
.avatar-wrap.is-ascii .myFaceImage {
    opacity: 0;
}
.avatar-wrap.is-ascii .ascii-face {
    opacity: 1;
}
.avatar-ring {
    position: absolute;
    inset: -9px;
    border-radius: 50%;
    border: 2.5px solid var(--green);
    opacity: 0.4;
    z-index: 0;
}
.profileChangeRing {
    position: absolute;
    inset: -8px;
    border-radius: 50%;
    opacity: 0.7;
    --ring-color: var(--green);
    background:
        conic-gradient(
            var(--ring-color) var(--progress),
            #222 0deg
        );
    z-index: 0;
}
.profileChangeRing::before {
    content: "";
    position: absolute;
    inset: 10px;
    border-radius: 100%;
    background-color: black;
}
.status-dot {
    position: absolute;
    bottom: 14px;
    right: 14px;
    width: 25px;
    height: 25px;
    border-radius: 50%;
    background: var(--green);
    border: 4px solid var(--bg);
    z-index: 2;
    /* Tight halo, not a neon bloom: the dot reads as live state, and --green-glow
       is the token that exists for exactly this edge treatment. */
    box-shadow: 0 0 0 3px var(--green-glow);
}
.zzz {
    position: absolute;
    top: 4%;
    right: 10%;
    color: var(--green-bright);
    font-weight: 700;
    font-family: var(--font-mono);
    opacity: 0;
    pointer-events: none;
    z-index: 3;
    text-shadow: 0 0 8px rgba(var(--green-bright-rgb), 0.6);
    animation: floatZzz 3.6s ease-in infinite;
}
.z1 {
    font-size: 0.8rem;
    animation-delay: 0s;
}
.z2 {
    font-size: 1.1rem;
    top: -2%;
    right: 0%;
    animation-delay: 0.9s;
}
.z3 {
    font-size: 1.4rem;
    top: -8%;
    right: -10%;
    animation-delay: 1.8s;
}
@keyframes floatZzz {
    0% {
        opacity: 0;
        transform: translate(0, 0) rotate(0deg) scale(0.8);
    }
    15% {
        opacity: 0.9;
    }
    80% {
        opacity: 0;
    }
    100% {
        opacity: 0;
        transform: translate(14px, -34px) rotate(12deg) scale(1.1);
    }
}
.text-section {
    display: flex;
    flex-direction: column;
    gap: var(--sp-3);
}
/* The one green moment in the hero: the terminal greeting. */
.tag-line {
    font-family: var(--font-mono);
    font-size: var(--fs-h3);
    color: var(--green);
    letter-spacing: 0.08em;
}
.name-txt {
    font-family: var(--font-display);
    font-size: var(--fs-display);
    font-weight: 700;
    color: var(--text-pri);
    letter-spacing: -0.03em;
    /* Not 1: the Thai name carries a tone mark (เฟรย์'s ์) that stacks above the
       consonant. line-height:1 forces the line box tighter than the font's own
       vertical metrics, and .about-inner clips overflow — so at this size the
       mark got cut off whenever the EN/TH toggle switched to Thai. */
    line-height: 1.15;
}
.cursor {
    font-family: var(--font-mono);
    color: var(--green);
    animation: blink 1.1s step-end infinite;
}
.status-txt {
    font-size: var(--fs-lead);
    color: var(--text-sec);
    font-weight: 400;
    line-height: 1.5;
}
/* The same prompt idiom as .tag-line's `> whoami`, one step quieter. The hero
   reads as two commands and their output: `> whoami` prints the name and the
   affiliation, `> interests` prints the list under it. */
.interest-label {
    font-family: var(--font-mono);
    font-size: var(--fs-small);
    color: var(--green);
    letter-spacing: 0.08em;
}
.interest-block,
.skills-block {
    display: flex;
    flex-direction: column;
    gap: var(--sp-2);
}
.interest-list {
    list-style: none;
    display: flex;
    flex-direction: column;
    gap: var(--sp-1);
}
.interest-list li {
    font-size: var(--fs-lead);
    color: var(--text-pri);
    display: flex;
    align-items: center;
    gap: var(--sp-3);
}
/* Structural marker, not a live element — stays neutral. A hyphen rather than a
   triangle because the terminal's own `whoami` prints this same list as
   "  - Backend Engineering" (readAbout in index.js); the page and the shell it
   ships with should not disagree about how they bullet the same data. */
.interest-list li::before {
    content: '-';
    font-family: var(--font-mono);
    color: var(--text-muted);
    font-size: 1rem;
}
.skill-group {
    display: flex;
    flex-direction: column;
    gap: var(--sp-2);
}
.skill-group-label {
    font-family: var(--font-mono);
    font-size: var(--fs-micro);
    color: var(--text-muted);
    letter-spacing: 0.05em;
}
.skill-grid {
    display: flex;
    flex-wrap: wrap;
    gap: var(--sp-2);
}
.contact-row {
    display: flex;
    gap: var(--sp-3);
    margin-top: var(--sp-2);
    flex-wrap: wrap;
}
/* Executables, not labels. Lowercase with a ./ in front is the one change that
   makes the contact row read as three things you could run rather than three
   buttons — and the prefix lives in CSS so the EN/TH dictionary keeps holding
   plain words. */
.contact-btn {
    text-decoration: none;
    font-family: var(--font-mono);
    font-size: var(--fs-body);
    font-weight: 500;
    letter-spacing: 0.08em;
    text-transform: lowercase;
    color: var(--text-sec);
    background: transparent;
    border: 1px solid var(--border);
    border-radius: var(--r-sm);
    padding: var(--sp-3) var(--sp-5);
    cursor: pointer;
    transition: color 0.2s, border-color 0.2s, background 0.2s;
}
.contact-btn::before {
    content: './';
    color: var(--text-muted);
    transition: color 0.2s;
}
.contact-btn:hover {
    color: var(--green);
    border-color: var(--green);
    background: var(--green-dim);
}
.contact-btn:hover::before {
    color: var(--green);
}
.translate-btn {
    position: absolute;
    top: var(--sp-5);
    right: var(--sp-5);
    font-family: var(--font-mono);
    /* Sized on its own rather than from the type scale: this is the hero's
       one control, and it reads as an affordance only at this weight. */
    font-size: clamp(1.5rem, 2vw, 1.75rem);
    font-weight: 500;
    letter-spacing: 0.1em;
    color: var(--text-sec);
    background: transparent;
    border: 1px solid var(--border);
    border-radius: var(--r-sm);
    padding: var(--sp-3) var(--sp-4);
    cursor: pointer;
    display: flex;
    align-items: center;
    gap: var(--sp-2);
    transition: color 0.2s, border-color 0.2s, background 0.2s;
    z-index: 2;
}
.translate-btn:hover,
.translate-btn.active {
    color: var(--green);
    border-color: var(--green);
    background: var(--green-dim);
}
.translate-icon {
    font-size: 0.85em;
    color: var(--green);
}

/* Sections ----------------------------------------------------------------- */
.projects-section,
.background-section {
    max-width: var(--section-max);
    margin: 0 auto;
    padding: var(--sp-9) var(--gutter);
    border-top: 1px solid var(--border);
}
/* Glass rather than a full-width band: max-content keeps the panel the width of
   the heading itself, so the raster still owns the space either side of it. */
.section-header {
    width: max-content;
    max-width: 100%;
    margin-bottom: var(--sp-5);
    padding: var(--sp-4) var(--sp-5);
    background: var(--glass);
    -webkit-backdrop-filter: var(--glass-blur);
    backdrop-filter: var(--glass-blur);
    border: 1px solid var(--border);
}
/* Annotation, not a live element — neutral, unlike the hero's `> whoami`. */
.section-tag {
    font-family: var(--font-mono);
    font-size: var(--fs-micro);
    color: var(--text-muted);
    letter-spacing: 0.1em;
}
.section-title {
    font-family: var(--font-display);
    font-size: var(--fs-h2);
    font-weight: 700;
    color: var(--text-pri);
    margin-top: var(--sp-2);
    letter-spacing: -0.02em;
    line-height: 1.1;
}

/* Contribution heatmap ------------------------------------------------------ */
/* Boxed like .about-inner / .timeline so it reads as its own card above the
   black hole backdrop — unboxed, the raster showed straight through the gaps
   between cells. The smaller heading keeps it visually subordinate to
   "Things I've Built" despite now sharing the card treatment. */
.contrib-block {
    margin-top: var(--sp-8);
    background: var(--surface);
    border: 1px solid var(--border);
    padding: var(--sp-6) var(--sp-6) var(--sp-7);
}
.contrib-title {
    font-family: var(--font-mono);
    font-size: var(--fs-small);
    font-weight: 400;
    color: var(--text-muted);
    letter-spacing: 0.05em;
    margin-bottom: var(--sp-4);
}
/* 53 columns cannot fit a phone, so the grid scrolls inside its own box and the
   page body never scrolls sideways. */
.contrib-scroll {
    overflow-x: auto;
    padding-bottom: var(--sp-2);
}
/* The page rail turned on its side. Transparent track rather than --bg, because
   this one rides a panel: the hatch is drawn in green at low alpha instead of
   the page's neutral --border. */
.contrib-scroll::-webkit-scrollbar {
    height: var(--scrollbar-w-thin);
}
.contrib-scroll::-webkit-scrollbar-track {
    background-color: transparent;
    background-image: repeating-linear-gradient(
        to right,
        rgba(var(--green-rgb), 0.12) 0 1px,
        transparent 1px 4px);
}
.contrib-scroll::-webkit-scrollbar-thumb {
    background-color: var(--border-strong);
    background-image: none;
    border: none;
    border-radius: var(--r-sm);
}
.contrib-scroll::-webkit-scrollbar-thumb:hover {
    background-color: var(--green);
}
.contrib-grid {
    display: grid;
    grid-auto-flow: column;
    grid-template-rows: repeat(7, 1fr);
    gap: 3px;
    min-width: max-content;
}
.contrib-cell {
    width: 11px;
    height: 11px;
    border-radius: 2px;
    background: var(--contrib-0);
}
.contrib-cell[data-level="1"] { background: var(--contrib-1); }
.contrib-cell[data-level="2"] { background: var(--contrib-2); }
.contrib-cell[data-level="3"] { background: var(--contrib-3); }
.contrib-cell[data-level="4"] { background: var(--contrib-4); }
/* Days outside the reported range: they hold the column's shape without
   claiming the day was empty. */
.contrib-cell.is-empty {
    background: transparent;
}
/* Stats and legend share one row, so the block stays compact. */
.contrib-footer {
    display: flex;
    align-items: flex-end;
    justify-content: space-between;
    flex-wrap: wrap;
    gap: var(--sp-4);
    margin-top: var(--sp-4);
}
.contrib-legend {
    display: flex;
    align-items: center;
    gap: var(--sp-1);
}
.contrib-legend-text {
    font-family: var(--font-mono);
    font-size: var(--fs-micro);
    color: var(--text-muted);
}
.contrib-legend-text:first-child { margin-right: var(--sp-1); }
.contrib-legend-text:last-child  { margin-left: var(--sp-1); }
.contrib-stats {
    display: flex;
    flex-wrap: wrap;
    gap: var(--sp-6);
}
.contrib-stat dt {
    font-family: var(--font-mono);
    font-size: var(--fs-micro);
    color: var(--text-muted);
    letter-spacing: 0.05em;
}
.contrib-stat dd {
    font-family: var(--font-mono);
    font-size: var(--fs-small);
    font-weight: 500;
    color: var(--text-pri);
    margin-top: var(--sp-1);
}

/* Project filter ----------------------------------------------------------- */
.filter-bar {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: var(--sp-3);
    margin-bottom: var(--sp-6);
    padding: var(--sp-4) var(--sp-5);
    background: var(--glass);
    -webkit-backdrop-filter: var(--glass-blur);
    backdrop-filter: var(--glass-blur);
    border: 1px solid var(--border);
}
.filter-label {
    font-family: var(--font-mono);
    font-size: var(--fs-small);
    color: var(--text-muted);
    letter-spacing: 0.05em;
}
.filter-tags {
    display: flex;
    flex-wrap: wrap;
    gap: var(--sp-2);
}
.filter-clear {
    font-family: var(--font-mono);
    font-size: var(--fs-micro);
    color: var(--text-sec);
    background: none;
    border: none;
    cursor: pointer;
    letter-spacing: 0.05em;
    padding: var(--sp-1) var(--sp-2);
    border-radius: var(--r-sm);
    transition: color 0.2s;
}
.filter-clear:hover {
    color: var(--green);
}
/* Same glass as the bar above it, hugging the one line of text it holds. */
.filter-empty {
    width: max-content;
    max-width: 100%;
    font-size: var(--fs-small);
    color: var(--text-muted);
    margin-bottom: var(--sp-6);
    padding: var(--sp-3) var(--sp-4);
    background: var(--glass);
    -webkit-backdrop-filter: var(--glass-blur);
    backdrop-filter: var(--glass-blur);
    border: 1px solid var(--border);
    border-radius: var(--r-lg);
}

/* Project cards ------------------------------------------------------------ */
.project-card.is-hidden {
    display: none;
}
/* The grid's own entrance moved to motion.js, where it is a staggered batch.
   It is written from JS rather than from here for the same reason it used to
   strip its classes on animationend: the tag filter hides these cards, and a
   start state this file owned could outlive the animation meant to clear it.
   See the Motion layer block at the end of this file. */
.project-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(min(420px, 100%), 1fr));
    gap: var(--sp-7);
}
.project-card {
    background: var(--glass-panel);
    -webkit-backdrop-filter: var(--glass-blur);
    backdrop-filter: var(--glass-blur);
    border: 1px solid var(--border);
    transition: border-color 0.2s, transform 0.2s;
}
.project-card:hover {
    border-color: var(--green);
    transform: translateY(-5px);
}
.project-img-link {
    display: block;
    text-decoration: none;
}
.project-img-wrap {
    position: relative;
    overflow: hidden;
    height: 300px;
}
.project-img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
    filter: grayscale(20%) brightness(0.85);
    transition: filter 0.3s, transform 0.3s;
}
/* The label is always on screen, parked in the corner. It used to live at
   opacity 0 behind a :hover, which meant that on a touch device — where there
   is no hover — nothing on the card said it was a link at all. Hover now only
   *strengthens* an affordance that is already legible, rather than being the
   only way to discover it. */
.project-img-overlay {
    position: absolute;
    inset: 0;
    background: transparent;
    display: flex;
    align-items: flex-end;
    justify-content: flex-end;
    padding: var(--sp-3);
    transition: background 0.25s;
}
.project-img-wrap:hover .project-img {
    filter: grayscale(0%) brightness(0.75);
    transform: scale(1.04);
}
.project-img-wrap:hover .project-img-overlay,
.project-img-link:focus-visible .project-img-overlay {
    background: rgba(var(--green-rgb), 0.12);
}
/* Its own chip, because it sits over photographs rather than over a known
   colour — the fill is what keeps it legible on a light image. */
.view-label {
    font-family: var(--font-mono);
    font-size: var(--fs-micro);
    font-weight: 700;
    color: var(--green-bright);
    letter-spacing: 0.05em;
    padding: var(--sp-1) var(--sp-3);
    border-radius: var(--r-pill);
    background: rgba(var(--bg-rgb), 0.82);
    -webkit-backdrop-filter: blur(6px);
    backdrop-filter: blur(6px);
    border: 1px solid var(--green-line);
    opacity: 0.85;
    transition: opacity 0.25s, border-color 0.25s;
}
.project-img-wrap:hover .view-label,
.project-img-link:focus-visible .view-label {
    opacity: 1;
    border-color: var(--green);
}
.project-info {
    padding: var(--sp-5) var(--sp-6) var(--sp-6);
    display: flex;
    flex-direction: column;
    gap: var(--sp-1);
}
.project-title {
    font-family: var(--font-display);
    font-size: var(--fs-card);
    font-weight: 700;
    color: var(--text-pri);
    letter-spacing: -0.01em;
}
.project-sub {
    font-size: var(--fs-body);
    color: var(--text-sec);
}
.skill-container {
    display: flex;
    gap: var(--sp-2);
    flex-wrap: wrap;
    margin-top: var(--sp-3);
}

/* Skill tags — a static span in cards, a button in the filter bar and hero.
   Two levels of specificity only; do not add a third. */
.skill-tag {
    font-family: var(--font-mono);
    font-size: var(--fs-small);
    font-weight: 500;
    letter-spacing: 0.06em;
    color: var(--green);
    background: var(--green-dim);
    border: 1px solid var(--green-line);
    border-radius: var(--r-sm);
    padding: var(--sp-1) var(--sp-3);
}
button.skill-tag {
    position: relative;
    font-family: var(--font-mono);
    cursor: pointer;
    transition: background 0.2s, border-color 0.2s, color 0.2s, transform 0.15s;
}
button.skill-tag:hover {
    border-color: var(--green);
    background: rgba(var(--green-rgb), 0.2);
    transform: translateY(-1px);
}
button.skill-tag.is-active {
    background: var(--green);
    color: var(--bg);
    border-color: var(--green);
}
/* Tooltip copy is set from the i18n dictionary via data-tooltip. */
button.skill-tag[data-tooltip]::after {
    content: attr(data-tooltip);
    position: absolute;
    bottom: calc(100% + 10px);
    left: 50%;
    transform: translateX(-50%) translateY(4px);
    background: var(--surface-2);
    color: var(--green-bright);
    border: 1px solid var(--green);
    border-radius: var(--r-sm);
    padding: var(--sp-1) var(--sp-3);
    font-size: var(--fs-micro);
    font-family: var(--font-mono);
    letter-spacing: 0.03em;
    white-space: nowrap;
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.2s ease, transform 0.2s ease;
    z-index: 10;
}
button.skill-tag[data-tooltip]:hover::after,
button.skill-tag[data-tooltip]:focus-visible::after {
    opacity: 1;
    transform: translateX(-50%) translateY(0);
}

.ripple {
    position: absolute;
    border-radius: 50%;
    background: radial-gradient(circle, rgba(var(--green-bright-rgb),0.5) 0%, rgba(var(--green-rgb),0.15) 60%, transparent 70%);
    transform: scale(0);
    pointer-events: none;
    z-index: 3;
    animation: rippleAnim 0.6s ease-out forwards;
}
@keyframes rippleAnim {
    to { transform: scale(1); opacity: 0; }
}

.toast {
    position: fixed;
    left: 50%;
    bottom: var(--sp-7);
    z-index: 200;
    transform: translate(-50%, 12px);
    opacity: 0;
    pointer-events: none;
    background: var(--surface-2);
    color: var(--green-bright);
    border: 1px solid var(--green);
    border-radius: var(--r-sm);
    padding: var(--sp-3) var(--sp-5);
    font-family: var(--font-mono);
    font-size: var(--fs-small);
    letter-spacing: 0.03em;
    transition: opacity 0.25s ease, transform 0.25s ease;
}
.toast.show {
    opacity: 1;
    transform: translate(-50%, 0);
}

/* Background timeline ------------------------------------------------------ */
.bg-switch {
    display: flex;
    justify-content: center;
    max-width: 840px;
    margin: 0 auto var(--sp-7);
}
/* A segmented control, not a label. This used to be one word — "Education" —
   that crossfaded to "cd ~/experience →" on hover, so at rest it looked like a
   heading and on touch it never revealed itself at all. Both destinations are
   now on screen permanently and the lit one is the current mode, which is the
   only arrangement where a visitor can see there is a second track to read
   without having to guess. The awards and medals live behind this control. */
.bg-toggle {
    position: relative;
    display: grid;
    grid-template-columns: 1fr 1fr;
    align-items: stretch;
    min-width: 320px;
    height: 46px;
    padding: 3px;
    font-family: var(--font-mono);
    font-size: var(--fs-micro);
    font-weight: 700;
    letter-spacing: 0.03em;
    /* --green-dim is green at 0.10, so on its own the raster reads straight
       through the button. Stacking it over the glass keeps the green identity
       and puts a floor under it. */
    background: linear-gradient(var(--green-dim), var(--green-dim)), var(--glass);
    -webkit-backdrop-filter: var(--glass-blur);
    backdrop-filter: var(--glass-blur);
    border: 1px solid var(--green-line);
    border-radius: var(--r-sm);
    cursor: pointer;
    transition: border-color 0.2s ease;
}
.bg-toggle:hover {
    border-color: var(--green);
}
/* The lit half. Sliding it rather than restyling both segments means the
   movement itself carries which way the switch just went. */
.bg-toggle-thumb {
    position: absolute;
    top: 3px;
    bottom: 3px;
    left: 3px;
    width: calc(50% - 3px);
    background: var(--green);
    border-radius: calc(var(--r-sm) - 2px);
    transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1);
}
.bg-toggle[data-mode="experience"] .bg-toggle-thumb {
    transform: translateX(100%);
}
.bg-toggle-seg {
    position: relative;
    display: flex;
    align-items: center;
    justify-content: center;
    color: var(--text-sec);
    transition: color 0.28s ease;
}
.bg-toggle-seg.is-active {
    color: var(--on-green);
}
/* The same card the hero is built from — glass panel, hairline border, large
   radius, green top edge — so the two long-form panels on the page read as one
   family. No max-width of its own: it fills the section's content box, which is
   how it lands on the same left edge as the hero card and the project grid. */
.timeline {
    width: 100%;
    margin: 0 auto;
    background: var(--glass-panel);
    -webkit-backdrop-filter: var(--glass-blur);
    backdrop-filter: var(--glass-blur);
    border: 1px solid var(--border);
    padding: var(--sp-8);
}
/* Both tracks are dealt the same cell, so the panel always stands as tall as
   the longer of the two and the Education/Experience switch cannot change the
   height of the page under you. It used to: Education runs four entries to
   Experience's three, so every press moved the footer, re-seated the scroll
   position and re-measured every scroll trigger below it. The cost is a band of
   empty panel under the shorter track, which is the honest reading anyway —
   the space belongs to the section, not to whichever track is on show. */
.timeline {
    display: grid;
}
.timeline-columns {
    grid-area: 1 / 1;
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: var(--sp-4) var(--sp-6);
}
/* Laid out but not shown, which is the whole point — `display: none` would give
   the height back. visibility takes it out of the picture, out of the
   accessibility tree and out of the tab order just as completely, and index.js
   goes on setting the `hidden` attribute exactly as it did. */
.timeline-columns[hidden] {
    visibility: hidden;
    pointer-events: none;
}
/* The two tracks run at very different lengths — four education entries beside
   one volunteer entry — so the short column leaves a tall empty block to the
   right of the long one. The rule makes that a boundary between two tracks
   instead of an unexplained hole: it says the space belongs to the second
   column and the column simply has less in it. Full-height by grid stretch, so
   it holds whichever track happens to be longer. */
.timeline-columns > .timeline-col + .timeline-col {
    padding-left: var(--sp-6);
    border-left: 1px solid var(--border);
}
.timeline-col-title {
    font-family: var(--font-mono);
    font-size: var(--fs-micro);
    font-weight: 700;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--text-muted);
    margin: 0 0 var(--sp-5);
    padding-bottom: var(--sp-3);
    border-bottom: 1px solid var(--border);
}
.timeline-track {
    position: relative;
    display: flex;
    flex-direction: column;
    gap: var(--sp-6);
}
.timeline-track::before {
    content: '';
    position: absolute;
    left: 7px;
    top: 4px;
    bottom: 4px;
    width: 1px;
    background: var(--border);
}
.timeline-item {
    position: relative;
    display: grid;
    grid-template-columns: 16px 1fr;
    column-gap: var(--sp-4);
    align-items: start;
}
.timeline-item.is-entering {
    animation: timelineItemIn 0.45s ease both;
}
@keyframes timelineItemIn {
    from { opacity: 0; transform: translateY(16px); }
    to   { opacity: 1; transform: translateY(0); }
}
.timeline-dot {
    grid-column: 1;
    justify-self: center;
    align-self: start;
    position: relative;
    margin-top: 6px;
    width: 16px;
    height: 16px;
    border-radius: 50%;
    background: var(--bg);
    border: 2.5px solid var(--border-strong);
    z-index: 2;
}
/* Green marks the live one — the rest of the track is structure. */
.timeline-item.is-current .timeline-dot {
    background: var(--green);
    border-color: var(--green);
    box-shadow: 0 0 14px rgba(var(--green-rgb), 0.6);
}
.timeline-item.is-current .timeline-dot::after {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: 50%;
    border: 2.5px solid var(--green);
    animation: pulseRing 1.8s ease-out infinite;
}
.timeline-item.is-open .timeline-dot {
    background: transparent;
    border-color: var(--green);
    border-style: dashed;
}
.timeline-item.is-open .timeline-dot::after {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: 50%;
    border: 2px dashed var(--green);
    animation: pulseRing 2.4s ease-out infinite;
}
@keyframes pulseRing {
    0%   { transform: scale(1); opacity: 0.7; }
    100% { transform: scale(2.3); opacity: 0; }
}
.timeline-content {
    grid-column: 2;
    text-align: left;
    padding: var(--sp-2) var(--sp-4);
    margin: calc(var(--sp-2) * -1) calc(var(--sp-4) * -1);
    border-radius: var(--r-sm);
    transition: background 0.2s ease, box-shadow 0.2s ease;
}
/* Only the education entries carry a photo, so only they get the pointer and
   the hover affordance. Previously every item looked clickable; five of the
   nine did nothing when clicked. */
.timeline-content[data-image] {
    cursor: pointer;
}
.timeline-content[data-image]:focus-visible {
    outline: none;
}
.timeline-content[data-image]:hover,
.timeline-content[data-image]:focus-visible {
    background: var(--green-dim);
    box-shadow: 0 0 0 1px var(--green-line);
}
.timeline-content[data-image]:hover .timeline-title,
.timeline-content[data-image]:focus-visible .timeline-title {
    color: var(--green-bright);
}
.timeline-content[data-image]:hover .timeline-hint,
.timeline-content[data-image]:focus-visible .timeline-hint {
    opacity: 1;
}
.timeline-year {
    font-family: var(--font-mono);
    font-size: var(--fs-micro);
    color: var(--text-muted);
    letter-spacing: 0.05em;
    margin-bottom: var(--sp-1);
}
.timeline-title {
    font-size: var(--fs-h3);
    font-weight: 600;
    color: var(--text-pri);
    line-height: 1.3;
    transition: color 0.2s ease;
}
.timeline-place {
    font-size: var(--fs-small);
    color: var(--text-sec);
    margin-top: var(--sp-1);
}
.timeline-note {
    font-size: var(--fs-small);
    color: var(--green-bright);
    font-style: italic;
    margin-top: var(--sp-1);
}
/* Amber = something earned: a GPAX, a medal, a placing. */
.timeline-award {
    font-family: var(--font-mono);
    font-size: var(--fs-micro);
    color: var(--amber);
    letter-spacing: 0.03em;
    margin-top: var(--sp-1);
}
/* Visible at rest, brighter on hover. These four cards open a photo, and at
   opacity 0 there was nothing telling a phone visitor that — the entries read
   as flat text. Dimmed rather than hidden keeps the hierarchy without hiding
   the affordance. */
.timeline-hint {
    display: inline-block;
    margin-top: var(--sp-1);
    font-family: var(--font-mono);
    font-size: var(--fs-micro);
    letter-spacing: 0.04em;
    color: var(--green);
    opacity: 0.6;
    transition: opacity 0.2s ease, transform 0.2s ease;
}

/* Timeline photo lightbox -------------------------------------------------- */
.timeline-lightbox {
    position: fixed;
    inset: 0;
    z-index: 400;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: var(--sp-5);
    background: rgba(3, 6, 3, 0.82);
    opacity: 0;
    visibility: hidden;
    transition: opacity 0.25s ease, visibility 0s linear 0.25s;
}
.timeline-lightbox.is-open {
    opacity: 1;
    visibility: visible;
    transition-delay: 0s;
}
.timeline-lightbox-frame {
    position: relative;
    width: min(640px, 90vw);
    max-height: 85vh;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: var(--r-lg);
    overflow: hidden;
    box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
    transform: scale(0.94) translateY(10px);
    transition: transform 0.25s ease;
}
.timeline-lightbox.is-open .timeline-lightbox-frame {
    transform: scale(1) translateY(0);
}
.timeline-lightbox-img-link {
    position: relative;
    display: block;
    text-decoration: none;
}
.timeline-lightbox-img {
    display: block;
    width: 100%;
    max-height: 65vh;
    object-fit: contain;
    background: var(--bg);
}
.timeline-lightbox-fb-overlay {
    position: absolute;
    inset: 0;
    background: rgba(var(--green-rgb), 0.14);
    display: flex;
    align-items: center;
    justify-content: center;
    opacity: 0;
    transition: opacity 0.25s ease;
}
.timeline-lightbox-img-link:hover .timeline-lightbox-fb-overlay,
.timeline-lightbox-img-link:focus-visible .timeline-lightbox-fb-overlay {
    opacity: 1;
}
.timeline-lightbox-img-link.is-disabled {
    cursor: default;
}
.timeline-lightbox-img-link.is-disabled .timeline-lightbox-fb-overlay {
    display: none;
}
.timeline-lightbox-fb-label {
    font-family: var(--font-mono);
    font-size: var(--fs-small);
    font-weight: 700;
    letter-spacing: 0.04em;
    color: var(--green-bright);
    background: rgba(var(--bg-rgb), 0.6);
    padding: var(--sp-2) var(--sp-4);
    border-radius: var(--r-sm);
    border: 1px solid var(--green);
}
.timeline-lightbox-caption {
    padding: var(--sp-3) var(--sp-4) var(--sp-4);
    font-size: var(--fs-small);
    color: var(--text-sec);
}
.timeline-lightbox-close {
    position: absolute;
    top: var(--sp-2);
    right: var(--sp-2);
    width: 32px;
    height: 32px;
    border-radius: 50%;
    background: rgba(var(--bg-rgb), 0.6);
    border: 1px solid var(--border);
    color: var(--text-pri);
    font-size: 1.2rem;
    line-height: 1;
    cursor: pointer;
    z-index: 2;
    transition: color 0.2s, border-color 0.2s;
}
.timeline-lightbox-close:hover {
    color: var(--green);
    border-color: var(--green);
}
.timeline-lightbox-empty {
    display: none;
    padding: var(--sp-7) var(--sp-5) var(--sp-4);
    text-align: center;
    color: var(--text-muted);
    font-size: var(--fs-small);
}
.timeline-lightbox.is-missing .timeline-lightbox-img {
    display: none;
}
.timeline-lightbox.is-missing .timeline-lightbox-empty {
    display: block;
}

/* Terminal widget ---------------------------------------------------------- */
.term-widget {
    position: fixed;
    bottom: var(--sp-5);
    right: var(--sp-5);
    z-index: 300;
}
/* Sits *on* the pill, not through it. At 27px the cat's hind legs landed across
   the middle of the trigger, straight over the word "terminal" — the label was
   unreadable at the one moment it had a job to do. The trigger is 60px tall, so
   anything above ~44px leaves the text clear and still reads as a cat perched
   on the button. */
.term-cat {
    position: absolute;
    bottom: 44px;
    left: 50%;
    width: 148px;
    transform: translateX(-50%);
    pointer-events: none;
    image-rendering: pixelated;
    filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.45));
    z-index: 2;
    transition: opacity 0.2s ease, transform 0.2s ease;
}
.term-widget:hover .term-cat {
    transform: translateX(-50%) translateY(-3px) scale(1.08);
}
.term-widget.is-open .term-cat {
    opacity: 0;
    transform: translateX(-50%) scale(0.85);
}
.term-trigger {
    position: relative;
    height: 60px;
    padding: 0 var(--sp-5);
    border-radius: var(--r-pill);
    background: linear-gradient(135deg, var(--green-bright), var(--green));
    border: none;
    color: var(--on-green);
    font-family: var(--font-mono);
    font-size: 1.15rem;
    font-weight: 700;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--sp-2);
    box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35);
    transition: transform 0.2s ease, box-shadow 0.2s ease, filter 0.2s ease;
}
.term-trigger:hover {
    transform: translateY(-3px) scale(1.04);
    filter: brightness(1.08);
    box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
}
.term-trigger:active {
    transform: translateY(-1px) scale(0.99);
}
.term-trigger-icon {
    pointer-events: none;
    font-weight: 700;
}
.term-trigger-label {
    pointer-events: none;
    font-size: 0.95rem;
    font-weight: 700;
    letter-spacing: 0.03em;
    text-transform: lowercase;
    color: var(--on-green);
}
.term-widget.is-open .term-trigger {
    opacity: 0;
    pointer-events: none;
    transform: scale(0.8);
}
.term-panel {
    position: absolute;
    bottom: 0;
    right: 0;
    width: clamp(340px, 30vw, 420px);
    height: min(560px, 72vh);
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: var(--r-lg);
    display: flex;
    flex-direction: column;
    overflow: hidden;
    box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
    transform-origin: bottom right;
    transform: translateY(12px);
    opacity: 0;
    visibility: hidden;
    transition: opacity 0.25s ease, transform 0.25s ease, visibility 0s linear 0.25s;
}
.term-widget.is-open .term-panel {
    transform: translateY(0);
    opacity: 1;
    visibility: visible;
    transition-delay: 0s;
}
.term-panel.is-flash {
    animation: termFlash 0.38s ease-out;
}
@keyframes termFlash {
    0% {
        border-color: var(--border);
        box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
    }
    25% {
        border-color: #dc2626;
        box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 55px rgba(220, 38, 38, 0.65);
    }
    100% {
        border-color: var(--border);
        box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
    }
}
.term-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: var(--sp-3) var(--sp-4);
    border-bottom: 1px solid var(--border);
    flex-shrink: 0;
}
.term-header-title {
    font-family: var(--font-mono);
    font-size: var(--fs-small);
    color: var(--green);
    letter-spacing: 0.03em;
}
.term-close {
    background: none;
    border: none;
    color: var(--text-muted);
    font-size: 1.3rem;
    line-height: 1;
    cursor: pointer;
    padding: var(--sp-1) var(--sp-2);
    border-radius: var(--r-sm);
    transition: color 0.2s;
}
.term-close:hover {
    color: var(--green);
}
.term-output {
    flex: 1;
    overflow-y: auto;
    padding: var(--sp-4);
    font-family: var(--font-mono);
    font-size: var(--fs-small);
    line-height: 1.65;
    color: var(--text-pri);
}
.term-output::-webkit-scrollbar {
    width: var(--scrollbar-w-thin);
}
.term-output::-webkit-scrollbar-track {
    background-color: transparent;
    background-image: repeating-linear-gradient(
        to bottom,
        rgba(var(--green-rgb), 0.12) 0 1px,
        transparent 1px 4px);
}
/* Square, like the page rail and every other box here - the old 3px radius was
   one of the last curves in the file. */
.term-output::-webkit-scrollbar-thumb {
    background-color: var(--border-strong);
    background-image: none;
    border: none;
    border-radius: var(--r-sm);
}
.term-output::-webkit-scrollbar-thumb:hover {
    background-color: var(--green);
}
.term-line {
    white-space: pre-wrap;
    word-break: break-word;
    animation: termLineIn 0.12s ease-out;
}
@keyframes termLineIn {
    from { opacity: 0; }
    to { opacity: 1; }
}
.term-line-cmd {
    color: var(--text-sec);
}
.term-line-cmd::before {
    content: '> ';
    color: var(--green);
}
.term-line-error {
    color: var(--text-muted);
}
.term-input-row {
    display: flex;
    align-items: center;
    gap: var(--sp-2);
    padding: var(--sp-3) var(--sp-4);
    border-top: 1px solid var(--border);
    flex-shrink: 0;
}
.term-prompt {
    color: var(--green);
    font-family: var(--font-mono);
    font-weight: 700;
}
/* The ghost hint is a second copy of the line rendered underneath the input, so
   the two have to agree on every metric that moves a glyph — family, size and
   line box alike. Anything that differs shows up as the hint drifting out from
   under the caret as the line gets longer, which is worse than no hint at all. */
.term-input-wrap {
    position: relative;
    flex: 1;                            /* moved off .term-input, which is no
                                           longer a direct child of the row */
    display: flex;
    min-width: 0;                       /* lets the input shrink inside the row */
}
.term-ghost {
    position: absolute;
    inset: 0;
    pointer-events: none;
    font-family: var(--font-mono);
    font-size: var(--fs-small);
    line-height: inherit;
    color: var(--text-muted);
    white-space: pre;
    overflow: hidden;
}
.term-input {
    width: 100%;
    background: none;
    border: none;
    outline: none;
    color: var(--text-pri);
    font-family: var(--font-mono);
    font-size: var(--fs-small);
    caret-color: var(--green);
}
.term-input::placeholder {
    color: var(--text-muted);
}
.term-input:disabled {
    opacity: 0.4;
    cursor: not-allowed;
}
.term-menu-item {
    cursor: pointer;
}
.term-menu-item.is-selected {
    color: var(--green);
}
.term-thinking {
    color: var(--text-sec);
}
/* The ellipsis animates its width over static "..." text rather than swapping
   characters, so the aria-live region announces the line once instead of on
   every tick. The reduced-motion block at the end of this file then settles it
   on the final frame — all three dots, shown statically. */
.term-thinking-dots {
    display: inline-block;
    overflow: hidden;
    vertical-align: bottom;
    white-space: nowrap;
    animation: termDots 1.2s steps(4, jump-none) infinite;
}
@keyframes termDots {
    from { width: 0; }
    to   { width: 3ch; }
}
.term-game-controls {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: var(--sp-2);
    /* Padding gives the commit flash a bar to fill; the negative horizontal
       margin cancels it so the row stays aligned with the other log lines. */
    margin: var(--sp-5) calc(var(--sp-1) * -1) var(--sp-1);
    padding: 2px var(--sp-1);
    border-radius: var(--r-sm);
}
.term-game-controls.is-committing {
    animation: termCommit 0.26s ease-out;
}
@keyframes termCommit {
    0%   { background: transparent; }
    35%  { background: rgba(var(--green-rgb), 0.38); box-shadow: 0 0 22px rgba(var(--green-rgb), 0.45); }
    100% { background: transparent; }
}
.term-line-commit {
    animation: termCommitIn 0.3s ease-out;
}
@keyframes termCommitIn {
    from { opacity: 0; transform: translateX(-8px); }
    to   { opacity: 1; transform: translateX(0); }
}
.term-game-status {
    color: var(--text-sec);
}
.term-game-btn {
    position: relative;                 /* anchors the floating +1 */
    font-family: var(--font-mono);
    font-size: var(--fs-micro);
    font-weight: 500;
    letter-spacing: 0.03em;
    color: var(--green);
    background: var(--green-dim);
    border: 1px solid var(--green-line);
    border-radius: var(--r-sm);
    padding: var(--sp-1) var(--sp-2);
    cursor: pointer;
    transition: background 0.2s ease, border-color 0.2s ease, opacity 0.2s ease;
}
.term-game-btn:hover:not(:disabled) {
    background: rgba(var(--green-rgb), 0.22);
    border-color: var(--green);
}
.term-game-btn:disabled {
    opacity: 0.35;
    cursor: not-allowed;
}
.term-game-btn.is-hit {
    animation: termBtnPop 0.22s ease-out;
}
@keyframes termBtnPop {
    0%   { transform: scale(1); }
    35%  { transform: scale(0.9); background: rgba(var(--green-rgb), 0.34); }
    70%  { transform: scale(1.06); }
    100% { transform: scale(1); }
}
/* Rises off the button and fades. Ends at opacity 0, so the reduced-motion
   block settles it invisibly even though the JS already skips creating it. */
.term-float {
    position: absolute;
    left: 50%;
    bottom: 100%;                       /* clears the button's own label */
    margin-bottom: 3px;
    z-index: 4;
    font-family: var(--font-mono);
    font-size: var(--fs-micro);
    font-weight: 700;
    line-height: 1.4;
    white-space: nowrap;
    pointer-events: none;
    color: var(--green-bright);
    text-shadow: 0 0 8px var(--green-glow);
    animation: termFloatUp 0.6s ease-out forwards;
}
@keyframes termFloatUp {
    from { opacity: 0; transform: translate(-50%, 6px) scale(0.85); }
    25%  { opacity: 1; transform: translate(-50%, 0) scale(1.1); }
    to   { opacity: 0; transform: translate(-50%, -18px) scale(1.15); }
}
/* The named moves in rps and guess sit in the same row as 21's +1, so they
   inherit its flash and spacing. Only the width is pinned: a row that resized
   itself as the labels changed would jump under the cursor mid-game. */
.term-game-btn.is-choice {
    min-width: 4.5em;
    text-align: center;
}
/* The completion list, and the one line that answers a typo. */
.term-line-hint {
    color: var(--text-muted);
}
/* Box art and the rain are pictures made of characters, so they need the one
   thing prose does not: no wrapping at all. .term-line is pre-wrap, which still
   breaks at spaces — and a space is exactly what holds these drawings apart, so
   a wrap lands mid-picture and shears it. `pre` keeps each row whole and the
   overflow scrolls instead, without a visible bar: at this size the bar would
   be thicker than the art sitting above it. */
.term-line-art,
.term-rain {
    white-space: pre;
    word-break: normal;
    overflow-x: auto;
    scrollbar-width: none;
}
.term-line-art::-webkit-scrollbar,
.term-rain::-webkit-scrollbar {
    display: none;
}
.term-rain {
    color: var(--green);
    line-height: 1.15;
    text-shadow: 0 0 6px var(--green-glow);
}

/* Footer ------------------------------------------------------------------- */
.footer {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--sp-2);
    text-align: center;
    padding: var(--sp-8) var(--sp-7);
    font-family: var(--font-mono);
    font-size: var(--fs-small);
    color: var(--text-sec);
    letter-spacing: 0.08em;
    border-top: 1px solid var(--border);
    /* The footer sits where the black hole is largest and brightest, which is
       why this one is a full-width band rather than a panel hugging its text. */
    background: var(--glass);
    -webkit-backdrop-filter: var(--glass-blur);
    backdrop-filter: var(--glass-blur);
}
/* No opacity multiplier here — it used to drag this to 4.14:1, under AA. */
.footer-copy {
    font-size: var(--fs-micro);
    color: var(--text-muted);
}

@keyframes blink {
    0%, 100% { opacity: 1; }
    50%      { opacity: 0; }
}
@keyframes fadeUp {
    from { opacity: 0; transform: translateY(20px); }
    to   { opacity: 1; transform: translateY(0); }
}

/* Responsive --------------------------------------------------------------- */
@media (max-width: 1024px) {
    /* The spine narrows here once, and all three sections follow it — they no
       longer each restate their own horizontal padding. */
    :root {
        --gutter: var(--sp-5);
    }
    .navbar {
        top: var(--sp-5);
        right: var(--sp-5);
        padding: var(--sp-2) var(--sp-3);
    }
    .nav-link {
        padding: var(--sp-2) var(--sp-3);
    }
    .about-section {
        padding: var(--sp-9) var(--gutter) var(--sp-7);
    }
    .about-inner {
        gap: var(--sp-6);
        padding: var(--sp-6) var(--sp-5);
    }
    .avatar-wrap {
        --avatar-size: 200px;
    }
    .projects-section,
    .background-section {
        padding: var(--sp-8) var(--gutter);
    }
    .project-grid {
        grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr));
        gap: var(--sp-6);
    }
    .timeline {
        max-width: 100%;
        /* Tracks .about-inner at this breakpoint — the two cards step down
           together rather than drifting apart. */
        padding: var(--sp-6) var(--sp-5);
    }
    .timeline-columns {
        grid-template-columns: 1fr;
        gap: var(--sp-7);
    }
    /* Stacked here, so the divider would cut across the flow rather than
       between the tracks. */
    .timeline-columns > .timeline-col + .timeline-col {
        padding-left: 0;
        border-left: none;
    }
}

@media (max-width: 640px) {
    .navbar {
        top: auto;
        bottom: 0;
        right: 0;
        left: 0;
        border-radius: 0;
        border-top: 1px solid var(--border);
        border-bottom: none;
        border-left: none;
        border-right: none;
        justify-content: center;
        padding: var(--sp-2) var(--sp-4);
    }
    .nav-link {
        padding: var(--sp-2) var(--sp-3);
        letter-spacing: 0.04em;
    }
    .about-section {
        padding: var(--sp-7) var(--sp-4) var(--sp-7);
        min-height: 100svh;
    }
    /* With one fewer skill group the hero fits without crushing its gaps to
       near zero, which is what the old 0.02rem values were doing. */
    .about-inner {
        flex-direction: column;
        align-items: center;
        text-align: center;
        padding: var(--sp-5) var(--sp-4);
        gap: var(--sp-5);
    }
    .text-section {
        align-items: center;
        gap: var(--sp-3);
    }
    .interest-list li {
        justify-content: center;
    }
    .skill-grid {
        justify-content: center;
    }
    .contact-row {
        flex-direction: column;
        align-items: stretch;
        justify-content: center;
        gap: var(--sp-2);
        width: 100%;
    }
    .contact-btn {
        text-align: center;
        padding: var(--sp-3) var(--sp-4);
    }
    .translate-btn {
        position: relative;
        top: auto;
        right: auto;
        align-self: flex-end;
        font-size: var(--fs-body);
        padding: var(--sp-2) var(--sp-3);
    }
    .avatar-wrap {
        --avatar-size: 150px;
    }
    .status-dot {
        width: 18px;
        height: 18px;
        bottom: 10px;
        right: 10px;
        border-width: 3px;
    }
    .projects-section,
    .background-section {
        padding: var(--sp-7) var(--sp-4);
    }
    .project-grid {
        grid-template-columns: 1fr;
        gap: var(--sp-5);
    }
    .project-img-wrap {
        height: 220px;
    }
    .project-info {
        padding: var(--sp-5) var(--sp-5) var(--sp-5);
    }
    .timeline {
        padding: var(--sp-5) var(--sp-4);
    }
    .timeline-track {
        gap: var(--sp-5);
    }
    .timeline-track::before {
        left: 6px;
    }
    .timeline-item {
        column-gap: var(--sp-3);
    }
    .timeline-dot {
        width: 14px;
        height: 14px;
    }
    /* Docked into the nav strip rather than floating above the page. As a
       floating widget it sat on top of whatever happened to be underneath it,
       and on a 390px screen that was the right quarter of the Email button —
       a tap there opened the terminal instead of the mail client. The bottom
       nav already reserves this strip, so nothing else can be under it. The
       nav gets padding-right below to keep its links clear. */
    .term-widget {
        bottom: 0;
        right: 0;
        height: 53px;
        display: flex;
        align-items: center;
        padding-right: var(--sp-3);
        z-index: 400;
    }
    /* Decorative, and the one part that spilled sideways over the page. */
    .term-cat {
        display: none;
    }
    .term-trigger {
        height: 38px;
        padding: 0 var(--sp-3);
        box-shadow: none;
    }
    .term-trigger:hover {
        transform: none;
    }
    .term-trigger-label {
        display: none;
    }
    .term-trigger-icon {
        font-size: 1.05rem;
    }
    /* Clears the docked terminal button (44px + its own 12px inset) with room
       to spare, so a wider text rendering can't push the last link under it. */
    .navbar {
        padding-right: 70px;
    }
    .term-panel {
        position: fixed;
        inset: 0;
        width: 100%;
        height: 100dvh;
        border-radius: 0;
        transform: translateY(30px);
    }
    .term-widget.is-open .term-panel {
        transform: translateY(0);
    }
    .term-input-row {
        padding-bottom: max(var(--sp-3), env(safe-area-inset-bottom));
    }
    /* The completion hint is the first thing to lose on a phone: the on-screen
       keyboard already takes the bottom half of the panel, and there is no tab
       key to accept it with. */
    .term-ghost {
        display: none;
    }
    .footer {
        padding: var(--sp-6) var(--sp-4) var(--sp-8);
    }
}

@media (max-width: 400px) {
    .navbar {
        gap: 0;
    }
    .nav-link {
        padding: var(--sp-2);
    }
}

@media (min-width: 1600px) {
    :root {
        --gutter: var(--sp-8);
    }
    .about-section {
        padding: var(--sp-9) var(--gutter);
    }
    .about-inner {
        gap: var(--sp-9);
        padding: var(--sp-8) var(--sp-9);
    }
    .avatar-wrap {
        --avatar-size: 320px;
    }
    .projects-section,
    .background-section {
        padding: var(--sp-9) var(--gutter);
    }
    .project-grid {
        gap: var(--sp-8);
    }
    .translate-btn {
        font-size: 2rem;
        padding: var(--sp-3) var(--sp-5);
    }
}

/* Black hole backdrop -------------------------------------------------------
   z-index -1 puts this above the body background (which propagates to the
   canvas, since html sets none) and below every section's own content, so it
   needs no stacking changes anywhere else. The scanline overlay at z-index 5
   still lays its texture over the top, which is what we want: the raster is
   part of the screen, not something in front of it. */
.blackhole {
    position: fixed;
    inset: 0;
    z-index: -1;
    pointer-events: none;
    overflow: hidden;
    contain: layout paint;
}
/* One <pre>, one string per frame. The gradient is clipped to the glyphs, so
   each character is coloured by where it sits — amber at the core, green at the
   rim — for the cost of a single paint rather than a second render pass. */
.blackhole__world {
    position: absolute;
    inset: 0;
    margin: 0;
    font-family: var(--font-mono);
    font-size: 16px;
    line-height: 1;
    letter-spacing: 0;
    white-space: pre;
    overflow: hidden;
    user-select: none;
    opacity: 0.42;
    background: radial-gradient(circle var(--bh-glow, 30vmin) at var(--bh-x, 50%) var(--bh-y, 50%),
        var(--amber) 0%,
        var(--amber) 24%,
        var(--green-bright) 50%,
        var(--green) 74%,
        rgba(var(--green-rgb), 0.45) 100%);
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
}
/* Fewer, larger cells on small screens: the raster's cost is per cell, and a
   phone reads a coarse ramp better than a fine one anyway.

   The opacities went up across the board when the big panels became glass. The
   panels now sit over the raster instead of hiding it, and glass costs the
   glyphs underneath roughly a third of their strength — at the old values the
   hole read as faint grain through the cards, and on a phone it was invisible
   entirely. */
@media (max-width: 1024px) {
    .blackhole__world { font-size: 18px; opacity: 0.36; }
}
@media (max-width: 640px) {
    .blackhole__world { font-size: 21px; opacity: 0.32; }
}
/* The blur is what earns --glass its low alpha: it spreads the raster's bright
   glyphs into a flat wash before the text is drawn over it. Where there is no
   backdrop-filter to do that spreading, the alpha has to carry the contrast on
   its own. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
    .section-header,
    .filter-bar,
    .filter-empty,
    .footer {
        background: rgba(var(--bg-rgb), 0.92);
    }
    /* The long-form panels went from opaque to glass so the raster could read
       through them. Without a blur to spread the glyphs first, that trade stops
       paying — bright characters land under body copy as hard speckle — so
       here the panels go back to being solid. */
    .about-inner,
    .timeline,
    .project-card {
        background: var(--surface);
    }
    .view-label {
        background: rgba(var(--bg-rgb), 0.95);
    }
    .bg-toggle {
        background: linear-gradient(var(--green-dim), var(--green-dim)),
                    rgba(var(--bg-rgb), 0.92);
    }
}

/* Motion layer --------------------------------------------------------------
   motion.js adds .is-gsap to <html>, and only once the vendored GSAP files are
   known to have loaded. Every rule under that class hands an entrance over to
   that file; without it — a missing vendor/, a blocked script — none of them
   match and the CSS entrances stay in charge.

   Nothing here sets a start state. Content is never hidden by this stylesheet
   waiting on a script that might not arrive; motion.js writes each hidden state
   itself and clears it again when the move finishes. */
.is-gsap .about-inner {
    animation: none;
}
/* Per-character spans for the section prompts. inline-block is what lets each
   one carry a transform of its own, and pre keeps the spaces the split hands
   back — a run of them would otherwise collapse and shift the line. */
.split-char {
    display: inline-block;
    white-space: pre;
}
/* The timeline's spine, promoted from .timeline-track::before to a real element
   so a tween can draw it down the track as you scroll. Same 1px in the same
   place, so the two renderings are interchangeable: the pseudo-element simply
   stands down wherever motion.js has inserted its replacement. Resting state is
   the full line, which is what a cleared transform leaves behind. */
.is-gsap .timeline-track::before {
    display: none;
}
.timeline-rail {
    position: absolute;
    left: 7px;
    top: 4px;
    bottom: 4px;
    width: 1px;
    background: var(--border);
    transform-origin: top center;
}
/* The lit block that travels between the nav links. It is the same fill the
   links used to carry on :hover, lifted out of them and made into one object
   that moves — so the bar reads as a selection being dragged along a row rather
   than as three independent buttons lighting up. motion.js positions it. */
.nav-indicator {
    position: absolute;
    top: var(--sp-2);
    bottom: var(--sp-2);
    left: 0;
    width: 0;
    border-radius: var(--r-sm);
    background: var(--green-dim);
    border: 1px solid var(--green-line);
    pointer-events: none;
}
/* Only once the block is actually in the bar. Under reduced motion motion.js
   builds none of this, the class never lands, and the links keep the fill they
   have always had. */
.navbar.has-indicator .nav-link:hover,
.navbar.has-indicator .nav-link.active {
    background: transparent;
}
/* While you are pointing at the bar, the pointer is the authority on which link
   is lit — otherwise the section you are in and the link under your cursor both
   claim it, and the block sits under only one of them. */
.navbar.has-indicator:hover .nav-link.active:not(:hover) {
    color: var(--text-sec);
}
.navbar.has-indicator:hover .nav-link.active:not(:hover)::before,
.navbar.has-indicator:hover .nav-link.active:not(:hover)::after {
    opacity: 0;
}

/* Every keyframe in this file resolves to a visible, neutral end state, so
   snapping to the last frame is safe: the zzz and the ring pulse land on
   opacity 0, the cursor lands lit, and entrances land fully faded in. */
@media (prefers-reduced-motion: reduce) {
    html {
        scroll-behavior: auto;
    }
    *, *::before, *::after {
        animation-duration: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.01ms !important;
        scroll-behavior: auto !important;
    }
}
