/* Tristan Yan-Klassen — GNC portfolio.

   Structure follows timothycai.com: a hero, a vertical experience timeline with
   company logos, and a portfolio of preview-image cards grouped by status. The
   hero is split rather than centred because the Polaris frame is portrait and
   deserves a column of its own.

   The character is its own: a cool ink palette rather than near-black, Space
   Grotesk set tight for display against Inter for reading, and a two-colour
   accent: ice blue against white, and nothing else. Colour carries structure
   here, not emphasis. */

:root {
    /* Cool ink, not neutral grey — there is a little blue in every step. */
    --bg:       #0c0f14;
    --bg-rgb:   12, 15, 20;   /* --bg in rgba form, for scrims */
    --bg-2:     #11151c;
    --panel:    #161b24;
    --panel-2:  #1c222d;

    --ink:   #e9edf3;
    --body:  #a3adbb;
    --muted: #737e8d;

    --line:      #232a35;
    --line-soft: #1b212b;

    --blue:      #8fb8f0;
    --blue-deep: #6f9fe0;   /* a step down, for the head of the timeline */
    --blue-hi:   #aecdf7;   /* a step up: every hover on a blue thing */

    /* The height of the sticky bar. It is fixed rather than left to the button
       inside it because the top of the page is laid out around it: the whole
       photograph is pulled up under the bar by exactly this much. */
    --nav-h: 68px;

    --radius:    14px;
    --radius-sm: 10px;
    --shadow-lg: 0 1px 2px rgba(0, 0, 0, 0.5), 0 26px 52px -26px rgba(0, 0, 0, 1);

    --display: "Space Grotesk", "Segoe UI", system-ui, sans-serif;
    --text:    "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;

    color-scheme: dark;
}

* { box-sizing: border-box; }

/* Scrollbar hidden, and no gutter reserved for it, so the layout is not offset
   by a track nobody sees. Scrolling itself is untouched. */
html { scrollbar-width: none; scrollbar-gutter: auto; scroll-behavior: smooth; scroll-padding-top: 96px; }
html::-webkit-scrollbar, body::-webkit-scrollbar { width: 0; height: 0; }

@media (prefers-reduced-motion: reduce) {
    html { scroll-behavior: auto; }
}

body {
    margin: 0;
    background: var(--bg);
    color: var(--ink);
    font-family: var(--text);
    font-size: 16px;
    line-height: 1.62;
    -webkit-font-smoothing: antialiased;
}

img { display: block; max-width: 100%; }

a { color: var(--blue); text-decoration: none; transition: color 0.15s ease; }
a:hover { color: var(--blue-hi); }

h1, h2, h3, h4 {
    margin: 0;
    font-family: var(--display);
    font-weight: 700;
    letter-spacing: -0.025em;
    line-height: 1.15;
}

p { margin: 0 0 14px; }
em { font-style: italic; color: var(--ink); }
strong { font-weight: 600; color: var(--ink); }

code {
    font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
    font-size: 0.86em;
    color: var(--blue);
}

.wrap { width: 100%; max-width: 1080px; margin: 0 auto; padding: 0 24px; }

/* Buttons ----------------------------------------------------------------- */

.btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    padding: 11px 22px;
    border-radius: 999px;
    font-size: 0.93rem;
    font-weight: 600;
    border: 1px solid transparent;
    transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}

.btn-solid { background: var(--blue); color: #0b1017; }
.btn-solid:hover { background: var(--blue-hi); color: #0b1017; }

/* Nav --------------------------------------------------------------------- */

/* Tinted rather than filled. The photograph runs up underneath the bar (see
   main's negative margin below), so the sky is always behind it and the bar
   reads as smoked glass at every scroll position instead of as a black strip
   at the top and glass everywhere else. The alpha is as low as the muted link
   colour will tolerate. */
.nav {
    position: sticky;
    top: 0;
    z-index: 40;
    height: var(--nav-h);
    background: rgba(var(--bg-rgb), 0.62);
    backdrop-filter: blur(18px) saturate(118%);
    -webkit-backdrop-filter: blur(18px) saturate(118%);
    border-bottom: 1px solid rgba(233, 237, 243, 0.09);
}

.nav-inner {
    max-width: 1080px;
    height: 100%;
    margin: 0 auto;
    padding: 0 24px;
    display: flex;
    align-items: center;
    gap: 20px;
}

/* The page starts at the top of the window, behind the bar, rather than below
   it. .top-zone gives the height straight back as padding, so every section
   stays exactly where it was and the only thing that changes is that the top
   of the photograph is no longer cut off at the bar. */
main { margin-top: calc(-1 * var(--nav-h)); }

/* Full height of the bar, so the marker can be hung off its bottom edge and
   land exactly on the bar's own border rather than at a guessed offset that
   moves with the font metrics. */
.nav-links { display: flex; align-items: center; gap: 2px; height: 100%;
             margin-left: auto; position: relative; }

/* Slides to whichever section you are in; driven by js/nav.js. It sits on the
   nav's own bottom border and thickens it under the current section, which is
   why nothing else in the bar is allowed to draw a box: the rule is the only
   mark, and it means one thing. */
.nav-marker {
    position: absolute;
    left: 0;
    bottom: 0;
    height: 2px;
    width: 0;
    border-radius: 2px;
    background: var(--blue);
    opacity: 0;
    transform: translateX(0);
    transition: transform 0.28s cubic-bezier(0.4, 0.1, 0.2, 1),
                width 0.28s cubic-bezier(0.4, 0.1, 0.2, 1),
                opacity 0.2s ease;
    pointer-events: none;
}

.nav-links a.is-current { color: var(--ink); }

.nav-links a {
    padding: 8px 14px;
    color: var(--muted);
    font-size: 0.91rem;
    font-weight: 500;
    transition: color 0.15s ease;
}

/* Nothing but the text. A filled pill was the only rounded rectangle in the
   bar and sat behind the label rather than under it, so the answer to "what am
   I pointing at" looked nothing like the answer to "where am I". */
.nav-links a:hover { color: var(--ink); }

.nav-btn { padding: 9px 18px; font-size: 0.89rem; }

/* Hero -------------------------------------------------------------------- */

/* One Polaris band behind both home and about. The crop is the full width of the
   photograph trimmed to the height of the vehicle and its plume, so the rocket
   runs the depth of the zone rather than sitting in a frame. */
.top-zone {
    position: relative;
    isolation: isolate;
    overflow: hidden;

    /* main is pulled up by the bar's height; this puts it back, so the zone
       starts at the top of the window while its contents start below the bar
       exactly where they did before. */
    padding-top: var(--nav-h);

    /* --polaris-band is the full height of the photograph at page width: its own
       aspect ratio (1600 x 2400), with a floor so it cannot collapse to a sliver
       on narrow screens. --polaris-crop is the one knob - the fraction of the top
       of the frame to cut off, which slides the image up and out of view by that
       much. Everything else here is derived from the two. */
    --polaris-band: max(calc(100vw * 2400 / 1600), min(86vh, 700px));
    --polaris-crop: 0.118;
    /* The crop is still measured from the top of the visible page, which now
       begins at the top of the window rather than under the bar, so the nav's
       height comes off the trim. max() keeps it from going negative on a
       narrow screen, where the band is at its floor. */
    --polaris-trim: calc(max(var(--polaris-band) * var(--polaris-crop), var(--nav-h)) - var(--nav-h));

    /* --polaris-cut is the matching knob for the BOTTOM of the frame. The image
       keeps its full width, so object-fit: cover still scales it by width and
       the shortfall is discarded at the bottom, which is the edge
       object-position does not anchor. */
    --polaris-cut: 0.166;

    /* The two crops are not independent. js/parallax.js slides the photograph
       until its bottom edge meets the bottom of the zone, so a shorter frame has
       further to travel and therefore drifts harder against the page: at 0.05 /
       0.07 the backdrop kept 80% of the page's scroll and barely moved at all.
       Trimming both ends buys the drift, but it also starts the frame lower, so
       to hold one moment fixed the two have to move together - here, roughly
       0.7px off the top for every 1px off the bottom, which is s / (span - s)
       for s the scroll that brings the hero button to the top of the window.
       These values keep that moment exactly where it was (506px into the frame,
       where it has always been) and take the photograph down to 55% of the
       page's scroll. Trim more off both, in that ratio, for more drift still;
       the cost is paid at the two ends, where less of the frame is ever seen. */
}

/* The whole frame at page width, pulled up by the trim so the top of the sky
   slides out of view. Nothing is cropped in the file itself - see .top-zone for
   the knob. */
.top-bg {
    position: absolute;
    top: calc(-1 * var(--polaris-trim));
    left: 0;
    z-index: 0;
    width: 100%;
    height: calc(var(--polaris-band) * (1 - var(--polaris-cut)));
    object-fit: cover;
    object-position: center top;
    /* The photograph dissolves out with its own alpha rather than being buried
       under a scrim, so it bleeds down through the experience timeline and ends
       on nothing at all - no line, whatever height the band works out to.
       Nothing is competing with it below, so it holds full strength most of the
       way and only gives way over the last stretch. */
    -webkit-mask-image: linear-gradient(to bottom,
        #000 0%, #000 86%,
        rgba(0, 0, 0, 0.94) 92%,
        rgba(0, 0, 0, 0.68) 96.5%,
        transparent 100%);
    mask-image: linear-gradient(to bottom,
        #000 0%, #000 86%,
        rgba(0, 0, 0, 0.94) 92%,
        rgba(0, 0, 0, 0.68) 96.5%,
        transparent 100%);
}

.top-veil {
    position: absolute;
    inset: 0;
    z-index: 1;

    /* How much of the vertical scrim survives down the middle of the frame.
       0 would leave the centre column completely untouched; the prose and the
       facts sit either side of it, so only the gutter - and the vehicle
       running down it - is relieved. */
    --veil-relief: 0.3;

    /* Sideways only. The centre is clear: the vehicle and its plume are what
       the photograph is of, and a scrim across them is a scrim across the
       subject. The edges still close up, so the frame dissolves into the page
       rather than ending on two vertical seams. */
    background: linear-gradient(90deg,
        rgba(var(--bg-rgb), 0.58) 0%,
        rgba(var(--bg-rgb), 0.38) 12%,
        rgba(var(--bg-rgb), 0.16) 24%,
        rgba(var(--bg-rgb), 0.02) 36%,
        rgba(var(--bg-rgb), 0) 50%,
        rgba(var(--bg-rgb), 0.02) 64%,
        rgba(var(--bg-rgb), 0.16) 76%,
        rgba(var(--bg-rgb), 0.38) 88%,
        rgba(var(--bg-rgb), 0.44) 100%);
}

/* The downward scrim, carried separately so it can be masked. Absolute lengths
   measured from the top of the zone, not percentages: this is here to keep type
   readable, and the type sits at a fixed number of pixels down the page whatever
   the backdrop is doing, while the band's height follows the viewport width.
   Every stop is offset by the bar, which the zone now sits under. Bright across
   the hero, closing over About, which sits on the body of the vehicle. It never
   reaches solid: the image's own mask does that, so the photograph carries on
   down into Experience. */
.top-veil::after {
    content: "";
    position: absolute;
    inset: 0;

    background: linear-gradient(to bottom,
        rgba(var(--bg-rgb), 0.05) 0,
        rgba(var(--bg-rgb), 0.03) calc(150px + var(--nav-h)),
        rgba(var(--bg-rgb), 0.03) calc(560px + var(--nav-h)),
        rgba(var(--bg-rgb), 0.14) calc(760px + var(--nav-h)),
        rgba(var(--bg-rgb), 0.24) calc(1000px + var(--nav-h)),
        rgba(var(--bg-rgb), 0.32) calc(1300px + var(--nav-h)),
        rgba(var(--bg-rgb), 0.34) calc(1700px + var(--nav-h)));

    /* Thinned down the gutter between the two About columns, which is where the
       airframe runs. Full strength under both columns of type; the ramps are
       wide enough that the transition is not visible as a band. */
    -webkit-mask-image: linear-gradient(90deg,
        #000 0%, #000 38%,
        rgba(0, 0, 0, var(--veil-relief)) 52%,
        rgba(0, 0, 0, var(--veil-relief)) 58%,
        #000 72%, #000 100%);
    mask-image: linear-gradient(90deg,
        #000 0%, #000 38%,
        rgba(0, 0, 0, var(--veil-relief)) 52%,
        rgba(0, 0, 0, var(--veil-relief)) 58%,
        #000 72%, #000 100%);
}

.hero, #about, #experience { position: relative; z-index: 2; background: none; }

/* The photograph is the subject at the top of the page and atmosphere by the
   time you reach the timeline, so it is taken down under this section. Its own
   layer rather than another stop on .top-veil::after, because that one is
   thinned down the middle for the vehicle - which is exactly the column the
   cards stand in, so the relief was landing where it was least wanted. The ramp
   is long enough that the top of it is not visible as an edge. */
#experience::before {
    content: "";
    position: absolute;
    inset: -30px 0 0;
    z-index: -1;
    background: linear-gradient(to bottom,
        rgba(var(--bg-rgb), 0) 0,
        rgba(var(--bg-rgb), 0.06) 90px,
        rgba(var(--bg-rgb), 0.14) 200px,
        rgba(var(--bg-rgb), 0.17) 100%);
}

/* Bottom padding deliberately smaller than the 76px a normal section carries:
   Experience supplies its own 76px on top, and About's own bottom edge is set
   by the facts column, which runs ~90px past the end of the prose. Matching the
   top value here left roughly 200px of dead space before the next heading. */
#about { padding: 104px 0 48px; }

.hero { padding: 92px 0 56px; text-align: center; }

.hero-copy { max-width: 720px; margin: 0 auto; }

/* Over a photograph rather than a panel, so everything here carries a shadow. */
.hero-copy h1,
.hero-copy .typed { text-shadow: 0 2px 14px rgba(0, 0, 0, 0.75), 0 1px 3px rgba(0, 0, 0, 0.6); }
.hero-portrait figcaption { text-shadow: 0 1px 6px rgba(0, 0, 0, 0.9), 0 1px 2px rgba(0, 0, 0, 0.8); }

.hero-portrait { margin: 0 auto 18px; width: max-content; }

.hero-portrait img {
    width: 250px;
    height: 250px;
    border-radius: 50%;
    object-fit: cover;
    /* A light ring, not a dark one: over the backdrop the portrait was sinking
       into the photograph behind it. */
    /* A broad soft pool behind the circle - over a photograph a tight drop
       shadow reads as nothing at all. No ring: the shadow does the separating. */
    box-shadow: 0 0 44px 18px rgba(0, 0, 0, 0.55),
                0 22px 46px -14px rgba(0, 0, 0, 0.95);
}

.hero-portrait figcaption {
    margin-top: 8px;
    font-size: 0.74rem;
    color: var(--body);
    text-align: center;
}

.hero h1 {
    font-size: clamp(2.1rem, 4.4vw, 3.2rem);
    letter-spacing: -0.04em;
    margin-bottom: 14px;
}

/* Typed interests, driven by js/typewriter.js. The first phrase is in the
   markup, so this line is correct before the script runs and stays correct
   without it. */
.typed {
    font-family: var(--display);
    font-size: clamp(1.05rem, 2.4vw, 1.5rem);
    font-weight: 700;
    letter-spacing: -0.025em;
    line-height: 1.3;
    /* Reserve the line so the hero does not shift as characters come and go. */
    min-height: 1.3em;
    margin: 0 0 28px;
    color: var(--ink);
}

/* The nav carries the same link, but the bar is small and sits at the edge of
   the window; the one thing the page is asking for should also be said plainly
   in the middle. The pool separates it from the sky, as with the portrait - the
   socials stay in the nav and the footer so the hero holds a single object. */
.hero-cta { margin: 0; }
.hero-cta .btn { box-shadow: 0 8px 26px rgba(0, 0, 0, 0.5); }

.typed-caret {
    display: inline-block;
    width: 2px;
    height: 1.02em;
    margin-left: 5px;
    vertical-align: -0.13em;
    background: var(--blue);
    animation: caret 1.05s steps(1) infinite;
}

@keyframes caret {
    0%, 50%      { opacity: 1; }
    50.01%, 100% { opacity: 0; }
}

.sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}

/* The socials sit in the bar rather than under the hero, where four buttons in
   a row were competing with the name. No ring and no plate - a plate here would
   be the one rounded rectangle in the bar, which is what the hover on the links
   was taken away for - so the glyph itself does the work, in the page's own link
   colour. Blue in the bar is not ambiguous: as a 2px rule it says where you are,
   as ink it says this goes somewhere, and the page keeps those apart everywhere
   else too. Size only here; the colour and the hover are inherited. */
.socials { display: flex; gap: 4px; margin: 0; padding: 0; list-style: none; }

.socials a {
    display: grid;
    place-items: center;
    width: 34px;
    height: 34px;
    /* Nothing is drawn on it, but the focus ring follows this, so the keyboard
       outline is a circle around the glyph rather than a box. */
    border-radius: 50%;
}

.socials svg { width: 20px; height: 20px; fill: currentColor; }


/* Sections ---------------------------------------------------------------- */

.section { padding: 76px 0; }
.section-tint { background: var(--bg-2); border-block: 1px solid var(--line-soft); }

/* A short accent rule above each section title — the one repeated flourish on
   the page, and what ties the sections together visually. */
.section-title { text-shadow: 0 2px 10px rgba(0, 0, 0, 0.55);
    font-size: clamp(1.6rem, 3.2vw, 2.15rem);
    letter-spacing: -0.03em;
    margin-bottom: 34px;
    padding-top: 18px;
    position: relative;
}

.section-title::before {
    content: "";
    position: absolute;
    top: 0;
    left: 0;
    width: 32px;
    height: 3px;
    border-radius: 2px;
    background: var(--blue);
}

/* About --------------------------------------------------------------------- */

/* Prose on the left, the facts on the right, split so the vehicle runs down the
   gutter between them rather than behind the type.

   The airframe sits at 51-53% of the photograph's width, and the backdrop is
   drawn at 100% width with no horizontal crop, so that is also 52vw on screen.
   The wrap is a fixed 1080 centred in the viewport, so the gutter's centre in
   content coordinates works out at 0.02W + 516 of 1032 - between 52.5% and 54%
   across every desktop width. The vehicle is on a rail and leans, so the tower
   sits further right than the nose: 110px of gap clears the whole of it. The
   gutter then sits 33px right of that centre line - 30% of its own width -
   which is the 1.39fr / 1fr split rather than the 1.2fr / 1fr centring alone
   would give. */
.about {
    display: grid;
    grid-template-columns: minmax(0, 1.39fr) minmax(0, 1fr);
    gap: 110px;
    align-items: start;
}

.about-text p { color: #cfd6e0; text-shadow: 0 1px 6px rgba(0, 0, 0, 0.7); }
.about-text p:last-of-type { margin-bottom: 0; }

/* Stacked in the right-hand cell. */
/* One grid for all three rows rather than a grid per row, so the label column
   is sized once by the longest label (max-content) and every row aligns to it.
   A fixed width would have to be guessed at and kept in sync by hand, and the
   guess was costing the value column room it needed. The rows are .fact
   wrappers set to display: contents so their cells join this grid directly. */
.about-facts {
    display: grid;
    grid-template-columns: max-content minmax(0, 1fr);
    margin: 0;
}

/* Three facts, ruled rather than boxed. A panel here stamps a rectangle over
   the launch photograph and reads as a card competing with the real ones
   further down; a hairline separates the rows without putting anything on top
   of the picture. Both columns are left-aligned, so the block has one straight
   edge down the labels and another down the values. */
.fact { display: contents; }

/* The rule is drawn on the two cells rather than on the row, and the gutter is
   the label's padding rather than a column gap, so the hairline runs unbroken
   across both columns. */
.fact-key,
.fact-val {
    padding: 9px 0;
    border-bottom: 1px solid rgba(233, 237, 243, 0.12);
}

.fact-key { padding-right: 14px; }

.fact:last-child .fact-key,
.fact:last-child .fact-val { border-bottom: 0; }

/* Both sit directly on the photograph with no plate under them, so both carry a
   shadow: a tight one to hold the letterform edges against a busy background,
   over a wider one that darkens the area around the text.

   The labels are the full --ink rather than a grey, with a little extra weight
   under them. Small letterspaced caps have less ink per letter than anything
   else on the page, so a grey that reads fine on a panel goes to nothing over
   the bright part of the frame behind this block. */
.fact-key {
    font-size: 0.86rem;
    font-weight: 500;
    letter-spacing: 0.07em;
    text-transform: uppercase;
    color: var(--ink);
    line-height: 1.75;
    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9), 0 2px 10px rgba(0, 0, 0, 0.65);
}

.fact-val {
    margin: 0;
    font-size: 1.1rem;
    color: var(--ink);
    line-height: 1.55;
    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8), 0 2px 10px rgba(0, 0, 0, 0.55);
}

/* Experience timeline ------------------------------------------------------ */

.timeline { position: relative; margin: 0; padding: 0 0 0 30px; list-style: none; }

/* The accent, not a grey. Drawn dark this rail vanishes into the backdrop;
   drawn light it becomes the brightest thing in the section and out-shouts the
   cards' own hairlines. Blue is neither: it is the colour the page already uses
   for structure - the rule above the heading, the marker in the bar - so it
   reads as the spine of the column rather than as a line laid over it. */
.timeline::before {
    content: "";
    position: absolute;
    left: 6px;
    top: 12px;
    bottom: 12px;
    width: 2px;
    background: linear-gradient(to bottom,
        var(--blue-deep),
        rgba(143, 184, 240, 0.62) 22%,
        rgba(143, 184, 240, 0.48));
}

.tl-item { position: relative; margin-bottom: 16px; }
.tl-item:last-child { margin-bottom: 0; }

.tl-item::before {
    content: "";
    position: absolute;
    /* `* { box-sizing }` does not match pseudo-elements, so without this the
       3px border sits outside the 14px and the dot lands 3px right of the rail. */
    box-sizing: border-box;
    left: -30px;
    top: 26px;
    width: 14px;
    height: 14px;
    border-radius: 50%;
    /* The ring picks up the rail it sits on, so a past role is a bead on the
       spine rather than a second kind of mark. */
    background: var(--panel);
    border: 3px solid rgba(143, 184, 240, 0.5);
}

/* The current role is the only solid one: with the rail itself in the accent,
   a blue ring no longer says anything the others do not, so this one is filled. */
.tl-item:first-child::before { background: var(--blue); border-color: var(--blue); }

/* Photo rail down the left, content to the right. Every row is the same shape
   whatever its bullet count, and a role without a usable photograph gets a
   monogram panel rather than an empty frame. */
/* This one stands on the photograph, not on the page, so it is treated the way
   the portrait is: a broad soft pool underneath doing the separating, and a
   light hairline instead of the page's dark --line, which only reads where the
   backdrop is dark. Without both the card is a hole cut in the picture, and the
   16px between two of them reads as a bright stripe. */
/* Lighter than --panel, and slightly transparent, which the tiles in Portfolio
   are not. They sit on the page; these sit on the lower third of the photograph,
   where the picture has gone to treeline and smoke and is nearly black - so a
   panel at the page's own value left the whole section reading as one dark band.
   The transparency lets what light is behind them come through, so the row picks
   up the plume rather than covering it. */
.tl-card {
    background: rgba(35, 42, 55, 0.94);
    border: 1px solid rgba(233, 237, 243, 0.11);
    border-radius: var(--radius);
    overflow: hidden;
    /* Eased off with the lighter panel: at full strength the pool under each
       card was darkening the gap to the next one and undoing the lift. */
    box-shadow: 0 2px 6px rgba(0, 0, 0, 0.26), 0 16px 34px -22px rgba(0, 0, 0, 0.7);
}

/* A chip in the row's own head, not a column of its own. The mark is here to say
   whose row this is and nothing more, so it is sized like a favicon rather than
   like an image: at a column's width it was the loudest thing in the section and
   the text was paying for it.

   White, because a logo is drawn for white, and all five of these are dark
   artwork that would sink into the panel. Each file is a square canvas with its
   mark at 80% and its own padding baked in, so one rule sizes all five however
   differently they are drawn - see img/logos/README.md. */
.tl-logo {
    flex: none;
    width: 44px;
    height: 44px;
    /* Just enough that the mark is not cut by the corner radius. The artwork
       carries its own margin - it is centred at 92% of a square canvas - so
       stacking a large padding on top of that left the chip mostly white. */
    padding: 3px;
    border-radius: 10px;
    background: #fff;
    object-fit: contain;
}

.tl-main { padding: 20px 24px 22px; min-width: 0; }

/* Not baseline-aligned any more: a 44px chip has no baseline worth aligning to,
   so the row hangs from the top and the id block pushes the date out to the end. */
.tl-head {
    display: flex;
    flex-wrap: wrap;
    align-items: flex-start;
    gap: 6px 16px;
    margin-bottom: 12px;
}

.tl-id { flex: 1 1 240px; min-width: 0; }

.tl-id h3 { font-size: 1.04rem; margin-bottom: 2px; }
.tl-org { margin: 0; color: var(--muted); font-size: 0.89rem; }

/* Dates over places, hard right, the way a resume sets them - so the two columns
   read as two columns and the eye can run down either one on its own. The line
   height is pulled in so that the second line here lands on the same line as the
   organisation opposite, which the body leading would otherwise push past. */
.tl-meta {
    flex: none;
    margin-left: auto;
    /* Tuned, not guessed: this puts the date optically level with the title and
       the place on the same line as the organisation opposite it. */
    padding-top: 1px;
    text-align: right;
}

.tl-date {
    margin: 0;
    font-family: var(--display);
    color: var(--muted);
    font-size: 0.85rem;
    font-weight: 500;
    line-height: 1.32;
    letter-spacing: -0.01em;
    white-space: nowrap;
}

.tl-place {
    margin: 2px 0 0;
    color: var(--muted);
    font-size: 0.89rem;
    line-height: 1.3;
    white-space: nowrap;
}

/* Role summary ------------------------------------------------------------ */

/* A short account of the role rather than the resume's own lines. The resume is
   one click away in the bar, so repeating it here would say the same thing
   twice, in the register of a document rather than of someone talking. */
.tl-text { margin: 0; color: var(--body); font-size: 0.92rem; }

/* Portfolio tiles ---------------------------------------------------------- */

/* Six projects in a 1 / 2 / 3 pyramid. The widths are set per row with --col-w
   rather than by one grid, so each row is its own flex line and nothing about
   one row constrains another. Polaris gets the full width because the vehicle
   is long and reads best on its side - and img/polaris-lead.jpg is cut to 3:1,
   narrower than the frame ever gets, so object-fit: cover always scales it by
   width and the nose and the fins are never clipped off the ends. */
.rows {
    --tile-img-h: clamp(176px, 16.5vw, 236px);
    --tile-h: calc(var(--tile-img-h) + 169px);
    display: flex;
    flex-direction: column;
    gap: 22px;
}

.row { display: flex; flex-wrap: wrap; gap: 22px; align-items: flex-start; }
.row-1 { --col-w: 100%; }
.row-2 { --col-w: calc(50% - 11px); }
.row-3 { --col-w: calc(33.333% - 15px); }

.tile {
    position: relative;
    flex: 0 0 var(--col-w);
    min-width: 0;
    display: flex;
    flex-direction: column;
    background: var(--panel);
    border: 1px solid var(--line);
    border-radius: var(--radius);
    overflow: hidden;
    transition: border-color 0.2s ease, box-shadow 0.2s ease;
}

/* Every tile the same height, and the same height in every row: the frame is
   fixed at --tile-img-h and the text block under it is a constant, so a row is
   not left to the aspect ratio of whichever photograph happens to be in it.
   Only once the dialog is running, though - see .tile-detail. */
.has-pop .tile { height: var(--tile-h); }

/* Every tile is a single hit target, so every tile lifts under the pointer.
   Four of them open a write-up and stretch .tile-btn's ::after to do it; the
   other two have no write-up and stretch their outward link instead. */
@media (hover: hover) {
    .has-pop .tile:hover { border-color: #2e3746; box-shadow: var(--shadow-lg); }
    .has-pop .tile:hover .tile-img img { transform: scale(1.04); }
    .has-pop .tile:hover .tile-btn,
    .tile:hover .tile-out { color: var(--blue-hi); }
}

.tile:has(.tile-btn:focus-visible),
.tile:has(.tile-out:focus-visible) { border-color: var(--blue); }
.tile-btn:focus-visible,
.tile-out:focus-visible { outline: 2px solid var(--blue); outline-offset: 3px; border-radius: 4px; }

/* Out of flow, so the frame is sized by the tile rather than by the intrinsic
   ratio of the file inside it. */
.tile-img {
    position: relative;
    flex: 0 0 var(--tile-img-h);
    height: var(--tile-img-h);
    overflow: hidden;
    border-bottom: 1px solid var(--line);
    background: var(--panel-2);
}

.tile-img img {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
    transition: transform 0.5s cubic-bezier(0.2, 0.7, 0.3, 1);
}

.tile-body { padding: 15px 18px 0; display: flex; flex-direction: column; flex: 1 1 auto; }

.tile-top { display: flex; flex-wrap: wrap; align-items: center; gap: 10px;
            min-height: 24px; margin-bottom: 7px; }
.tile-org { color: var(--muted); font-size: 0.82rem; font-weight: 500; }

.tile-h4 { font-size: 1.05rem; margin-bottom: 6px; }

/* Pinned to two lines, so the cue below it sits on the same baseline in every
   tile whatever the summary says. */
.tile-sum {
    display: -webkit-box;
    color: var(--body);
    font-size: 0.9rem;
    min-height: 2.9em;
    line-height: 1.45;
    margin: 0;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
}

.inline-row { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 18px;
              margin-top: 11px; padding-bottom: 16px; }

/* The cue is the tile's button, stretched over the whole tile by its ::after.
   A <button> wrapping the tile could not legally contain the GitHub and video
   links; this way the whole tile is one hit target and those stay real links.

   It carries the same blue as those links at rest rather than waiting for a
   hover to earn it: the row reads as one set of things you can click, and a
   grey cue beside two blue links looks disabled. */
.tile-btn {
    display: inline-flex;
    align-items: center;
    gap: 0.3em;
    padding: 0;
    border: 0;
    background: none;
    font: inherit;
    font-size: 0.84rem;
    font-weight: 600;
    color: var(--blue);
    cursor: pointer;
    transition: color 0.15s ease;
}

.tile-btn::after { content: ""; position: absolute; inset: 0; z-index: 1; }

/* The same stretch for a tile whose whole job is one outward link. It is on the
   anchor rather than on a pseudo-button, so it works with scripting off too:
   the tile is a link, and clicking anywhere on it opens the video or the repo
   in a new tab.

   .tile-links is positioned so that on the other four tiles its links sit above
   .tile-btn's ::after. That would also be the containing block for this one,
   which would pin the stretch to the width of the link instead of the tile, so
   on a tile with no button it goes back to static and .tile becomes the anchor
   the inset resolves against. */
.tile:not(:has(.tile-btn)) .tile-links { position: static; }
.tile-out::after { content: ""; position: absolute; inset: 0; z-index: 1; }

.chev { width: 0.8em; height: 0.8em; flex: none; }

.tile-links { position: relative; z-index: 2; display: inline-flex; flex-wrap: wrap;
              gap: 16px; font-size: 0.84rem; font-weight: 600; }
.tile-links a { display: inline-flex; align-items: center; color: var(--blue); }
.tile-links a:hover { color: var(--blue-hi); }

.out { width: 0.78em; height: 0.78em; margin-left: 0.3em; }

/* Write-up ------------------------------------------------------------------ */

/* It lives in the tile, not in the dialog: with scripting off every tile shows
   its full text inline and nothing is unreachable. js/projects.js sets
   .has-pop, which is what hides it and turns the cue on. */
.tile-detail { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
               gap: 28px; padding-bottom: 20px; }
.has-pop .tile-detail { display: none; }
.has-pop .tile-btn::after { border-radius: var(--radius); }
body:not(.has-pop) .tile-btn { display: none; }

.detail-prose p { color: var(--body); font-size: 0.93rem; margin: 0 0 12px; }
.detail-prose p:last-child { margin-bottom: 0; }
.detail-links { display: flex; flex-wrap: wrap; gap: 16px; margin: 18px 0 0;
                font-size: 0.86rem; font-weight: 600; }
.detail-links a { display: inline-flex; align-items: center; color: var(--blue); }
.detail-links a:hover { color: var(--blue-hi); }

/* A write-up figure. The CAD renders come on white, so the figure is the white
   plate itself rather than a dark card with a white rectangle inside it.

   One knob gives all six the same shape. Left at their own proportions they are
   a tall launch photograph, a wide block diagram, a 16:9 video still and three
   things in between, so the right half of the dialog changed size and weight
   every time another project was opened, and the short ones left a hole under
   themselves next to a long column of prose. A square is the shape closest to
   all six sources at once; 4 / 3 and 3 / 4 are the other two worth trying, and
   changing this line is the whole change. */
:root { --fig-aspect: 1 / 1; }

.fig { margin: 0; align-self: start; }
.fig img {
    display: block;
    width: 100%;
    aspect-ratio: var(--fig-aspect);
    object-fit: cover;
    border-radius: var(--radius-sm);
    background: #fff;
}

/* A photograph can be cropped to fit the box. A drawing cannot: trimming a
   block diagram removes blocks. These two sit whole on their white plate, and
   the white around them reads as paper rather than as a gap. */
.fig-render img { object-fit: contain; }

/* Same problem the other way round: the bench frame is wider than the box, and
   a centred crop took the front of the camera lens off with it. The arm sits
   hard against the left of its own photograph, so the square is taken from that
   edge and the whole loss is spent on the far end of the bench. */
.fig-arm img { object-position: left center; }
/* Awards -------------------------------------------------------------------- */

/* The photograph backs the awards AND the bottom bar, so it has the height of
   the page end to run in and the medals are not cut off by a section boundary. */
.awards-zone {
    position: relative;
    isolation: isolate;
    overflow: hidden;

    /* Two knobs for the right-hand end of the photograph, where you are stood.
       The first is how much of the picture survives the mask across the faces,
       the second how much scrim sits over them. Raise the first or drop the
       second to brighten; they are separate because the mask controls how much
       is there and the veil controls how dark what is there looks. */
    --face-photo: 0.94;
    --face-veil:  0.15;

    /* The middle of the frame, where the flag is. It is kept separate from
       --face-photo because this band runs behind the right-hand end of the
       award lines: enough picture to read a maple leaf, not so much that the
       type has to fight it. */
    --flag-photo: 0.54;
}

/* The photograph fades out with its own alpha mask rather than being hidden
   under a coloured scrim. A scrim has to ramp toward a colour, and any ramp
   between two dark values over a dark photo shows a shoulder; masking removes
   the pixels instead, so the left side is simply the page background. */
.awards-bg {
    position: absolute;
    inset: 0;
    z-index: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
    object-position: center 52%;
    -webkit-mask-image: linear-gradient(90deg,
        transparent 0%,
        rgba(0, 0, 0, 0.008) 22%,
        rgba(0, 0, 0, 0.03) 32%,
        rgba(0, 0, 0, 0.11) 38%,
        rgba(0, 0, 0, 0.30) 45%,
        rgba(0, 0, 0, var(--flag-photo)) 52%,
        rgba(0, 0, 0, var(--flag-photo)) 61%,
        rgba(0, 0, 0, 0.74) 67%,
        rgba(0, 0, 0, var(--face-photo)) 74%,
        rgba(0, 0, 0, var(--face-photo)) 84%,
        rgba(0, 0, 0, 0.78) 90%,
        rgba(0, 0, 0, 0.54) 95%,
        rgba(0, 0, 0, 0.38) 100%),
        linear-gradient(to top,
        transparent 0,
        transparent 56px,
        rgba(0, 0, 0, 0.30) 94px,
        rgba(0, 0, 0, 0.72) 142px,
        #000 200px);
    -webkit-mask-composite: source-in;
    mask-image: linear-gradient(90deg,
        transparent 0%,
        rgba(0, 0, 0, 0.008) 22%,
        rgba(0, 0, 0, 0.03) 32%,
        rgba(0, 0, 0, 0.11) 38%,
        rgba(0, 0, 0, 0.30) 45%,
        rgba(0, 0, 0, var(--flag-photo)) 52%,
        rgba(0, 0, 0, var(--flag-photo)) 61%,
        rgba(0, 0, 0, 0.74) 67%,
        rgba(0, 0, 0, var(--face-photo)) 74%,
        rgba(0, 0, 0, var(--face-photo)) 84%,
        rgba(0, 0, 0, 0.78) 90%,
        rgba(0, 0, 0, 0.54) 95%,
        rgba(0, 0, 0, 0.38) 100%),
        linear-gradient(to top,
        transparent 0,
        transparent 56px,
        rgba(0, 0, 0, 0.30) 94px,
        rgba(0, 0, 0, 0.72) 142px,
        #000 200px);
    mask-composite: intersect;
}

/* Many stops rather than four: a short ramp between widely spaced values reads
   as a hard vertical edge against a dark photograph. */
/* Blurs whatever is behind it, so the left of the photograph dissolves instead
   of merely dimming — a soft region has no discernible boundary. Masked out well
   before the faces so the right stays sharp. */
.awards-blur {
    position: absolute;
    inset: 0;
    z-index: 1;
    pointer-events: none;
    -webkit-backdrop-filter: blur(13px);
    backdrop-filter: blur(13px);
    -webkit-mask-image: linear-gradient(90deg,
        #000 0%, #000 27%, rgba(0, 0, 0, 0.78) 37%,
        rgba(0, 0, 0, 0.44) 46%, rgba(0, 0, 0, 0.15) 54%, transparent 60%,
        transparent 86%, rgba(0, 0, 0, 0.22) 93%, rgba(0, 0, 0, 0.42) 100%);
    mask-image: linear-gradient(90deg,
        #000 0%, #000 27%, rgba(0, 0, 0, 0.78) 37%,
        rgba(0, 0, 0, 0.44) 46%, rgba(0, 0, 0, 0.15) 54%, transparent 60%,
        transparent 86%, rgba(0, 0, 0, 0.22) 93%, rgba(0, 0, 0, 0.42) 100%);
}

.awards-veil {
    position: absolute;
    inset: 0;
    z-index: 2;
    background:
        linear-gradient(90deg,
            rgba(var(--bg-rgb), 0.72) 0%,
            rgba(var(--bg-rgb), 0.62) 16%,
            rgba(var(--bg-rgb), 0.48) 30%,
            rgba(var(--bg-rgb), 0.30) 44%,
            rgba(var(--bg-rgb), 0.18) 54%,
            rgba(var(--bg-rgb), var(--face-veil)) 62%,
            rgba(var(--bg-rgb), var(--face-veil)) 84%,
            rgba(var(--bg-rgb), 0.30) 92%,
            rgba(var(--bg-rgb), 0.44) 100%),
        linear-gradient(to bottom,
            var(--bg) 0%,
            rgba(var(--bg-rgb), 0.35) 11%,
            rgba(var(--bg-rgb), 0.12) 32%,
            rgba(var(--bg-rgb), 0.12) 78%,
            rgba(var(--bg-rgb), 0.26) 100%);
}

.awards-section { position: relative; z-index: 3; background: none; padding-bottom: 130px; }
.awards-inner { position: relative; z-index: 3; }

.awards { margin: 0; padding: 0; list-style: none; display: grid; gap: 2px; max-width: 696px; }

.awards li {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 4px 14px;
    justify-content: space-between;
    padding: 19px 2px;
    border-bottom: 1px solid rgba(244, 247, 251, 0.14);
}

.awards li:first-child { border-top: 1px solid rgba(244, 247, 251, 0.14); }

/* Both sit over a photograph rather than a flat panel, so they run brighter
   than equivalent text elsewhere and carry a faint shadow to hold their edges
   over the lighter parts of the frame. */
.award-name {
    font-family: var(--display);
    font-weight: 700;
    font-size: 1.04rem;
    letter-spacing: -0.02em;
    color: #f4f7fb;
    text-shadow: 0 1px 3px rgba(0, 0, 0, 0.55);
}

.award-note {
    color: #d3dbe5;
    font-size: 0.87rem;
    text-shadow: 0 1px 3px rgba(0, 0, 0, 0.55);
}

/* Footer ---------------------------------------------------------------------- */

/* Solid, so the bar closes the page off cleanly and the photograph stops at its
   top edge. No border: the fill is the separation. */
/* No fill: the photograph above dissolves into the page background with its own
   alpha mask, so a solid bar here would only reintroduce the hard horizontal
   edge it used to cut across the picture. The bar is the type and the space
   around it. */
.foot {
    position: relative;
    z-index: 3;
    padding: 18px 0 22px;
    background: none;
}

/* Copyright hard left, the same three icons the bar carries hard right, both on
   the .wrap column so they line up with every other edge on the page. The set is
   repeated rather than reduced to one address: a lone mailto at the foot of the
   page had nothing to belong to. */
.foot-inner {
    display: flex;
    flex-wrap: wrap;
    gap: 6px 24px;
    justify-content: space-between;
    align-items: center;
}

.foot-copy {
    margin: 0;
    font-size: 0.82rem;
    color: var(--muted);
}


/* Responsive ---------------------------------------------------------------- */

@media (max-width: 980px) {
    .row-3 { --col-w: calc(50% - 11px); }
}

@media (max-width: 900px) {
    .about { grid-template-columns: minmax(0, 1fr); gap: 30px; }
    .nav-links { display: none; }
    .nav-btn { margin-left: auto; }
}

@media (max-width: 720px) {
    .rows { --tile-img-h: 200px; }
    .row-2, .row-3 { --col-w: 100%; }
    .tile-detail { grid-template-columns: minmax(0, 1fr); gap: 20px; }

    .hero { padding: 50px 0 24px; }

    .section { padding: 58px 0; }
    .section-title { text-shadow: 0 2px 10px rgba(0, 0, 0, 0.55); margin-bottom: 26px; }

    .timeline { padding-left: 24px; }
    .tl-item::before { left: -23px; top: 22px; width: 12px; height: 12px; }
    .tl-main { padding: 16px 18px 18px; }
    .tl-logo { width: 38px; height: 38px; border-radius: 9px; padding: 2px; }
    .tl-meta { flex-basis: 100%; margin-left: 0; padding-top: 0; text-align: left; }

}

/* Phone only. The award rows are a name and its note on one line, pushed apart,
   which works while both fit. Below this they stop fitting together and the row
   wraps - but only the long ones, so the column ended up with two rows reading
   left-and-right and two reading top-and-bottom. Stacking all four makes the
   list read one way the whole way down. */
@media (max-width: 560px) {
    .awards li {
        flex-direction: column;
        align-items: flex-start;
        gap: 2px;
    }
}

@media (prefers-reduced-motion: reduce) {
    .tile, .tile-img img { transition: none; }
    .tile:hover .tile-img img { transform: none; }
}

/* Project dialog ----------------------------------------------------------- */

/* Native <dialog>: the focus trap, the Escape key and the inert background come
   with the element and do not have to be written or maintained. */
.pop {
    width: min(920px, 92vw);
    max-height: 86vh;
    padding: 0;
    overflow: auto;
    overscroll-behavior: contain;
    margin: auto;
    border: 1px solid var(--line);
    border-radius: var(--radius);
    background: var(--bg-2);
    color: var(--ink);
    box-shadow: var(--shadow-lg);
}
.pop::-webkit-scrollbar { width: 0; height: 0; }

.pop::backdrop {
    background: rgba(6, 8, 12, 0.66);
    -webkit-backdrop-filter: blur(6px);
    backdrop-filter: blur(6px);
}

/* The page behind is frozen while the dialog is up, so closing it puts the
   reader back exactly where they were. The site hides its scrollbar and
   reserves no gutter for it, so taking the overflow away shifts nothing. */
html.pop-open, html.pop-open body { overflow: hidden; }

.pane { padding: 26px 28px 30px; }
.pane:focus { outline: none; }

.pane-head { display: flex; align-items: flex-start; gap: 16px; margin-bottom: 16px; }
.pane-head > div { flex: 1; min-width: 0; }
.pane-title { font-size: 1.32rem; margin: 0; }
.pane-org { color: var(--muted); font-size: 0.84rem; margin: 2px 0 0; }
.pane-org:empty { display: none; }

.pane-close {
    flex: none;
    display: grid;
    place-items: center;
    width: 32px;
    height: 32px;
    border-radius: 999px;
    border: 1px solid var(--line);
    background: var(--panel);
    color: var(--muted);
    cursor: pointer;
}
.pane-close svg { width: 16px; height: 16px; }
.pane-close:hover { color: var(--ink); border-color: #2e3746; }
.pane-close:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }

/* The write-up is copied in from the tile, so it arrives with its own grid. */
.pane-body .tile-detail { display: grid; padding-bottom: 0; }

@media (max-width: 760px) {
    .pane { padding: 22px 20px 26px; }
    .pane-body .tile-detail { grid-template-columns: minmax(0, 1fr); gap: 20px; }
    .pane-title { font-size: 1.16rem; }
}
