html,
body {
    margin: 0;
    padding: 0;
    background-color: #111111;
    color: #e0e0e0;
    font-family: 'IBM Plex Sans', sans-serif;
    font-size: 14px;
    line-height: 1.5;
    overflow-x: hidden;
    width: 100%;
    max-width: 100vw;
}

/* same sticky-breaking guard as .container, lifted only on the games page */
html:has(.games-page),
body:has(.games-page) {
    overflow-x: visible;
    scrollbar-width: none;
    -ms-overflow-style: none;
}

html:has(.games-page)::-webkit-scrollbar,
body:has(.games-page)::-webkit-scrollbar {
    display: none;
}

.container {
    padding: 35px 35px 40px 35px;
    max-width: none;
    position: relative;
    min-height: 100vh;
    overflow-x: hidden;
    width: 100%;
    box-sizing: border-box;
}

/* position:sticky breaks if any ancestor has a non-visible overflow —
   this page has no ascii-art to clip, so it's safe to lift the guard here */
.container:has(.games-page) {
    overflow-x: visible;
}

.content {
    position: relative;
    z-index: 1;
    max-width: 600px;
}

.content:has(.post) {
    max-width: 760px;
}

.content:has(.timeline) {
    max-width: none;
}

.nav-section {
    display: flex;
    align-items: flex-start;
    margin-bottom: 80px;
}

.ascii-art {
    position: absolute;
    top: -130px;
    left: 175px;
    font-family: 'Commit Mono', monospace;
    font-size: 8px;
    line-height: 1;
    white-space: pre;
    background: linear-gradient(to bottom, #9966cc, #4488dd);
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
    background-clip: text;
    z-index: -1;
    pointer-events: none;
    will-change: transform;
}

.nav-links {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 4px;
}

.nav-links a {
    color: #e0e0e0;
    text-decoration: none;
    font-size: 14px;
}

.nav-links a:hover {
    background-color: #f0f0f0;
    color: #111111;
    border-radius: 3px;
}

.swe-text {
    background: linear-gradient(to bottom, #9966cc, #4488dd);
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
    background-clip: text;
    text-decoration: none;
}

.section {
    margin-bottom: 80px;
}

.intro-text {
    color: #e0e0e0;
    margin-bottom: 10px;
}

.footnote-link {
    color: #e0e0e0;
    text-decoration: underline;
}

.footnote-link:hover {
    background-color: #f0f0f0;
    color: #111111;
    border-radius: 3px;
}

.games-intro {
    color: #b0b0b0;
    font-size: 1.05em;
    max-width: 640px;
}

.games-intro p {
    margin: 0 0 14px 0;
}

/* No full-bleed breakout below 768px — that's only worthwhile for the
   desktop zigzag timeline; on mobile it misaligned this against the rest of
   the page's normal padding and added avoidable width math that could clip
   content on narrow windows. */
.games-layout {
    box-sizing: border-box;
    width: 100%;
    /* Cancels .container's own top padding (35px here) so the sticky bar
       starts already flush with the true viewport top, instead of sitting
       35px down and visibly closing that gap over the first bit of
       scrolling before it "catches" — same technique used to start the
       desktop timeline flush with the top. */
    margin-top: -35px;
}

/* Below 1024px there's no room for the sidebar treatment, so this becomes a
   compact bar pinned to the top of the viewport instead — still driven by
   the same JS, just laid out differently. The intro and the dynamic
   label/text occupy the SAME grid cell (stacked, not sequential) so one
   crossfades into the other in place instead of a second element appearing
   alongside/underneath it, which is what caused a glitchy stacking effect
   right as the bar caught its sticky position while scrolling. */
.games-caption {
    display: grid;
    align-items: center;
    position: sticky;
    top: 0;
    z-index: 2;
    background-color: #111111;
    /* Full-bleed just for this bar, independent of .games-layout's own
       (no longer full-bleed on mobile) box, so the divider reaches the true
       screen edges while the text inside stays inset to match everything
       else on the page. */
    box-sizing: border-box;
    margin-left: -35px;
    margin-right: -35px;
    width: calc(100% + 70px);
    padding: 14px 35px 14px 35px;
    border-bottom: 1px solid #222222;
    /* Fixed height, not content-driven — the intro and each game's caption
       are all different lengths, and letting the bar grow/shrink to fit
       whichever is currently showing pushed the whole timeline below it up
       and down. Capping it (with the text itself clamped further down) is
       what actually decouples timeline position from caption length. */
    height: 220px;
    overflow: hidden;
    /* iOS Safari has a known compositing bug where a position:sticky
       element flickers/jumps when a nearby element's transform updates
       frequently (.timeline's does, every scroll frame) — giving this its
       own static GPU layer is the standard workaround. Safe to set
       unconditionally here since, unlike .timeline, nothing ever
       overwrites this element's transform from JS. */
    -webkit-transform: translateZ(0);
    -webkit-backface-visibility: hidden;
}

.games-intro--pinned,
.games-caption-dynamic {
    grid-area: 1 / 1;
    transition: opacity 0.3s ease;
}

.games-caption-dynamic {
    opacity: 0;
    pointer-events: none;
}

.games-caption.has-active .games-intro--pinned {
    opacity: 0;
    pointer-events: none;
}

.games-caption.has-active .games-caption-dynamic {
    opacity: 1;
    pointer-events: auto;
}

.timeline-track {
    position: relative;
}

.timeline {
    /* Tried native position:sticky here to sidestep iOS's compositor-
       thread lag on the JS transform — reverted, see games-timeline.js for
       why (its release timing baking in .timeline's own content height
       conflicted with the fill-pacing slowdownRunway added to the track's
       height, so it released much later than the fill model expected). */
    position: relative;
    /* No top margin — the line itself (::before/::after, top:0) should
       start flush with whatever's directly above it (the sticky caption
       bar's bottom border on mobile), so they read as connected. */
    margin: 0 0 10px 0;
    /* Also fixes a real bug, not just spacing: without this, the first
       item's offsetTop is 0, exactly matching filledPx (0) at page load
       before any scrolling — item.offsetTop <= filledPx then falsely
       marks it "reached" immediately. */
    padding-top: 24px;
    will-change: transform;
    /* JS writes a translate3d(...) transform every frame (see
       games-timeline.js), which promotes this to its own GPU layer on its
       own — a static -webkit-transform here would risk conflicting with
       that as a separate (prefixed vs unprefixed) property. Just the
       backface-visibility hint, which doesn't have that conflict risk. */
    -webkit-backface-visibility: hidden;
}

.timeline::before {
    content: "";
    position: absolute;
    /* Centered on the dot: dot center is left:0 + width:12px/2 = 6px, so
       this 3px-wide line needs left = 6 - 3/2 = 4.5px, not 6px (which
       centers the LINE at 7.5px, 1.5px right of the dot). */
    left: 4.5px;
    top: 0;
    width: 3px;
    height: var(--timeline-line-length, 100%);
    background-color: #333333;
}

.timeline::after {
    content: "";
    position: absolute;
    left: 4.5px;
    top: 0;
    width: 3px;
    /* Full length, scaled down visually via transform rather than sized via
       a calc()'d height driven by --timeline-progress — height is a layout
       property, so animating it every scroll frame forced a real reflow on
       every frame. transform:scaleY is compositor-only (no layout cost),
       which is the difference between "fine on desktop" and "janky on iOS
       Safari" for something updating this often. */
    height: var(--timeline-line-length, 0px);
    transform: scaleY(var(--timeline-progress, 0));
    transform-origin: top;
    background-color: #5cbf3d;
    pointer-events: none;
}

@keyframes timeline-ping {
    0% {
        box-shadow: 0 0 0 0 rgba(92, 191, 61, 0.6);
    }
    70% {
        box-shadow: 0 0 0 7px rgba(92, 191, 61, 0);
    }
    100% {
        box-shadow: 0 0 0 7px rgba(92, 191, 61, 0);
    }
}

.timeline-item {
    position: relative;
    width: 100%;
    box-sizing: border-box;
    padding-left: 26px;
    margin-bottom: 90px;
    text-align: left;
}

.timeline-item::before {
    content: "";
    position: absolute;
    top: 4px;
    left: 0;
    width: 12px;
    height: 12px;
    border-radius: 50%;
    background-color: #666666;
    transition: background-color 0.4s ease;
}

.timeline-item.is-reached::before {
    background-color: #5cbf3d;
}

.timeline-year {
    font-size: 0.9em;
    color: #666666;
    margin-bottom: 2px;
}

.timeline-title {
    color: #e0e0e0;
    font-size: 1.1em;
}

.timeline-item.is-active .timeline-title {
    color: #ffffff;
}

.timeline-item.is-active::before {
    animation: timeline-ping 1.4s ease-out;
}

.games-caption-label {
    font-size: 0.95em;
    color: #5cbf3d;
    margin-bottom: 8px;
    min-height: 1em;
    transition: opacity 0.11s ease, transform 0.11s ease;
}

.games-caption-text {
    color: #e0e0e0;
    font-size: 1.05em;
    line-height: 1.6;
    white-space: pre-line;
    min-height: 1.6em;
    transition: opacity 0.11s ease, transform 0.11s ease;
    /* Clamped to a fixed number of lines so long captions can't blow out
       .games-caption's fixed height on mobile — reset at 1024px+, where
       the sidebar isn't height-constrained the same way. */
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 7;
    overflow: hidden;
}

.games-caption.is-swapping .games-caption-label,
.games-caption.is-swapping .games-caption-text {
    opacity: 0;
    transform: translateX(8px);
}

.games-caption-text.is-empty::before {
    content: "no caption yet.";
    color: #666666;
}

.intro-subtext {
    color: #b0b0b0;
    margin-bottom: 80px;
}

.posts-list {
    text-align: left;
}

.section-title {
    font-size: 14px;
    font-weight: normal;
    margin-bottom: 20px;
    color: #ffffff;
}

.tab-bar {
    margin-bottom: 30px;
}

.tab-link {
    color: #ffffff;
    text-decoration: none;
}

.tab-link:not(.is-active):hover {
    text-decoration: underline;
}

.tab-link.is-active {
    color: #ffffff;
    font-weight: bold;
}

.tab-separator {
    margin: 0 6px;
    color: #e0e0e0;
}

.project-item {
    margin-bottom: 12px;
    color: #e0e0e0;
}

.project-name {
    color: #e0e0e0;
    text-decoration: none;
}

.project-name:hover {
    color: #ffffff;
    text-decoration: underline;
}

.project-separator {
    margin: 0 6px;
    color: #e0e0e0;
}

.project-desc {
    color: #e0e0e0;
}

.blog-item {
    margin-bottom: 28px;
    color: #e0e0e0;
}

.blog-date {
    color: #ffffff;
}

.blog-title {
    display: inline-block;
    max-width: 100%;
    color: #ffffff;
    text-decoration: none;
}

.blog-thumb {
    display: block;
    width: 100%;
    min-width: 300px;
    max-width: 310px;
    height: 90px;
    object-fit: cover;
    border-radius: 0;
    border: 2px solid #ffffff;
    box-shadow: 4px 4px 0 0 #333333;
    box-sizing: border-box;
    margin-bottom: 10px;
}

.blog-text {
    display: inline;
}

.blog-title:hover .blog-text {
    background-color: #f0f0f0;
    color: #111111;
    border-radius: 3px;
}

.blog-title:hover .blog-date {
    color: #111111;
}

.blog-more {
    margin-top: 12px;
}

/* Post / article content */
.post-meta {
    margin-bottom: 8px;
}

.post-title {
    font-size: 20px;
    font-weight: normal;
    color: #ffffff;
    margin: 0 0 30px 0;
}

.post-image {
    display: block;
    width: 100%;
    height: auto;
    max-width: min(100%, calc(100vw - 70px));
    margin: 0 0 30px 0;
}

.post-content {
    color: #e0e0e0;
    line-height: 1.4;
}

.post-content h1,
.post-content h2,
.post-content h3,
.post-content h4 {
    color: #ffffff;
    font-weight: normal;
    margin: 30px 0 12px 0;
}

.post-content p {
    margin: 0 0 13px 0;
}

.post-content a {
    color: #66b3ff;
    text-decoration: none;
}

.post-content a:hover {
    color: #99ccff;
    text-decoration: underline;
}

.post-content ul,
.post-content ol {
    margin: 0 0 16px 0;
    padding-left: 24px;
}

.post-content li {
    margin-bottom: 6px;
}

.post-content blockquote {
    margin: 0 0 16px 0;
    padding-left: 16px;
    border-left: 2px solid #4488dd;
    color: #b0b0b0;
}

.post-content code {
    background-color: #1c1c1c;
    padding: 2px 5px;
    border-radius: 3px;
    font-size: 0.9em;
}

.post-content pre {
    background-color: #1c1c1c;
    padding: 16px;
    border-radius: 4px;
    overflow-x: auto;
    margin: 0 0 16px 0;
}

.post-content pre code {
    background-color: transparent;
    padding: 0;
}

.post-content img {
    max-width: 100%;
}

.post-content hr {
    border: none;
    border-top: 1px solid #333333;
    margin: 30px 0;
}

.post-divider {
    border: none;
    border-top: 1px solid #333333;
    margin: 20px 0 30px 0;
}

.post-footer {
    margin-top: 40px;
}

.footer {
    margin-top: 80px;
    color: #666666;
    font-size: 0.85em;
}

.footer a {
    color: #666666;
}

.footer a:hover {
    color: #999999;
}

/* Scale up for non-mobile screens */
@media (min-width: 768px) {
    body {
        font-size: 16px;
    }

    .container {
        padding: 60px 60px 60px 60px;
    }

    .nav-links a {
        font-size: 16px;
    }

    .section-title {
        font-size: 16px;
    }

    .post-title {
        font-size: 26px;
    }

    .post-image {
        max-width: min(100%, calc(100vw - 120px));
    }

    .ascii-art {
        font-size: 14px;
        top: -310px;
        left: 180px;
    }

    .games-layout {
        margin-left: -60px;
        margin-right: -60px;
        margin-top: -60px;
        width: calc(100% + 120px);
        padding: 0 40px;
    }

    .timeline::before {
        left: 50%;
        transform: translateX(-50%);
    }

    .timeline::after {
        left: 50%;
        /* Combines centering with the scaleY fill — a plain translateX(-50%)
           here would silently replace (not add to) the base rule's scaleY,
           since they're the same property. */
        transform: translateX(-50%) scaleY(var(--timeline-progress, 0));
    }

    .timeline-item {
        width: 50%;
        padding-left: 0;
    }

    .timeline-item.left {
        padding-right: 64px;
        text-align: right;
    }

    .timeline-item.right {
        left: 50%;
        padding-left: 64px;
        text-align: left;
    }

    /* dot moves off the spine; ::before becomes the branch stub connecting to it */
    .timeline-item.left::before,
    .timeline-item.right::before {
        top: 8px;
        left: auto;
        width: 36px;
        height: 3px;
        border-radius: 0;
        background-color: #333333;
        border: none;
        box-shadow: none;
        /* Override the base .timeline-item.is-active::before ping — this
           is the stem at this breakpoint (the dot moved to ::after), and
           only the circle should ping. */
        animation: none;
        transition: background-color 0.4s ease;
    }

    .timeline-item.left::before {
        right: 0;
    }

    .timeline-item.right::before {
        left: 0;
    }

    .timeline-item.is-reached.left::before,
    .timeline-item.is-reached.right::before {
        background-color: #5cbf3d;
    }

    .timeline-item.left::after,
    .timeline-item.right::after {
        content: "";
        position: absolute;
        top: 4px;
        width: 12px;
        height: 12px;
        border-radius: 50%;
        background-color: #666666;
        transition: background-color 0.4s ease;
    }

    .timeline-item.left::after {
        right: 36px;
    }

    .timeline-item.right::after {
        left: 36px;
    }

    .timeline-item.is-reached.left::after,
    .timeline-item.is-reached.right::after {
        background-color: #5cbf3d;
    }

    .timeline-item.is-active.left::after,
    .timeline-item.is-active.right::after {
        animation: timeline-ping 1.4s ease-out;
    }
}

@media (min-width: 1024px) {
    body {
        font-size: 17px;
    }

    /* /games on wide screens is the one exception to the site-wide bump —
       cancels it back to the pre-bump 16px so its em-based text (year/title,
       caption label/text, intro) renders exactly as it already did. */
    .container:has(.games-page) {
        font-size: 16px;
    }

    .container {
        padding: 80px 80px 80px 80px;
    }

    .nav-links a {
        font-size: 17px;
    }

    .section-title {
        font-size: 17px;
    }

    .post-title {
        font-size: 32px;
    }

    .post-image {
        max-width: min(100%, calc(100vw - 160px));
    }

    .project-item {
        white-space: nowrap;
    }

    .ascii-art {
        font-size: 18px;
        top: -370px;
        left: 240px;
    }

    .games-layout {
        margin-left: -80px;
        margin-right: -80px;
        margin-top: -80px;
        width: calc(100% + 160px);
        padding: 0 60px;
        display: grid;
        grid-template-columns: 460px 1fr;
        gap: 80px;
        align-items: start;
    }

    .games-caption {
        display: flex;
        flex-direction: column;
        justify-content: flex-start;
        /* Reset the mobile grid's vertical-centering align-items — in this
           flex-column context that property controls horizontal alignment
           instead, which would otherwise center this text instead of
           leaving it left-aligned. */
        align-items: stretch;
        position: sticky;
        top: 0;
        /* Reset the mobile bar's full-bleed treatment — this is a grid item
           here, and the negative margin/explicit width would break the
           grid's own column sizing. */
        margin-left: 0;
        margin-right: 0;
        width: auto;
        border-bottom: none;
        /* Sticky can only stay stuck while its containing block (the grid
           row, sized by .timeline-track) still has at least the sticky
           element's own height left below the current scroll position —
           in the last stretch it necessarily un-sticks so it doesn't
           overflow the row. A shorter box needs less of that stretch to
           un-stick in, so it's kept well short of 100vh to push that
           release past where the timeline content has already finished
           revealing, instead of noticeably shifting right near the end. */
        height: 60vh;
        overflow: visible;
        box-sizing: border-box;
        padding: 60px 0;
    }

    .games-intro--pinned {
        display: block;
        margin-bottom: 32px;
        /* Undo the mobile grid-stack crossfade — desktop always shows the
           intro and the dynamic label/text together, sequentially. */
        grid-area: auto;
        opacity: 1;
        pointer-events: auto;
    }

    .games-caption-dynamic {
        grid-area: auto;
        opacity: 1;
        pointer-events: auto;
    }

    /* .has-active toggling .games-intro--pinned's opacity is a
       (.games-caption.has-active X) selector — matching that specificity
       here (rather than the plain rule above) so this reliably wins once
       .has-active gets added while scrolled on desktop too. */
    .games-caption.has-active .games-intro--pinned {
        opacity: 1;
        pointer-events: auto;
    }

    .games-caption-label {
        font-size: 0.85em;
    }

    .games-caption-text {
        font-size: 1.15em;
        /* Undo the mobile line-clamp/fixed-height treatment — the sidebar
           isn't height-constrained the same way, so show captions in full. */
        display: block;
        -webkit-line-clamp: unset;
        overflow: visible;
    }

    .timeline {
        max-width: 1000px;
        margin-top: 0;
        padding-top: 60px;
    }

    .timeline-title {
        font-size: 1.1em;
    }

    /* Longer branch stems on larger screens, where there's room for them. */
    .timeline-item.left {
        padding-right: 100px;
    }

    .timeline-item.right {
        padding-left: 100px;
    }

    .timeline-item.left::before,
    .timeline-item.right::before {
        width: 72px;
    }

    .timeline-item.left::after {
        right: 72px;
    }

    .timeline-item.right::after {
        left: 72px;
    }
}

@media (min-width: 1440px) {
    body {
        font-size: 19px;
    }

    /* Same /games wide-screen exception as the 1024px tier above. */
    .container:has(.games-page) {
        font-size: 18px;
    }

    .container {
        padding: 100px 100px 100px 100px;
    }

    .nav-links a {
        font-size: 19px;
    }

    .section-title {
        font-size: 19px;
    }

    .post-title {
        font-size: 38px;
    }

    .post-image {
        max-width: min(100%, calc(100vw - 200px));
    }

    .project-item {
        white-space: nowrap;
    }

    .ascii-art {
        font-size: 22px;
        top: -460px;
        left: 300px;
    }

    .games-layout {
        margin-left: -100px;
        margin-right: -100px;
        margin-top: -100px;
        width: calc(100% + 200px);
        padding: 0 80px;
        grid-template-columns: 540px 1fr;
        gap: 100px;
    }

    .timeline {
        max-width: 1100px;
        padding-top: 80px;
    }
}
