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

/* Satoshi is a variable font (wght 300-900); one file covers every weight the site uses. */
@font-face {
    font-family: "Satoshi";
    src: url("fonts/Satoshi-Variable.woff2") format("woff2-variations");
    font-weight: 300 900;
    font-style: normal;
    font-display: swap;
}

@font-face {
    font-family: "Geist Pixel Square";
    src: url("fonts/GeistPixel-Square.woff2") format("woff2");
    font-weight: 400;
    font-style: normal;
    font-display: swap;
}

@font-face {
    font-family: "Geist Pixel Grid";
    src: url("fonts/GeistPixel-Grid.woff2") format("woff2");
    font-weight: 400;
    font-style: normal;
    font-display: swap;
}

@font-face {
    font-family: "Geist Mono";
    src: url("fonts/GeistMono-Regular.woff2") format("woff2");
    font-weight: 400;
    font-style: normal;
    font-display: swap;
}

:root {
    --font-body: "Satoshi", ui-sans-serif, system-ui, sans-serif;
    --font-pixel: "Geist Pixel Square", "Satoshi", sans-serif;
    --font-pixel-grid: "Geist Pixel Grid", "Satoshi", sans-serif;
    --font-mono: "Geist Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;

    /* The chapter rail on writeup pages: the width of the list itself and the
       gutter between it and the prose. The two together are what the writeup
       .wrap gives up on its left (see the CHAPTER NAV block). Kept here because
       both the fixed rail and the in-flow .wrap need the same numbers. */
    --rail-w: 12rem;
    --rail-gap: 2.5rem;

    --bg: #ffffff;
    --bg-alt: #f6f6f9;
    --ink: #141418;
    --muted: #63656e;
    --line: #e8e8ee;
    --accent: #6a5fd1;
    --accent-2: #4aa3c7;
    --accent-3: #e58ea9;
    --accent-glow: rgba(30, 32, 48, 0.09);
    --accent-glow-2: rgba(30, 32, 48, 0.06);
    --accent-halo: rgba(106, 95, 209, 0.11);
    --accent-halo-soft: rgba(106, 95, 209, 0.04);
    /* Midpoint of --gradient, so the dot grid shares the hover border's
       silver rather than introducing a second hue behind the text. */
    --grid-dot: #a3a6b1;
    --gradient: linear-gradient(120deg, #dcdde2, #a3a6b1 50%, #e4e5e9);
    --wave-rgb: 112, 98, 205;
    --wave-alpha: 0.95;
    --nav-bg: rgba(255, 255, 255, 0.8);
    --tile-hover: #f4f4f8;

    color-scheme: light;
}

html[data-theme="dark"] {
    --bg: #0e0e12;
    --bg-alt: #14141a;
    --ink: #ececf1;
    --muted: #9a9ba7;
    --line: #26262f;
    --accent: #a99ef2;
    --accent-2: #7cc3e0;
    --accent-3: #eba0b9;
    --accent-glow: rgba(205, 210, 238, 0.1);
    --accent-glow-2: rgba(205, 210, 238, 0.07);
    --accent-halo: rgba(169, 158, 242, 0.14);
    --accent-halo-soft: rgba(169, 158, 242, 0.05);
    --grid-dot: #9a9dad;
    --gradient: linear-gradient(120deg, #3b3d48, #9a9dad 50%, #3b3d48);
    --wave-rgb: 158, 148, 246;
    --wave-alpha: 1.45;
    --nav-bg: rgba(14, 14, 18, 0.8);
    --tile-hover: #181820;

    color-scheme: dark;
}

html {
    font-size: 120%;
    scroll-behavior: smooth;
}

body {
    font-family: var(--font-body);
    font-size: 1.02rem;
    font-weight: 300;
    background: var(--bg);
    color: var(--ink);
    line-height: 1.75;
    -webkit-font-smoothing: antialiased;
    overflow-x: hidden;
    transition:
        background-color 0.3s ease,
        color 0.3s ease;
}

a {
    text-decoration: none;
    color: inherit;
}

.wrap {
    max-width: 64rem;
    margin: 0 auto;
    padding: 0 1.75rem;
}

.display {
    font-family: "Satoshi", ui-sans-serif, sans-serif;
}

/* NAV */
.nav {
    position: sticky;
    top: 0;
    background: var(--nav-bg);
    backdrop-filter: blur(6px);
    border-bottom: 1px solid var(--line);
    z-index: 100;
    transition: background-color 0.3s ease;
}

.nav-right {
    display: flex;
    align-items: center;
    /* Resume, language and theme are the controls that have to stay reachable,
       so this cluster never shrinks or wraps. The brand gives way instead. */
    flex: 0 0 auto;
    gap: 0.7rem;
}

.language-toggle {
    display: inline-flex;
    align-items: center;
    gap: 0.1rem;
    height: 34px;
    padding: 2px;
    border: 1px solid var(--line);
    border-radius: 4px;
    font-family: var(--font-mono);
    font-size: 0.68rem;
    line-height: 1;
}

.language-option {
    min-width: 2rem;
    height: 28px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    padding: 0 0.42rem;
    border-radius: 3px;
    text-align: center;
    border: 0;
    background: transparent;
    color: inherit;
    font: inherit;
    cursor: pointer;
    transition: background-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
}

.language-option.is-current {
    background: var(--bg-alt);
    color: var(--accent);
    animation: language-switch 0.24s ease-out;
}

@keyframes language-switch {
    0% { transform: scale(0.88); }
    70% { transform: scale(1.06); }
    100% { transform: scale(1); }
}

@media (prefers-reduced-motion: reduce) {
    .language-option.is-current { animation: none; }
}


.theme-toggle {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 34px;
    height: 34px;
    border-radius: 4px;
    border: 1px solid var(--line);
    background: transparent;
    color: var(--ink);
    cursor: pointer;
    flex-shrink: 0;
    position: relative;
    transition:
        border-color 0.2s ease,
        color 0.2s ease,
        transform 130ms cubic-bezier(0.23, 1, 0.32, 1);
}

.theme-toggle:hover {
    border-color: var(--accent);
    color: var(--accent);
}

@media (hover: hover) and (pointer: fine) {
    .theme-toggle:active {
        transform: scale(0.92);
    }
}

.theme-toggle svg {
    position: absolute;
    width: 16px;
    height: 16px;
    transition: opacity 0.25s ease, transform 0.35s cubic-bezier(0.2, 0.8, 0.2, 1);
}

.theme-toggle .icon-sun {
    opacity: 0;
    transform: scale(0.7);
}

.theme-toggle .icon-moon {
    opacity: 1;
    transform: scale(1);
}

html[data-theme="dark"] .theme-toggle .icon-sun {
    opacity: 1;
    transform: scale(1);
}

html[data-theme="dark"] .theme-toggle .icon-moon {
    opacity: 0;
    transform: scale(0.7);
}

@media (prefers-reduced-motion: reduce) {
    .theme-toggle svg { transition: none; }
}

.nav-inner {
    position: relative;
    display: flex;
    align-items: center;
    justify-content: space-between;
    /* space-between alone lets the brand and the control cluster touch at
       narrow widths, because neither side is allowed to shrink. This is the
       floor on the gap between them. */
    gap: 1rem;
    height: 64px;
    font-size: 0.9rem;
}

/* The brand yields space instead of pushing the controls off-screen. It used to
   be inline-flex to sit the avatar beside the name; with the avatar gone it is
   plain truncated text, and min-width: 0 is what actually lets a flex item
   shrink below its content width and ellipsis rather than overflow. */
.brand {
    display: block;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-family: var(--font-body);
    font-weight: 400;
    letter-spacing: -0.01em;
    font-size: 1.05rem;
}

/* The avatar sat in the navbar at 34px while the brand text was beside it; it
   now heads the About sidebar next to the GitHub handle, where it is the
   section's only visual anchor and can afford to be larger. */
.about-id {
    display: flex;
    align-items: center;
    gap: 0.95rem;
    /* The card is a column flex with align-items: flex-start, so this row
       shrink-to-fits its content and is not capped by the card at all. Without
       the cap it overflows the card rather than compressing, and the handle's
       ellipsis never gets a chance to engage. */
    max-width: 100%;
}

/* Geist Mono is the Geist face this site actually loads — there is no plain
   Geist sans here, Satoshi is the body font — and mono is the convention for a
   handle anyway, matching the other labels in this card. */
.about-handle {
    font-family: var(--font-mono);
    font-size: 1.2rem;
    color: var(--ink);
    /* Last-resort safety net. The avatar cannot shrink, so the handle absorbs
       whatever is left and ellipses rather than pushing the pair past the card
       edge on a pathologically narrow screen. In practice the scale-down below
       means this never actually triggers. */
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.about-avatar {
    width: 76px;
    height: 76px;
    flex: 0 0 auto;
    object-fit: cover;
    border-radius: 8px;
}

/* The avatar cannot shrink and the handle has a fixed intrinsic width, so below
   about 390px the row outgrows the card: 76px + 12.24px gap + 152px of handle
   needs 246px, and the card's inner width is ~220px there once the wrap gutter
   and the card's own padding are taken out. Stepping both down keeps the pair on
   one line instead of spilling past the card edge. */
@media (max-width: 400px) {
    .about-id {
        gap: 0.75rem;
    }

    .about-avatar {
        width: 60px;
        height: 60px;
        border-radius: 7px;
    }

    .about-handle {
        font-size: 1.05rem;
    }
}

.nav-links {
    display: flex;
    align-items: center;
    gap: 2rem;
    position: absolute;
    left: 50%;
    transform: translateX(-50%);
}

.nav-links a {
    color: var(--muted);
    transition: color 0.2s;
    padding-bottom: 2px;
    border-bottom: 1px solid transparent;
}

.nav-links a:hover {
    color: var(--ink);
    border-bottom-color: var(--accent);
}

.nav-links .contact-link {
    color: var(--muted);
}

.nav-links .contact-link:hover {
    color: var(--accent);
}

/* HERO */
.hero {
    position: relative;
    min-height: 94vh;
    display: flex;
    align-items: center;
    border-bottom: 1px solid var(--line);
    overflow: hidden;
    background: var(--bg);
}

.hero-grain {
    position: absolute;
    inset: 0;
    z-index: 0;
    pointer-events: none;
    opacity: 0.5;
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.045'/%3E%3C/svg%3E");
}

.hero-inner {
    position: relative;
    z-index: 1;
    margin-left: clamp(1.25rem, 4vw, 4rem);
    margin-right: 0;
    padding: 7rem 0;
    width: 100%;
}

.fade-item {
    opacity: 0;
    transform: translateY(14px);
    animation: rise 0.8s ease forwards;
}

.fade-1 {
    animation-delay: 0.05s;
}
.fade-2 {
    animation-delay: 0.3s;
}
.fade-3 {
    animation-delay: 0.55s;
}
.fade-4 {
    animation-delay: 0.8s;
}

@keyframes rise {
    to {
        opacity: 1;
        transform: translateY(0);
    }
}

@media (prefers-reduced-motion: reduce) {
    .fade-item {
        opacity: 1;
        transform: none;
        animation: none;
    }
}

.name-wrap {
    position: relative;
    display: block;
    width: fit-content;
    margin-bottom: 1.1rem;
}

h1 {
    font-family: var(--font-pixel);
    font-weight: 400;
    font-size: clamp(2.8rem, 8.6vw, 7rem);
    letter-spacing: -0.01em;
    line-height: 1.04;
    position: relative;
}

.role-badge {
    display: inline-block;
    padding: 0.45rem 1.05rem;
    border-radius: 4px;
    border: 1px solid transparent;
    background:
        linear-gradient(var(--bg), var(--bg)) padding-box,
        var(--gradient) border-box;
    font-family: var(--font-mono);
    font-weight: 400;
    font-size: 0.78rem;
    color: var(--ink);
    margin-bottom: 2rem;
}

.lede {
    font-size: 1.14rem;
    font-weight: 300;
    color: var(--muted);
    max-width: 34rem;
    margin-bottom: 0;
}

.hero-actions {
    display: flex;
    align-items: center;
    gap: 1.75rem;
    margin-top: 2.75rem;
    margin-bottom: 3rem;
}

.lede-note {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    margin-top: 0.75rem;
    font-size: 0.9rem;
    opacity: 0.75;
}

.note-dot {
    width: 6px;
    height: 6px;
    flex-shrink: 0;
    border-radius: 50%;
    background: currentColor;
}

.btn-primary {
    display: inline-flex;
    align-items: center;
    gap: 0.55rem;
    padding: 0.85rem 1.85rem;
    border-radius: 4px;
    border: 1px solid transparent;
    background:
        linear-gradient(var(--bg), var(--bg)) padding-box,
        var(--gradient) border-box;
    color: var(--ink);
    font-weight: 400;
    font-size: 0.95rem;
    box-shadow: 0 0 0 0 transparent;
    transition:
        box-shadow 0.25s ease,
        transform 130ms cubic-bezier(0.23, 1, 0.32, 1);
}

.contact-mark {
    width: 1rem;
    height: 1rem;
    flex: 0 0 auto;
}

.cv-link {
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
    height: 34px;
    padding: 0 0.75rem;
    border: 1px solid var(--line);
    border-radius: 4px;
    color: var(--muted);
    font-size: 0.85rem;
    line-height: 1;
    white-space: nowrap;
    transition:
        color 0.2s,
        border-color 0.2s,
        transform 130ms cubic-bezier(0.23, 1, 0.32, 1);
}

.cv-link svg {
    width: 0.95rem;
    height: 0.95rem;
    flex: 0 0 auto;
}

.cv-link:hover {
    color: var(--ink);
    border-color: var(--accent);
}

/* The card sits up beside the lede instead of below it.
   The lede is one short line occupying only the left of the measure — at 1440 it
   ends around x=746 of a 1293px content width — so the right half of that band
   is empty at exactly the height where the card can live. Lifting it 80px puts
   its top level with the lede, and the two then read as a single band rather
   than as a heading with a stranded box underneath.

   Desktop only, for two reasons. Below 1180px the layout stacks and the lede
   spans the full measure, so the card would ride up over the heading instead of
   into blank space. And the single-line assumption only holds in the two-column
   range — which is the same range this is scoped to. */
@media (min-width: 1181px) {
    .about-side {
        margin-top: -4.2rem;
    }
}

/* ABOUT */
.about-grid {
    display: grid;
    /* The sidebar carries the identity block and the contribution grid, so it
       gets slightly more than half the measure. 26 weeks of cells at the
       default 15px column pitch is 386px, which needs about 480px of card to sit
       without scrolling at the full 64rem wrap. */
    grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
    gap: 3.5rem;
    align-items: start;
}

.about-body p {
    color: var(--muted);
    font-size: 0.98rem;
    line-height: 1.7;
    max-width: 34rem;
}

.about-body p + p {
    margin-top: 1rem;
}

.about-side {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 1.5rem;
    padding: 1.5rem;
    border: 1px solid var(--line);
    border-radius: 6px;
    background: var(--bg);
}

.cv-link--about {
    height: auto;
    padding: 0.85rem 1.6rem;
    font-size: 0.95rem;
}

.cv-link--about svg {
    width: 1.05rem;
    height: 1.05rem;
}

.about-facts {
    width: 100%;
    display: flex;
    flex-direction: column;
    gap: 0.9rem;
    padding-top: 1.25rem;
    border-top: 1px solid var(--line);
}

.about-fact dt {
    font-family: var(--font-mono);
    font-size: 0.7rem;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--muted);
    margin-bottom: 0.15rem;
}

.about-fact dd {
    font-size: 0.92rem;
    color: var(--ink);
}

.about-github {
    display: inline-flex;
    align-items: center;
    gap: 0.55rem;
    color: var(--muted);
    font-size: 0.88rem;
    border-bottom: 1px solid transparent;
    transition: color 0.2s, border-color 0.2s;
}

.about-github .github-mark {
    width: 1.1rem;
    height: 1.1rem;
    flex: 0 0 auto;
}

.about-github:hover {
    color: var(--accent);
    border-bottom-color: var(--accent);
}

/* The contribution grid, sitting in the About card between the facts and the
   GitHub link.

   This element is a <section>, which is the whole difficulty. Three things are
   fighting here, all of them section scale and none of them section-specific:

     section { padding: 5rem 0; border-bottom: 1px solid var(--line) }  (line 835)
     .activity { margin-top: 3rem; padding-top: 2rem }                 (line 1435)

   The blanket `section` rule is the sneaky one: it is why the block grew a 96px
   chin under the legend and a rule below the thing it had already ruled off on
   top. Both paddings and the bottom border have to be cancelled explicitly --
   overriding only padding-top is what let the chin through in the first place.

   Scoped through .about-side deliberately: the base .activity rule sits later
   in the file (line ~1400, under ATMOSPHERE) with equal specificity, so a bare
   .about-activity here would simply lose the cascade and the override would be
   dead code. */
.about-side .about-activity {
    /* Same trap as .about-id: the card is a column flex with
       align-items: flex-start, so without a cap this shrink-to-fits its content
       and spills past the card instead of scrolling inside .grid-scroll. */
    width: 100%;
    max-width: 100%;
    margin-top: 0;
    /* The rule above the count is the block's divider, matching .about-facts.
       The <section> default adds a matching rule under the legend, which reads
       as the grid being boxed off from the GitHub link for no reason. */
    border-bottom: 0;
    padding-top: 1.1rem;
    /* The card's own 1.5rem gap already separates the block from the GitHub
       link below it. */
    padding-bottom: 0;
}

/* The legend is right-aligned on the full-width strip, where the grid spans the
   measure and the two share an edge. In the card the grid is narrower than the
   measure, so a right-aligned legend floats ~78px past the grid's last column
   and the block reads as two unrelated pieces. Left-aligning it puts it under
   the grid's first column, where the whole block is one left-aligned unit. */
.about-side .about-activity .activity-bottom {
    justify-content: flex-start;
}

/* A phone cannot spare the height, and the card is already carrying the
   identity block, Resume, three facts and a link. The component also removes
   itself when the snapshot is missing, so this is belt-and-braces rather than
   the only guard. */
@media (max-width: 640px) {
    .about-activity {
        display: none;
    }
}

/* Stack About well before the sidebar becomes too narrow to hold the grid. At
   the old 860px breakpoint the two-column layout resumed at 861px with a card
   inner width of ~280px, while 26 weeks of cells needs ~401px — so the grid
   scrolled sideways across the whole 900-1150px band, which is most laptops and
   iPad in landscape. Stacking to 1180px keeps the card full width there, and
   above it the two-column sidebar is comfortably wider than the grid. */
@media (max-width: 1180px) {
    .about-grid {
        grid-template-columns: minmax(0, 1fr);
        gap: 2.25rem;
    }
}

@media (hover: hover) and (pointer: fine) {
    .cv-link:active {
        transform: scale(0.95);
    }
}

.btn-primary:hover {
    box-shadow:
        0 10px 28px -16px var(--accent-glow),
        0 6px 20px -16px var(--accent-glow-2);
}

@media (hover: hover) and (pointer: fine) {
    .btn-primary:active {
        transform: scale(0.96);
    }
}

.text-link {
    display: inline-flex;
    align-items: center;
    font-size: 0.92rem;
    line-height: 1.35;
    align-self: center;
    color: var(--muted);
    border-bottom: 1px solid var(--line);
    padding-bottom: 2px;
    transition:
        color 0.2s,
        border-color 0.2s;
}

.text-link:hover {
    color: var(--accent);
    border-bottom-color: var(--accent);
}

.text-link--icon {
    padding: 0;
    justify-content: center;
    border: 0;
}

.text-link--icon .github-mark {
    width: 1.5rem;
    height: 1.5rem;
}

.scroll-cue {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    color: var(--muted);
    font-family: var(--font-mono);
    font-size: 0.72rem;
}

.scroll-cue .stem {
    width: 1px;
    height: 34px;
    background: var(--line);
    position: relative;
    overflow: hidden;
}

.scroll-cue .stem::after {
    content: "";
    position: absolute;
    top: -100%;
    left: 0;
    width: 100%;
    height: 100%;
    background: var(--accent);
    animation: trickle 2.2s ease-in-out infinite;
}

@keyframes trickle {
    0% {
        top: -100%;
    }
    60% {
        top: 100%;
    }
    100% {
        top: 100%;
    }
}

@media (prefers-reduced-motion: reduce) {
    .scroll-cue .stem::after {
        animation: none;
        top: 0;
    }
    html { scroll-behavior: auto; }
}

/* SECTIONS */
section {
    position: relative;
    z-index: 0;
    padding: 5rem 0;
    border-bottom: 1px solid var(--line);
}

section.panel {
    background: var(--bg-alt);
}

.chapter-num {
    position: absolute;
    z-index: 0;
    top: 0.25rem;
    right: 0.25rem;
    font-family: var(--font-pixel-grid);
    font-weight: 400;
    font-size: clamp(5rem, 12vw, 9.5rem);
    line-height: 1;
    color: var(--line);
    user-select: none;
    pointer-events: none;
    opacity: 0.6;
    transform: translateY(0.35rem);
    transition: color 0.55s ease, opacity 0.55s ease,
        transform 0.7s cubic-bezier(0.2, 0.8, 0.2, 1), text-shadow 0.55s ease;
}

main > section > .wrap {
    position: relative;
    z-index: 1;
}

.chapter-num.is-lit {
    color: var(--accent);
    opacity: 1;
    transform: translateY(0);
    text-shadow: 0 0 16px var(--accent-glow), 0 0 34px var(--accent-glow-2);
}

.chapter-num::before {
    content: "";
    position: absolute;
    inset: -14% -38%;
    z-index: -1;
    border-radius: 50%;
    background: radial-gradient(ellipse closest-side, var(--accent-halo) 0%, var(--accent-halo-soft) 40%, transparent 70%);
    opacity: 0;
    transform: scale(0.75);
    pointer-events: none;
    transition: opacity 0.7s ease, transform 0.9s cubic-bezier(0.2, 0.8, 0.2, 1);
}

.chapter-num.is-lit::before {
    opacity: 1;
    transform: scale(1);
    animation: chapter-num-pulse 4.5s ease-in-out infinite;
}

@keyframes chapter-num-pulse {
    0%, 100% { opacity: 0.85; transform: scale(0.92, 1); }
    50% { opacity: 1; transform: scale(1.12, 1.05); }
}

.chapter-num { isolation: isolate; }

@media (prefers-reduced-motion: reduce) {
    .chapter-num, .chapter-num::before { transition: none; animation: none; transform: none; }
}

.chapter-nav {
    position: fixed;
    z-index: 30;
    top: 50%;
    /* The rail sits inside the page block, in the space the writeup .wrap
       reserves for it, rather than hanging in the viewport margin. It used to be
       pinned at calc((100vw - 64rem) / 2 - 11.25rem), which is off the left edge
       of every screen narrower than roughly 1660px -- and since it is only shown
       from 1200px up, that was most of the range where it was meant to work.

       50% is the centre of the initial containing block, which is the same box
       the content .wrap is centred in, so the block's left edge is simply
       "50% - 32rem". The max() covers widths where the 64rem block has itself
       started to clamp, where that figure would drift left of the real edge. */
    left: max(1.75rem, calc(50% - 32rem + 1.75rem));
    width: var(--rail-w);
    display: grid;
    gap: 0;
    transform: translateY(-50%);
    opacity: 1;
    visibility: visible;
    transition: opacity 0.3s ease, visibility 0.3s ease;
}

/* Reserving the rail's space on the page side of the fixed list above.
   Every .wrap on a writeup moves its text right by the rail plus the gutter, so
   the header, the body, the prev/next row and the footer all stay on one left
   edge, and the rail fills the space that opens up on the left.

   This is what makes the rail fit at 1200px instead of 1660px: the space is
   taken from the block rather than from the viewport. It costs the writeup
   about 280px of measure, which is affordable because the prose is already
   capped at 40rem -- only code blocks and rules get shorter.

   Scoped to writeups by :has(.pp-head), and excludes .nav-inner so the navbar
   keeps the same position on every page: its link row is centred with
   left: 50% against that wrapper, so shifting it would drag the links off the
   middle of the screen. */
@media (min-width: 1200px) {
    body:has(.pp-head) .wrap:not(.nav-inner) {
        padding-left: calc(1.75rem + var(--rail-w) + var(--rail-gap));
    }
}

.chapter-nav.is-at-top {
    opacity: 0;
    visibility: hidden;
    pointer-events: none;
}

/* Rows are ruled off from each other rather than separated by a gap, which is
   what gives the list its "one chapter per line" read. The transparent left
   border is there to hold the 2px slot open, so the current chapter's accent bar
   does not nudge its text 2px to the right of the others. */
.chapter-nav a {
    display: grid;
    grid-template-columns: 1.7rem 1fr;
    align-items: baseline;
    gap: 0.6rem;
    padding: 0.62rem 0.7rem;
    border-top: 1px solid var(--line);
    border-left: 2px solid transparent;
    color: var(--muted);
    font-size: 0.82rem;
    line-height: 1.35;
    transition:
        color 0.3s ease,
        border-color 0.3s ease,
        background-color 0.3s ease,
        text-shadow 0.3s ease;
}

.chapter-nav a:last-child {
    border-bottom: 1px solid var(--line);
}

/* No border-bottom here: the first chapter row's border-top is the rule under
   the title, and having both stacks them into a double line. Padding-left is 0
   so the title shares its left edge with the current chapter's accent bar. */
.chapter-project-title {
    margin: 0;
    padding: 0.35rem 0 0.7rem;
    color: var(--ink);
    font-family: var(--font-mono);
    font-size: 0.82rem;
    line-height: 1.3;
    opacity: 0;
    visibility: hidden;
    transform: translateY(-8px);
    transition: opacity 0.3s ease, transform 0.3s ease, visibility 0.3s;
}

.chapter-project-title.is-visible {
    opacity: 1;
    visibility: visible;
    transform: translateY(0);
}

.chapter-project-title.gsap-managed {
    transition: none;
}

.chapter-index {
    color: var(--muted);
    font-family: var(--font-mono);
    font-size: 0.68rem;
    transition: color 0.3s ease, text-shadow 0.3s ease;
}

.chapter-nav a:hover {
    color: var(--accent);
    border-left-color: var(--accent);
}

/* Chapters already read keep the accent text, but they no longer carry a bar.
   With a rule per row the bar is the current chapter's marker, and repeating it
   down every row read so far turned the rail into a solid accent stripe. */
.chapter-nav a.is-past {
    color: var(--accent);
    text-shadow: 0 0 10px var(--accent-halo);
}

.chapter-nav a.is-past .chapter-index {
    color: var(--accent);
    text-shadow: 0 0 10px var(--accent-halo);
}

.chapter-nav a.is-current {
    color: var(--ink);
    border-left: 2px solid var(--accent);
    background: linear-gradient(90deg, var(--accent-halo-soft), transparent 80%);
    text-shadow: 0 0 14px var(--accent-halo);
}

.chapter-nav a.is-current .chapter-index {
    color: var(--accent);
    text-shadow: 0 0 12px var(--accent-halo);
}

.chapter-nav a:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 3px;
}

.section-title {
    display: flex;
    align-items: center;
    gap: 0.8rem;
    font-family: var(--font-body);
    font-weight: 300;
    letter-spacing: -0.02em;
    font-size: clamp(2rem, 4.2vw, 2.7rem);
    line-height: 1.15;
    margin-bottom: 0.75rem;
}

@property --text-reveal-progress {
    syntax: "<percentage>";
    inherits: false;
    initial-value: 0%;
}

[data-text-reveal] {
    opacity: 0;
    transform: translate3d(0, var(--text-reveal-y, 12px), 0);
}

[data-text-reveal="sweep"] {
    --text-reveal-progress: 0%;
    --text-reveal-y: 12px;
    opacity: 1;
    -webkit-mask-image: linear-gradient(100deg, #000 calc(var(--text-reveal-progress) - 26%), transparent var(--text-reveal-progress));
    mask-image: linear-gradient(100deg, #000 calc(var(--text-reveal-progress) - 26%), transparent var(--text-reveal-progress));
    transition:
        --text-reveal-progress 1.5s cubic-bezier(0.5, 0.1, 0.2, 1) var(--text-reveal-delay, 0s),
        transform 1.2s cubic-bezier(0.16, 1, 0.3, 1) var(--text-reveal-delay, 0s);
}

[data-text-reveal="sweep"].is-revealing {
    --text-reveal-progress: 126%;
    transform: none;
}

@media (prefers-reduced-motion: reduce) {
    [data-text-reveal] {
        opacity: 1;
        transform: none;
        -webkit-mask-image: none;
        mask-image: none;
        transition: none;
    }
}

.facet-icon {
    flex-shrink: 0;
    overflow: visible;
    will-change: transform;
}

.facet-icon polygon,
.facet-icon polyline {
    fill: none;
    stroke: var(--accent);
    stroke-width: 1.5;
    stroke-linejoin: round;
    opacity: 0.9;
    transition: stroke 0.4s ease;
}

.facet-icon {
    filter: drop-shadow(0 0 6px var(--accent-glow-2));
}

.section-sub {
    color: var(--muted);
    font-size: 0.95rem;
    max-width: 32rem;
    margin-bottom: 2.5rem;
}

/* PROJECT GRID */
.grid {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 1.5rem;
}

.project-card {
    position: relative;
    display: flex;
    flex-direction: column;
    border: 1px solid var(--line);
    border-radius: 6px;
    background: var(--bg);
    transition: box-shadow 0.25s ease;
    /* Only the reveal animates --enter-y; JS and GSAP never fight over the
       transform property itself. The card no longer tilts. */
    transform: translateY(var(--enter-y, 0px));
}

/* Cursor-lit dot grid: a dot pattern masked to a soft circle at the pointer,
   so the grid materialises under the cursor and falls off toward the edges.
   Reads like a shader preview rather than a hover highlight. */
.project-card::before {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    /* Translucent silver, not solid: the dots stay behind the text as texture
       and never compete with it for contrast. */
    background-image: radial-gradient(
        color-mix(in srgb, var(--grid-dot) 60%, transparent) 0.9px,
        transparent 1.2px
    );
    background-size: 14px 14px;
    /* Grid stays anchored to the card; only the light moves across it. */
    background-position: 0 0;
    -webkit-mask: radial-gradient(circle 150px at var(--mx, 50%) var(--my, 50%), #000, transparent 70%);
    mask: radial-gradient(circle 150px at var(--mx, 50%) var(--my, 50%), #000, transparent 70%);
    opacity: 0;
    transition: opacity 0.3s ease;
    pointer-events: none;
}

.project-card:hover::before {
    opacity: 0.26;
}

/* A cursor-following pattern is motion; reduced-motion users get the static
   hover treatment (border glow + purple name) and nothing that tracks. */
@media (prefers-reduced-motion: reduce) {
    .project-card::before {
        display: none;
    }
}

.project-card::after {
    content: "";
    position: absolute;
    inset: -1px;
    border-radius: 7px;
    padding: 1px;
    background: var(--gradient);
    -webkit-mask:
        linear-gradient(#000 0 0) content-box,
        linear-gradient(#000 0 0);
    -webkit-mask-composite: xor;
    mask:
        linear-gradient(#000 0 0) content-box exclude,
        linear-gradient(#000 0 0);
    opacity: 0;
    transition: opacity 0.25s ease;
    pointer-events: none;
}

.project-card:hover::after {
    opacity: 1;
}

.project-card:hover {
    box-shadow:
        0 18px 40px -24px var(--accent-glow),
        0 10px 30px -22px var(--accent-glow-2);
}

/* Fallback when the cursor spotlight is unavailable (touch, reduced motion):
   plain CSS press feedback instead. */
.project-card.static-card {
    transition:
        box-shadow 0.25s ease,
        transform 120ms ease;
}

.project-card.static-card:active {
    transform: translateY(var(--enter-y, 0px)) scale(0.985);
}

.project-body {
    position: relative;
    z-index: 1;
    padding: 1.25rem 1.4rem 1.5rem;
    display: flex;
    flex-direction: column;
    flex: 1;
}

.tile-top {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 0.75rem;
    margin-bottom: 0.5rem;
}

.tile-name {
    font-family: var(--font-pixel);
    font-weight: 400;
    letter-spacing: -0.01em;
    font-size: 1.22rem;
    color: var(--ink);
    transition: color 0.2s;
}

.project-card:hover .tile-name {
    color: var(--accent);
}

.tile-status {
    font-family: var(--font-mono);
    font-weight: 400;
    font-size: 0.7rem;
    color: var(--muted);
    white-space: nowrap;
    flex-shrink: 0;
}

.tile-desc {
    color: var(--muted);
    font-size: 0.9rem;
    margin-bottom: 0.75rem;
    flex: 1;
}

.tile-stack {
    font-family: var(--font-mono);
    font-size: 0.74rem;
    color: var(--muted);
}

.tile-stack .sep {
    margin: 0 0.4rem;
    color: var(--line);
}

/* Stack row and the details cue share one line: stack left, cue hard right. */
.tile-foot {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 0.75rem;
}

.tile-more {
    font-family: var(--font-mono);
    font-size: 0.74rem;
    color: var(--accent);
    white-space: nowrap;
}

/* TIMELINE */
.timeline {
    --rail: 2.6rem;
    --node: 13px;
    --track: 2px;
    --gap: 2.8rem;
    --line-x: calc(var(--rail) + var(--track) / 2);
    position: relative;
    margin-top: 0.5rem;
    padding-left: var(--rail);
}

.timeline-list {
    list-style: none;
    margin: 0;
    padding: 0;
}

.timeline-item {
    position: relative;
    padding: 0 0 var(--gap) 1.6rem;
}

.timeline-item::before,
.timeline-item::after {
    content: "";
    position: absolute;
    left: 0;
    top: calc(var(--node) / 2);
    width: var(--track);
}

.timeline-item::before {
    bottom: calc(-1 * var(--node) / 2 - 0.5px);
    z-index: 0;
    background: color-mix(in srgb, var(--ink) 14%, transparent);
}

.timeline-item::after {
    z-index: 1;
    height: var(--segment-height, 0px);
    background: color-mix(in srgb, var(--accent) 78%, var(--ink));
}

.timeline-item:last-child {
    padding-bottom: 0;
}

.timeline-item:last-child::before {
    bottom: auto;
    height: calc(100% - var(--node) / 2);
}

.timeline-item:last-child::after {
    height: var(--segment-height, 0px);
}

/* An upcoming entry is a placeholder, not a role yet, so its segment stays dashed
   instead of filling. The fill is switched off outright rather than made faint: at a
   low opacity it still paints a wash over the whole segment once the reading line
   passes, burying the stripes and leaving the placeholder as the most solid line on
   the track. `background: none` keeps the element measurable while painting nothing. */
.timeline-item.is-upcoming::before {
    background: repeating-linear-gradient(
        to bottom,
        color-mix(in srgb, var(--ink) 14%, transparent) 0 4px,
        transparent 4px 9px
    );
}

.timeline-item.is-upcoming::after {
    background: none;
}

.timeline-cursor {
    position: absolute;
    left: var(--line-x);
    top: var(--cursor-y, 0px);
    z-index: 3;
    width: var(--node);
    height: var(--node);
    border-radius: 50%;
    background: #fff;
    border: 2px solid var(--bg);
    box-shadow: 0 0 0 2px var(--accent), 0 0 14px var(--accent-glow);
    transform: translate(-50%, -50%);
}

.timeline-body {
    transition: opacity 0.45s ease;
}

.timeline[data-state="live"] .timeline-item:not(.is-passed) .timeline-body {
    opacity: 0.78;
}

.timeline-tag {
    display: inline-block;
    font-family: var(--font-mono);
    font-size: 0.76rem;
    color: var(--accent);
    margin-bottom: 0.45rem;
}

.timeline-title {
    font-family: var(--font-body);
    font-weight: 400;
    letter-spacing: -0.015em;
    font-size: 1.35rem;
    margin-bottom: 0.3rem;
}

.timeline-company {
    color: var(--accent);
    font-size: 0.95rem;
    font-weight: 400;
    margin-bottom: 0.5rem;
}

.timeline-desc {
    color: var(--muted);
    font-size: 0.92rem;
    max-width: 30rem;
}

/* ACTIVITY — GitHub contribution grid. Rendered by js/github-activity.js from an
   activity.json snapshot, which the deploy workflow generates on every run so
   the published copy is never older than a day. The month axis is positioned and
   its offsets are computed from the same 15px column pitch as the grid, so the
   two are only ever as accurate as that number staying in sync. */
.activity {
    --cell: 11px;
    --cell-gap: 4px;
    --cell-radius: 2px;
    margin-top: 3rem;
    padding-top: 2rem;
    border-top: 1px solid var(--line);
}

.activity-total {
    display: flex;
    align-items: baseline;
    gap: 0.55rem;
    margin-bottom: 0.75rem;
    font-size: 0.85rem;
    color: var(--muted);
}

.activity-total strong {
    display: inline-flex;
    color: var(--ink);
    font: 1.65rem/1 var(--font-mono);
    letter-spacing: -0.06em;
    font-variant-numeric: tabular-nums;
}

/* One digit is a fixed-height window onto a strip that slides through it. The
   per-digit transition-delay is set inline by the script, so the number rolls up
   right to left. */
.count-digit {
    display: inline-block;
    height: 1em;
    overflow: hidden;
}

.count-reel {
    display: flex;
    flex-direction: column;
    transform: translateY(-10em);
    transition: transform 1s cubic-bezier(0.45, 0, 0.2, 1);
}

.count-reel span {
    height: 1em;
    line-height: 1em;
}

/* Cell size is a variable so a narrower slot can shrink the cells rather than
   scaling the whole grid, which would blur the text around it. It is declared on
   .activity rather than .activity-grid so the legend squares shrink with the
   cells — they are the same .day element, and a legend of 11px swatches under a
   4px grid is a legend that lies about the scale. The JS derives its column
   pitch from the computed grid-auto-columns and row-gap, so the month axis stays
   aligned at any size without a second number to keep in sync. */
.grid-scroll {
    overflow-x: auto;
    padding: 0.85rem 0 0.3rem;
}

.activity-grid {
    display: grid;
    grid-template-rows: repeat(7, var(--cell));
    grid-auto-flow: column;
    grid-auto-columns: var(--cell);
    gap: var(--cell-gap);
    width: max-content;
}

.day {
    width: var(--cell);
    height: var(--cell);
    border-radius: var(--cell-radius);
    background: var(--line);
}

.day[data-level="1"] {
    background: color-mix(in srgb, var(--accent) 24%, var(--bg));
}

.day[data-level="2"] {
    background: color-mix(in srgb, var(--accent) 46%, var(--bg));
}

.day[data-level="3"] {
    background: color-mix(in srgb, var(--accent) 70%, var(--bg));
}

.day[data-level="4"] {
    background: var(--accent);
}

.months {
    position: relative;
    height: 1em;
    margin-top: 0.45rem;
    color: var(--muted);
    font: 0.65rem var(--font-mono);
}

.months span {
    position: absolute;
    top: 0;
    white-space: nowrap;
}

.activity-bottom {
    display: flex;
    justify-content: flex-end;
    margin-top: 0.75rem;
}

.legend {
    display: flex;
    align-items: center;
    gap: 4px;
    color: var(--muted);
    font-size: 0.68rem;
}

.legend span:first-child {
    margin-right: 3px;
}

@media (prefers-reduced-motion: reduce) {
    .count-reel {
        transition: none;
    }
}

/* SKILLS */
.skill-group {
    display: grid;
    grid-template-columns: 180px minmax(0, 1fr);
    gap: 1.5rem;
    align-items: start;
    padding: 1.25rem 0;
    border-top: 1px solid var(--line);
}

#skills .wrap {
    display: block;
}

.skill-key {
    font-family: var(--font-mono);
    color: var(--muted);
    font-size: 0.8rem;
    letter-spacing: 0.08em;
    text-transform: uppercase;
}

.skill-val {
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem;
}

.skill-val span {
    display: inline-flex;
    align-items: center;
    gap: 0.45rem;
    min-width: 0;
    width: max-content;
    padding: 0.34rem 0.72rem;
    border: 1px solid var(--line);
    border-radius: 2px;
    color: var(--muted);
    font-family: var(--font-mono);
    font-size: 0.9rem;
    line-height: 1.4;
    --bob-scale: 4;
    animation: bob 3.8s ease-in-out infinite;
    animation-delay: calc(var(--i, 0) * -0.42s);
    transition: color 0.25s ease, border-color 0.25s ease, box-shadow 0.3s ease;
}

.skill-val span i {
    display: inline-block;
    font-size: 1.15rem;
    line-height: 1;
    filter: grayscale(1);
    opacity: 0.58;
    transition: filter 0.22s ease, opacity 0.22s ease;
}

.skill-val span:nth-child(1) { --i: 0; }
.skill-val span:nth-child(2) { --i: 1; }
.skill-val span:nth-child(3) { --i: 2; }
.skill-val span:nth-child(4) { --i: 3; }
.skill-val span:nth-child(5) { --i: 4; }
.skill-val span:nth-child(6) { --i: 5; }
.skill-val span:nth-child(7) { --i: 6; }

.skill-val span:hover {
    color: var(--accent);
    border-color: var(--accent);
    box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 18%, transparent),
        0 0 18px -4px var(--accent-glow), 0 0 28px -10px var(--accent-glow-2);
}

.skill-val span:hover i {
    filter: none;
    opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
    .skill-val span { animation: none; }
}

@media (max-width: 600px) {
    .skill-group {
        grid-template-columns: 1fr;
        gap: 0.75rem;
    }
}

/* CONTACT */
.contact-lede {
    color: var(--muted);
    max-width: 32rem;
    margin-bottom: 2.5rem;
}

.contact-row {
    display: flex;
    align-items: baseline;
    gap: 1.5rem;
    padding: 1.1rem 0;
    border-top: 1px solid var(--line);
    transition: opacity 0.2s;
}

.contact-row:first-of-type {
    border-top: none;
}

.contact-row:hover {
    opacity: 0.75;
}

.contact-key {
    display: inline-flex;
    align-items: center;
    gap: 0.55rem;
    font-family: var(--font-mono);
    color: var(--muted);
    font-size: 0.8rem;
    width: 6.5rem;
    flex-shrink: 0;
}

.contact-icon {
    width: 1rem;
    height: 1rem;
    flex: 0 0 1rem;
}

.contact-val {
    font-size: 1rem;
    color: var(--ink);
    border-bottom: 1px solid transparent;
}

.contact-row:hover .contact-val {
    color: var(--accent);
    border-bottom-color: var(--accent);
}

/* FOOTER */
.footer {
    padding: 2rem 0;
}

.footer-inner {
    display: flex;
    justify-content: space-between;
    flex-wrap: wrap;
    gap: 0.5rem;
    color: var(--muted);
    font-size: 0.82rem;
}

.footer-inner .display {
    font-weight: 400;
}

@media (max-width: 860px) {
    .grid {
        grid-template-columns: repeat(2, 1fr);
    }
}

/* Below this the rail would be taking more of the block than the prose can
   spare. Mirrors the min-width in the CHAPTER NAV block above. */
@media (max-width: 1199px) {
    .chapter-nav {
        display: none;
    }
}

/* The link row is centred by absolute positioning, so it is out of the flex row
   and cannot see the brand or the controls crowding in from either side. Below
   about 1100px the six links plus the controls need ~1000px, and the last link
   ends up underneath the Resume button. Dropping to Contact alone well before
   that point is what keeps the row honest; above 1100px there is enough slack
   for the centring to be safe. */
@media (max-width: 1100px) {
    .nav-links a:not(.contact-link) {
        display: none;
    }
}

@media (max-width: 640px) {
    /* The 120% root is tuned for the desktop measure. On a phone it inflates
       every rem-based length (type, gutters, section padding) by about 20%, which
       reads as the page being zoomed in, so the rem scale drops back to 100%. */
    html {
        font-size: 100%;
    }

    .hero {
        min-height: auto;
    }

    /* The base rule reserves a left gutter only, because on desktop the copy is
       too narrow to reach the right edge. On a phone the badge and lede do reach
       it, which leaves the hero flush right against a 1.25rem left gutter. Move
       the gutter into padding so both sides get one. */
    .hero-inner {
        margin-left: 0;
        padding: 6rem 1.25rem 3.5rem;
    }

    /* Narrow screens: reclaim horizontal room for the control cluster. The
       1.75rem wrap gutter costs 56px, which is most of a 320px viewport, and
       the EN/NL pair carries 2rem of min-width per button. */
    .nav .wrap {
        padding-left: 1.15rem;
        padding-right: 1.15rem;
    }

    .nav-inner {
        gap: 0.6rem;
    }

    /* Shrink the name slightly rather than let it ellipsis. The brand is within
       a couple of pixels of its natural width around 350-360px, so this is the
       difference between "Omar Hopman" and "Omar Hopm…". */
    .brand {
        font-size: 0.93rem;
    }

    .language-option {
        min-width: 1.5rem;
        padding: 0 0.25rem;
    }

    .nav-right .cv-link {
        padding: 0 0.55rem;
    }

    .nav-right .cv-link .cv-label {
        display: none;
    }

    .grid {
        grid-template-columns: 1fr;
    }

    .timeline { --rail: 2.1rem; --gap: 2.4rem; }

    .footer-inner {
        flex-direction: column;
    }

    /* The 6.5rem key column and 1.5rem gap leave the value about half the
       measure, which is less than an email address needs, so the value used to
       run past the right edge (on top of body's overflow-x: hidden, which hid
       it). Stack the row instead and the value gets the full width. The break
       rule is the fallback for a value with no break opportunity at all. */
    .contact-row {
        flex-direction: column;
        align-items: flex-start;
        gap: 0.35rem;
    }

    .contact-key {
        width: auto;
    }

    .contact-val {
        overflow-wrap: anywhere;
    }
}

/* Below ~470px the centred Contact link runs under the control cluster: the
   absolutely positioned row cannot see what is beside it, and at 414-450px they
   genuinely overlap. Contact is one scroll away in the footer and at #contact,
   whereas language and theme have no alternative route, so the link goes. */
@media (max-width: 470px) {
    .nav-links {
        display: none;
    }
}

/* Only on genuinely tiny viewports does the name still not fit beside Resume,
   EN/NL and theme. Resume is the one that goes: it is the only control with a
   duplicate on the page, in the About sidebar card, whereas language and theme
   have no alternative route anywhere. */
@media (max-width: 360px) {
    .nav-right .cv-link {
        display: none;
    }
}

/* WAVE BACKGROUND */
.hero-wave {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    z-index: 0;
    pointer-events: none;
    -webkit-mask-image: linear-gradient(90deg, rgba(0, 0, 0, 0.4), #000 55%);
    mask-image: linear-gradient(90deg, rgba(0, 0, 0, 0.4), #000 55%);
}

/* NAME: letters drift like they're floating on water */
h1 .word {
    display: inline-block;
    white-space: nowrap;
}

h1 .ch {
    display: inline-block;
    animation: bob 4.8s ease-in-out infinite;
    animation-delay: calc(var(--i) * -0.34s);
}

h1 .ci {
    display: inline-block;
    transform-origin: 50% 85%;
}

h1 .ci.splash {
    animation: splash 0.95s cubic-bezier(0.22, 0.9, 0.3, 1) both;
}

@keyframes bob {
    0%,
    100% {
        transform: translateY(0) rotate(0deg);
    }
    28% {
        transform: translateY(calc(-0.05em * var(--bob-scale, 1))) rotate(-0.7deg);
    }
    55% {
        transform: translateY(calc(0.012em * var(--bob-scale, 1))) rotate(0.5deg);
    }
    78% {
        transform: translateY(calc(-0.026em * var(--bob-scale, 1))) rotate(0.25deg);
    }
}

@keyframes splash {
    0% {
        transform: translateY(0) scale(1, 1);
    }
    28% {
        transform: translateY(-0.17em) scale(0.96, 1.05);
    }
    52% {
        transform: translateY(0.035em) scale(1.04, 0.96);
    }
    74% {
        transform: translateY(-0.05em) scale(0.99, 1.01);
    }
    100% {
        transform: translateY(0) scale(1, 1);
    }
}

@media (prefers-reduced-motion: reduce) {
    h1 .ch,
    h1 .ci.splash {
        animation: none;
    }
}

/* ============================================================
   PROJECT DETAIL PAGES  (projects/*.html)
   Shared with the main page: same tokens, same type scale.
   ============================================================ */

/* Header */
.pp-head {
    position: relative;
    padding: 5.5rem 0 3.5rem;
    border-bottom: 1px solid var(--line);
    background: var(--bg);
}

.pp-head-layout {
    display: grid;
    grid-template-columns: minmax(0, 1fr) 13rem;
    align-items: center;
    gap: 3rem;
}

.pp-head-copy {
    min-width: 0;
}

.pp-head-layout--landscape {
    grid-template-columns: minmax(0, 1fr) minmax(0, 1.35fr);
}

.pp-head-layout--text {
    grid-template-columns: 1fr;
}

.pp-head-layout--landscape .pp-visual-placeholder--landscape {
    width: 100%;
    margin: 0;
}

@media (max-width: 900px) {
    .pp-head-layout--landscape {
        grid-template-columns: 1fr;
        gap: 2.25rem;
    }

    .pp-head-layout--landscape .pp-visual-placeholder--landscape {
        justify-self: end;
        width: min(100%, 50rem);
        margin: 0 0 0 auto;
    }
}

.pp-back {
    display: inline-block;
    font-family: var(--font-mono);
    font-size: 0.82rem;
    color: var(--muted);
    margin-bottom: 2.25rem;
    transition: color 0.2s;
}

.pp-back:hover {
    color: var(--accent);
}

.pp-title {
    font-family: var(--font-pixel);
    font-weight: 400;
    font-size: clamp(2.4rem, 7vw, 4.6rem);
    line-height: 1.05;
    letter-spacing: -0.01em;
    margin-bottom: 1.1rem;
    /* The main h1 wraps each letter so it can bob; these pages have no
       JS letter-splitter, so the plain element needs this instead. */
    overflow-wrap: break-word;
}

.pp-tagline {
    color: var(--muted);
    font-size: 1.08rem;
    font-weight: 300;
    max-width: 38rem;
    margin-bottom: 1.75rem;
}

/* Meta pills */
.pp-meta {
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem;
    margin-bottom: 2rem;
}

.pill {
    display: inline-block;
    padding: 0.3rem 0.75rem;
    border: 1px solid var(--line);
    border-radius: 4px;
    background: var(--bg-alt);
    font-family: var(--font-mono);
    font-size: 0.72rem;
    color: var(--muted);
    line-height: 1.5;
}

.pill-accent {
    border-color: transparent;
    background:
        linear-gradient(var(--bg), var(--bg)) padding-box,
        var(--gradient) border-box;
    color: var(--ink);
}

/* Actions */
.pp-actions {
    display: flex;
    flex-wrap: wrap;
    gap: 0.75rem;
}

.btn-ghost {
    display: inline-flex;
    align-items: center;
    gap: 0.55rem;
    padding: 0.7rem 1.5rem;
    border: 1px solid var(--line);
    border-radius: 4px;
    background: transparent;
    font-size: 0.92rem;
    font-weight: 400;
    transition:
        border-color 0.2s ease,
        color 0.2s ease,
        transform 130ms cubic-bezier(0.23, 1, 0.32, 1);
}

.github-mark {
    width: 1.05rem;
    height: 1.05rem;
    fill: currentColor;
    flex: 0 0 auto;
}

.btn-ghost:hover {
    border-color: var(--accent);
    color: var(--accent);
}

@media (hover: hover) and (pointer: fine) {
    .btn-ghost:active {
        transform: scale(0.97);
    }
}

/* Body sections */
main.wrap {
    padding: 0 1.75rem;
}

.pp-visual-placeholder {
    display: grid;
    place-items: center;
    margin: 2.5rem auto 0;
    border: 1px dashed var(--line);
    background: var(--bg-alt);
    color: var(--muted);
    font-family: var(--font-mono);
    font-size: 0.78rem;
    text-align: center;
}

.pp-visual-placeholder--landscape {
    position: relative;
    width: min(100%, 50rem);
    aspect-ratio: 16 / 9;
    margin: 1.75rem 0 0 auto;
    animation: preview-arrive 0.65s cubic-bezier(0.2, 0.8, 0.2, 1) both;
}

@keyframes preview-arrive {
    from { opacity: 0; transform: translateX(-1rem); }
    to { opacity: 1; transform: translateX(0); }
}

@media (prefers-reduced-motion: reduce) {
    .pp-visual-placeholder--landscape,
    .pp-visual-placeholder--portrait { animation: none; }
}

.pp-placeholder-image {
    display: none;
}

.pp-placeholder-image[src] {
    position: absolute;
    inset: 0;
    display: block;
    width: 100%;
    height: 100%;
    object-fit: contain;
}

.pp-placeholder-image[src] + span {
    display: none;
}

.pp-visual-placeholder.is-clickable {
    cursor: zoom-in;
}

.preview-lightbox {
    position: fixed;
    inset: 0;
    display: none;
    place-items: center;
    width: 100vw;
    max-width: none;
    height: 100vh;
    max-height: none;
    margin: 0;
    padding: clamp(1rem, 4vw, 3rem);
    border: 0;
    background: transparent;
    color: var(--ink);
    opacity: 0;
    transition: opacity 0.26s ease;
}

.preview-lightbox[open] {
    display: grid;
}

.preview-lightbox::backdrop {
    background: rgba(14, 14, 18, 0);
    transition: background-color 0.26s ease;
}

.preview-lightbox.is-visible {
    opacity: 1;
}

.preview-lightbox.is-visible::backdrop {
    background: rgba(14, 14, 18, 0.82);
}

.preview-lightbox-image {
    display: block;
    max-width: min(92vw, 1100px);
    max-height: 88vh;
    width: auto;
    height: auto;
    object-fit: contain;
    border-radius: 4px;
    box-shadow: 0 1.5rem 5rem rgba(0, 0, 0, 0.35);
    transform: scale(0.94);
    transition: transform 0.26s cubic-bezier(0.2, 0.8, 0.2, 1);
}

.preview-lightbox.is-visible .preview-lightbox-image {
    transform: scale(1);
}

@media (prefers-reduced-motion: reduce) {
    .preview-lightbox,
    .preview-lightbox::backdrop,
    .preview-lightbox-image {
        transition: none;
    }
}

.pp-visual-placeholder--portrait {
    position: relative;
    display: block;
    width: min(100%, 13rem);
    margin: 0;
    justify-self: end;
    padding: 0.3rem;
    overflow: hidden;
    border: 1px solid var(--line);
    border-radius: 1rem;
    background: linear-gradient(155deg, var(--bg-alt), var(--bg));
    box-shadow: inset 0 0 0 5px var(--bg-alt), 0 12px 36px var(--accent-glow);
    animation: preview-arrive 0.65s cubic-bezier(0.2, 0.8, 0.2, 1) both;
}

.pp-visual-placeholder--portrait img {
    display: block;
    width: 100%;
    height: auto;
    border-radius: 0.7rem;
}

.pp-section {
    padding: 3.25rem 0;
    border-bottom: 1px solid var(--line);
}

.pp-section:last-of-type {
    border-bottom: none;
}

.pp-h2 {
    font-family: var(--font-body);
    font-weight: 400;
    font-size: 1.5rem;
    letter-spacing: -0.015em;
    margin-bottom: 1rem;
}

.pp-body {
    color: var(--muted);
    max-width: 40rem;
}

.pp-body strong,
.why-list strong {
    color: var(--ink);
    font-weight: 400;
}

/* Prose lists */
.why-list {
    max-width: 40rem;
    padding-left: 1.15rem;
    color: var(--muted);
}

.why-list li {
    margin-bottom: 0.9rem;
    padding-left: 0.35rem;
}

.why-list li:last-child {
    margin-bottom: 0;
}

.why-list li::marker {
    color: var(--accent);
}

/* Inline code */
code {
    font-family: var(--font-mono);
    font-size: 0.86em;
    padding: 0.1em 0.35em;
    border-radius: 4px;
    background: var(--bg-alt);
    border: 1px solid var(--line);
    color: var(--ink);
    overflow-wrap: break-word;
}

/* Code blocks */
.snippet {
    max-width: 52rem;
    margin-top: 1.75rem;
    border: 1px solid var(--line);
    border-radius: 6px;
    overflow: hidden;
    background: var(--bg-alt);
}

.snippet-bar {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 1rem;
    padding: 0.6rem 0.95rem;
    border-bottom: 1px solid var(--line);
    font-family: var(--font-mono);
    font-size: 0.72rem;
    color: var(--muted);
}

.snippet-bar .lang {
    color: var(--accent);
    text-transform: uppercase;
    letter-spacing: 0.06em;
}

.snippet pre {
    margin: 0.65rem;
    padding: 1.25rem 1.4rem;
    overflow-x: auto;
    border: 1px solid var(--line);
    border-radius: 4px;
    background: var(--bg);
    font-family: var(--font-mono);
    font-size: 0.82rem;
    line-height: 1.7;
    tab-size: 4;
}

.snippet pre code {
    padding: 0;
    border: none;
    border-radius: 0;
    background: none;
    font-size: inherit;
    color: var(--ink);
    overflow-wrap: normal;
    white-space: pre;
}

.snippet-note {
    margin: 0;
    padding: 0.25rem 1rem 1rem;
    font-size: 0.9rem;
    color: var(--muted);
    max-width: 40rem;
}

/* Syntax tokens — kept muted so they read as one system, not a rainbow. */
.tok-key { color: var(--accent); }
.tok-typ { color: var(--accent-2); }
.tok-str { color: var(--accent-3); }
.tok-num { color: var(--accent-2); }
.tok-com { color: var(--muted); font-style: italic; opacity: 0.85; }

/* Prev / next */
.pp-next {
    display: flex;
    align-items: center;
    justify-content: space-between;
    flex-wrap: wrap;
    gap: 1rem;
    padding-top: 2.5rem;
    padding-bottom: 3rem;
    font-family: var(--font-mono);
    font-size: 0.82rem;
    color: var(--muted);
}

.pp-next a {
    transition: color 0.2s;
}

.pp-next a:hover {
    color: var(--accent);
}

.pp-next .prev {
    margin-left: auto;
}

@media (max-width: 640px) {
    .pp-head {
        padding: 3.5rem 0 2.5rem;
    }

    .pp-section {
        padding: 2.5rem 0;
    }

    .pp-head-layout {
        grid-template-columns: 1fr;
        gap: 2rem;
    }

    .pp-visual-placeholder--portrait {
        justify-self: center;
    }

    .pp-next {
        flex-direction: column;
        align-items: flex-start;
        gap: 0.6rem;
    }

    .pp-next .prev {
        margin-left: 0;
    }

    .snippet pre {
        font-size: 0.78rem;
    }
}
