/* I define reusable color values here as custom properties (CSS variables). Anywhere else in this file I can write var(--bg) instead of retyping #050506 — and if I ever want to retheme the whole site, I only change the value once, here, instead of hunting through every rule that uses it */
:root {

    --bg: #050506;
    --text: #EDEAE3;
    --dim: #6B7280;
    --accent: #214b53;
    --panel: #0c0d10;
    --border: rgba(255, 255, 255, 0.08);
    --accent-2: #214b53;

    /* the navy I picked specifically as the theme color for my Project Highlights section, standing apart from the teal accent the rest of the site uses */
    --highlight: #041642;

}

/* I apply box-sizing:border-box to every single element on the page (the * selector matches everything). This is the box-model behavior where width/height include padding and border instead of adding on top of them — without this, a lot of my clamp()-based spacing later on would make elements grow past the size I actually set */
* {

    box-sizing: border-box;

}

/* I turn on smooth scrolling site-wide — when a link jumps to an anchor on the same page, the browser animates the scroll instead of snapping instantly */
html {

    scroll-behavior: smooth;

}

/* I zero out the default margin browsers put on <html> and <body>, and make sure body can grow to fill the full page height */
html,
body {

    margin: 0;
    min-height: 100%;

}

/* my base styles for the whole page */
body {

    /* these pull straight from the variables above, instead of hardcoding the colors again */
    background: var(--bg);
    color: var(--text);

    /* my font stack: the browser tries IBM Plex Sans first (loaded from Google Fonts in my <head>); system-ui falls back to whatever clean default font the visitor's OS provides if that fails to load; sans-serif is the final generic fallback every browser understands */
    font-family: 'IBM Plex Sans', system-ui, sans-serif;

    /* a browser-specific hint (the -webkit- prefix means it's only recognized by Chrome/Safari/Edge) that smooths text edges a bit more, closer to how the design actually looks */
    -webkit-font-smoothing: antialiased;

    /* I reserve empty space at the very bottom of the page so my fixed tech-stack ticker (which floats on top of everything, position:fixed) never covers the real content underneath it */
    padding-bottom: 6.5rem;

}

/* browsers give <button> elements their own default font and color instead of inheriting from the page — this line tells buttons to just use whatever font/color their surrounding text has, so they blend in instead of looking like a native OS button */
button {

    font: inherit;
    color: inherit;

}

/* a utility class I'll reuse on things like project titles that need to be clickable but shouldn't look like a button or a link. "all: unset" strips away every single default style a browser would normally apply to whatever element this is on (color, font, borders, cursor, everything) — then I add cursor:pointer back on its own, since unset would have removed that too, and I still want it obvious this is clickable */
.link-reset {

    all: unset;
    cursor: pointer;

}

/* my fixed nav bar. It starts invisible (opacity:0) and slid up slightly (translateY(-10px)) — processing.js will add a "scrolled" class to <body> once the visitor scrolls past the hero, and the rule right after this one fades it in when that happens */
.site-nav {

    /* position:fixed takes it completely out of normal document flow and locks it to the browser window itself, not to wherever it sits in the HTML — so it stays put even while the rest of the page scrolls underneath it. Setting top, left, and right to 0 stretches it across the full width of the screen, pinned to the very top */
    position: fixed;
    top: 0;
    left: 0;
    right: 0;

    /* z-index controls stacking order when elements overlap — higher numbers sit on top of lower ones. 40 is high enough that this bar stays above the page content scrolling underneath it */
    z-index: 40;

    /* I lay the logo lockup and the nav links out in a row, vertically centered, with justify-content:space-between pushing them to opposite ends of the bar (logo far left, links far right) */
    display: flex;
    align-items: center;
    justify-content: space-between;

    /* my inner spacing: 0.9rem top/bottom, 1.5rem left/right */
    padding: 0.9rem 1.5rem;

    /* a mostly-opaque dark background, plus backdrop-filter which blurs whatever page content is scrolling underneath the bar — this is what gives it that frosted-glass look instead of a flat solid bar */
    background: rgba(5, 5, 6, 0.7);

    /* safari needs its own -webkit- prefixed copy of this property, or it silently ignores the blur entirely on older versions instead of just applying it unprefixed */
    -webkit-backdrop-filter: blur(14px);
    backdrop-filter: blur(14px);

    /* a barely-visible hairline along the bottom edge to separate it from the page below */
    border-bottom: 1px solid rgba(255, 255, 255, 0.06);

    /* the bar starts invisible, nudged up 10px, and pointer-events:none means it can't be clicked or hovered while it's hidden — without this, an invisible bar could still block clicks meant for whatever's behind it */
    opacity: 0;
    transform: translateY(-10px);
    pointer-events: none;

    /* I animate opacity and transform smoothly over 0.3 seconds whenever either one changes, instead of snapping instantly */
    transition: opacity 0.3s ease, transform 0.3s ease;

}

/* this is a compound selector: it only matches .site-nav when it's nested inside a <body> that also has class="scrolled". processing.js will add "scrolled" to <body> once the visitor scrolls down — when that happens, these values override the hidden state above, and the transition I just set makes that change fade in smoothly instead of popping */
body.scrolled .site-nav {

    opacity: 1;
    transform: translateY(0);
    pointer-events: auto;

}

/* .site-nav .lockup only matches an element with class="lockup" that's nested inside .site-nav — this lets me reuse simple class names like "lockup" elsewhere on the page without their styles leaking into each other */
.site-nav .lockup {

    display: flex;
    align-items: center;
    gap: 0.7rem;

}

/* my logo inside the nav bar, sized small and fixed at 32px tall; width:auto keeps its original proportions instead of stretching or squashing it */
.site-nav .mark {

    display: block;
    height: 32px;
    width: auto;

}

/* the name text sitting next to my logo — Fraunces gives it a slightly serif, editorial feel that plain sans-serif text wouldn't have */
.site-nav .name {

    font-family: 'Fraunces', serif;
    font-weight: 600;
    font-size: 0.92rem;
    color: var(--text);

}

/* my small tagline under the name in the nav bar — IBM Plex Mono at a tiny size keeps it reading as a label, not a second headline */
.site-nav .tag {

    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.56rem;
    letter-spacing: 0.1em;

    /* forces the text to render in all caps regardless of how it's typed in the HTML — I wrote "Full-stack developer" in the HTML, and this displays it as "FULL-STACK DEVELOPER" without me retyping it */
    text-transform: uppercase;
    color: var(--dim);
    margin-top: 0.1rem;

}

/* the row of nav links themselves — flex lays them out in a line with a fixed gap between each one */
.site-nav .navlinks {

    display: flex;
    gap: 1.5rem;
    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.72rem;
    letter-spacing: 0.04em;

}

/* text-decoration:none strips the underline browsers put on links by default */
.site-nav .navlinks a {

    color: #9BA3B0;
    text-decoration: none;
    transition: color 0.2s ease;

}

/* a comma here means "apply this to either selector" — so this rule covers both :hover (while the mouse is over a link) and .active (the link matching whichever screen is currently showing, which processing.js will keep updated) */
.site-nav .navlinks a:hover,
.site-nav .navlinks a.active {

    color: var(--accent);

}

/* a media query — a conditional block of CSS that only applies while the browser window matches the condition in parentheses. This one only takes effect when the viewport is 480px wide or narrower (roughly phone-sized), and only affects .site-nav rules, since that's all I'm nesting inside it */
@media (max-width: 480px) {

    /* at this narrow width the name+tagline text would wrap awkwardly and make the bar too tall, so I just hide that text block entirely and leave the logo mark and nav links */
    .site-nav .text {

        display: none;

    }

    /* I also shrink the link row's gap and font size a touch so it doesn't crowd the narrower bar */
    .site-nav .navlinks {

        gap: 1rem;
        font-size: 0.68rem;

    }

}

/* ---------- hero / Home section ---------- */

/* position:relative doesn't visually move this element at all, but it does one important thing: it makes .hero a "positioned ancestor" for anything inside it that uses position:absolute (like .scroll-hint further down) — an absolutely positioned element measures its top/left/bottom/right from the nearest ancestor that has any position value other than the default "static", so this line is what lets me anchor .scroll-hint precisely inside the hero instead of relative to the whole page */
.hero {

    position: relative;

    /* the hero fills the full height of the browser window on load. plain vh measures the tallest possible viewport, before safari's address bar collapses out of the way, so its own vh value keeps changing as that bar shows and hides while scrolling — svh means small viewport height, the height safari already assumes with its address bar showing, so this stays fixed instead of resizing the hero underneath a visitor mid-scroll. browsers that do not know svh yet just skip that second line and keep using plain vh above it */
    min-height: 100vh;
    min-height: 100svh;

    /* I stack the logo, name, tagline, and scroll hint vertically, centered both horizontally (align-items) and vertically (justify-content) within the hero's height */
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;

    /* text-align:center handles the text itself; gap puts consistent breathing room between each stacked child without needing individual margins */
    text-align: center;
    gap: 1.6rem;
    padding: 2rem 1.5rem;

    /* background-image can take a comma-separated list of layers, stacked in order with the FIRST one on top — so this gradient sits visually above the photo, darkening it for text readability. The gradient goes from 50% dark at the top to 92% dark at the bottom, so the sky stays a little visible up top while the very bottom (behind the tech-stack ticker) goes nearly black */
    background-image:
        linear-gradient(180deg, rgba(5, 5, 6, 0.5) 0%, rgba(5, 5, 6, 0.72) 55%, rgba(5, 5, 6, 0.92) 100%),
        url(../artifacts/images/wallpaper.jpg);

    /* cover scales the image up or down so it completely fills the element's box with no gaps, cropping whatever overflows — this is different from "contain," which would shrink the image to fit fully inside the box and can leave empty space */
    background-size: cover;

    /* background-position takes a horizontal value then a vertical value. "center" keeps it horizontally centered; "35%" pulls the visible crop up so it shows more of the upper 35% of the photo (roughly where the eagle sits) rather than dead-center, which would crop closer to the mountains below it */
    background-position: center 35%;

    /* without this, background-size:cover would still tile/repeat the image if it needed to — no-repeat guarantees exactly one copy */
    background-repeat: no-repeat;

}

/* filter:drop-shadow works like box-shadow but follows the actual visible shape of the image/icon instead of its rectangular box — useful here since a PNG logo isn't a solid rectangle */
.hero .mark {

    display: block;
    height: 140px;
    width: auto;
    filter: drop-shadow(0 18px 40px rgba(240, 145, 63, 0.16));

}

/* clamp(min, preferred, max) again, same as the fundamentals primer covered — the name's font size scales with viewport width (5vw) but never drops below 2rem or grows past 3.1rem */
.hero .name {

    font-family: 'Fraunces', serif;
    font-weight: 600;
    font-size: clamp(2rem, 5vw, 3.1rem);
    color: var(--text);
    letter-spacing: -0.01em;

}

/* my tagline text in the hero itself — the same mono, uppercase treatment as the nav tagline above, just bigger since this is the main hero copy */
.hero .tag {

    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.85rem;
    letter-spacing: 0.16em;
    text-transform: uppercase;
    color: var(--dim);

}

/* .hero .tag b only targets the <b> tag (wrapping the middle-dot) inside .tag — I use this to recolor just that dot to the accent orange, without affecting the rest of the tagline text around it */
.hero .tag b {

    color: var(--accent);
    font-weight: 600;

}

/* position:absolute pulls this completely out of normal document flow — it won't take up space or push any sibling around. bottom/left/transform then place it 28px up from .hero's bottom edge (that's the "positioned ancestor" relationship from .hero's position:relative above), centered horizontally the same way the old .navigator rule did it: push its left edge to the exact horizontal center, then shift it back left by half its own width */
.scroll-hint {

    position: absolute;
    bottom: 28px;
    left: 50%;
    transform: translateX(-50%);

    /* I style the "scroll for more" hint text itself — small monospace type, muted gray, wide letter-spacing, forced uppercase, so it reads as a quiet UI cue rather than body copy */
    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.65rem;
    color: #5B6472;
    letter-spacing: 0.1em;
    text-transform: uppercase;

}

/* ---------- multi-screen navigation ---------- */

/* by default, EVERY element with class="screen" is hidden. This is what makes Home, About, Privacy, and Terms behave like separate pages instead of one long scrolling page */
.screen {

    display: none;

}

/* only the one screen that ALSO carries class="active" gets shown */
.screen.active {

    display: block;

}

/* shows the fixed nav immediately on any non-Home screen, instead of waiting for a scroll-position threshold that only makes sense on Home's tall hero */
body.on-subpage .site-nav {

    opacity: 1;
    transform: translateY(0);
    pointer-events: auto;

}

/* a small floating button that scrolls back up to my Project Highlights section — bottom:7.5rem keeps it clear of my floating tech ticker, which reserves the bottom 6.5rem of the page for itself */
.back-to-highlights {

    /* this is a <button>, so i strip its native default styling the same way i did for my carousel arrows, and draw everything myself instead */
    appearance: none;
    background: rgba(5, 5, 6, 0.7);

    /* safari needs its own -webkit- prefixed copy of this property, the same as my nav bar above */
    -webkit-backdrop-filter: blur(14px);
    backdrop-filter: blur(14px);
    border: 1px solid rgba(255, 255, 255, 0.14);
    border-radius: 50%;
    width: 48px;
    height: 48px;
    cursor: pointer;

    /* i fix this to the bottom right corner of the viewport instead of scrolling with the page, at z-index 35 — just below my nav bar's own 40, so the nav bar still stays on top of it if the two ever overlap */
    position: fixed;
    right: 1.5rem;

    /* i raised this from 7.5rem, since my ticker below it got taller once i added its label */
    bottom: 9rem;
    z-index: 35;

    /* it starts hidden and nudged down slightly, the same fade-and-rise-in treatment my nav bar uses above, and pointer-events:none keeps it unclickable while invisible */
    opacity: 0;
    transform: translateY(10px);
    pointer-events: none;
    transition: opacity 0.3s ease, transform 0.3s ease, background 0.2s ease, border-color 0.2s ease;

}

/* I draw an upward chevron the same plain-css-border way I drew my carousel arrows, instead of a text character or emoji — a 10px square with only its top and right borders visible, rotated -45deg to point straight up */
.back-to-highlights::before {

    content: '';
    position: absolute;
    top: 50%;
    left: 50%;
    width: 10px;
    height: 10px;
    border-top: 2px solid #fff;
    border-right: 2px solid #fff;
    transform: translate(-50%, -50%) rotate(-45deg);

}

/* I only reveal this button once "past-highlights" is true — processing.js sets that once the Highlights section has scrolled fully out of view above, not just once the page is generally scrolled, since jumping back up to a section that's still on screen would be pointless. body.on-subpage means a different screen is showing entirely, so I exclude that case too */
body.past-highlights:not(.on-subpage) .back-to-highlights {

    opacity: 1;
    transform: translateY(0);
    pointer-events: auto;

}

/* on hover I tint it with the same navy highlight color the Highlights section itself uses, so it visually hints at where clicking it takes you */
.back-to-highlights:hover {

    background: rgba(4, 22, 66, 0.85);
    border-color: var(--highlight);

}

/* cancels out body's global 6.5rem bottom padding (reserved for the floating ticker on long scrolling pages) specifically while the fixed-height, non-scrolling About screen is showing, since that padding would otherwise just be wasted empty space there */
#screen-about {

    margin-bottom: -6.5rem;

}

/* ---------- Projects: the pin-and-scrub magazine mechanic ---------- */

/* #projects .eyebrow is a descendant selector combining an id (#projects, the outer Projects section) with a class (.eyebrow) — it only matches an .eyebrow that's nested inside the element with id="projects", so it can never affect the About widgets' eyebrows, which live inside #about instead. This also sidesteps the "later rule wins" tie-breaker entirely: an id selector has higher specificity than a plain class selector, so #projects .eyebrow will always beat a bare .eyebrow rule no matter which one is written first or second in the file */
#projects .eyebrow {

    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.7rem;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--dim);
    text-align: center;

}

/* position:relative here for the same reason as .hero earlier — it makes .pin-stage a positioned ancestor for anything inside it that needs position:absolute */
.pin-stage {

    position: relative;

}

/* the inner content wrapper for my Project Highlights banner — the same centered-column layout as before, but no longer forced to a full viewport height, since this section isn't scroll-pinned anymore now that its cards change on a click or on their own timer instead of on scroll position */
.pin-inner {

    display: flex;
    flex-direction: column;
    justify-content: center;
    padding: 4rem 1.5rem;

    /* caps how wide the content can stretch on a big monitor, then centers that capped box horizontally with auto left/right margins. I widened this from 1000px so the highlight banner below can read as close to full width instead of a boxed-in column */
    max-width: 1300px;
    margin: 0 auto;

}

/* i put the background gradient on this wrap element instead of on the card stacked inside it, so the background never blurs/fades/moves — only the text card on top of it does, once processing.js starts animating between projects */
.feature-wrap {

    /* i give it a relative position so .feature-card can stack absolutely inside it, a dark panel background as a fallback, and a subtle border and rounded corners to match my other cards. height:50vh makes this roughly half the viewport tall now that it's the only thing in this stage, with min-height as a floor so it never gets too cramped on a short screen */
    position: relative;

    /* the banner takes the screen now, stopping just short of my tech stack bar. i worked the number
       out by measuring rather than by eye: with this section scrolled to the top of the window the
       banner starts 84px down, past my nav bar, and the top of the tech stack label pill sits 119px
       up from the bottom. taking 232px off the window height puts the bottom of the banner about
       30px clear of that pill, which is close without the two ever touching. svh rather than vh,
       because safari's address bar makes vh lie about how tall the window actually is */
    height: calc(100svh - 232px);
    min-height: 400px;
    width: 100%;
    background: var(--panel);
    border: 1px solid var(--border);
    border-radius: 20px;

    /* this used to stack gradient layers over the whole banner, one darkening the bottom for the text
       and one washing everything in my navy. both made sense while the card was mostly empty, but a
       real screenshot fills it now and every one of those layers sat on top of the picture, pulling
       it blue. there is no tint here at all any more. my screenshots are warm and dark on their own,
       and the scrim behind my words is the only thing left doing any covering */
    background-image: none;

    /* i clip anything that would otherwise overflow this box's rounded corners — without it, a sharp-cornered child could poke out past the border-radius curve */
    overflow: hidden;

    /* i animate the hover glow below smoothly instead of it snapping on and off instantly */
    transition: filter 0.3s ease;

}

/* i glow the banner's own edge on hover using filter: drop-shadow instead of box-shadow — box-shadow gets clipped away by .feature-wrap's own overflow:hidden above, since that hides anything painted past the box's edges, but a filter is applied to the whole rendered box afterward and is not clipped by its own overflow, so this is the one way to get a glow around a box that also needs to clip its own contents. two layered drop-shadows give the glow a softer falloff than one alone would */
.feature-wrap:hover {

    filter: drop-shadow(0 0 14px rgba(4, 22, 66, 0.65)) drop-shadow(0 0 32px rgba(4, 22, 66, 0.35));

}

/* I style both my prev/next arrow buttons the same way here, a small dark rounded square that floats on top of the banner near its left or right edge. z-index keeps them clickable above whichever feature-card happens to be on top at the moment, since the cards themselves have no z-index of their own */
.stage-arrow {

    /* this is a <button>, and every browser draws its own default background, border, and padding on a plain button — appearance:none turns that native styling off, and the other three zero out what appearance:none alone doesn't reliably clear everywhere, so nothing renders here except the chevron I draw myself below */
    appearance: none;
    background: none;
    border: none;
    padding: 0;

    /* top:45% sits just above the dead center of the banner, in the empty space above my card's title/blurb/date group, which all sit anchored to the bottom of the banner — at a full 50% a longer blurb could grow tall enough to sit right underneath the arrow */
    position: absolute;
    top: 45%;
    transform: translateY(-50%);
    width: 40px;
    height: 40px;
    cursor: pointer;
    z-index: 5;

    /* I start this button fully invisible, so it stays out of the way until a visitor's mouse or keyboard focus actually lands on it — see the hover/focus rule below, and the touch-device fallback further down */
    opacity: 0;
    transition: opacity 0.25s ease;

}

/* I draw the actual chevron with plain css borders instead of a text character or an image, so it always renders as a crisp shape instead of depending on how a font glyph happens to look on a given device. a 10px square with only its top and right borders visible forms one pointed corner, which rotate below turns into a left or right pointing arrow. translate(-50%, -50%) centers this box on the button's own center point using its own width and height, so rotating it in either direction below stays centered on that same point instead of drifting off to one side the way a fixed pixel margin would */
.stage-arrow::before {

    content: '';
    position: absolute;
    top: 50%;
    left: 50%;
    width: 10px;
    height: 10px;
    border-top: 2px solid #fff;
    border-right: 2px solid #fff;

}

/* the left arrow sits on the banner's left edge, its chevron rotated to point left */
.stage-arrow-prev {

    left: 1rem;

}

/* I rotate this arrow's chevron -135deg so the same corner shape from .stage-arrow::before points left instead of down-right */
.stage-arrow-prev::before {

    transform: translate(-50%, -50%) rotate(-135deg);

}

/* the right arrow sits on the opposite edge, its chevron rotated the other way so it points right */
.stage-arrow-next {

    right: 1rem;

}

/* I rotate this arrow's chevron the other way, 45deg, so it points right instead of left */
.stage-arrow-next::before {

    transform: translate(-50%, -50%) rotate(45deg);

}

/* I fade the arrow in on three occasions: hovering the arrow itself, giving it keyboard focus, or — the "near" class processing.js adds and removes as the mouse moves — getting close to that side's edge of the banner. moving the mouse anywhere else on the banner leaves it hidden */
.stage-arrow:hover,
.stage-arrow:focus-visible,
.stage-arrow.near {

    opacity: 1;

}

/* a touch screen has no real hover state to reveal an invisible button with, so on a device that can't hover at all i keep the arrows visible all the time instead of hiding a control a touch visitor could never bring back */
@media (hover: none) {

    .stage-arrow {

        opacity: 1;

    }

}

/* I shrink the arrows a little on a narrow phone screen, so they take up less of the banner's own width */
@media (max-width: 480px) {

    .stage-arrow {

        width: 32px;
        height: 32px;

    }

}

/* i use .feature-card for the actual project text, positioned on top of .feature-wrap's background. inset:0 is shorthand for setting top, right, bottom, AND left all to 0 in one line — combined with position:absolute, that stretches this element to exactly fill its positioned ancestor (.feature-wrap) completely */
.feature-card {

    /* i stretch it to fill .feature-wrap completely, with my own padding inside so the text doesn't touch the edges. i bumped the padding up from 2rem now that this card is a bigger banner instead of a small box */
    position: absolute;
    inset: 0;
    padding: 2.5rem 3rem;

    /* justify-content:flex-end in a column flex container pushes all the content down to the bottom of the box instead of the top or center — so the title/text sit low, near the bottom edge, with empty space above them */
    display: flex;
    flex-direction: column;
    justify-content: flex-end;

}

/* i pin this small badge to the top right corner of the feature card, styled like a little pill-shaped tag */
.feature-card .pin-badge {

    position: absolute;
    top: 1rem;
    right: 1rem;
    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.6rem;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--text);
    border: 1px solid rgba(4, 22, 66, 0.35);
    background: rgba(5, 5, 6, 0.5);
    padding: 0.25rem 0.55rem;
    border-radius: 999px;

}

/* i style the small eyebrow-style tag label that sits above the project title in the feature card */
.feature-card .tag {

    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.66rem;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--text);

}

/* i style the project title itself, my serif display font at a large size. font-size uses clamp so it scales up a little on a wider screen instead of staying one fixed size, since this card is much bigger than it used to be */
.feature-card h2 {

    font-family: 'Fraunces', serif;
    font-weight: 700;
    font-size: clamp(1.6rem, 2.6vw, 2.1rem);
    margin: 0.5rem 0 0.5rem;

}

/* i make the project title a <button> (so it's clickable to open the full project page) wrapped inside this h2 — buttons center their text by default, so this puts it back to the left like normal heading text */
.feature-card h2 button {

    text-align: left;

}

/* the title stays my regular text color on hover now, the cursor turning into a pointer is the cue that it's clickable */
.feature-card h2 button:hover {

    color: var(--text);

}

/* i used to cap this paragraph at about 44ch per line, which is a comfortable reading measure, but on
   this card it cost me six lines of text and every one of those lines is a line of my own screenshot
   a visitor cannot see. it runs the width of the card now, which lands it in two or three lines and
   hands the height back to the picture. 78ch is still short of the point where a line gets hard to
   track back from, so it reads fine */
.feature-card p {

    font-size: 0.86rem;
    color: #C9CCD3;
    line-height: 1.55;
    margin: 0 0 0.25rem;
    max-width: 78ch;

    /* i hold this to three lines on this card and this card only. the point of the banner is the
       screenshot behind it, and an unbounded blurb takes as many lines as the card is narrow, which
       on a small window swallowed most of the picture. three lines is enough to say what the project
       is, and the whole blurb is still there in full further down the page and on the project's own
       page, so nothing is actually lost. the two -webkit- properties are not optional here: the line
       clamp only works inside a -webkit-box, which is how every browser implements it */
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;

}

/* i style the small date line under the project description */
.feature-card .date {

    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.66rem;
    color: #8a8f99;
    margin-top: 0.6rem;

}

/* will-change is a hint to the browser: "this property is about to be animated a lot, so prepare for it in advance" — it can make animations smoother by letting the browser optimize ahead of time, but I only use it on things that actually animate (via GSAP in processing.js later), since overusing it can waste memory */
.card-meta {

    will-change: opacity, filter, transform;

}

/* I will split the project title into one span per letter with processing.js, so each character can fade in/out on its own slightly staggered from its neighbors, rather than the whole word swapping at once */
.morph-title {

    display: inline-block;

}

/* I style each individual letter span that processing.js creates inside the title */
.morph-title .ch {

    display: inline-block;
    will-change: opacity, filter, transform;

}

/* I lay my little progress dots out in a centered row under the feature card */
.progress-dots {

    display: flex;
    justify-content: center;
    gap: 0.5rem;
    margin-top: 2rem;

}

/* I size each dot as a tiny circle, dim by default */
.progress-dots .dot {

    width: 6px;
    height: 6px;
    border-radius: 50%;
    background: rgba(4, 22, 66, 0.35);
    transition: background 0.25s ease, transform 0.25s ease;

}

/* I brighten and enlarge whichever dot processing.js marks active, to show which project is showing */
.progress-dots .dot.active {

    background: var(--highlight);
    transform: scale(1.4);

}

/* I give my second stage its own set of progress dots, floating over the full-bleed background near the bottom */
#progressDots2 {

    position: absolute;
    bottom: 2.5rem;
    left: 50%;
    transform: translateX(-50%);
    z-index: 5;
    margin-top: 0;

}

/* ---------- full-page project view (opened by clicking any project title) ---------- */

.view.fullpage {

    min-height: 100vh;

    /* the same safari address-bar fix i used on my hero and my crossfade stage, so a project page cannot resize out from under a visitor mid-scroll either */
    min-height: 100svh;

    /* I use 3-value padding again here: 5rem top, 1.5rem left-and-right, 5rem bottom */
    padding: 5rem 1.5rem 5rem;

}

/* I cap the project page's content to a readable width and center it on the page */
.project-page-inner {

    /* this used to hold a project to a 760px column down the middle of the window, which is a fine
       measure for prose but left two thirds of a wide screen empty and squeezed my screenshots into
       a thumbnail beside it. the page takes the width it is given now, with a ceiling high enough
       that the layout does not stretch absurdly on an ultrawide monitor */
    max-width: 1500px;
    margin: 0 auto;
    padding: 0 2rem;

}

/* I style my "back to home" pill-shaped button that sits at the top of a full-page project view */
.back-btn {

    background: none;
    border: 1px solid var(--border);
    border-radius: 999px;
    padding: 0.5rem 1rem;
    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.7rem;
    color: var(--dim);
    cursor: pointer;
    margin-bottom: 2.5rem;
    transition: color 0.2s ease, border-color 0.2s ease;

    /* I reuse this same class on a real <a> link (the "Back to home" links on Privacy/Terms) as well as a <button> — display:inline-block and text-decoration:none make sure it looks identical either way, since a plain <a> would otherwise render as underlined inline text instead of matching the button's boxed look */
    display: inline-block;
    text-decoration: none;

}

/* I tint the back button with my accent color on hover, border included */
.back-btn:hover {

    color: var(--accent);
    border-color: rgba(26, 29, 84, 0.4);

}

/* I give the hero banner at the top of a project page rounded corners, my dark panel background, and the same two-layer gradient tint I use elsewhere on project cards */
.project-hero {

    border-radius: 22px;
    padding: 3rem 2rem;
    margin-bottom: 2rem;
    background: var(--panel);
    border: 1px solid var(--border);
    background-image: linear-gradient(0deg, rgba(5, 5, 6, 0.92), rgba(5, 5, 6, 0.15)), linear-gradient(135deg, rgba(6, 14, 242, 0.3), rgba(9, 5, 200, 0.05));

}

/* I style the small eyebrow tag above the project hero title */
.project-hero .project-hero-tag {

    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.72rem;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--accent);

}

/* I size the project hero's title with clamp so it scales with the viewport */
.project-hero h1 {

    font-family: 'Fraunces', serif;
    font-weight: 700;
    font-size: clamp(1.8rem, 4vw, 2.6rem);
    margin: 0.6rem 0 0.6rem;

}

/* I style the small date line under the project hero title */
.project-hero .project-hero-date {

    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.7rem;
    color: #8a8f99;

}

/* I cap the project body text to a comfortable reading width, same idea as my other body copy */
.project-body p {

    font-size: 0.95rem;
    color: #C9CCD3;
    line-height: 1.7;
    max-width: 60ch;

}

/* I lay out my three project meta stats (like role, stack, year) in an even 3 column grid */
.project-meta-row {

    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 0.75rem;
    margin-top: 2rem;

}

/* I give each meta stat its own bordered, rounded, centered box */
.project-meta-row .meta {

    border: 1px solid var(--border);
    border-radius: 12px;
    padding: 0.8rem;
    text-align: center;

}

/* I style the small uppercase label above each meta stat's value */
.project-meta-row .k {

    display: block;
    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.6rem;
    text-transform: uppercase;
    color: var(--dim);
    letter-spacing: 0.06em;

}

/* I style the actual value text under each meta stat's label */
.project-meta-row .v {

    display: block;
    font-size: 0.85rem;
    font-weight: 600;
    margin-top: 0.25rem;

}

/* ---------- Privacy Policy / Terms & Conditions ---------- */
/* I reuse .view.fullpage, .project-page-inner, and .back-btn from directly above — same full-height padding, centered column, and back link, just with plainer text styling suited to legal copy instead of a project's big gradient hero */

.legal-page {

    max-width: 700px;
    margin: 0 auto;

}

/* I style the small eyebrow tag above a legal page's title */
.legal-page .legal-tag {

    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.72rem;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    color: var(--accent);

}

/* I size a legal page's title with clamp so it scales with the viewport */
.legal-page h1 {

    font-family: 'Fraunces', serif;
    font-weight: 700;
    font-size: clamp(1.8rem, 4vw, 2.4rem);
    margin: 0.6rem 0 0.3rem;

}

/* I style the small "last updated" line under a legal page's title */
.legal-page .legal-updated {

    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.7rem;
    color: #8a8f99;
    margin-bottom: 2.5rem;

}

/* I style each section heading within the legal body copy */
.legal-body h2 {

    font-family: 'Fraunces', serif;
    font-weight: 700;
    font-size: 1.15rem;
    margin: 2.2rem 0 0.7rem;

}

/* I cap the legal body paragraphs to a comfortable reading width */
.legal-body p {

    font-size: 0.92rem;
    color: #C9CCD3;
    line-height: 1.7;
    max-width: 65ch;
    margin: 0 0 1rem;

}

/* I tint any link inside the legal body copy with my accent color */
.legal-body a {

    color: var(--accent);

}

/* I give bulleted lists in the legal body copy a little left padding for the bullets, and matching bottom margin */
.legal-body ul {

    padding-left: 1.1rem;
    margin: 0 0 1rem;

}

/* I style each individual bullet item the same way as my body paragraphs */
.legal-body li {

    font-size: 0.92rem;
    color: #C9CCD3;
    line-height: 1.7;
    max-width: 62ch;
    margin-bottom: 0.4rem;

}

/* ---------- shared footer ---------- */
/* I place this once, after every .screen, rather than duplicating it inside each one — since only one .screen is ever visible at a time, whichever footer follows just naturally renders under whatever screen is currently showing, no JS needed */

.site-footer {

    border-top: 1px solid var(--border);
    margin-top: 3rem;

}

/* I cap the footer's inner content to a readable width, centered, with some padding */
.footer-inner {

    max-width: 1000px;
    margin: 0 auto;
    padding: 1.6rem 1.5rem;

    /* I add flex-wrap:wrap so the copyright text and links can drop to a second line on a narrow screen instead of getting forced into one row and overflowing or squeezing painfully tight */
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: 0.8rem;

}

/* I style the small copyright text in the footer */
.footer-copy {

    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.68rem;
    color: var(--dim);

}

/* I reuse the .navlinks class name for a second, deliberately quieter-looking nav in my footer — reusing the name means the shared JS click-handling (which looks for anything with a data-screen attribute) picks these up automatically too, without needing separate logic */
.site-footer .navlinks {

    display: flex;
    gap: 1.2rem;
    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.68rem;
    letter-spacing: 0.03em;

}

/* I style each footer nav link as plain dim text with no underline by default */
.site-footer .navlinks a {

    color: var(--dim);
    text-decoration: none;
    transition: color 0.2s ease;

}

/* I tint a footer nav link with my accent color on hover, or whenever it points at the screen currently showing */
.site-footer .navlinks a:hover,
.site-footer .navlinks a.active {

    color: var(--accent);

}

/* ---------- Posts: placeholder slot on Home ---------- */

.home-posts {

    max-width: 900px;
    margin: 0 auto;
    padding: 1rem 1.25rem 4rem;

}

/* I style this as a placeholder box until real posts/writing content exists here */
.posts-pending {

    /* "dashed" is a border-style value — like "solid" but drawn as a dashed line, a common visual convention for "this is a placeholder/incomplete area" */
    border: 1px dashed rgba(255, 255, 255, 0.14);
    border-radius: 16px;

    /* I center the placeholder text and keep it small and dim, so it reads as a quiet placeholder rather than a big statement */
    padding: 2.4rem 1.25rem;
    text-align: center;
    color: var(--dim);
    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.72rem;
    letter-spacing: 0.03em;

}

/* i let this section grow to fit its content and scroll naturally on every screen size, not just mobile, since my real contact form is now taller than a single screen on most desktops too */
.about {

    height: auto;
    max-height: none;
    overflow: visible;
    box-sizing: border-box;

    display: block;

    /* i kept the bottom padding at the same minimum the floating ticker clearance uses everywhere else on my site, so my last contact row does not end up hidden behind it */
    padding: clamp(4rem, 11vh, 6rem) 1.25rem clamp(6.5rem, 12vh, 7.5rem);
    scroll-margin-top: 90px;

}

/* i am giving both my hero and my contact anchor the same scroll offset, so a jump to either one lands below my fixed nav bar instead of underneath it */
.hero,
#contact {

    scroll-margin-top: 90px;

}

/* i am laying out the about screen as a 2 column grid, my narrower left column (timezone plus hobbies panels) next to a wider right column i will use for the profile card */
.grid {

    width: 100%;
    max-width: 900px;
    margin: 0 auto;

    display: grid;
    grid-template-columns: minmax(240px, 300px) 1fr;
    gap: clamp(0.7rem, 2.2vh, 1.25rem);
    align-items: start;

}

/* below 720px i drop to a single column, and i move my profile card to the front so a visitor sees my photo and name first instead of the timezone widget */
@media (max-width: 720px) {

    .grid {

        grid-template-columns: 1fr;

    }

    .profile {

        order: -1;

    }

}

/* my shared card look, reused by both left column panels and by the profile card on the right */
.panel {

    background: rgba(255, 255, 255, 0.02);
    border: 1px solid rgba(255, 255, 255, 0.08);
    border-radius: 20px;
    box-shadow: 0 16px 40px rgba(0, 0, 0, 0.6), inset 0 1px 1px rgba(255, 255, 255, 0.05);

    /* clamp instead of a fixed value so this shrinks on a shorter viewport rather than pushing the about screen taller than 100dvh */
    padding: clamp(0.65rem, 1.4vh, 1.5rem) clamp(1rem, 2.6vw, 1.4rem);

}

/* my left column, stacking the timezone panel above the hobbies panel */
.left {

    display: flex;
    flex-direction: column;
    gap: clamp(0.55rem, 1.8vh, 1.1rem);

}

/* my small uppercase label style, used above both the timezone widget and the hobbies list, and also above my contact rows in the profile card. i am leaving this unscoped on purpose since projects .eyebrow already wins inside the projects section no matter what order the rules sit in */
.eyebrow {

    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.68rem;
    letter-spacing: 0.14em;
    text-transform: uppercase;
    color: var(--dim);
    display: flex;
    align-items: center;
    gap: 0.4rem;
    margin-bottom: clamp(0.25rem, 0.6vh, 0.5rem);

}

/* sizes the small icon that can sit inside one of my eyebrow labels */
.eyebrow svg {

    width: 13px;
    height: 13px;

}

/* my timezone widget, the big clock style number at the top of the left column */
.clock {

    font-family: 'IBM Plex Mono', monospace;
    font-weight: 600;
    font-size: clamp(1.5rem, 4.4vh, 2.3rem);
    letter-spacing: -0.01em;
    color: #fff;
    display: flex;
    align-items: baseline;
    gap: 0.15rem;

}

/* the seconds portion of my clock, its own span so I can color just it red, separately from the hour and minute next to it */
.clock .seconds {

    color: #ff4136;

}

/* the small timezone abbreviation sitting next to the clock number */
.clock .zone {

    font-size: 0.85rem;
    color: var(--accent);
    margin-left: 0.3rem;
    font-weight: 500;

}

/* the date line under my clock */
.date {

    font-size: 0.82rem;
    color: var(--dim);
    margin-top: 0.5rem;

}

/* my city and country line under the date */
.place {

    font-size: 0.82rem;
    color: #9CA3AF;
    margin-top: 0.15rem;
    font-weight: 500;

}

/* the column that stacks my rows, used both for the hobbies list and for the contact rows in my profile card */
.rows {

    display: flex;
    flex-direction: column;
    gap: clamp(0.2rem, 0.4vh, 0.45rem);

}

/* my shared row style, one icon plus a label and a subline, reused for both a hobby row and a contact row */
.lrow {

    display: flex;
    align-items: center;
    gap: 0.85rem;
    padding: clamp(0.25rem, 0.5vh, 0.6rem) 0.85rem;
    border-radius: 13px;
    background: rgba(255, 255, 255, 0.025);
    border: 1px solid rgba(255, 255, 255, 0.06);

}

/* the small square icon tile on the left of each row */
.lrow .icon {

    flex: none;
    border-radius: 9px;
    width: clamp(22px, 3.6vh, 30px);
    height: clamp(22px, 3.6vh, 30px);
    background: #111214;
    border: 1px solid rgba(255, 255, 255, 0.05);
    display: flex;
    align-items: center;
    justify-content: center;
    color: var(--accent);

}

/* wraps the label and subline text so they stack instead of sitting side by side */
.lrow .text {

    display: flex;
    flex-direction: column;
    min-width: 0;

}

/* my row's bold top line, for example a hobby name or a contact method name */
.lrow .label {

    font-size: 0.8rem;
    font-weight: 700;
    color: #EDEAE3;

}

/* my row's smaller second line underneath the label. text-overflow ellipsis plus white-space nowrap means a value too long for the row gets cut short with 3 dots instead of wrapping or overflowing */
.lrow .sub {

    font-size: 0.72rem;
    color: var(--dim);
    margin-top: 0.1rem;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;

}

/* my contact rows in the profile card are real clickable links, so this variant adds a hover state and a trailing arrow on top of the shared row look above */
.lrow.link {

    justify-content: space-between;
    text-decoration: none;
    transition: border-color 0.25s ease, background 0.25s ease;

}

/* i lighten the border and background of a contact row slightly on hover, as visual feedback that the whole row is clickable */
.lrow.link:hover {

    border-color: rgba(255, 255, 255, 0.16);
    background: rgba(255, 255, 255, 0.05);

}

/* i tint the icon with my accent color too, matching the hover state on its parent row */
.lrow.link:hover .icon {

    color: var(--accent);

}

/* i set flex-direction row explicitly here, because without it this collided with my unrelated top-level left class further up, which stacks flex-direction column, and that was stacking my icon above my label instead of beside it */
.lrow .left {

    display: flex;
    flex-direction: row;
    align-items: center;
    gap: 0.85rem;
    min-width: 0;

}

/* the small trailing arrow on a contact row */
.lrow .arrow {

    flex: none;
    color: #4B5563;
    transition: transform 0.25s ease, color 0.25s ease;

}

/* on hover i brighten the arrow and nudge it up and to the right, a small directional cue that the row is clickable */
.lrow.link:hover .arrow {

    color: #9CA3AF;
    transform: translate(2px, -2px);

}

/* my profile card centers its children, so this row list needs its own explicit width, or it would shrink to hug its own text instead of spanning the card */
.profile .rows {

    width: 100%;
    max-width: 340px;
    text-align: left;

}

/* my profile card itself, the right column of the about grid */
.profile {

    display: flex;
    flex-direction: column;
    align-items: center;
    text-align: center;
    padding-top: clamp(0.2rem, 0.6vh, 0.4rem);

}

/* wraps my avatar circle so i have a positioned box to place the glow behind it */
.avatar-wrap {

    position: relative;
    width: 60px;
    height: 60px;
    /* margin-bottom: clamp(0.25rem, 0.7vh, 0.8rem);
    width: clamp(52px, 7vh, 132px);
    height: clamp(52px, 7vh, 132px); */
    margin-bottom: 0.5rem;
    flex: 0 0 60px;

}

/* a soft blurred glow sitting behind my avatar circle */
.avatar-glow {

    position: absolute;
    inset: -10px;
    border-radius: 50%;
    width: 80px;
    height: 80px;
    border-radius: 50%;

    background: radial-gradient(circle, rgba(10, 11, 101, 0.35), transparent 70%);
    filter: blur(6px);
    pointer-events: none;

}

/* my avatar circle, now showing my real profile photo instead of the initials placeholder it used to show */
.avatar {

    position: relative;
    width: 60px;
    height: 60px;
    min-width: 60px;
    max-width: 60px;
    min-height: 60px;
    max-height: 60px;
    border-radius: 50%;
    overflow: hidden;
    display: block;

    /* this said flex-shrink: 0 0 60px, which is not a valid value for that property at all. it was
       reaching for the flex shorthand my avatar-wrap uses, and browsers dropped the whole line, so
       the circle was only holding its size because of the fixed width and height above. flex-shrink
       on its own is what i actually meant: never let this shrink */
    flex-shrink: 0;

    /* it is a button now, and a button brings its own padding and its own font with it */
    padding: 0;
    font: inherit;

    /* the cursor says the photo opens, the same cue any other profile picture gives */
    cursor: zoom-in;
    background: #14151a;
    border: 1px solid rgba(255, 255, 255, 0.12);
    box-sizing: border-box;
    align-items: center;
    justify-content: center;
    font-family: 'Fraunces', serif;
    font-weight: 600;
    font-size: clamp(1rem, 3vh, 2.1rem);
    color: #8b90a0;
    
}

/* i size my actual profile photo to fill the avatar circle completely and crop it to match, instead of letting it distort or spill outside the circle */
.avatar img {

    display: block;
    width: 100%;
    height: 100%;
    min-width: 0;
    min-height: 0;
    max-width: 100%;
    max-height: 100%;
    object-fit: cover;
    object-position: center;
    border-radius: 50%;

}

/* the small caption under my avatar */
.avatar-caption {

    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.62rem;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: #4B5563;
    margin-bottom: clamp(0.3rem, 0.8vh, 0.7rem);

}

/* my name, shown big in the profile card */
.profile h1 {

    font-family: 'Fraunces', serif;
    font-weight: 600;
    font-size: clamp(1.35rem, 3.6vh, 2rem);
    margin: 0 0 0.2rem;

}

/* my role line under my name */
.role {

    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.85rem;
    color: var(--accent);
    letter-spacing: 0.02em;

}

/* my short tagline under my role. max-width is set in px instead of ch here, because i measured the actual rendered height at different widths against my real tagline text, and a ch based width still rendered 3 lines instead of the 2 i wanted */
.tagline {

    font-size: 0.88rem;
    color: #9CA3AF;
    line-height: 1.5;
    margin-top: clamp(0.25rem, 0.7vh, 0.6rem);
    margin-bottom: 0;
    max-width: 465px;

}

/* a thin divider line, used twice in my profile card, once before stats and once before contact */
.divider {

    height: 1px;
    background: rgba(255, 255, 255, 0.08);
    margin: clamp(0.3rem, 0.8vh, 0.7rem) 0;

}

/* my 3 column stat grid, sitting between the two dividers */
.stats {

    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 0.6rem;
    width: 100%;
    max-width: 340px;

}

/* one stat capsule */
.stat {

    background: rgba(255, 255, 255, 0.03);
    border: 1px solid rgba(255, 255, 255, 0.06);
    border-radius: 11px;
    text-align: center;
    padding: clamp(0.25rem, 0.5vh, 0.4rem);

}

/* the small label on a stat, for example years or projects */
.stat .k {

    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.6rem;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--dim);

}

/* the actual number or value on a stat */
.stat .v {

    font-size: 0.82rem;
    font-weight: 700;
    margin-top: 0.2rem;

}

/* i am styling the floating tech-stack ticker that stays pinned near the bottom of the screen the whole time. left 50 percent plus translatex negative 50 percent centers it horizontally regardless of window width */
.ticker-wrap {

    position: fixed;
    left: 50%;
    transform: translateX(-50%);
    bottom: 44px;

    /* 276px rather than 320px, and i measured this on my own machine rather than working it out on paper.
       at 320px the bar was wide enough for eight or nine whole icons and never once showed seven. i sampled
       the visible count across a full scroll cycle at 272, 276 and 280, and 276 was the only one that held
       seven every time without ever dipping to six */
    width: min(276px, calc(100% - 40px));

    /* back to holding nothing but the icons. it grew to 74px when the label lived inside it, and the
       label has now moved out into its own pill floating above, so the bar gets that height back */
    height: 52px;

    /* the strip itself is a plain rounded bar again. my label no longer sits in a raised plateau cut out
       of this shape, it floats in its own small pill further down, so there is nothing here that a border
       cannot trace and the hairline comes back */
    border-radius: 18px;
    background: rgba(255, 255, 255, 0.04);
    border: 1px solid rgba(255, 255, 255, 0.08);

    /* safari needs its own -webkit- prefixed copy of this property, the same as my nav bar and my back-to-highlights button */
    -webkit-backdrop-filter: blur(14px);
    backdrop-filter: blur(14px);
    box-shadow: 0 12px 34px rgba(0, 0, 0, 0.55);

    /* the bar deliberately does not hide its overflow any more. my label pill hangs out of the top of it
       and hidden would have chopped the pill in half. the inner viewport below does the clipping instead */
    overflow: visible;

    /* i animate both bottom and width smoothly, since processing.js will later toggle this ticker between its normal position and a narrower docked position under my hero tagline */
    transition: bottom 0.5s cubic-bezier(0.4, 0, 0.2, 1), width 0.5s cubic-bezier(0.4, 0, 0.2, 1);

}

/* the docked variant that processing.js toggles on while the ticker sits under my hero tagline. it used to
   narrow the bar, but narrowing it now would drop the strip to six icons, so it carries the same width and
   the docking reads through the change in position instead. i am keeping the hook here so the class still
   has somewhere to land if i ever want the docked bar to look different again */
.ticker-wrap.docked-tag {

    width: min(276px, calc(100% - 40px));

}

/* the small label that sits above my scrolling icons, so the strip explains itself */
.ticker-label {

    /* i lift the pill clean out of the bar and park it just above, so the two are separate objects that
       happen to sit together rather than one containing the other */
    position: absolute;
    bottom: calc(100% + 7px);
    left: 50%;
    transform: translateX(-50%);

    /* it takes exactly the width its own letters need and not a pixel more */
    width: max-content;
    max-width: calc(100% - 16px);

    /* its own frosted fill and hairline, a touch brighter than the bar below so the two read apart */
    background: rgba(255, 255, 255, 0.07);
    border: 1px solid rgba(255, 255, 255, 0.12);

    /* safari wants its own prefixed copy of this, same as everywhere else i blur something */
    -webkit-backdrop-filter: blur(14px);
    backdrop-filter: blur(14px);

    /* fully round ends, which is what makes it a pill rather than a box */
    border-radius: 999px;

    /* barely any padding, so the pill hugs the letters instead of floating around them */
    padding: 0 8px;

    /* thin, just tall enough to hold the type */
    height: 16px;
    display: flex;
    align-items: center;
    justify-content: center;
    box-sizing: border-box;
    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.56rem;
    text-transform: uppercase;
    letter-spacing: 0.14em;
    color: var(--dim);

    /* i let clicks and hovers pass straight through the label to the ticker underneath, so it never blocks the pause-on-hover behaviour */
    pointer-events: none;

}

/* the box that hides my overflowing icons. this job used to belong to the bar itself, but the bar now
   has to let my label pill hang out of its top edge, so the clipping lives in here where it only ever
   affects the icons. inherit keeps its corners matching the bar's own rounding exactly */
.ticker-viewport {

    height: 100%;
    overflow: hidden;
    border-radius: inherit;

}

/* the inner track that actually holds and scrolls the tech logo tiles. width max-content lets it grow as wide as all its tiles need, since processing.js scrolls it by nudging its position every frame instead of using a css keyframe animation */
.ticker-track {

    display: flex;
    align-items: center;

    /* a mask fades the tiles to transparent right at each edge, so a tile scrolling in or out fades
       instead of getting a hard visual cut. this used to sit on the wrapper, but the wrapper now carries
       my outline, and a fade there ate both ends of it */
    -webkit-mask-image: linear-gradient(90deg, transparent 0, #000 24px, #000 calc(100% - 24px), transparent 100%);
    mask-image: linear-gradient(90deg, transparent 0, #000 24px, #000 calc(100% - 24px), transparent 100%);

    /* the label no longer takes a slice off the top, so the icons get the bar's full height */
    height: 100%;
    width: max-content;
    padding: 0;
    will-change: transform;

}

/* i split my ticker into two of these groups, each one holding one full copy of my tile list, so i can measure a single copy's width for a seamless wrap instead of measuring the whole doubled-up track */
.ticker-group {

    display: flex;
    align-items: center;

    /* this pairs with the 30px tile below to put 35px between one tile's centre and the next. i did not
       pick that by eye. a scrolling strip can never hold one fixed count, so i sampled the visible icons
       sixty times across a full scroll cycle at each candidate size, and 30 by 5 was the only pairing
       that showed seven whole icons 97 percent of the time and never dropped to six */
    gap: 5px;

    flex-shrink: 0;

    /* the side padding is half the gap, so where one copy of my tile list meets the next the spacing
       carries on unbroken instead of doubling up at the seam */
    padding: 0 2.5px;

}

/* if a visitor's system is set to reduce motion, i freeze the ticker in place instead of scrolling it */
@media (prefers-reduced-motion: reduce) {

    .ticker-track {

        transform: none !important;

    }

}

/* one square tile in the ticker, holding a single tech logo */
.tile {

    flex: none;

    /* 36px down to 30px, the other half of reliably fitting seven icons rather than six */
    width: 30px;
    height: 30px;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: default;

}

/* sizes the actual logo svg inside a tile. i round the corners slightly so any logo with a square background of its own still matches the rest of the page */
.tile svg {

    /* the artwork shrinks with its tile, keeping the same breathing room inside it as before */
    width: 26px;
    height: 26px;
    border-radius: 6px;
    overflow: hidden;

}

/* i am styling the single shared tooltip that pops up over whichever ticker tile a visitor hovers, repositioned and refilled by processing.js instead of building one tooltip per tile */
#stack-tooltip {

    position: fixed;
    width: 230px;
    max-width: calc(100vw - 24px);
    background: #14161c;
    border: 1px solid rgba(255, 255, 255, 0.1);
    border-radius: 26px;
    box-shadow: 0 16px 40px rgba(0, 0, 0, 0.55);
    padding: 0.9rem 1.1rem;
    opacity: 0;

    /* i anchor this by its bottom edge, so it always grows upward from the tile no matter how tall its content is or how close to the bottom of the screen the ticker sits */
    transform-origin: bottom center;
    transform: translate(-50%, -6px) scale(0.55);
    transition: opacity 0.2s ease, transform 0.38s cubic-bezier(0.34, 1.56, 0.64, 1);
    pointer-events: none;
    z-index: 50;

}

/* processing.js adds this class on hover. the overshoot in the transition curve above is what sells the popped out feel, and reversing this same transition on mouseleave is what makes it look like it sinks back in instead of just fading */
#stack-tooltip.visible {

    opacity: 1;
    transform: translate(-50%, 0) scale(1);

}

/* the tech name inside my tooltip */
#stack-tooltip .tt-name {

    font-family: 'Fraunces', serif;
    font-weight: 600;
    font-size: 0.95rem;
    color: #EDEAE3;

}

/* the short description line under the name */
#stack-tooltip .tt-desc {

    font-size: 0.76rem;
    color: #9CA3AF;
    line-height: 1.5;
    margin-top: 0.35rem;

}

/* the row holding my usage note and my usage percentage side by side */
#stack-tooltip .tt-row {

    display: flex;
    justify-content: space-between;
    align-items: baseline;
    margin-top: 0.6rem;
    padding-top: 0.6rem;
    border-top: 1px solid rgba(255, 255, 255, 0.08);

}

/* the short note on how i use this piece of tech */
#stack-tooltip .tt-use {

    font-size: 0.68rem;
    color: #7C8593;
    max-width: 130px;
    line-height: 1.4;

}

/* the big percentage number, for example how many of my projects use it */
#stack-tooltip .tt-pct {

    font-family: 'IBM Plex Mono', monospace;
    font-weight: 600;
    font-size: 0.95rem;
    color: #08E8DE;
    white-space: nowrap;

}

/* the small label under the percentage number */
#stack-tooltip .tt-pct-label {

    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.58rem;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: #5B6472;
    display: block;
    text-align: right;

}

/* the small triangle pointer under my tooltip, rotated 45 degrees so a plain square reads as a diamond tip pointing down at the tile */
#stack-tooltip .tt-arrow {

    position: absolute;
    bottom: -6px;
    left: 50%;
    transform: translateX(-50%) rotate(45deg);
    width: 11px;
    height: 11px;
    background: #14161c;
    border-right: 1px solid rgba(255, 255, 255, 0.1);
    border-bottom: 1px solid rgba(255, 255, 255, 0.1);

}

/* i style the contact form's text inputs and textarea the same way, since they share the same look */
#contact-form input,
#contact-form textarea {

    width: 100%;
    background: var(--chip-bg, #1a1a1a);
    border: 1px solid var(--panel-border, #333);
    border-radius: 8px;
    padding: 10px 12px;
    color: inherit;
    font-family: inherit;
    font-size: 0.9rem;
    margin-bottom: 12px;
    box-sizing: border-box;

}

/* the message box specifically should not let visitors resize it sideways, only vertically */
#contact-form textarea {

    resize: vertical;

}

/* i give the turnstile widget some breathing room above and below it */
#contact-form .cf-turnstile {

    margin-bottom: 12px;

}

/* the send button matches the look of the other buttons/links on my site */
#contact-form button {

    width: 100%;
    padding: 10px 12px;
    border-radius: 8px;
    border: none;
    background: var(--accent);
    color: #111;
    font-family: inherit;
    font-size: 0.9rem;
    font-weight: 600;
    cursor: pointer;

}

/* the status line under the button, for showing my success or error message after a submit */
#contact-form #cf-status {

    margin-top: 10px;
    font-size: 0.85rem;

}

/* the row of links to each project's source on github, sitting under the status, stack and role boxes */
.project-links {

    display: flex;
    flex-wrap: wrap;
    gap: 0.6rem;
    margin-top: 1.25rem;

}

/* each individual repo link, drawn as an outlined pill rather than a plain underlined link */
.repo-link {

    display: inline-block;
    padding: 0.55rem 1rem;
    border: 1px solid var(--border);
    border-radius: 999px;
    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.72rem;
    letter-spacing: 0.04em;
    color: var(--text);
    text-decoration: none;
    transition: border-color 0.2s ease, background 0.2s ease;

}

/* on hover i tint it with the same navy my highlights section uses, so it reads as clickable */
.repo-link:hover {

    background: rgba(4, 22, 66, 0.85);
    border-color: var(--highlight);

}

/* ---------- seeing it run: device frames on each project page ---------- */

/* the phone's outer shell, with the deep corner rounding that makes it read as a handset */
.demo-body {

    position: relative;
    background: #17171c;
    border: 1px solid #2b2b34;
    border-radius: 26px;
    padding: 8px;

}

/* a soft blinking block while a recording is still playing, so it reads as live rather than frozen.
   this used to be aimed at .demo-term, the terminal the old always-visible demo block drew. that
   block is gone and the replay happens in .demo-open-replay now, so the caret had quietly stopped
   appearing at all and a running recording looked like frozen text. playSession still adds the
   is-playing class exactly as it always did, it was only the selector that had been left behind */
.demo-open-replay.is-playing::after {

    content: '';
    display: inline-block;
    width: 0.5em;
    height: 1em;
    margin-left: 1px;
    background: #6fce8b;
    vertical-align: text-bottom;
    animation: demoCaret 1s steps(2, start) infinite;

}

/* the caret's two state blink */
@keyframes demoCaret {

    50% { opacity: 0; }

}

/* a visitor who has asked for reduced motion gets no blinking caret */
@media (prefers-reduced-motion: reduce) {

    .demo-open-replay.is-playing::after {

        animation: none;

    }

}

/* aspect-ratio only arrived in safari 15, and without it these screens would have no height of
   their own at all, so an older browser gets a plain fixed height instead. the frames end up very
   slightly off their exact proportions there, which is a far better outcome than collapsing flat */
@supports not (aspect-ratio: 16 / 10) {

}

/* on a narrow phone the two frames stack, and the phone frame grows a little so it stays readable */
@media (max-width: 560px) {

}

/* ---------- projects dashboard ---------- */

/* the dashboard is a normal scrolling screen, so i give it the same page gutters my other screens use */
#screen-dashboard {

    /* the bottom figure is the one that matters here. my tech strip floats over the page rather than
       sitting in it, and at 4rem this screen ran out of scroll before my last panel heading had cleared
       the strip, so the words Busiest repositories sat underneath it at every screen size i tested.
       9rem gives the page enough room to scroll that heading up into clear space */
    padding: 7rem 1.5rem 9rem;

}

/* a narrow screen stacks my dashboard panels, which makes the last one much taller, and 9rem ran out of
   scroll before its heading had cleared the floating strip. i give the small sizes more room rather than
   padding every screen out unnecessarily */
@media (max-width: 700px) {

    #screen-dashboard {

        padding-bottom: 14rem;

    }

}

/* i cap the content width so a wide monitor does not stretch my charts into unreadable ribbons */
.dash-inner {

    max-width: 1100px;
    margin: 0 auto;

}

/* the heading block at the top of the dashboard */
.dash-head {

    margin-bottom: 2rem;

}

/* the dashboard's own title */
.dash-head h1 {

    font-size: clamp(1.6rem, 4vw, 2.4rem);
    margin: 0.4rem 0 0.6rem;

}

/* the sentence under the title that says what was counted and when */
.dash-sub {

    color: var(--dim);
    font-size: 0.86rem;
    line-height: 1.6;
    max-width: 62ch;
    margin: 0;

}

/* my headline numbers, laid out as a row that wraps into a grid on narrower screens */
.dash-kpis {

    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
    gap: 0.75rem;
    margin-bottom: 1.5rem;

}

/* one headline number and its label */
.dash-kpi {

    border: 1px solid var(--border);
    border-radius: 14px;
    padding: 1rem 1.1rem;
    background: var(--panel);

}

/* the number itself, sized to be read first */
.dash-kpi-v {

    font-size: 1.7rem;
    font-weight: 600;
    line-height: 1.1;

}

/* the small caption naming what the number counts */
.dash-kpi-k {

    margin-top: 0.35rem;
    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.58rem;
    text-transform: uppercase;
    letter-spacing: 0.09em;
    color: var(--dim);

}

/* a panel holding one chart */
.dash-panel {

    border: 1px solid var(--border);
    border-radius: 14px;
    padding: 1.2rem 1.3rem 1.3rem;
    background: var(--panel);
    margin-bottom: 1rem;

}

/* two panels side by side, dropping to one column when there is no room */
.dash-row {

    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
    gap: 1rem;

}

/* the title above a chart, which is what names the single series so no legend is needed */
.dash-panel-title {

    font-size: 0.95rem;
    font-weight: 600;
    margin: 0 0 0.3rem;

}

/* the smaller line under a chart title giving it context */
.dash-panel-note {

    color: var(--dim);
    font-size: 0.74rem;
    line-height: 1.55;
    margin: 0 0 0.9rem;
    max-width: 70ch;

}

/* the box a chart is drawn into, which also anchors its tooltip */
.dash-chart {

    position: relative;

}

/* the drawing itself, always filling the width it is given */
.dash-svg {

    display: block;
    width: 100%;
    height: auto;
    overflow: visible;

}

/* my axis numbers and labels wear plain text colours, never a series colour */
.dash-axis {

    fill: var(--dim);
    font-family: 'IBM Plex Mono', monospace;
    font-size: 9px;

}

/* the invisible hit areas that make every mark easy to hover */
.dash-hit {

    fill: transparent;

}

/* a bar lifts slightly when the pointer is over it, confirming what is being read */
.dash-hit-bar {

    transition: opacity 0.12s ease;

}

/* on hover the bar dims slightly, confirming which one is under the pointer */
.dash-hit-bar:hover {

    opacity: 0.78;

}

/* the tooltip that reports the exact value under the pointer */
.dash-tip {

    position: absolute;
    pointer-events: none;
    background: #15161b;
    border: 1px solid rgba(255, 255, 255, 0.14);
    border-radius: 8px;
    padding: 0.4rem 0.6rem;
    font-size: 0.72rem;
    line-height: 1.35;
    white-space: nowrap;
    z-index: 5;
    box-shadow: 0 8px 22px rgba(0, 0, 0, 0.5);

}

/* the label line inside a tooltip */
.dash-tip-k {

    display: block;
    color: var(--dim);
    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.62rem;

}

/* the value line inside a tooltip */
.dash-tip-v {

    display: block;
    color: var(--text);
    font-weight: 600;

}

/* my horizontal bar lists, used where the labels are too long to sit under a vertical bar */
.dash-bars {

    display: flex;
    flex-direction: column;
    gap: 0.5rem;

}

/* one row of a horizontal bar list */
.dash-bar-row {

    display: grid;
    grid-template-columns: minmax(0, 9.5rem) 1fr auto;
    align-items: center;
    gap: 0.6rem;

}

/* the name at the start of the row, truncated rather than allowed to wrap the layout apart */
.dash-bar-label {

    font-size: 0.74rem;
    color: var(--text);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;

}

/* the faint track the bar is drawn along */
.dash-bar-track {

    background: rgba(255, 255, 255, 0.05);
    border-radius: 999px;
    height: 9px;
    overflow: hidden;

}

/* the bar itself */
.dash-bar-fill {

    display: block;
    height: 100%;
    border-radius: 999px;

}

/* the number at the end of the row, so the exact value never has to be guessed off the bar */
.dash-bar-value {

    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.7rem;
    color: var(--dim);
    min-width: 2.2rem;
    text-align: right;

}

/* the collapsed table of the same figures, for reading rather than looking */
.dash-table {

    margin-top: 1.4rem;
    border: 1px solid var(--border);
    border-radius: 14px;
    background: var(--panel);
    padding: 0.9rem 1.2rem;

}

/* the line a visitor clicks to open that table */
.dash-table summary {

    cursor: pointer;
    font-size: 0.8rem;
    color: var(--dim);

}

/* each grouping inside the table */
.dash-table h3 {

    font-size: 0.75rem;
    text-transform: uppercase;
    letter-spacing: 0.08em;
    color: var(--dim);
    font-family: 'IBM Plex Mono', monospace;
    margin: 1.2rem 0 0.5rem;

}

/* the list of figures under each grouping */
.dash-table ul {

    list-style: none;
    padding: 0;
    margin: 0;

}

/* one figure, its name on the left and its count on the right */
.dash-table li {

    display: flex;
    justify-content: space-between;
    gap: 1rem;
    padding: 0.3rem 0;
    border-bottom: 1px solid rgba(255, 255, 255, 0.05);
    font-size: 0.78rem;

}

/* on a phone the dashboard gets tighter gutters and slightly smaller headline numbers */
@media (max-width: 560px) {

    #screen-dashboard {

        padding: 6rem 1rem 3rem;

    }

    .dash-kpi-v {

        font-size: 1.4rem;

    }

    .dash-bar-row {

        grid-template-columns: minmax(0, 7rem) 1fr auto;

    }

}

/* ---------- the project page rail: shuffling screens and the demo launcher ---------- */

/* i turn a project's body into two columns, the writing on the left and the rail on the right */
.project-body {

    display: grid;

    /* the right hand column was a fixed 340px, which is why the screenshots were thumbnails. it takes
       a share of the page now instead, so a wider window gives the picture more room rather than
       spending all of it on a longer line of text. the 420px floor keeps it usable on a laptop and
       the 46 percent ceiling stops it from swallowing the words on a very wide one */
    grid-template-columns: minmax(0, 1fr) minmax(420px, 46%);
    gap: 3rem;
    align-items: start;

}

/* the left column holds everything that was already there, and must be allowed to shrink */
.project-col-main {

    min-width: 0;

}

/* the rail itself, which follows the reader down a long page rather than scrolling away */
.project-rail {

    /* older safari only understands the prefixed spelling of this, and a browser that understands
       neither simply leaves the rail where it is, which is a perfectly fine way to read the page */
    position: -webkit-sticky;
    position: sticky;
    top: 96px;
    display: flex;
    flex-direction: column;
    gap: 1.25rem;
    min-width: 0;

}

/* the stack of screens. each one sits on top of the last, so they cross fade in place instead of
   pushing the launcher underneath up and down as they change */
.rail-stack {

    position: relative;
    aspect-ratio: 16 / 11;
    border-radius: 14px;
    overflow: hidden;
    background: rgba(255, 255, 255, 0.03);
    border: 1px solid rgba(255, 255, 255, 0.08);

}

/* older safari has no aspect-ratio, so those versions get a fixed height instead of a collapsed box */
@supports not (aspect-ratio: 16 / 11) {

    .rail-stack {

        height: 230px;

    }

}

/* one screen in the stack, hidden until it is the one showing */
.rail-shot {

    position: absolute;
    inset: 0;
    margin: 0;
    opacity: 0;
    transition: opacity 0.45s ease;
    pointer-events: none;

}

/* the screen currently showing */
.rail-shot.on {

    opacity: 1;

}

/* a real capture fills the frame from the top, since the top of a page is the part worth seeing */
.rail-shot img {

    width: 100%;
    height: 100%;
    object-fit: cover;
    object-position: top center;
    display: block;

}

/* a console screen is rendered as text rather than as a picture, so it stays crisp at any size */
.rail-term {

    margin: 0;
    width: 100%;
    height: 100%;
    padding: 0.85rem 0.95rem;
    box-sizing: border-box;
    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.56rem;
    line-height: 1.45;
    color: #cfe6ea;
    background: #05070a;
    overflow: hidden;
    white-space: pre;

}

/* the caption strip along the bottom of whichever screen is showing */
.rail-shot figcaption {

    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    padding: 0.5rem 0.75rem;
    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.52rem;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    color: var(--dim);
    background: linear-gradient(transparent, rgba(0, 0, 0, 0.85) 55%);

}

/* the row of dots that says how many screens there are and lets a visitor pick one */
.rail-dots {

    position: absolute;
    right: 0.6rem;
    top: 0.6rem;
    display: flex;
    gap: 0.35rem;
    z-index: 2;

}

/* one dot */
.rail-dot {

    width: 6px;
    height: 6px;
    padding: 0;
    border-radius: 50%;
    border: none;
    background: rgba(255, 255, 255, 0.25);
    cursor: pointer;
    transition: background 0.2s ease, transform 0.2s ease;

}

/* the dot for the screen currently showing */
.rail-dot.on {

    background: rgba(255, 255, 255, 0.9);
    transform: scale(1.25);

}

/* the launcher block under the stack */
.rail-launch {

    display: flex;
    flex-direction: column;
    gap: 0.75rem;

}

/* the words above the two buttons */
.rail-launch-title {

    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.62rem;
    text-transform: uppercase;
    letter-spacing: 0.14em;
    color: var(--dim);

}

/* the two buttons sit side by side and share the width evenly */
.rail-launch-btns {

    display: flex;
    gap: 0.6rem;

}

/* one device button */
.rail-btn {

    flex: 1;
    padding: 0.7rem 0.5rem;
    border-radius: 10px;
    border: 1px solid rgba(255, 255, 255, 0.14);
    background: rgba(255, 255, 255, 0.05);
    color: var(--text, #e8e9ec);
    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.62rem;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    cursor: pointer;
    transition: background 0.18s ease, border-color 0.18s ease;

}

/* the hover and keyboard focus state, which must be visible for anyone navigating by keyboard */
.rail-btn:hover,
.rail-btn:focus-visible {

    background: rgba(255, 255, 255, 0.1);
    border-color: rgba(255, 255, 255, 0.3);

}

/* the small status line under the buttons, empty until there is something to say */
.rail-note {

    margin: 0;
    font-size: 0.74rem;
    line-height: 1.5;
    color: var(--dim);
    min-height: 0;

}

/* the same line once it is carrying a refusal, which should read as a warning rather than a note */
.rail-note.warn {

    color: #f0b46b;

}

/* ---------- the demo stage that opens under the buttons ---------- */

/* the whole opened demo, frame and caption together */
.demo-open {

    /* this used to be a small block tucked under the two device buttons. it covers the window now,
       so a demo is something a visitor actually uses rather than something they squint at. fixed
       rather than absolute, because it should stay put over the page no matter where they scrolled
       to before opening it */
    position: fixed;
    inset: 0;
    z-index: 200;

    display: flex;
    flex-direction: column;
    gap: 0.75rem;
    padding: 1rem 1.25rem 1.25rem;
    box-sizing: border-box;

    /* nearly opaque rather than solid, so a sliver of the page underneath still shows through and
       the thing reads as a layer over my site instead of a jump to somewhere else */
    background: rgba(5, 5, 6, 0.97);

    /* safari wants its own prefixed copy of this, the same as everywhere else i blur something */
    -webkit-backdrop-filter: blur(6px);
    backdrop-filter: blur(6px);

    /* it takes keyboard focus when it opens, and a focus ring around the whole window would be odd */
    outline: none;

}

/* the page behind stops scrolling while a demo is over it, so a visitor scrolling inside the demo
   does not find my site sliding about underneath them */
body.demo-is-open {

    overflow: hidden;

}

/* the close button and the link out sit together at the right of the bar across the top */
.demo-open-actions {

    display: flex;
    align-items: center;
    gap: 0.6rem;

}

/* the demo's own address, offered as a plain link for anybody who would rather have it in its own
   tab than in my frame. it only exists when the demo is really hosted somewhere */
.demo-open-out {

    padding: 0.3rem 0.7rem;
    border-radius: 999px;
    border: 1px solid rgba(255, 255, 255, 0.14);
    color: var(--dim);
    text-decoration: none;
    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.54rem;
    text-transform: uppercase;
    letter-spacing: 0.1em;

}

/* the link under the pointer or the keyboard */
.demo-open-out:hover,
.demo-open-out:focus-visible {

    color: var(--text, #e8e9ec);
    border-color: rgba(255, 255, 255, 0.3);

}

/* the bar across the top of it, naming what is open and offering the way out */
.demo-open-head {

    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.75rem;

}

/* what is open */
.demo-open-title {

    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.56rem;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    color: var(--dim);

}

/* the way out */
.demo-close {

    padding: 0.3rem 0.7rem;
    border-radius: 999px;
    border: 1px solid rgba(255, 255, 255, 0.14);
    background: transparent;
    color: var(--dim);
    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.54rem;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    cursor: pointer;

}

/* the close button under the pointer or the keyboard */
.demo-close:hover,
.demo-close:focus-visible {

    color: var(--text, #e8e9ec);
    border-color: rgba(255, 255, 255, 0.3);

}

/* the device shell drawn around the screen */
.demo-shell {

    background: #1b1b21;
    border: 1px solid #2c2c36;
    padding: 10px;
    box-shadow: 0 18px 40px rgba(0, 0, 0, 0.5);

    /* the shell takes whatever height is left between the bar above it and the caption below, rather
       than standing at a fixed size, and centres itself so a phone shaped frame sits in the middle */
    flex: 1 1 auto;
    min-height: 0;
    display: flex;
    margin: 0 auto;
    box-sizing: border-box;

}

/* a desktop demo uses the whole width it is given */
.demo-open.desktop .demo-shell {

    width: 100%;

}

/* a phone demo stays the size of a phone. it used to take whatever height was going, which on a
   laptop drew an 800px tall handset that no phone has ever been, so it is capped at 730px now, the
   height of the screen i am imitating. on a window too short for that it shrinks to fit instead of
   being cut off, and the width follows the height so the shape is right either way. margin auto
   floats it in the middle of the space left over rather than pinning it to the top */
.demo-open.phone .demo-shell {

    flex: 0 1 auto;
    height: min(730px, calc(100svh - 150px));

    /* i let the browser work the width out from the height rather than computing it myself in a
       nested calc. my first attempt did the arithmetic by hand and came out at 280px against a
       687px height, which is a 0.41 shape where a handset is 0.53, so it drew something far too
       narrow. aspect-ratio against the height it already has cannot get that wrong */
    aspect-ratio: 390 / 730;
    width: auto;
    max-width: 100%;
    margin: auto;

}

/* older safari has no aspect-ratio, and those versions get the width worked out the long way rather
   than a frame with no width at all */
@supports not (aspect-ratio: 390 / 730) {

    .demo-open.phone .demo-shell {

        width: 390px;
        max-width: 100%;

    }

}

/* a desktop shell has the squarer corners of a laptop lid */
.demo-open.desktop .demo-shell {

    border-radius: 12px;

}

/* a phone shell is far rounder than a laptop lid. it used to carry a 240px cap and its own centring
   here as well, from when this frame sat inside the 340px rail column and had to be narrower than it.
   the demo covers the window now and works its width out from its height, so both of those were
   fighting the new sizing and holding the handset far too narrow. only the corners belong here */
.demo-open.phone .demo-shell {

    border-radius: 34px;

}

/* the screen inside the shell */
.demo-open-screen {

    position: relative;
    overflow: hidden;
    background: #000;
    border-radius: 6px;

}

/* the screen fills its shell in both cases now. the shape is decided by the shell above, which is
   sized against the window, rather than by an aspect ratio on this box that ignored the window */
.demo-open .demo-open-screen {

    flex: 1 1 auto;
    width: 100%;
    min-height: 0;

}

/* a handset keeps its rounded glass */
.demo-open.phone .demo-open-screen {

    border-radius: 26px;

}

/* the same fallback as above for a browser with no aspect-ratio support */
@supports not (aspect-ratio: 16 / 10) {

    .demo-open.desktop .demo-open-screen {

        height: 260px;

    }

    .demo-open.phone .demo-open-screen {

        height: 420px;

    }

}

/* a hosted demo fills its screen completely and is fully interactive */
.demo-live {

    width: 100%;
    height: 100%;
    border: 0;
    display: block;

}

/* a recorded replay fills it the same way, but is text rather than a live page */
.demo-open-replay {

    margin: 0;
    width: 100%;
    height: 100%;
    padding: 0.8rem;
    box-sizing: border-box;
    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.58rem;
    line-height: 1.5;
    color: #cfe6ea;
    background: #05070a;
    overflow: auto;
    white-space: pre-wrap;

}

/* the message shown when a project has neither a hosted demo nor a recording */
.demo-empty {

    display: flex;
    align-items: center;
    justify-content: center;
    height: 100%;
    padding: 1rem;
    text-align: center;
    color: var(--dim);
    font-size: 0.78rem;

}

/* the line under the frame, which says whether this is live or a recording. it sits at the same size
   as the button text above it, so the rail reads as one block rather than the caption shouting */
.demo-open-caption {

    margin: 0;
    font-size: 0.62rem;
    line-height: 1.5;
    color: var(--dim);

}

/* ---------- narrow screens ---------- */

/* below this width there is no room for two columns, so the rail drops underneath the writing */
@media (max-width: 900px) {

    .project-body {

        grid-template-columns: minmax(0, 1fr);
        gap: 2rem;

    }

    /* and it stops following the reader, since a sticky block on a phone just eats the screen */
    .project-rail {

        position: static;

    }

}

/* the projects section on my home screen. it used to be a stage pinned to the viewport that crossfaded
   between one-screen cards, which is why it never showed more than a title and a sentence per project.
   it is a plain block of page now, so the list inside it can be as tall as it needs to be */
.home-projects {

    /* the same width my project pages use, so a project looks identical in both places */
    max-width: 1500px;
    margin: 0 auto;
    padding: 6rem 2rem 2rem;

}

/* the list itself, one project under the next with real air between them so two never read as one */
.home-projects-list {

    display: flex;
    flex-direction: column;
    gap: 6rem;
    margin-top: 1.5rem;

}

/* one project in that list. it carries no styling of its own on purpose, because everything inside it
   is the same markup my project pages use and is already styled by those rules */
.home-project {

    width: 100%;

}

/* the rail follows the reader down a project page, and it should do the same inside one of these
   blocks, but a block ends where the next project begins, so i stop it a little sooner than the page
   version does rather than letting it ride down into the project below */
.home-project .project-rail {

    top: 88px;

}

/* below this width the two columns cannot stand side by side, so a project stacks its words above its
   demo, the same way a project page already does at this size */
@media (max-width: 860px) {

    .home-projects {

        padding: 4rem 1.25rem 1rem;

    }

    /* less air between projects on a narrow screen, where that gap costs a lot more of the view */
    .home-projects-list {

        gap: 4rem;

    }

}

/* the layer of real project screens that now sits behind a highlights card. it fills the card and
   stays behind everything else on it, which is what the zero stacking order below is for */
.feature-shots {

    position: absolute;
    inset: 0;
    z-index: 0;
    overflow: hidden;

}

/* one screen in that layer. they all sit in the same spot and cross fade between each other, so the
   card never changes height and my words never move while the picture behind them does */
.feature-shot {

    position: absolute;
    inset: 0;
    margin: 0;
    opacity: 0;

    /* a slow fade, slower than the rails use, because this one is behind text and a quick swap back
       there reads as a flicker rather than as a change of picture */
    transition: opacity 0.9s ease;

}

/* the screen currently showing */
.feature-shot.on {

    opacity: 1;

}

/* how a real capture sits in the banner */
.feature-shot img {

    display: block;
    width: 100%;
    height: 100%;
    /* the picture fills the banner edge to edge rather than being letterboxed inside it. i anchor it
       to the top, so what gets trimmed is the empty desk space at the bottom of the window and never
       the part of my app a visitor actually wants to look at */
    object-fit: cover;
    object-position: center top;

}

/* a recorded terminal screen back here is texture rather than something anybody is meant to read,
   so it keeps a small type size. it used to take a light blur as well, but that softened my real
   screenshots along with it, and a screenshot i blurred is a screenshot nobody can see */
.feature-shot .rail-term {

    font-size: 0.6rem;
    padding: 1.4rem 1.6rem;

}

/* the veil. this started as a solid band of my theme navy across the bottom third of the card, which
   made the banner read as a coloured panel with a picture peeking over the top of it. it is a scrim
   now: two translucent washes, one coming in from the left behind my words and one rising off the
   bottom edge, both gone well before they reach the far side. they are the same near black my page
   background already uses rather than my navy, because a coloured scrim tints whatever is under it
   and what is under it is a screenshot i want seen in its own colours */
.feature-veil {

    position: absolute;
    inset: 0;
    z-index: 1;

    /* one ellipse anchored to the bottom left corner, rather than the two straight gradients this
       used to be. those ran the full height and the full width of the card, so what was meant to be
       a patch behind my words became a curtain down the entire left side and a band across the whole
       bottom. a radial fades in every direction at once, which is what makes this read as a corner
       the picture is lifted off, instead of a panel the picture is behind */
    background: radial-gradient(68% 58% at 0% 100%,
        rgba(5, 5, 6, 0.94) 0%,
        rgba(5, 5, 6, 0.82) 32%,
        rgba(5, 5, 6, 0.42) 60%,
        rgba(5, 5, 6, 0.12) 80%,
        rgba(5, 5, 6, 0) 100%);

}

/* everything i actually wrote on the card has to sit above both of those layers. the badge is already
   positioned in its own corner, so it only needs the stacking order, not the position */
.feature-card .tag,
.feature-card h2,
.feature-card p,
.feature-card .date {

    position: relative;
    z-index: 2;

    /* everything i wrote is held to a column down the left, the way a hero banner does it, so the
       right hand side of the card is nothing but screenshot. the 300px floor is not decoration: at
       52 percent of a narrow card the column got tight enough that my longest title broke in the
       middle of a word, and a floor is what stops that without widening the column on a big screen */
    max-width: max(300px, min(52%, 46ch));

}

/* the pinned badge, lifted above the picture the same way */
.feature-card .pin-badge {

    z-index: 2;

}

/* the stack is something a visitor drives now, so it says so under the cursor and takes a focus ring
   when somebody tabs to it. touch-action pan-y is the line that makes swiping work properly: it tells
   the browser i am handling horizontal drags myself and it should keep handling vertical scrolling,
   which stops a swipe across the screens from fighting a scroll down the page */
.rail-stack {

    cursor: grab;
    touch-action: pan-y;

}

/* the ring only shows for a keyboard, never for a mouse click */
.rail-stack:focus-visible {

    outline: 2px solid var(--accent);
    outline-offset: 3px;

}

/* one of the two arrows that sit over the screens. they hold the vertical middle and hug their own
   edge, and they stay out of the way until the pointer is over the stack */
.rail-arrow {

    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    width: 30px;
    height: 30px;
    border-radius: 999px;
    border: 1px solid rgba(255, 255, 255, 0.16);
    background: rgba(5, 5, 6, 0.6);

    /* safari wants its own prefixed copy of this, the same as everywhere else i blur something */
    -webkit-backdrop-filter: blur(8px);
    backdrop-filter: blur(8px);
    cursor: pointer;
    padding: 0;

    /* they sit above the screens and above the caption strip, or a click would land on the picture */
    z-index: 3;

    /* hidden until wanted, rather than sitting on top of my screenshots the whole time */
    opacity: 0;
    transition: opacity 0.25s ease, border-color 0.25s ease, background 0.25s ease;

}

/* the chevron itself, drawn with two borders on a square turned 45 degrees rather than an image */
.rail-arrow::before {

    content: '';
    position: absolute;
    top: 50%;
    left: 50%;
    width: 7px;
    height: 7px;
    border-left: 1.5px solid rgba(255, 255, 255, 0.85);
    border-bottom: 1.5px solid rgba(255, 255, 255, 0.85);

}

/* the back arrow hugs the left edge and its chevron points that way */
.rail-arrow-prev {

    left: 8px;

}

/* turning the square puts the two borders on the left and bottom into a chevron pointing left */
.rail-arrow-prev::before {

    transform: translate(-40%, -50%) rotate(45deg);

}

/* the forward arrow is the same thing mirrored */
.rail-arrow-next {

    right: 8px;

}

/* the same square turned the other way points right */
.rail-arrow-next::before {

    transform: translate(-60%, -50%) rotate(-135deg);

}

/* both arrows come up when the pointer is anywhere over the stack, and whichever one has focus or
   the pointer on it comes up on its own too, so a keyboard never chases an invisible button */
.rail-stack:hover .rail-arrow,
.rail-stack:focus-within .rail-arrow,
.rail-arrow:focus-visible {

    opacity: 1;

}

/* the arrow under the pointer or the keyboard */
.rail-arrow:hover,
.rail-arrow:focus-visible {

    background: rgba(5, 5, 6, 0.85);
    border-color: rgba(255, 255, 255, 0.35);

}

/* on a touch screen there is no hover to reveal them with, so they simply stay visible */
@media (hover: none) {

    .rail-arrow {

        opacity: 1;

    }

}

/* the left column of a project can now be as wide as half a large monitor, and a line of prose that
   long is genuinely hard to read back from. i hold the blurb to a sensible measure inside it while
   the stats and the links underneath still use the full width of the column */
.project-col-main > p {

    max-width: 68ch;

}

/* on a narrow window the two columns cannot stand side by side, so the picture drops below the words
   rather than being squeezed into a strip */
@media (max-width: 900px) {

    .project-body {

        grid-template-columns: minmax(0, 1fr);
        gap: 2rem;

    }

    /* and the page stops reserving side padding it cannot spare */
    .project-page-inner {

        padding: 0 1.25rem;

    }

}

/* the framed demo itself, filling the screen it was given */
.demo-open .demo-live {

    width: 100%;
    height: 100%;
    border: 0;
    display: block;

}

/* a recorded run fills the same space rather than sitting in a short box, and scrolls if the run is
   longer than the window is tall */
.demo-open .demo-open-replay {

    height: 100%;
    overflow: auto;
    box-sizing: border-box;

}

/* the readme note on a project that has not got one yet. it wears the same pill as the real links so
   the row still reads as one set of things, but it is dimmer, it does not react to the pointer, and
   it is a span rather than an anchor, so there is nothing to click and nothing to land on by tab */
.repo-link.is-pending {

    opacity: 0.45;
    cursor: default;
    border-style: dashed;

}

/* the ring when a keyboard lands on my profile photo, never for a mouse click */
.avatar:focus-visible {

    outline: 2px solid var(--accent);
    outline-offset: 3px;

}

/* the layer my profile photo opens into. it covers the window and sits above everything except a
   demo, which is the one thing that should stay on top if both were ever somehow open at once */
.avatar-view {

    position: fixed;
    inset: 0;
    z-index: 190;

    display: flex;
    align-items: center;
    justify-content: center;
    padding: 2rem;
    box-sizing: border-box;

    background: rgba(5, 5, 6, 0.94);

    /* safari wants its own prefixed copy of this, the same as everywhere else i blur something */
    -webkit-backdrop-filter: blur(8px);
    backdrop-filter: blur(8px);

    /* clicking the empty space around the photo closes it, so the whole layer takes pointer events */
    cursor: zoom-out;

}

/* the page behind stops scrolling while the photo is over it */
body.avatar-is-open {

    overflow: hidden;

}

/* the photo itself, as large as it can be without spilling off any edge. the cursor goes back to an
   arrow over the picture, so only the space around it reads as somewhere to click to close */
.avatar-view img {

    max-width: min(100%, 720px);
    max-height: 100%;
    object-fit: contain;
    border-radius: 18px;
    box-shadow: 0 24px 70px rgba(0, 0, 0, 0.6);
    cursor: default;

}

/* the way out, parked in the corner rather than over the photo */
.avatar-view-close {

    position: absolute;
    top: 1rem;
    right: 1.25rem;
    padding: 0.35rem 0.8rem;
    border-radius: 999px;
    border: 1px solid rgba(255, 255, 255, 0.16);
    background: rgba(5, 5, 6, 0.6);
    color: var(--dim);
    font-family: 'IBM Plex Mono', monospace;
    font-size: 0.56rem;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    cursor: pointer;

}

/* the close button under the pointer or the keyboard */
.avatar-view-close:hover,
.avatar-view-close:focus-visible {

    color: var(--text, #e8e9ec);
    border-color: rgba(255, 255, 255, 0.35);

}
