
/* Custom scrollbar for a more 'tech' feel */
::-webkit-scrollbar {
    width: 6px;
}
::-webkit-scrollbar-track {
    background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
    background: #1f2937;
    border-radius: 0px;
}
::-webkit-scrollbar-thumb:hover {
    background: #000;
}

/* Logo Letter Animations */
.logo-char {
    display: inline-block;
    opacity: 0;
    transform: translateX(-1rem);
    transition-property: opacity, transform;
    transition-duration: 300ms;
    transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}

.group:hover .logo-char {
    opacity: 1;
    transform: translateX(0);
}

/* --- Logo intro ---------------------------------------------------------
   The mark arrives spelled out and rolls up into the J. It is the hover
   played backwards: same properties, same order, letters leaving in the
   reverse of the order they arrive in, so the intro teaches the affordance
   instead of being a separate trick.

   This is the one animation in the project that touches layout. The pill has
   to physically shrink, and the hover already drives it with max-width and
   padding, so a second mechanism would have made the intro and the hover feel
   different. Nothing reflows: the header is justify-between, the masthead
   line is absolutely positioned, and the nav is pinned to the far end, so the
   logo's box is the only thing that changes size.

   No forwards fill anywhere, so once the intro is over every element falls
   back to its resting state and hover behaves exactly as before.
   --------------------------------------------------------------------- */
.logo-name {
    animation: logoRollUp 500ms var(--ease-in-out) 840ms backwards;
}

.logo-pill {
    animation: logoUnpad 500ms var(--ease-in-out) 840ms backwards;
}

/* The J turns while the pill rolls up, the same full turn the hover does,
   run the other way. Same delay and duration as the collapse so the two
   read as one movement rather than two. */
.logo-mark {
    animation: logoSpinBack 500ms var(--ease-in-out) 840ms backwards;
}

@keyframes logoRollUp {
    from { max-width: 12rem; }
    to   { max-width: 0; }
}

@keyframes logoSpinBack {
    from { transform: rotate(360deg); }
    to   { transform: rotate(0deg); }
}

@keyframes logoUnpad {
    from { padding-right: 1.5rem; }
    to   { padding-right: 0; }
}

/* The n goes first and the a last, the mirror of the hover stagger. */
.logo-char {
    animation: logoCharOut 260ms var(--ease-in-out) backwards;
}

.logo-char:nth-child(7) { animation-delay: 800ms; }
.logo-char:nth-child(6) { animation-delay: 830ms; }
.logo-char:nth-child(5) { animation-delay: 860ms; }
.logo-char:nth-child(4) { animation-delay: 890ms; }
.logo-char:nth-child(3) { animation-delay: 920ms; }
.logo-char:nth-child(2) { animation-delay: 950ms; }
.logo-char:nth-child(1) { animation-delay: 980ms; }

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

@media (prefers-reduced-motion: reduce) {
    .logo-name,
    .logo-pill,
    .logo-mark,
    .logo-char {
        animation: none;
    }
}

/* --- Scrolled Navigation State --- */
/* Using standard CSS ensures this works even if Tailwind CDN misses it */
.scrolled-nav {
    background-color: rgba(255, 255, 255, 0.9); /* High visibility white */
    -webkit-backdrop-filter: blur(12px); /* Safari support */
    backdrop-filter: blur(12px);
    padding-top: 1rem !important; /* py-4 equivalent */
    padding-bottom: 1rem !important;
    border-bottom: 1px solid #e5e7eb; /* neutral-200 */
    box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
    will-change: background-color, padding;
}

/* ============================================================
   Mobile menu panel
   Two things had quietly broken this. The panel used bg-white/98,
   which is not a step on Tailwind's opacity scale, so it compiled to
   nothing and the panel had no background at all: the page showed
   straight through the three links. And it was nested inside the
   header, which carries a backdrop-filter once scrolled, and a
   filtered element becomes the containing block for its fixed
   descendants, so inset: 0 resolved against a 72px tall header
   rather than the viewport. The panel now lives outside the header
   and owns its own geometry here, where no ancestor can take it away.
   ============================================================ */
.nav-panel {
    position: fixed;
    inset: 0;
    z-index: 40;
    /* white carries the legibility, the blur only places it above the page */
    background-color: rgba(255, 255, 255, 0.94);
    -webkit-backdrop-filter: blur(18px) saturate(150%);
    backdrop-filter: blur(18px) saturate(150%);
    opacity: 0;
    transition: opacity 220ms var(--ease-out);
}

.nav-panel.is-open { opacity: 1; }

.nav-panel-link {
    opacity: 0;
    transform: translateY(14px);
}

.nav-panel.is-open .nav-panel-link {
    opacity: 1;
    transform: translateY(0);
    transition: opacity 260ms var(--ease-out), transform 260ms var(--ease-out);
}

.nav-panel.is-open .nav-panel-link:nth-child(1) { transition-delay: 60ms; }
.nav-panel.is-open .nav-panel-link:nth-child(2) { transition-delay: 115ms; }
.nav-panel.is-open .nav-panel-link:nth-child(3) { transition-delay: 170ms; }

@media (prefers-reduced-motion: reduce) {
    .nav-panel { transition-duration: 120ms; }
    .nav-panel-link { transform: none; }
    .nav-panel.is-open .nav-panel-link {
        transform: none;
        transition: opacity 120ms var(--ease-out);
        transition-delay: 0ms;
    }
}

/* ============================================================
   Availability dot
   A live status indicator next to the location line. This is the one
   place the chrome carries a colour, and the second looping animation
   in the project after the scroll hint, both for the same reason: the
   whole job of the mark is to say something is live right now, and a
   still dot cannot say it. The ring is transform and opacity only, and
   reduced motion keeps the dot and drops the ring.
   ============================================================ */
.status-dot {
    display: inline-block;
    position: relative;
    width: 7px;
    height: 7px;
    margin-right: 0.6em;
    vertical-align: 0.06em;
    border-radius: 50%;
    background: #16A34A;
}

.status-dot::after {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: 50%;
    background: #16A34A;
    animation: status-pulse 2.4s var(--ease-out) infinite;
    will-change: transform, opacity;
}

@keyframes status-pulse {
    0%   { transform: scale(1);   opacity: 0.5; }
    70%  { transform: scale(2.8); opacity: 0; }
    100% { transform: scale(2.8); opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
    .status-dot::after { animation: none; display: none; }
}

/* Navigation Link Hover Effect */
.nav-link {
    position: relative;
    display: inline-block;
    text-decoration: none;
}

.nav-link::after {
    content: '';
    position: absolute;
    width: 100%;
    height: 2px;
    bottom: -4px;
    left: 0;
    background-color: currentColor; /* Matches the text color dynamically */
    transform: scaleX(0);
    transform-origin: right;
    transition: transform 220ms var(--ease-out);
}

.nav-link:hover::after {
    transform: scaleX(1);
    transform-origin: left;
}

/* --- Page Load Animations --- */
@keyframes fadeInUp {
    from {
        opacity: 0;
        transform: translateY(20px);
    }
    to {
        opacity: 1;
        transform: translateY(0);
    }
}

.animate-on-load {
    /* display: inline-block; REMOVED to prevent layout issues on sections */
    opacity: 0; /* Start hidden */
    animation: fadeInUp 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
    will-change: opacity, transform;
}

/* --- Scroll Reveal Text (Reading Pattern) --- */
.scroll-reveal-text {
    color: #4b5563; /* neutral-600: readable resting state (7.56:1 on white, WCAG AA) */
    transition: color 0.6s ease;
}

.scroll-reveal-text.active {
    color: #111827; /* neutral-900: Active dark gray/black */
}

/* --- Focus states for keyboard navigation --- */
a:focus-visible,
button:focus-visible {
    outline: 2px solid #111827;
    outline-offset: 3px;
}
/* ============================================================
   Motion tokens
   ============================================================ */
:root {
    --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
    --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
    /* The site has one appearance and it is the light one. "only light" opts the
       page out of the browser's own dark treatment: dark form controls and
       scrollbars, and Chrome on Android auto darkening the whole page. */
    color-scheme: only light;
}

html, body {
    background-color: #ffffff;
}

/* ============================================================
   Portrait meltdown — About section
   Silhouette: a gooey mask (disc + pool lobes + drip lobes, all hidden
   inside the disc at rest) sags into an irregular puddle.
   Photo: stretched downward in CSS while an feDisplacementMap drags the
   pixels into vertical streaks. The displacement scale is driven from
   script.js, because SVG filter attributes are not CSS properties.
   ============================================================ */
.melt {
    position: relative;
    aspect-ratio: 1;
}

.melt-svg {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: auto;
    overflow: visible;
    pointer-events: none; /* the square .melt box is the hover target */
}

.melt-body,
.melt-pool,
.melt-drip,
.melt-photo {
    transform-box: view-box;
    transform-origin: 120px 120px;
    transition-property: transform;
    transition-duration: 540ms;
    transition-timing-function: var(--ease-out);
}

.melt-photo { transform-origin: 120px 40px; }
.melt-pool-1 { transform-origin: 118px 188px; }
.melt-pool-2 { transform-origin: 88px 176px; }
.melt-pool-3 { transform-origin: 154px 172px; }
.melt-drip-1 { transform-origin: 74px 164px; }
.melt-drip-3 { transform-origin: 166px 160px; }

@media (hover: hover) and (pointer: fine) {
    .melt:hover .melt-body {
        transform: translateY(18px) scale(1.01, 1.09);
        transition-duration: 950ms;
    }
    /* the image itself runs downward, anchored near the top of the head */
    .melt:hover .melt-photo {
        transform: translateY(30px) scale(1.05, 1.75);
        transition-duration: 1250ms;
    }
    /* the pool spreads and sags into an irregular blob */
    .melt:hover .melt-pool-1 {
        transform: translateY(96px) scale(2.4, 2.6);
        transition-duration: 1150ms;
        transition-delay: 60ms;
    }
    .melt:hover .melt-pool-2 {
        transform: translateY(60px) scale(2, 2.4);
        transition-duration: 1080ms;
        transition-delay: 110ms;
    }
    .melt:hover .melt-pool-3 {
        transform: translateY(70px) scale(2.1, 2.5);
        transition-duration: 1120ms;
        transition-delay: 40ms;
    }
    /* two lobes sag off the sides at different rates */
    .melt:hover .melt-drip-1 {
        transform: translateY(160px) scale(0.75, 2.4);
        transition-duration: 1200ms;
        transition-delay: 140ms;
    }
    .melt:hover .melt-drip-3 {
        transform: translateY(140px) scale(0.8, 2.2);
        transition-duration: 1140ms;
        transition-delay: 80ms;
    }
}

@media (prefers-reduced-motion: reduce) {
    .melt-body,
    .melt-pool,
    .melt-drip,
    .melt-photo {
        transition-duration: 1ms;
    }
    .melt:hover .melt-body,
    .melt:hover .melt-pool,
    .melt:hover .melt-drip,
    .melt:hover .melt-photo {
        transform: none;
        transition-delay: 0ms;
    }
}

/* ============================================================
   Hover microinteractions
   ============================================================ */

/* Experience rows and Services items: the content slides, the rule stays put. */
.row-slide {
    transition: border-color 240ms ease;
}

.row-slide > * {
    transition: transform 260ms var(--ease-out);
}

@media (hover: hover) and (pointer: fine) {
    .row-slide:hover > *            { transform: translateX(10px); }
    .row-slide:hover > *:nth-child(2) { transition-delay: 40ms; }
    .row-slide:hover > *:nth-child(3) { transition-delay: 80ms; }
}

/* Tool tags. These are spans, not controls: nothing happens when you click a
   tool name. They used to lift 2px and darken their border on hover, which is
   the exact signature of a filter chip, so people tried to click them. The
   transition stays for the scroll reveal; the hover response is gone. */
.tag {
    transition: border-color 160ms var(--ease-out),
                color 160ms var(--ease-out);
}

/* Contact CTA: the whole line steps toward the reader. */
.cta-mail {
    transition: transform 220ms var(--ease-out), color 220ms ease;
}

@media (hover: hover) and (pointer: fine) {
    .cta-mail:hover { transform: translateX(6px); }
}

/* ============================================================
   Contact CTA
   The headline keeps the left and the address takes the right. On a
   real pointer the address is drawn as dust: a few hundred specks
   sitting exactly on its glyphs, drifting on their own and scattering
   away from the cursor before they settle back.
   Underneath it is an ordinary mailto link. Without the script, on a
   touch screen, or under reduced motion the plain text is what shows,
   because the address is the one thing on this page that has to stay
   readable, and a phone has no cursor for it to react to anyway.
   ============================================================ */
.cta-block {
    display: grid;
    gap: 3rem;
    align-items: center;
    /* The panel is the sizing container for both halves, so the headline and
       the address are measured against the room they actually have rather
       than against the viewport. The container's max-width steps mean those
       two numbers disagree: between 768 and 1023 the panel's inner width
       drops from 496 to 480 while the headline jumps from 30px to 48px, and
       a 48px headline wants 503. That is what split the three authored lines
       into five. */
    container-type: inline-size;
    /* Padding is declared here rather than left to p-12 / md:p-24, because at
       320 those 96px took more than half the panel and left 176px for the
       headline. It reaches the designed 48px at 600 and holds. */
    padding: clamp(1.5rem, 8vw, 3rem);
}

@media (min-width: 768px) {
    .cta-block { padding: 6rem; }
}

/* The longest line, "something awesome?", measures 10.479px for every 1px of
   font size, so 9.2cqw keeps it at 96% of the panel's inner width at every
   size and the three lines hold. It reaches its 48px ceiling at 1024, where
   the panel is wide enough for it, and never grows past the size the two
   column split at 1536 was measured against. */
.cta-block h2 {
    font-size: clamp(20px, 9.2cqw, 48px);
}

/* Two columns only where the address genuinely has room beside the headline.
   The headline's natural width is 503px at its 48px size and does not change,
   so below 1536px the panel's inner width leaves under 450px for the address,
   which is less than it gets stacked. It stacks there instead, right aligned,
   which keeps the address on the right without taking the headline's room. */
@media (min-width: 1536px) {
    .cta-block {
        grid-template-columns: auto minmax(0, 1fr);
        gap: 3rem;
    }
}

.cta-dust {
    position: relative;
    display: block;
    justify-self: end;
    font-family: 'Space Grotesk', sans-serif;
    font-weight: 600;
    line-height: 1.15;
    letter-spacing: -0.01em;
    white-space: nowrap;
    transition: color 220ms var(--ease-out);
    /* Below 640px the container is fluid and so is the panel's padding, so the
       size comes off the panel's own inner width and lands on the 34px the
       640 band starts at. The 16px floor is the point below which an address
       stops being readable. */
    font-size: clamp(16px, 6.9cqw, 34px);
}

/* The address takes most of the space left over, never the whole panel. The
   address measures 10.45px wide for every 1px of font size, and the
   container's max-width steps make the panel's inner width constant inside
   each band rather than fluid, so these are the bands and the sizes are
   measured rather than guessed. Stacked, it runs at roughly 60 to 70% of the
   inner width; in two columns it fills what is left beside the headline.
   The sizes only ever go up with the viewport: a step that shrank on the way
   up would read as a mistake. */
@media (min-width: 640px)  { .cta-dust { font-size: 34px; } }   /* 480 inner, stacked */
@media (min-width: 1024px) { .cta-dust { font-size: 48px; } }   /* 736 inner, stacked */
@media (min-width: 1280px) { .cta-dust { font-size: 56px; } }   /* 992 inner, stacked */
@media (min-width: 1536px) { .cta-dust { font-size: 62px; } }   /* 1248 inner, beside */
@media (min-width: 1920px) { .cta-dust { font-size: 96px; } }   /* 1632 inner, beside */

.cta-dust-label {
    display: block;
    transition: opacity 320ms var(--ease-out);
}

.cta-dust-canvas {
    position: absolute;
    /* The bleed is the room the specks have to scatter into. A canvas is a
       replaced element with an intrinsic 300x150, and inset alone leaves it
       at that size rather than stretching it, so the width and the height
       are stated outright. */
    left: -0.42em;
    top: -0.62em;
    width: calc(100% + 0.84em);
    height: calc(100% + 1.24em);
    /* laid out from the start but invisible: the script sizes the dust from
       this element's own box, and a display:none canvas has no box to read */
    opacity: 0;
    pointer-events: none;
    transition: opacity 420ms var(--ease-out);
}

/* the script turns the dust on, so plain text is the default state. Not
   scoped to .cta-dust, because the hero name mounts the same engine. */
.is-dust .cta-dust-canvas { opacity: 1; }
.is-dust .cta-dust-label { opacity: 0; }

/* The 404 stage. A full height field with the mark drawn as dust: the J in its
   circle, morphing through the ten things the job is made of and back to the
   mark. Under it sit the message, the one action the page offers, and then the
   index of every project.

   .n4-mark is a fixed square rather than a box that fits its text. The canvas is
   sized from this element, and the logo is sized from the canvas, so a box that
   grew and shrank with each word would have resized the mark with it. The label
   is centred inside the square and the sampler centres the ink to match. */
.n4-stage {
    position: relative;
    min-height: 100vh;
    min-height: 100svh;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 2rem;
    text-align: center;
}

.n4-mark {
    position: relative;
    width: min(62vmin, 520px);
    height: min(62vmin, 520px);
    font-family: 'Space Grotesk', sans-serif;
    font-weight: 700;
    font-size: clamp(2.75rem, 9vmin, 5.5rem);
    line-height: 1;
    letter-spacing: -0.02em;
    color: #111827;
    text-align: center;
}

.n4-mark .cta-dust-label {
    position: absolute;
    inset: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    transition: opacity 420ms var(--ease-out);
}

.n4-note {
    max-width: 34rem;
    font-size: 1.0625rem;
    line-height: 1.6;
    color: #6b7280;
}

/* The page's one action. A dead URL is a dead end, so the stage ends on
   something to press rather than leaving the index to be found below the fold.
   It is the only filled button on the site, which is the point: there is exactly
   one thing to do here. */
.n4-home {
    display: inline-flex;
    align-items: center;
    padding: 0.875rem 1.75rem;
    border-radius: 9999px;
    background: #111827;
    color: #ffffff;
    font-size: 0.9375rem;
    font-weight: 600;
    transition: transform 200ms var(--ease-out), opacity 200ms var(--ease-out);
}

@media (hover: hover) and (pointer: fine) {
    .n4-home:hover {
        transform: translateY(-2px);
        opacity: 0.9;
    }
}

@media (prefers-reduced-motion: reduce) {
    .n4-mark .cta-dust-label { transition-duration: 160ms; }
    .n4-home { transition: opacity 160ms var(--ease-out); }
    .n4-home:hover { transform: none; }
}

/* The hero name as dust. The canvas is a sibling of .line-mask rather than a
   child of it: the mask clips to the type so the entrance can wipe, and the
   specks need to scatter well outside that box. The wrapper only has to be a
   positioning context, since the type itself comes from the h1. */
.hero-dust {
    position: relative;
    display: inline-block;
}

.hero-dust .cta-dust-label { transition: opacity 420ms var(--ease-out); }

@media (prefers-reduced-motion: reduce) {
    .cta-dust-canvas,
    .cta-dust-label { transition-duration: 160ms; }
}

@media (hover: hover) and (pointer: fine) {
    .cta-dust:not(.is-dust):hover { color: #d1d5db; }
}

.cta-dust:focus-visible {
    outline: 2px solid currentColor;
    outline-offset: 10px;
}

@media (prefers-reduced-motion: reduce) {
    .nav-link::after {
        transition-duration: 120ms;
    }
    .row-slide > *,
    .tag,
    .cta-mail {
        transition-property: border-color, color;
    }
    .row-slide:hover > *,
    .tag:hover,
    .cta-mail:hover {
        transform: none;
    }
}


/* ============================================================
   Page-load sequence (homepage hero)
   The three headline lines ride up from behind a mask. A 1.05 line box
   is shorter than the glyphs, so the mask pads for ascenders and
   descenders and then takes the padding straight back out of the layout.
   ============================================================ */
.line-mask {
    display: inline-block;
    overflow: hidden;
    padding: 0.14em 0 0.18em;
    margin: -0.14em 0 -0.18em;
    vertical-align: bottom;
}

/* The hero entrance is one gesture, not three polite rises. The two black
   lines are wiped in from the left and the recessive grey line counters from
   the right, so the headline composes itself from two directions and locks
   on the same beat the badge lands on. clip-path is the sanctioned fourth
   property here, the same one .cover-reveal already uses.

   .line-mask keeps its overflow and its padding/negative-margin pair. The
   sweep does not need the mask, but the h1 box is measured against it, so it
   stays exactly as it was. */
.line-rise {
    display: inline-block;
    clip-path: inset(0 100% 0 0);
    transform: translateX(-0.1em);
    animation: lineSweep 0.82s var(--ease-out) forwards;
    will-change: transform, clip-path;
}

.line-counter {
    clip-path: inset(0 0 0 100%);
    transform: translateX(0.1em);
    animation-name: lineSweepBack;
}

@keyframes lineSweep {
    to { clip-path: inset(0 0 0 0); transform: translateX(0); }
}

@keyframes lineSweepBack {
    to { clip-path: inset(0 0 0 0); transform: translateX(0); }
}

/* The badge is the closing beat, after the last line has landed. */
.badge-pop {
    opacity: 0;
    animation: badgePop 240ms var(--ease-out) 1000ms forwards;
    will-change: transform, opacity;
}

@keyframes badgePop {
    from { opacity: 0; transform: rotate(-1deg) scale(0.9); }
    to   { opacity: 1; transform: rotate(-1deg) scale(1); }
}

@keyframes fadeIn {
    from { opacity: 0; }
    to   { opacity: 1; }
}

/* ============================================================
   Covers wipe in as their card enters the viewport.
   Scoped to .js so the grid is fully visible when the script does not
   run. Hover on the cards is untouched.
   ============================================================ */
.js .cover-reveal {
    clip-path: inset(0 0 100% 0);
    transition: clip-path 700ms var(--ease-out);
}

.js .cover-reveal.is-revealed {
    clip-path: inset(0 0 0 0);
}

/* ============================================================
   Reading progress
   ============================================================ */
.scroll-progress {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    height: 2px;
    background: #111827;
    transform: scaleX(0);
    transform-origin: left center;
    z-index: 60;
    will-change: transform;
}

@media (prefers-reduced-motion: reduce) {
    .animate-on-load,
    .badge-pop {
        animation-name: fadeIn;
        animation-duration: 0.4s;
    }
    .line-rise,
    .line-counter {
        transform: none;
        clip-path: none;
        animation-name: fadeIn;
        animation-duration: 0.4s;
    }
    .js .cover-reveal {
        clip-path: none;
        transition: none;
    }
}

/* ============================================================
   Project walkthrough (iMapp subpage)
   A frame that stays put while the live site passes through it, so a
   case study shows the whole page instead of five disconnected crops.
   Scroll drives translate3d on two long screenshots; nothing here
   animates on its own.
   ============================================================ */
.walk {
    position: relative;
    /* the run is the screenshot's height minus the frame's, so a site that is
       twice as long needs more scroll or it flies past. Per project, not global. */
    height: var(--walk-h, 320vh);
    /* the field is the site's own background, so the frame does not read as a
       rectangle dropped on an unrelated colour */
    background: var(--walk-field, #0F0C22);
}

.walk-stage {
    position: sticky;
    top: 0;
    height: 100vh;
    height: 100svh;
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 2rem;
    /* the fixed header is opaque and sits over the stage, so the devices
       are centred in what is left below it, not in the whole viewport */
    padding: 6rem 1.5rem 2.5rem;
    overflow: hidden;
}

/* Both devices are sized off the shorter of the available width and
   height, so the frame never outgrows the viewport it is pinned to. */
.walk-window {
    flex: 0 1 auto;
    width: min(900px, 100%, calc((100svh - 10rem) * 1.6));
    border-radius: 12px;
    overflow: hidden;
    background: var(--walk-page, #0F0C22);
    box-shadow:
        0 0 0 1px rgba(255, 255, 255, 0.07),
        0 24px 56px -30px rgba(0, 0, 0, 0.45);
}

.walk-chrome {
    position: relative;
    height: 34px;
    background: #191532;
    border-bottom: 1px solid rgba(255, 255, 255, 0.06);
    display: flex;
    align-items: center;
    gap: 7px;
    padding: 0 12px;
}

.walk-dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: #3A3358;
}

.walk-url {
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
    background: #0F0C22;
    border: 1px solid rgba(255, 255, 255, 0.07);
    border-radius: 999px;
    padding: 3px 14px;
    font-size: 11px;
    color: #9995B8;
}

.walk-view {
    position: relative;
    aspect-ratio: 16 / 10;   /* the 1440x900 viewport it was captured at */
    overflow: hidden;
}

.walk-phone {
    flex: 0 0 auto;
    width: min(250px, calc((100svh - 10rem) * 0.42));
    border-radius: 34px;
    padding: 8px;
    background: #14112A;
    box-shadow:
        0 0 0 1px rgba(255, 255, 255, 0.10),
        0 24px 48px -28px rgba(0, 0, 0, 0.5);
}

.walk-screen {
    position: relative;
    aspect-ratio: 390 / 844;
    border-radius: 26px;
    overflow: hidden;
    background: #0F0C22;
}

.walk-view img,
.walk-screen img {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: auto;
    display: block;
    will-change: transform;
}

/* Below the phone breakpoint the two devices cannot sit side by side. The
   desktop frame would shrink to a quarter scale and show nothing readable,
   so the phone carries it here and the desktop design stays in the hero. */
@media (max-width: 767px) {
    .walk-window { display: none; }
    .walk { height: var(--walk-h-sm, 360vh); }
    .walk-phone {
        width: min(300px, 72vw, calc((100svh - 10rem) * 0.42));
    }
}

/* Reduced motion: no pinning and no scroll linking. The frame simply
   grows to the full height of the page it holds, so the whole site is
   still visible, just all at once. */
.walk-static { height: auto; }

.walk-static .walk-stage {
    position: static;
    height: auto;
}

.walk-static .walk-view,
.walk-static .walk-screen {
    aspect-ratio: auto;
}

.walk-static .walk-view img,
.walk-static .walk-screen img {
    position: static;
    transform: none;
}

/* A project with no mobile capture runs the walkthrough solo. The desktop
   frame then keeps every breakpoint, and below 768px it drops the pinning
   instead of holding three screens of scroll on a page rendered at a fifth
   of its scale. */
.walk--solo .walk-view {
    /* iKantory defaults to 3/2, the frame its cover video was built in. A
       project captured at a real viewport width sets its own ratio on the
       .walk element instead, as a pair, since one is a ratio and the other
       is the number the width is computed from. */
    aspect-ratio: var(--walk-ratio, 3 / 2);
}

.walk--solo .walk-window {
    width: min(900px, 100%, calc((100svh - 10rem) * var(--walk-ratio-x, 1.5)));
}

@media (max-width: 767px) {
    .walk--solo { height: auto; }
    .walk--solo .walk-window { display: block; }
    .walk--solo .walk-stage {
        position: static;
        height: auto;
        padding: 1.5rem 1rem;
    }
    .walk--solo .walk-view { aspect-ratio: auto; }
    .walk--solo .walk-view img { position: static; }
}

/* Light sites get a light window. Same frame, inverted chrome. */
.walk--light .walk-window {
    box-shadow:
        0 0 0 1px rgba(0, 0, 0, 0.08),
        0 20px 44px -30px rgba(0, 0, 0, 0.18);
}

.walk--light .walk-chrome {
    background: #F1EFF2;
    border-bottom-color: rgba(0, 0, 0, 0.08);
}

.walk--light .walk-dot { background: #D3CDD6; }

.walk--light .walk-url {
    background: #FFFFFF;
    border-color: rgba(0, 0, 0, 0.08);
    color: #6B6474;
}

/* The static mode has to beat the per-project variants declared above it:
   .walk--solo .walk-view and .walk-static .walk-view carry the same
   specificity, so order is what decides. Keep this block last. */
.walk-static .walk-view,
.walk-static .walk-screen {
    aspect-ratio: auto;
}

.walk-static .walk-view img,
.walk-static .walk-screen img {
    position: static;
}

/* ============================================================
   Walkthrough, screens variant
   A project that is a set of application screens rather than one long
   page. The stage stays put and the screens travel through it sideways,
   one at a time, with a hold on each so there is time to read it. Same
   scroll-driven mechanism as the page walkthrough, other axis.

   The panel field is the boards' own near-white, so a white board never
   sits as a bright rectangle on a darker field. The device video is the
   one dark panel, and it is meant to read as a different kind of thing.
   ============================================================ */
.walk--screens {
    height: var(--walk-h, 460vh);
    --walk-field: #FDFEFF;
    /* The panel format is the board's own. Crop to the whole window plus an
       even margin and set both here, or the panel crops back into the artwork. */
    --walk-ratio: 4 / 3;
    --walk-ratio-x: 1.3333;
    /* no percentage here: the track's gutters are derived from --panel-w,
       so a percentage width would make the two resolve against each other */
    --panel-w: min(980px, calc(100vw - 8rem), calc((100svh - 12rem) * var(--walk-ratio-x, 1.3333)));
    border-top: 1px solid #e5e7eb;
    border-bottom: 1px solid #e5e7eb;
}

.walk--screens .walk-stage {
    padding: 6rem 0 3.5rem;
}

.walk-track-window {
    position: relative;
    width: 100%;
    overflow: hidden;
}

.walk-track {
    display: flex;
    align-items: center;
    gap: 2.5rem;
    /* the leading and trailing gutters put panel one and panel seven in the
       middle of the stage at each end of the run */
    padding: 0 calc((100% - var(--panel-w)) / 2);
    will-change: transform;
}

.walk-panel {
    flex: 0 0 var(--panel-w);
    aspect-ratio: var(--walk-ratio, 4 / 3);
    /* A screenshot cropped to the application window brings that window's own
       rounded corners with it. If the panel clips at a smaller radius the two
       arcs both show and the corner reads as a grey smudge, so a project that
       crops to the window sets this from --panel-w and the radius tracks the
       panel at every width. */
    border-radius: var(--panel-radius, 14px);
    overflow: hidden;
    background: #FDFEFF;
    box-shadow: 0 18px 44px -22px rgba(17, 24, 39, 0.28);
}

.walk-panel img,
.walk-panel video {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
}

/* position in the sequence, no words */
.walk-steps {
    /* The rail lives on a dark --walk-field in every variant, and its
       :focus-visible ring is drawn in currentColor. Without this the ring
       inherited body #111827 and sat at 1.18:1 on GolemPi's black. */
    color: #fff;
    position: absolute;
    left: 50%;
    bottom: 1.5rem;
    transform: translateX(-50%);
    display: flex;
    gap: 6px;
}

/* The rail reports position and, once the script is running, takes you
   there. The bar stays 2px; the hit area is a 20px band around it, which
   is what makes it usable without making it look like a control. */
.walk-step {
    position: relative;
    width: 22px;
    height: 2px;
    padding: 0;
    border: 0;
    background: #d1d5db;
    transition: background-color 200ms var(--ease-out);
}

.walk-step.is-active { background: #111827; }

.walk-step[role="button"] {
    cursor: pointer;
    -webkit-appearance: none;
    appearance: none;
}

.walk-step[role="button"]::after {
    content: "";
    position: absolute;
    inset: -9px -3px;
}

@media (hover: hover) and (pointer: fine) {
    .walk-step[role="button"]:hover { background: #6b7280; }
    .walk--app .walk-step[role="button"]:hover { background: rgba(255, 255, 255, 0.55); }
    .walk-step[role="button"]:hover.is-active { background: #111827; }
    .walk--app .walk-step[role="button"]:hover.is-active { background: var(--walk-accent, #111827); }
}

.walk-step[role="button"]:focus-visible {
    outline: 2px solid currentColor;
    outline-offset: 6px;
}

@media (max-width: 767px) {
    /* the panel is no longer --panel-w wide here, and --panel-radius is
       derived from it, so keep the variable meaning the rendered width */
    .walk--screens { height: auto; --panel-w: calc(100vw - 2rem); }
    .walk--screens .walk-stage {
        position: static;
        height: auto;
        padding: 2rem 1rem;
    }
    .walk--screens .walk-track {
        flex-direction: column;
        gap: 1.5rem;
        padding: 0;
        transform: none !important;
    }
    .walk--screens .walk-panel { flex: 0 0 auto; width: 100%; }
    .walk--screens .walk-steps { display: none; }
}

/* Reduced motion on a wide screen: the stage stops being pinned, so a row of
   panels would leave six of the seven off the side. Stack them instead, the
   same way the phone breakpoint does.

   The height has to be repeated here. `.walk-static { height: auto }` is
   declared earlier in the file and carries the same specificity as the
   variant's own height, so the variant wins on order and the block keeps its
   scroll height while the content stacks past the bottom of it. */
.walk--screens.walk-static { height: auto; --panel-w: min(980px, calc(100vw - 3rem)); }

.walk--screens.walk-static .walk-track-window { overflow: visible; }

.walk--screens.walk-static .walk-track {
    flex-direction: column;
    gap: 1.5rem;
    padding: 0 1.5rem;
}

.walk--screens.walk-static .walk-panel {
    flex: 0 0 auto;
    width: 100%;
    max-width: 980px;
    margin: 0 auto;
}

.walk--screens.walk-static .walk-steps { display: none; }

/* ============================================================
   Walkthrough, application variant
   A desktop application, drawn board by board with the window at one
   identical rect on every board. So the window does not move. It is
   pinned, dead still, and scroll only changes what is inside it, which
   is what using the application actually looked like.

   Two things follow from that. The boards already carry the app's own
   minimise, maximise and close controls, so this variant adds no chrome
   of its own: a frame around a frame would read as a nested rectangle.
   And because nothing travels, the phone breakpoint keeps the pinning
   instead of unstacking, since ten screens dumped into a column is the
   thing this section exists to avoid.
   ============================================================ */
.walk--app {
    /* one viewport for the pin plus a beat per screen, so a four screen tour
       and a ten screen one both hold each screen for the same distance */
    --walk-screens: 10;
    height: calc(100vh + var(--walk-screens) * 52vh);
    --walk-field: #0A0620;
    --walk-page: #16113D;
}

.walk--app .walk-window {
    /* --walk-ratio-x is the same number as --walk-ratio, written as a
       multiplier, because calc cannot divide by a ratio. Set both or neither. */
    width: min(var(--walk-max, 1060px), 100%, calc((100svh - 11rem) * var(--walk-ratio-x, 1.7778)));
}

.walk--app .walk-view {
    /* Thorg's 1280x720 window by default. A tall, narrow utility window such as
       Brass Golem's 460x815 sets its own. */
    aspect-ratio: var(--walk-ratio, 16 / 9);
}

.walk--app .walk-frames {
    position: absolute;
    inset: 0;
}

.walk--app .walk-frame {
    position: absolute;
    inset: 0;
    opacity: 0;
    will-change: transform, opacity;
}

/* the first screen is what the section shows before the script runs */
.walk--app .walk-frame:first-child { opacity: 1; }

.walk--app .walk-frame img {
    position: static;
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
}

/* the rail picks up the application's own accent rather than a neutral, so
   each project sets --walk-accent next to its field colour */
.walk--app .walk-step { background: rgba(255, 255, 255, 0.18); }
.walk--app .walk-step.is-active { background: var(--walk-accent, #CF39A9); }

/* a frame can hold a video as well as a board, and only the img was styled */
.walk--app .walk-frame video {
    position: static;
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
}

@media (max-width: 767px) {
    /* the base rule hides the desktop frame for the two device layout,
       and this variant has no phone to fall back to */
    .walk--app .walk-window { display: block; }
    .walk--app { height: calc(100vh + var(--walk-screens) * 42vh); }
    .walk--app .walk-stage { padding: 5.5rem 1rem 3.5rem; }
}

/* Reduced motion: the stage unpins, so the ten screens stack into a
   column and the whole first run is visible at once instead of in
   sequence. Higher specificity than .walk-static on purpose, since this
   block sits after it. */
.walk--app.walk-static { height: auto; }

.walk--app.walk-static .walk-window {
    width: 100%;
    max-width: 1060px;
    margin: 0 auto;
    border-radius: 0;
    background: none;
    box-shadow: none;
}

.walk--app.walk-static .walk-view {
    aspect-ratio: auto;
    overflow: visible;
}

.walk--app.walk-static .walk-frames {
    position: static;
    display: flex;
    flex-direction: column;
    gap: 1.5rem;
}

.walk--app.walk-static .walk-frame {
    position: static;
    opacity: 1;
    border-radius: 10px;
    overflow: hidden;
}

.walk--app.walk-static .walk-frame img { height: auto; }

.walk--app.walk-static .walk-steps { display: none; }

/* ============================================================
   Walkthrough, terminal variant
   A command line has nothing to photograph, which turns out to be the
   point: the terminal here is real text, not a screenshot, so it stays
   sharp at any size, weighs nothing and can be read by a screen reader.
   Scroll prints the session line by line, the way it actually ran.
   ============================================================ */
.walk--term {
    --walk-lines: 30;
    height: calc(100vh + var(--walk-lines) * 9vh);
    --walk-field: #0C0F14;
}

.walk--term .walk-stage { padding: 6rem 1.5rem 3rem; }

.walk--term .walk-window {
    width: min(920px, 100%);
    border-radius: 10px;
    background: #14181F;
    box-shadow:
        0 0 0 1px rgba(255, 255, 255, 0.07),
        0 24px 56px -30px rgba(0, 0, 0, 0.45);
}

.term-bar {
    height: 32px;
    display: flex;
    align-items: center;
    gap: 7px;
    padding: 0 12px;
    border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}

.term-dot { width: 8px; height: 8px; border-radius: 50%; background: #333B47; }

.term-title {
    position: absolute;
    left: 50%;
    transform: translateX(-50%);
    font-size: 11px;
    color: #7A8493;
    letter-spacing: 0.02em;
}

.term-body {
    position: relative;
    height: min(64svh, 560px);
    overflow: hidden;
    padding: 1.1rem 1.35rem;
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    font-size: clamp(11px, 1.15vw, 13.5px);
    line-height: 1.62;
    color: #C3CBD6;
}

.term-line {
    white-space: pre-wrap;
    opacity: 0;
    will-change: transform, opacity;
}

/* without the script the whole session is simply there */
.walk-static .term-line,
html:not(.js) .term-line { opacity: 1; }

.term-cmd    { color: #FFFFFF; }
.term-cmd::before { content: "$ "; color: #5C93F5; }
.term-in     { color: #FFFFFF; }
.term-in::before  { content: "> "; color: #4AA24D; }
.term-ok     { color: #7FD08A; }
.term-dim    { color: #7A8493; }
.term-key    { color: #E8C56B; }

/* Reduced motion: no pinning, the session simply sits there in full. */
.walk--term.walk-static { height: auto; }

.walk--term.walk-static .walk-window { margin: 0 auto; }

.walk--term.walk-static .term-body {
    height: auto;
    overflow: visible;
}

/* The base mobile rule hides .walk-window for the two device layout and
   resets .walk to --walk-h-sm. This variant has nothing but the terminal, so
   both together left a tall block of scroll with nothing in it. Like
   .walk--app it keeps its pinning and turns its own window back on: text
   prints just as well at 390px as at 1440px, it is the only thing on the
   stage, and there is no phone variant to fall back to. */
@media (max-width: 767px) {
    .walk--term { height: calc(100vh + var(--walk-lines) * 9vh); }
    .walk--term .walk-window { display: block; }
    .walk--term .walk-stage { padding: 5.5rem 1rem 2.5rem; }
    .walk--term .term-body {
        height: min(64svh, 500px);
        padding: 0.9rem 1rem;
        /* a touch under the 11px floor of the clamp, which buys back the
           characters the narrow viewport takes away and keeps the longest
           line (68 characters) down to a single wrap */
        font-size: 10.5px;
    }
}

/* The before and after of a single command's output, side by side. */
.cmd-pair {
    display: grid;
    grid-template-columns: 1fr;
    gap: 1px;
    background: rgba(255, 255, 255, 0.08);
    border-radius: 10px;
    overflow: hidden;
}

@media (min-width: 768px) {
    .cmd-pair { grid-template-columns: 1fr 1fr; }
}

.cmd-side {
    background: #14181F;
    padding: 1.1rem 1.25rem;
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    font-size: 12.5px;
    line-height: 1.6;
    color: #C3CBD6;
    white-space: pre-wrap;
}

.cmd-label {
    display: block;
    font-family: var(--font-ui, inherit);
    font-size: 11px;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: #7A8493;
    margin-bottom: 0.6rem;
}

/* the command tree, a real nested list rather than a picture of one */
.tree, .tree ul { list-style: none; margin: 0; padding: 0; }

.tree ul { margin-left: 1.15rem; border-left: 1px solid #e5e7eb; }

.tree li {
    position: relative;
    padding: 0.3rem 0 0.3rem 1.15rem;
    font-size: 0.95rem;
}

.tree ul > li::before {
    content: "";
    position: absolute;
    left: 0;
    top: 1.05rem;
    width: 0.8rem;
    height: 1px;
    background: #e5e7eb;
}

.tree code {
    font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    font-size: 0.85em;
    background: #f3f4f6;
    padding: 0.12em 0.42em;
    border-radius: 4px;
    color: #111827;
}

.tree .tree-note { color: #4b5563; }

/* ============================================================
   Scroll affordance
   A pinned frame that only responds to scroll gives no sign of it. This
   is the sign: small, low contrast, and gone the moment it is obeyed.
   It is the one looping animation in the project, which is the standard
   exception, because attracting attention to an affordance is its whole
   job and it stops as soon as the affordance is used.
   ============================================================ */
.walk-hint {
    position: absolute;
    left: 50%;
    bottom: 1.4rem;
    transform: translateX(-50%);
    display: flex;
    align-items: center;
    gap: 0.4rem;
    font-size: 10px;
    font-weight: 500;
    letter-spacing: 0.16em;
    text-transform: uppercase;
    color: var(--walk-hint-ink, rgba(255, 255, 255, 0.6));
    pointer-events: none;
    transition: opacity 260ms var(--ease-out);
}

.walk-hint svg {
    width: 11px;
    height: 11px;
    animation: walkHintBob 2.4s var(--ease-in-out) infinite;
}

@keyframes walkHintBob {
    0%, 100% { transform: translateY(0); }
    50%      { transform: translateY(3px); }
}

/* the screens variant runs on a light field, so it needs dark ink */
.walk--screens { --walk-hint-ink: rgba(17, 24, 39, 0.38); }

/* the rail moves up to leave the hint the bottom line */
.walk-steps { bottom: 3.2rem; }

/* nothing to hint at when the stage is not pinned */
.walk-static .walk-hint { display: none; }

/* below the phone breakpoint these variants unpin and stack, so there is no
   affordance left to point at */
@media (max-width: 767px) {
    .walk--solo .walk-hint,
    .walk--screens .walk-hint { display: none; }
}

@media (prefers-reduced-motion: reduce) {
    .walk-hint svg { animation: none; }
}

/* ============================================================
   Page to page transition
   The cover a reader clicks is the hero of the page they land on, so
   it stays on screen and grows into it instead of the page blinking.
   This is a cross document view transition: a browser without support
   just navigates, which is the same site without the flourish.
   ============================================================ */
@view-transition {
    navigation: auto;
}

/* the whole page underneath crosses over faster than the cover moves,
   so the cover is what the eye follows */
::view-transition-old(root),
::view-transition-new(root) {
    animation-duration: 240ms;
    animation-timing-function: var(--ease-out);
}

::view-transition-group(*) {
    animation-duration: 460ms;
    animation-timing-function: var(--ease-in-out);
}

/* The card is 4:3 and the hero is 21:9. Left alone the two snapshots
   stretch into each other and the artwork squashes, so both are held to
   the group box and cropped, the same way object-cover crops the img. */
::view-transition-old(cover-climbey-app),
::view-transition-new(cover-climbey-app),
::view-transition-old(cover-golempi),
::view-transition-new(cover-golempi),
::view-transition-old(cover-promptuj-pro),
::view-transition-new(cover-promptuj-pro),
::view-transition-old(cover-pana-jana),
::view-transition-new(cover-pana-jana),
::view-transition-old(cover-brass-clay-golem),
::view-transition-new(cover-brass-clay-golem),
::view-transition-old(cover-golem-cli),
::view-transition-new(cover-golem-cli),
::view-transition-old(cover-imapp),
::view-transition-new(cover-imapp),
::view-transition-old(cover-thorg-miner),
::view-transition-new(cover-thorg-miner),
::view-transition-old(cover-ikantory),
::view-transition-new(cover-ikantory),
::view-transition-old(cover-blokey),
::view-transition-new(cover-blokey),
::view-transition-old(cover-selected-logos-symbols),
::view-transition-new(cover-selected-logos-symbols),
::view-transition-old(cover-select),
::view-transition-new(cover-select),
::view-transition-old(cover-ikantor),
::view-transition-new(cover-ikantor) {
    height: 100%;
    width: 100%;
    object-fit: cover;
}

@media (prefers-reduced-motion: reduce) {
    ::view-transition-group(*),
    ::view-transition-image-pair(*),
    ::view-transition-old(*),
    ::view-transition-new(*) {
        animation: none;
    }
}

/* ============================================================
   Next project
   The link used to say "Next Project" and nothing else, so the only way
   to find out what was next was to click. It now names the project, and
   on a real pointer the cover rises above it. The thumbnail is absolute,
   so nothing in the row moves and the layout is the same at rest.
   ============================================================ */
.next-project {
    position: relative;
    display: inline-flex;
    align-items: baseline;
    gap: 0.5rem;
    font-size: 1.125rem;
    line-height: 1.75rem;
    font-weight: 500;
    color: #4b5563;
    transition: color 200ms var(--ease-out);
}

.next-label {
    font-size: 0.75rem;
    font-weight: 700;
    letter-spacing: 0.12em;
    text-transform: uppercase;
    color: #6b7280;
    transition: color 200ms var(--ease-out);
}

.next-name { color: #111827; }

.next-arrow {
    display: inline-block;
    transition: transform 220ms var(--ease-out);
}

.next-thumb {
    position: absolute;
    right: 0;
    bottom: calc(100% + 0.9rem);
    width: 168px;
    aspect-ratio: 4 / 3;
    overflow: hidden;
    background: #f3f4f6;
    box-shadow: 0 18px 40px -20px rgba(17, 24, 39, 0.45);
    opacity: 0;
    transform: translateY(10px) scale(0.96);
    transform-origin: bottom right;
    pointer-events: none;
}

.next-thumb img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
}

@media (hover: hover) and (pointer: fine) {
    .next-thumb { transition: opacity 240ms var(--ease-out), transform 240ms var(--ease-out); }
    .next-project:hover .next-thumb,
    .next-project:focus-visible .next-thumb {
        opacity: 1;
        transform: translateY(0) scale(1);
    }
    .next-project:hover .next-label { color: #111827; }
    .next-project:hover .next-arrow { transform: translateX(4px); }
}

/* Nothing to hover with, so there is no preview to reveal and the name on
   its own is already the whole improvement. display:none also means the
   lazy image is never fetched on a phone. */
@media (hover: none), (pointer: coarse) {
    .next-thumb { display: none; }
}

/* At 390px the row is 342px wide and the two links wanted roughly 345px
   between them, so a long project name pushed the arrow off the edge. The
   label moves onto its own line above the name, and both links step down a
   size, which also stops the footer competing with the body copy it sits
   under. */
@media (max-width: 767px) {
    .next-project {
        font-size: 0.9375rem;
        line-height: 1.35;
        flex-wrap: wrap;
        justify-content: flex-end;
        text-align: right;
        gap: 0 0.35rem;
    }

    .next-label {
        flex: 0 0 100%;
        font-size: 0.625rem;
        letter-spacing: 0.1em;
        margin-bottom: 0.2rem;
    }

    .back-to-work { font-size: 0.9375rem; }
}

@media (prefers-reduced-motion: reduce) {
    .next-thumb { transition: opacity 200ms var(--ease-out); transform: none; }
    .next-project:hover .next-thumb,
    .next-project:focus-visible .next-thumb { transform: none; }
    .next-project:hover .next-arrow { transform: none; }
}

/* Tailwind's hover transforms live in tailwind.css, which carries no
   prefers-reduced-motion block of its own, and the hand-written reduced-motion
   rules above can only name hand-written classes. These five utilities are the
   site's whole hover motion vocabulary outside styles.css: the card cover
   scale, the J spin, the arrow badge rise, the card title slide and the gallery
   image scale. Under reduce the state change stays and the travel goes: the
   badge still appears, it just does not move to get there. Specificity matches
   Tailwind's own so no !important is needed, and styles.css loads second. */
@media (prefers-reduced-motion: reduce) {
    .translate-y-8,
    .hover\:scale-105:hover,
    .group:hover .group-hover\:scale-105,
    .group:hover .group-hover\:translate-y-0,
    .group:hover .group-hover\:translate-x-2,
    .group:hover .group-hover\:rotate-\[360deg\] {
        transform: none;
    }
}

/* The first screen on a phone was entirely interface. At 390 the hero runs
   about 784px against an 844px viewport (h1 measured at 252px, the rest is the
   declared box), so the first project cover started below the fold on a site
   whose whole argument is that the covers lead. The vertical budget is trimmed
   here rather than in the markup because the responsive spacing utilities this
   needs (pt-28, mt-10, pb-12, md:mb-12) are not compiled into tailwind.css and
   there is no build step to add them. Saving: 136px. */
@media (max-width: 767px) {
    .hero-first { padding-top: 8rem; padding-bottom: 2.5rem; }
    .hero-first h1 { margin-bottom: 2rem; }
    .hero-place { margin-top: 2.5rem; }
    .work-head { padding-top: 1.5rem; margin-bottom: 2rem; }
}

/* ALL WORK index at the foot of every project page. Two columns from md, so
   thirteen rows do not add a screen of scroll to a case study that is already
   long. The grid is hand-written because the column-gap token this needs is
   not compiled into tailwind.css. */
.work-index {
    display: grid;
    grid-template-columns: 1fr;
    column-gap: 3rem;
}

@media (min-width: 768px) {
    .work-index { grid-template-columns: 1fr 1fr; }
}

.work-index-row {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 1rem;
    padding: 0.75rem 0;
    border-bottom: 1px solid #f3f4f6;
    transition: border-color 200ms var(--ease-out);
}

a.work-index-row:hover { border-bottom-color: #111827; }

/* The project the reader is already on. It is not a link, so it carries no
   hover and no row slide, and the muted value is not the only signal. */
.work-index-here { border-bottom-color: #e5e7eb; }

/* The site's one client quote. It shipped in Tailwind's `italic`, which fakes a
   slant on a face that has no true italic, and the project rejected that once
   already. A hairline and an indent carry the quotation instead. */
.pull-quote {
    padding-left: 1.5rem;
    border-left: 1px solid #e5e7eb;
}

/* Skip link. Every page opened with the logo, three nav links and the mobile
   button before the content, and there was no way past them from a keyboard.
   It sits off screen until it takes focus, which is the only time it is
   wanted, and it is the first focusable thing in the document. */
.skip-link {
    position: absolute;
    left: 1.5rem;
    top: 0;
    z-index: 100;
    padding: 0.625rem 1rem;
    background: #111827;
    color: #fff;
    font-size: 0.875rem;
    font-weight: 500;
    letter-spacing: 0.02em;
    border-radius: 0 0 4px 4px;
    transform: translateY(-110%);
    transition: transform 180ms var(--ease-out);
}

.skip-link:focus-visible {
    transform: translateY(0);
    outline: 2px solid #fff;
    outline-offset: -5px;
}

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

/* The landmark takes focus from the skip link but is not a tab stop itself. */
main:focus { outline: none; }

/* Homepage card metadata. The grid is thirteen cards whose only differentiator
   was a Type tag with four values, seven of them reading "Product design", so
   the hero's claim about nineteen years was nowhere visible in the work. The
   year sits under the tag. Hand-written because items-end and text-right are
   not compiled into tailwind.css. */
.card-meta {
    display: flex;
    flex-direction: column;
    align-items: flex-end;
    gap: 0.5rem;
}

.card-year {
    font-size: 0.8125rem;
    color: #6b7280;
    letter-spacing: 0.02em;
    white-space: nowrap;
}
