/* ==========================================================================
   BUILDWEB — Refined High-Contrast Centered Hero Layout
   Palette: WHITE (#FFFFFF) | BLACK (#000000) | IVORY (#E6E2D3) | OFF-WHITE (#EEEEEE)
   ========================================================================== */

/* Fonts are requested by the <link> in the document head, not from here. An @import
   is discovered only after the stylesheet itself has downloaded and parsed, so it
   serialises what the preconnect above it was set up to parallelise. */

:root {
    /* Color Tokens */
    --color-white: #FFFFFF;
    --color-black: #000000;
    --color-ivory: #E6E2D3;
    --color-off-white: #EEEEEE;

    /* Channel form of ivory, for canvas strokes that need runtime alpha */
    --color-ivory-rgb: 230, 226, 211;

    /* And of black, for the same reason in the other direction: a scrim that
       has to fade the page's own background over something behind it — the
       pricing hero's proof field — needs black at a runtime alpha, and
       `rgba(0, 0, 0, x)` written inline would be the one literal on a page
       whose whole palette is four tokens. */
    --color-black-rgb: 0, 0, 0;

    /* Semantic Color Maps */
    --color-bg: var(--color-black);
    --color-surface: #0A0A0A;
    --color-text-main: var(--color-white);
    --color-text-sub: var(--color-off-white);
    --color-text-muted: rgba(238, 238, 238, 0.6);
    --color-accent: var(--color-ivory);
    --color-rule: rgba(238, 238, 238, 0.12);

    /* Typography */
    --font-heading: 'Instrument Sans', system-ui, -apple-system, sans-serif;
    --font-body: 'IBM Plex Sans', system-ui, -apple-system, sans-serif;
    --font-mono: 'IBM Plex Mono', monospace;

    /* Fluid Type Scale — both steps scale together so their ratio stays stable.
       The gap between them is the hierarchy: hero climbs, lead holds back. */
    --text-hero: clamp(2.25rem, 1.25rem + 3.6vw, 4rem);
    --text-lead: clamp(0.9375rem, 0.875rem + 0.25vw, 1.0625rem);

    /* Line Heights */
    --leading-tight: 1.1;
    --leading-body: 1.6;

    /* Measure (readable line length) */
    --measure-title: 22ch;
    --measure-lead: 48ch;

    /* Radii System */
    --radius-sm: 6px;
    --radius-md: 10px;
    --radius-full: 999px;

    /* Spacing Scale (8-Point System) */
    --space-4: 4px;
    --space-8: 8px;
    --space-12: 12px;
    --space-16: 16px;
    --space-20: 20px;
    --space-24: 24px;
    --space-32: 32px;
    --space-48: 48px;
    --space-64: 64px;
    --space-96: 96px;

    /* Container System */
    --container-max-width: 1000px;
    --container-padding: var(--space-24);

    /* Headroom the fixed dock needs. Zero by default, because below 768px the dock
       floats at the BOTTOM of the viewport and reserving space at the top would
       just open a gap. Any full-height section that centres its content owes the
       dock this much, or the content centres itself underneath it — which is
       exactly what the journey heading was doing: its top landed at y=65 against a
       dock occupying 24–77, a 12px collision that only showed up on shorter
       viewports because that is where the centred block runs out of room.

       Two tokens because the dock moves: exactly one of them is ever non-zero, so
       anything pinned to the top reads --dock-clear and anything pinned to the
       bottom reads --dock-clear-bottom, and neither has to know where the dock
       currently is. */
    --dock-clear: 0px;
    --dock-clear-bottom: 104px;

    /* Minimum comfortable target for a finger. Controls may look smaller than this;
       what matters is that the hit area does not. */
    --target-min: 44px;
}

@media (min-width: 768px) {
    :root {
        --container-padding: var(--space-48);
        /* Where the dock moves to the top: its 24px offset, the pill itself, and
           24px of air under it before content is allowed to start. The bottom of
           the viewport is free again from here up. */
        --dock-clear: 104px;
        --dock-clear-bottom: 0px;
    }
}

/* ---- The scale below 768px ----

   Both tokens above were written for a wide viewport and then left to bottom out
   on a phone, and bottoming out is the problem: `--text-hero`'s 3.6vw term only
   overtakes its own 2.25rem floor at 555px, so every phone got a flat 36px, and
   `--text-lead` bottomed out at 15px. Measured at 412x915 that is a 2.4x jump
   between the two sizes in the hero, and — the part that actually shows — 36px
   set the headline's longest line at 364px inside a 364px measure. The ink
   reached both gutter walls exactly, which is what makes a phone look like a
   desktop page that was squeezed rather than one that was set.

   The headline takes a real mobile ramp instead of a floor: 28px at 320, 28.9 at
   375, 30 at 412, capped at 40 before the breakpoint. That is the size at which
   this sentence sets in two lines with 10-15px of optical gutter beyond the 24px
   container padding, which is the number that was missing.

   The lead moves its FLOOR only — 0.9375rem to 1rem — and keeps the desktop
   formula untouched. The floor is the only part that was ever binding below
   768px (the slope term reaches just 15.9px at 767), so this raises body copy to
   16px across every phone width and is exactly continuous with desktop at the
   breakpoint: both sides resolve to 15.92px-vs-16px there. The headline cannot
   be continuous in the same way — desktop is already at 47.6px when the query
   flips — but 768 is where the dock moves overhead and the container padding
   doubles, so the type stepping there steps with everything else.

   Ratio after: 1.9x rather than 2.4x. Hierarchy, not two unrelated sizes. ---- */
@media (max-width: 767.98px) {
    :root {
        --text-hero: clamp(1.75rem, 1.1rem + 3vw, 2.5rem);
        --text-lead: clamp(1rem, 0.875rem + 0.25vw, 1.0625rem);
    }
}

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

html {
    font-family: var(--font-body);
    font-size: 16px;
    color: var(--color-text-main);
    background-color: var(--color-bg);
    line-height: 1.5;
    -webkit-font-smoothing: antialiased;
    /* Every tappable element below defines its own :active state, so the browser's
       default tap flash — a grey box with no relation to the ivory accent, and
       inconsistent between iOS and Android — is replaced rather than layered under it. */
    -webkit-tap-highlight-color: transparent;
}

/* Anchor links jump instantly by default, which loses the reader's place. Smooth is
   gated on the motion preference — the whole point of `reduce` is not being moved. */
@media (prefers-reduced-motion: no-preference) {
    html { scroll-behavior: smooth; }
}

/* The dock only sits at the top from 768px up; below that it floats at the bottom
   and clears nothing, so reserving headroom there would just open a gap. */
@media (min-width: 768px) {
    :target,
    section[id] {
        scroll-margin-top: var(--space-96);
    }
}

/* ==========================================================================
   FOCUS
   One ring, everywhere, on the accent. Keyboard users navigate entirely by this
   indicator, so it is defined once at the top rather than per component — a
   component that forgets to opt in is the failure mode being designed out.
   `:focus-visible` (not `:focus`) keeps it off mouse clicks without removing it
   from the keyboard, which is what makes the "remove the ugly outline" instinct
   unnecessary in the first place.
   ========================================================================== */

:focus-visible {
    outline: 2px solid var(--color-ivory);
    outline-offset: 2px;
    border-radius: var(--radius-sm);
}

/* Ivory-on-ivory would vanish where the ring lands on a filled control, so the
   offset carries it out onto the black page. Pill-shaped controls get a pill ring. */
.dock-link:focus-visible,
.dock-logo:focus-visible {
    outline-offset: 3px;
    border-radius: var(--radius-full);
}

/* Reachable by keyboard, out of the way of everyone else. Not `display: none`,
   which would take it out of the tab order and defeat the purpose. */
.skip-link {
    position: absolute;
    top: var(--space-8);
    left: var(--space-8);
    z-index: 1100;
    transform: translateY(-200%);
    padding: var(--space-12) var(--space-20);
    background-color: var(--color-ivory);
    color: var(--color-black);
    font-family: var(--font-body);
    font-size: 0.875rem;
    font-weight: 600;
    text-decoration: none;
    border-radius: var(--radius-md);
    transition: transform 160ms ease-out;
}

.skip-link:focus-visible {
    transform: translateY(0);
    outline-offset: 3px;
}

/* Screen-reader-only text that still occupies the accessibility tree. */
.visually-hidden {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip: rect(0 0 0 0);
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
}

/* An inline <svg> holding nothing but <symbol> definitions for <use> to
   instance. It paints nothing, but an inline element still reserves a line box,
   so it is taken out of flow rather than left to open a stray gap wherever it
   happens to sit in the document. */
.svg-sprite {
    position: absolute;
    width: 0;
    height: 0;
    overflow: hidden;
}

body {
    min-height: 100vh;
    display: flex;
    flex-direction: column;
    background-color: var(--color-bg);
}

.site-container {
    width: 100%;
    max-width: var(--container-max-width);
    margin: 0 auto;
    padding: 0 var(--container-padding);
}

/* ==========================================================================
   FLOATING PILL DOCK NAVIGATION
   ========================================================================== */

.dock-header {
    position: fixed;
    left: 0;
    right: 0;
    z-index: 1000;
    display: flex;
    justify-content: center;
    pointer-events: none;
    bottom: var(--space-24);
    padding: 0 var(--space-16);
}

.floating-dock-nav {
    width: 100%;
    max-width: 480px;
    display: flex;
    justify-content: center;
}

/* The pill itself. Only the chrome and `align-items` live here, because the two
   widths lay their contents out differently and both override `display` anyway. */
.dock-container {
    pointer-events: auto;
    background-color: rgba(0, 0, 0, 0.9);
    /* Prefixed first, unprefixed second, the same shape every mask-image in this
       file already uses. Safari only unprefixed this in 18.0 — every iOS 17 and
       earlier phone reads the prefixed line and nothing else, and this site's
       audience is largely on phones. Without it the dock lost its blur on those
       devices and became a flat 0.9-alpha panel with the page legible through it.
       The rgba background is what keeps that failure survivable rather than
       invisible: with no colour underneath, an unsupported blur is a transparent
       bar. Note the two are NOT interchangeable in order — a browser supporting
       both must land on the standard property last. */
    -webkit-backdrop-filter: blur(16px);
            backdrop-filter: blur(16px);
    border: 1px solid var(--color-rule);
    border-radius: var(--radius-full);
    padding: 4px var(--space-8);
    display: flex;
    align-items: center;
    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8);
    width: 100%;
}

/* Two of these, one either side of the mark. Boxes only from 768px up: on phones
   the rule further down flattens them so all five things in the pill share one
   distribution. */
.dock-menu {
    display: flex;
    align-items: center;
    /* Guarantees the 8px minimum between adjacent targets no matter how the free
       space is distributed. */
    gap: var(--space-8);
    list-style: none;
    padding: 0;
    margin: 0;
}

.dock-menu-start { justify-content: flex-start; }
.dock-menu-end { justify-content: flex-end; }

.dock-item {
    display: flex;
    align-items: center;
}

.dock-link {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    /* The pill reads as ~34px of ink, but a fingertip needs 44. Padding sets the
       look; min-height/min-width set the hit area, and they are not the same number. */
    min-height: var(--target-min);
    min-width: var(--target-min);
    padding: var(--space-8) var(--space-16);
    border-radius: var(--radius-full);
    color: var(--color-text-sub);
    text-decoration: none;
    font-family: var(--font-body);
    font-weight: 500;
    font-size: 0.875rem;
    white-space: nowrap;
    transition: background-color 200ms ease, color 200ms ease, padding 200ms ease;
}

/* Matched on the attribute's PRESENCE, not on one of its values. Two different
   values are both correct here and both in use: the scrollspy writes "true" for the
   section of this page you are currently inside, and a link to a different page that
   IS the page you are on carries "page", which is the value ARIA defines for exactly
   that. Pinning the selector to "true" meant contact.html and pricing.html rendered a
   dock with nothing selected at all — and nothing else ever sets this attribute, so
   presence alone is already the precise test. */
.dock-link[aria-current] {
    background-color: var(--color-ivory);
    color: var(--color-black);
    padding: var(--space-8) var(--space-20);
}

/* Hover only where a real pointer exists. On touch, :hover latches after a tap
   and would leave a second tab stuck open next to the selected one. */
@media (hover: hover) {
    .dock-link:hover {
        background-color: var(--color-ivory);
        color: var(--color-black);
        padding: var(--space-8) var(--space-20);
    }
}

/* :active has no such latching problem — it clears the instant the finger lifts —
   so it is not gated behind (hover: hover). A lighter fill than the full selected
   state, so a press on an unselected tab reads as "registering" rather than as
   having already navigated before the scroll catches up. */
.dock-link:active {
    background-color: rgba(230, 226, 211, 0.18);
}

.dock-icon {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
    color: inherit;
    transition: color 200ms ease;
}

.dock-label {
    display: inline-block;
    max-width: 0;
    opacity: 0;
    overflow: hidden;
    margin-left: 0;
    white-space: nowrap;
    transition: max-width 300ms ease, opacity 250ms ease 50ms, margin-left 300ms ease;
}

.dock-link[aria-current] .dock-label {
    max-width: 140px;
    opacity: 1;
    margin-left: var(--space-8);
}

@media (hover: hover) {
    .dock-link:hover .dock-label {
        max-width: 140px;
        opacity: 1;
        margin-left: var(--space-8);
    }
}

/* ---- Phone layout: five items, one rhythm ----

   Placed after the base .dock-menu / .dock-item rules on purpose — same specificity,
   so source order is what makes these win.

   The pill was `display: grid; grid-template-columns: 1fr auto 1fr`, which nailed the
   BW mark to its exact midpoint and gave each list half of what was left. The mark
   did land dead centre — and the bar still read as broken, because the two halves do
   not hold the same amount of ink. The selected item wears its label, so at 375px the
   left side carried 124px (Home 76 + gap + Pricing 44) against the right's 92, and
   each list packed to its own outer edge. Measured x-positions: Home 17, Pricing 97,
   mark 170, Journey 267, Contact 315 — spacings of 4, 29, [mark], 62, 4. Two clusters
   shoved against the ends with a 62px hole beside the mark.

   Equal columns cannot fix that: whichever side is holding the label is the side with
   no slack, so the slack all collects in the one gap on the other side. The only
   distribution that survives the label moving between items is one that treats all
   five as peers, so the lists are flattened with `display: contents` and a single
   space-between spreads the same amount between every neighbour. With Home selected
   at 375px that is 243px of content in 341px — four gaps of 24 or 25.

   What is traded is the mark's fixed midpoint: it now sits where the items leave it.
   Measured across all four selections at 375px it lands between 19px right of centre
   (Pricing selected) and 31px left (Contact us, the widest label) — and the gaps stay
   even in every one of those states: 25, 23, 21, 17px as the selected pill grows. It
   moves under the transition the label expansion already runs on, and an even bar
   that shifts beats a rigid one with a hole in it.

   `flex-shrink: 0` on the items is the floor the grid's min-content columns used to
   provide. Without it a flex item can render narrower than the link inside it and the
   icons draw on top of the mark — the failure logged when this was first built with
   `flex: 1 1 0; min-width: 0`: at 320px the right-hand list rendered 118px wide
   holding 166px of links, and Journey's icon landed on the mark. Overflowing the
   pill's end is recoverable; overlapping is not.

   The 320px budget still holds, because at that width there is no slack to spread:
   every gap sits on the 4px floor the next block sets and the worst case — Contact
   us expanded, on contact.html — measures the same to the pixel as the grid did. It
   runs 3px into the pill's 8px of right padding there, which it also did before.

   767.98, not 767: a viewport can land on a fraction (density scaling, browser zoom),
   and 767.5 matches neither `max-width: 767px` nor `min-width: 768px`. The pill would
   then fall back to a bare flex row for that band — no distribution, no centring, and
   the wordmark unclipped in a bar with no room for it. The .98 closes the band. */
@media (max-width: 767.98px) {
    .dock-container {
        justify-content: space-between;
        /* A floor, not the spacing: space-between hands out everything above it.
           It only binds at the narrow end, where there is nothing to hand out. */
        gap: var(--space-8);
        /* The base border is 12% white — nearly invisible against the page behind
           it once the pill is floating over content instead of a page edge. Phones
           are the only place it floats over arbitrary content, so the brighter edge
           and the extra outline ring stay scoped here rather than on the shared rule. */
        border-color: rgba(230, 226, 211, 0.3);
        box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8), 0 0 0 1px rgba(230, 226, 211, 0.08);
    }

    /* No box, so the four items and the mark become siblings in one flex row and one
       distribution covers all of them. The <ul>s keep their list semantics — a
       display:contents element stays in the accessibility tree, and the role="list"
       in the markup covers the engines that drop it anyway. */
    .dock-menu {
        display: contents;
    }

    .dock-item {
        flex-shrink: 0;
    }

    /* The selected item gets no filled pill and no label on a phone — both were asked
       for, in that order. Ivory ink against the other items' off-white is what carries
       over from the box, and on its own it would not be enough: #E6E2D3 against
       #EEEEEE is a difference in warmth, not brightness, and outdoors the bar would
       read as four identical icons. The dot below is what actually marks the page; the
       warmth just agrees with it.

       With the label collapsed, every link is the same box at every width — the icon
       is 18px and both the 12px and 16px paddings land under the 44px min-width floor,
       so all four measure 44 below 400px and 50 above it. The pill has no uneven item
       left in it. */
    .dock-link[aria-current] {
        background-color: transparent;
        color: var(--color-accent);
        padding: var(--space-8) var(--space-16);
    }

    .dock-link[aria-current] .dock-label {
        max-width: 0;
        opacity: 0;
        margin-left: 0;
    }

    /* The marker the label used to be. A dot, because it has to say "this one" without
       saying it at the width of a word: it is drawn inside the link's own box, so no
       item changes size and the even spacing this pass built survives. 4px across,
       sitting 4px under the icon — the icon ends 13px above the link's bottom edge
       inside its 44px box, so `bottom: 5px` lands it in that space.

       No aria-hidden and no faked label: an empty ::after is already invisible to a
       screen reader, and the aria-current on the link is the announcement. The dot is
       the sighted half of the same statement. */
    .dock-link {
        position: relative;
    }

    .dock-link[aria-current]::after {
        content: '';
        position: absolute;
        bottom: 5px;
        left: 50%;
        width: 4px;
        height: 4px;
        margin-left: -2px;
        border-radius: 50%;
        background-color: var(--color-accent);
    }

    /* ---- Tucking the bar out of the way ----

       The transform is on the nav, not on .dock-header, and that is not a preference:
       the GSAP entrance tween in main.js animates the header's `y` and leaves an
       inline transform behind, which beats anything this stylesheet says about it. The
       nav is the header's only child, so moving it moves the pill.

       Far enough to clear the pill (53px), the gap it floats above (24px) and the 32px
       shadow blur beneath it — stop at the first two and the shadow stays behind as a
       smudge along the bottom edge after the bar itself has gone. */
    .floating-dock-nav {
        transition: transform 320ms cubic-bezier(0.4, 0, 0.2, 1);
    }

    .dock-header.is-tucked .floating-dock-nav {
        transform: translateY(calc(100% + var(--space-24) + var(--space-32)));
    }
}

/* Under `reduce` the bar never tucks — main.js reads the same preference and leaves
   the class off. This is the belt to that braces: if the class ever does land, the bar
   must not slide to get there. */
@media (max-width: 767.98px) and (prefers-reduced-motion: reduce) {
    .floating-dock-nav {
        transition: none;
    }
}

/* The base hover rule paints the same ivory box, and it is gated behind (hover: hover)
   — which a laptop window dragged narrower than 768px still satisfies. Undo it on the
   same terms, or the box the selected item just lost comes straight back under the
   pointer. Nothing to undo on a touch screen, so this stays gated too. */
@media (max-width: 767.98px) and (hover: hover) {
    .dock-link:hover {
        background-color: transparent;
        color: var(--color-accent);
        padding: var(--space-8) var(--space-16);
    }

    /* Same for the label it expands: leaving this one would put a word under the
       pointer that the selected item is no longer allowed to show. */
    .dock-link:hover .dock-label {
        max-width: 0;
        opacity: 0;
        margin-left: 0;
    }
}

/* These trims were fit surgery, and the thing they were fitting is gone.

   The history is worth keeping, because it is the record of what this bar costs. A
   rule that suppressed the selected item's label below 480px used to live here, then
   was removed as too aggressive; then a fourth item (Journey) was added and the label
   stopped fitting for real — on contact.html at 320px, "Contact us" expanded, the
   contents came to 309px inside 294px of usable width, 15px over. Rather than drop
   the word, 20px came out of the trim: header padding 12→8, the expanded pill's own
   padding 12→8, and the gap between its icon and its label 8→4. That bought 5px of
   headroom on the narrowest screen anyone still ships.

   The label is now suppressed below 768px by request, so none of that is load-bearing
   any more: at 320px the four 44px targets and the mark total 211px inside 286px. The
   trims stay because a tighter bar on a 320px screen is the right look anyway, and
   because the 8px header padding is what keeps the pill off the screen edge — but
   they are headroom now, not necessity. The rule that shortened the icon-to-label gap
   went with the label.

   Budget for a fifth item, which used not to fit: it does now. A 44px target plus its
   gap is 48px and there are 59px spare at 320px. */
@media (max-width: 400px) {
    .dock-header {
        padding: 0 var(--space-8);
    }

    /* On the container now, not the lists: below 768px the lists generate no box,
       so the spacing between every pair of items is the container's. This block
       follows the phone layout above, so 4 wins over its 8 at these widths. */
    .dock-container {
        gap: var(--space-4);
    }

    /* All three states in one list on purpose. The selected and hovered states each
       carry their own padding from the blocks above, and both outrank a bare
       `.dock-link` on specificity — set only the plain one here and the other two
       would stay at 16px and render 50px wide next to 44px neighbours, which is the
       uneven row this pass exists to get rid of. */
    .dock-link,
    .dock-link[aria-current],
    .dock-link:hover {
        padding: var(--space-8) var(--space-12);
    }
}

/* ---- Brand mark ----

   The BW monogram, traced to vector from the only logo file in the repo — a
   background-removed PNG that was 90% empty canvas around a 190×100 mark, carried
   blue/cyan halo on 29% of its opaque pixels, and was pure white rather than the
   brand ivory. The silhouette was sound, so the ALPHA channel was traced and the
   colour discarded: the fringing lived in RGB on pixels that were already the
   right shape.

   This was painted as a CSS mask so it could inherit `currentColor` from the link.
   That reserved its box but painted nothing outside Chromium — a mask pulls its
   image as a style resource, which stricter engines refuse to load cross-origin,
   and every file:// document counts as its own origin. The mark simply vanished,
   leaving a 40px hole beside the wordmark. An <img> loads under the far looser
   rules for embedded content, so it renders the same everywhere.

   The trade is `currentColor`: an <img> cannot inherit it, so the file ships with
   the fill baked in. Both lockups that use it — the dock and the footer — are
   --color-white, so one asset still covers both. What is lost is the mark tracking
   the ivory :active shift; the wordmark beside it still makes that state visible. */
.brand-mark {
    display: block;
    flex: none;
    width: 1.75em;
    height: auto;
    aspect-ratio: 190 / 100;
}

/* The logo shows at every width. On phones the dock has no room for the wordmark
   beside five items, so only the BW mark is painted — but the wordmark is clipped
   rather than display:none'd, because the mark is aria-hidden and removing the text
   outright would leave this link with no accessible name at all on mobile. */
.dock-logo {
    display: inline-flex;
    align-items: center;
    flex: none;
    gap: var(--space-4);
    min-height: var(--target-min);
    padding: 0 var(--space-4);
    font-family: var(--font-heading);
    font-weight: 700;
    font-size: 0.875rem;
    letter-spacing: 0.04em;
    color: var(--color-white);
    text-decoration: none;
    transition: color 200ms ease;
}

.dock-logo:active {
    color: var(--color-ivory);
}

/* Sized against the wordmark's CAP HEIGHT, not the em box. `align-items: center`
   already centres the two boxes to within a fifth of a pixel, so the mark reading
   as misaligned was never a centring bug — it was a proportion one. At the 2.5em
   this rule used to set, the mark stood 21px tall against 13px caps and overhung
   the text by 4px top and bottom; two elements that share no optical band read as
   misaligned however precisely they are centred. 1em tall lands ~1.5px over the
   caps, which is the same ratio the footer lockup already uses and reads as one
   unit. Driven from height with width:auto so the 190:100 aspect-ratio derives the
   width (1.9em) — the mark stays tied to the type size at every breakpoint. */
.dock-logo .brand-mark {
    width: auto;
    height: 1em;
}

/* .98 for the same reason as the phone-layout block above: this and `min-width: 768`
   have to meet, or a fractional viewport gets neither.

   This block sits below `.dock-logo` rather than up with the rest of the phone layout
   because it has to: same specificity, so it only wins from here. */
@media (max-width: 767.98px) {
    /* The mark's padding, brought up to the links'. 4px was invisible while the items
       either side wore filled pills; now that every item is bare ink on one
       background the eye measures glyph to glyph, and 4px left the mark sitting
       nearer its neighbours than they sit to each other — 50px against 59px at 375px.
       Affordable only now: suppressing the label freed ~75px at 320px. */
    .dock-logo {
        padding: 0 var(--space-12);
    }

    .dock-wordmark {
        position: absolute;
        width: 1px;
        height: 1px;
        margin: -1px;
        padding: 0;
        overflow: hidden;
        clip: rect(0 0 0 0);
        clip-path: inset(50%);
        white-space: nowrap;
        border: 0;
    }
}

@media (min-width: 768px) {
    .dock-header {
        bottom: auto;
        top: var(--space-24);
        padding: 0 var(--space-24);
    }

    /* 600, and it was 780. The pill is a fixed width holding a variable amount of
       ink, and at 780 the difference was not slack, it was a hole: measured across
       all four pages the contents come to 422–452px inside 32px of padding, so 296
       to 326px of the bar — 40% of it — was empty in the middle, between the
       wordmark and the first icon. On a page with a full hero underneath it that
       reads as a wide bar. Over the journey hero, which is one word and a line of
       copy on an otherwise black screen, the bar's own emptiness is the second
       empty thing on the screen and the two compound.

       The width has to stay FIXED rather than shrink to fit, and that is what sets
       the floor. Hovering an item expands its label (see the (hover: hover) rule on
       .dock-label), so a content-width pill would resize under the pointer and slide
       every item sideways on a mouse-over. Sized to the worst case instead, the
       expansion is absorbed and only the items move:

         452  the widest resting state (contact, "Contact us" selected)
         +76  the widest label a hover can open next to it
          +8  that label's margin
          +8  the selected/hovered pill's extra padding, both sides
         +32  the container's own padding
         ---
         576  worst case, on the page that has the least room to give

       600 leaves 24px over that and takes 180px out of the hole. Anything below
       ~580 starts clipping a hovered label on contact, which is the failure this
       number exists to avoid. */
    .floating-dock-nav {
        max-width: 600px;
    }

    /* Back to flex from the phone layout's `1fr auto 1fr` grid: at this width the
       mark belongs at the left with the items packed to the right, which is a
       one-dimensional row and nothing the centring grid is for. */
    .dock-container {
        display: flex;
        padding: var(--space-8) var(--space-16);
        /* 8px rather than 24: with the mark ordered out of the way this gap now
           falls BETWEEN the two lists, where it has to match their own 8px item
           gap or the four items read as two separate groups. The mark keeps its
           distance from them by taking all the slack via margin-right instead. */
        gap: var(--space-8);
    }

    /* Back to the left, and the two lists close up beside each other on the right —
       the single row this dock has always been at this width. `order` rather than a
       different DOM order, because the phone layout needs the mark in the middle of
       the markup and only one of the two can own source order. */
    .dock-logo {
        order: -1;
        margin-right: auto;
    }

    .dock-menu {
        flex: 0 0 auto;
        justify-content: flex-start;
    }

    /* From tablets up there is room for the wordmark to stand beside the mark, so
       the logo grows into the full lockup. Everything else it needs — the hit area,
       the colour, the :active state — it already carries from the base rule. */
    .dock-logo {
        gap: var(--space-12);
        font-size: 1rem;
        padding: 0 var(--space-12) 0 var(--space-8);
    }
}

/* ==========================================================================
   PROPORTIONAL CENTERED HERO SECTION
   ========================================================================== */

.hero-centered {
    position: relative;
    overflow: hidden;
    min-height: 100vh;
    display: flex;
    align-items: center;
    justify-content: center;
    background-color: var(--color-black);
    /* Was a flat 64px, which is less than the 104px the dock occupies at the top
       from 768px up — the same latent collision the journey section had, and it
       would have surfaced on any short viewport once the headline wrapped to three
       lines. `max()` keeps the original 64px on phones, where --dock-clear is 0
       because the dock sits at the bottom instead. */
    padding-top: max(var(--space-64), var(--dock-clear));
    padding-bottom: var(--space-64);
    text-align: center;
}

/* Gravitational distortion grid — sits behind the hero copy */
.gravity-grid {
    position: absolute;
    inset: 0;
    display: block;
    width: 100%;
    height: 100%;
    pointer-events: none;
    z-index: 0;
}

.hero-centered .site-container {
    position: relative;
    z-index: 1;
}

.hero-center-wrapper {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    text-align: center;
    gap: var(--space-20);
    max-width: 880px;
    margin: 0 auto;
}

/* With the two CTAs gone the home hero is down to a headline and one line of
   copy, so 20px reads as cramped against the empty space around it. Scoped to
   #hero rather than raised on the shared wrapper: the pricing hero leads with an
   eyebrow label, which has to sit tight to the heading it labels — widening the
   gap there would orphan it from its own title. */
#hero .hero-center-wrapper {
    gap: var(--space-32);
}

.hero-title {
    font-family: var(--font-heading);
    font-size: var(--text-hero);
    font-weight: 600;
    line-height: var(--leading-tight);
    color: var(--color-white);
    letter-spacing: -0.02em;
    margin: 0 auto;
    text-align: center;
    max-width: var(--measure-title);
}

/* The caret takes NO width in the line box, and that is the entire point.

   It is an atomic inline, which UAX #14 treats as an object — so a line-break
   opportunity exists between the headline's last character and this, whether or
   not there is whitespace in the markup. At 320px the last line measured 263px
   against a 272px measure with 21px of caret to place, so the break fired and
   the caret took a LINE OF ITS OWN: a stray backslash under "template.", and a
   headline 159px tall instead of 119px. Four lines to set three lines of type.

   A zero-width box always fits, so the break never fires; `overflow` stays
   visible, so the glyph still paints. It renders 3px inside the title's own
   right edge at 320px, which is the tightest case on the site — nothing clips,
   and nothing else in the line box moves.

   Verified a no-op from 900px up: desktop sets this headline in two lines with
   room to spare, so the caret was never breaking there and the box it occupies
   was never load-bearing. This is not a mobile override; it is the rule being
   correct at every width. */
.flashing-cursor {
    display: inline-block;
    width: 0;
    color: var(--color-ivory);
    font-family: var(--font-mono);
    font-weight: 400;
    margin-left: 3px;
    animation: cursor-blink 1s steps(2, start) infinite;
}

@keyframes cursor-blink {
    0%, 100% { opacity: 1; }
    50% { opacity: 0; }
}

/* An infinite blink is exactly the kind of perpetual motion `reduce` is asking to be
   spared. The cursor still renders — it is part of the headline's look — it just
   stops flashing. main.js skips the typing itself under the same query. */
@media (prefers-reduced-motion: reduce) {
    .flashing-cursor { animation: none; }
}

.hero-subheadline {
    font-family: var(--font-body);
    font-size: var(--text-lead);
    line-height: var(--leading-body);
    color: var(--color-off-white);
    max-width: var(--measure-lead);
    margin: 0 auto;
    text-align: center;
    text-wrap: pretty;
    opacity: 0.9;
}

/* ---- Scroll cue ----

   Sits at the foot of the hero, clear of whichever end the dock is currently
   parked at. A label and a rail with a mark running down it: the same mono-label
   plus geometric-primitive vocabulary the rest of the page is drawn from, rather
   than a bouncing chevron borrowed from somewhere else. */

.scroll-hint {
    position: absolute;
    left: 50%;
    bottom: calc(var(--space-32) + var(--dock-clear-bottom));
    transform: translateX(-50%);
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--space-12);
    pointer-events: none;
    transition: opacity 400ms ease;
}

/* Retires itself once the reader has demonstrably got the message. Set from
   main.js on the first real scroll. */
.scroll-hint.is-done { opacity: 0; }

.scroll-hint-label {
    font-family: var(--font-mono);
    font-size: 0.625rem;
    font-weight: 600;
    letter-spacing: 0.18em;
    text-transform: uppercase;
    color: var(--color-text-muted);
}

.scroll-hint-rail {
    position: relative;
    display: block;
    width: 1px;
    height: 48px;
    background-color: rgba(230, 226, 211, 0.22);
    overflow: hidden;
}

.scroll-hint-dot {
    position: absolute;
    top: 0;
    left: 0;
    display: block;
    width: 1px;
    height: 16px;
    background-color: var(--color-ivory);
    animation: scroll-hint-travel 2s cubic-bezier(0.65, 0, 0.35, 1) infinite;
}

@keyframes scroll-hint-travel {
    0%        { transform: translateY(-16px); opacity: 0; }
    25%, 65%  { opacity: 1; }
    100%      { transform: translateY(48px); opacity: 0; }
}

/* Same call as the headline cursor and the LIVE dot: the indefinite loop stops,
   the cue itself stays. A static mark at the top of the rail still reads as
   "there is further to go" without anything moving. */
@media (prefers-reduced-motion: reduce) {
    .scroll-hint-dot { animation: none; }
}

/* ==========================================================================
   HERO STAGE — MACBOOK / EDITOR

   The hero's product shot. A laptop rising out of the fold with the editor this
   page was written in on its screen, cropped by the bottom edge so the page
   reads as continuing past it.

   Two things carry the illusion and neither is a photograph: the deck below the
   lid (a sliver wider than the screen, lit along its hinge, notched at the
   front), and the fact that everything inside the screen scales from ONE
   number. .mb-screen is a container, .vs sets its font-size in cqw, and every
   measurement below is in em — so the whole editor, chrome included, resolves
   at whatever size the laptop happens to be. There are no breakpoint-by-
   breakpoint font sizes here because there is nothing to re-decide.
   ========================================================================== */

/* The hero stops centring one block and becomes a column: copy at the top,
   laptop beneath it taking the slack. .hero-centered already has overflow:
   hidden, which is what crops the laptop against the fold. */
.hero-has-stage {
    flex-direction: column;
    /* flex-start, not centre, and no margin autos: the copy is anchored under
       the dock and the laptop follows at a fixed distance, so the gap between
       them does not swing with viewport height. Centring the column would crop
       the headline off the top on short screens, which is the one thing here
       that must never be cut. */
    justify-content: flex-start;
    align-items: stretch;
    padding-bottom: 0;

    /* Exactly one screen. `height`, not `min-height`, because min-height lets
       the laptop push the section to ~135vh — the reader then scrolls a screen
       and a third of hero before reaching anything else. The laptop is cropped
       against this edge and faded out by ::after below, which is the whole
       composition: the machine continues past the fold rather than ending.

       min-height is the floor for landscape phones, where 100vh is under 400px
       and the headline itself would otherwise be clipped. Below that the
       section grows and the laptop simply goes further off-screen. */
    height: 100vh;
    min-height: 560px;
}

.hero-stage {
    position: relative;
    z-index: 1;
    display: flex;
    justify-content: center;
    /* Every pixel here is one fewer pixel of editor on screen: with the hero
       pinned to 100vh, the dock (104) and the copy (~322) already claim most of
       it. Kept tight on short viewports, allowed to breathe on tall ones where
       the room exists. */
    margin-top: clamp(var(--space-16), 2.5vh, var(--space-48));
}

/* The fade. The laptop runs past the bottom of a 100vh hero and
   .hero-centered's overflow:hidden would otherwise cut it dead on a hard line,
   which reads as a rendering fault rather than as depth. Dissolving it into the
   page's own black over the last 200px turns the crop into the thing the
   reference shot does with its cards — the object continues, the light just
   runs out — and gives the scroll cue a ground dark enough to read against.

   Stops at 0.97 rather than 1: a hair of the machine still showing at the very
   bottom edge is what says "cropped" instead of "faded to nothing".

   z-index 2: over the laptop (1), under the cue (3). */
.hero-has-stage::after {
    content: "";
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    /* 140, not 200. At 200 the fade began at the exact pixel the first line of
       code did on a 720px-tall window, so the entire file was dimming and the
       only thing rendering at full strength was the chrome — the opposite of
       the point. This leaves a band of clear code under the breadcrumbs before
       anything starts dissolving. */
    height: 140px;
    z-index: 2;
    pointer-events: none;
    background: linear-gradient(to bottom,
        rgba(0, 0, 0, 0) 0%,
        rgba(0, 0, 0, 0.45) 35%,
        rgba(0, 0, 0, 0.85) 68%,
        rgba(0, 0, 0, 0.97) 100%);
}

/* Back to the base rule's bottom anchor now the hero is one screen again. It
   was re-anchored to 100vh while the hero was allowed to grow, and with a 100vh
   hero that override put it a third of the way UP the screen, on top of the
   code — which is the "SCROLL" sitting across line 2 in the reference shot. */
.hero-has-stage .scroll-hint {
    bottom: calc(var(--space-24) + var(--dock-clear-bottom));
    z-index: 3;
}

/* The cue now sits over the faded bottom of the editor rather than over bare
   black, and the fade alone only gets that area to about 60% at the cue's top
   edge — enough for the code behind to compete with a 10px mono label. A soft
   local pool of black under just the cue costs nothing and makes it
   unconditional, whatever the laptop happens to be showing there. */
.hero-has-stage .scroll-hint::before {
    content: "";
    position: absolute;
    left: 50%;
    top: 50%;
    width: 200px;
    height: 150px;
    transform: translate(-50%, -50%);
    z-index: -1;
    pointer-events: none;
    background: radial-gradient(ellipse at center,
        rgba(0, 0, 0, 0.85) 0%,
        rgba(0, 0, 0, 0.5) 45%,
        transparent 72%);
}

/* The pool of light the laptop sits in. Sterling's cards have one; without it a
   dark object on a dark page has no reason to be where it is. Ivory at 0.05,
   the panel-fill tint already in use, rather than a new colour. */
.stage-glow {
    position: absolute;
    left: 50%;
    top: 30%;
    width: min(1100px, 120vw);
    aspect-ratio: 2 / 1;
    transform: translate(-50%, -30%);
    background: radial-gradient(
        ellipse at center,
        rgba(230, 226, 211, 0.10) 0%,
        rgba(230, 226, 211, 0.04) 35%,
        transparent 70%
    );
    pointer-events: none;
}

/* Deliberately wider than the copy above it, and wider than it needs to be to
   fit — only the top third is ever on screen, so width is bought purely to make
   the SCREEN big. A big screen is what lets the code sit at a realistic ratio to
   it (real VS Code renders 13px type in a ~1440pt window, just under 1% of the
   width) while still landing at ~10 actual pixels. The earlier 800px version
   had to run the type at 1.28% of screen width to stay legible, and that is
   precisely what made it read as a zoomed-in screenshot rather than an editor. */
.macbook {
    position: relative;
    width: min(1040px, 94vw);
}

.mb-lid {
    position: relative;
    /* The bezel. Bottom is deeper than the sides, as it is on the real machine —
       equal bezels all round is the tell that a laptop mock was drawn by
       someone who did not look at one. */
    padding: 1.1% 1.1% 2.2%;
    /* Tokens, not the 12px/5px a photograph of a lid would suggest. An earlier
       pass had both, which put a fourth and fifth radius on a site whose token
       contract allows three — the same defect the pricing cards were rewritten
       to remove. 10/6 is within a pixel of the real corner at this size. */
    border-radius: var(--radius-md) var(--radius-md) var(--radius-sm) var(--radius-sm);
    background: linear-gradient(180deg, #2A2C30 0%, #17181B 55%, #0E0F11 100%);
    /* A single hairline, not a shadow: the site has no shadow scale and this is
       an object on a black page, so its edge is the only thing separating it. */
    border: 1px solid rgba(230, 226, 211, 0.16);
}

/* The screen glass. inline-size container: everything inside is sized in em off
   .vs's cqw font-size, so the editor scales with the laptop and nothing inside
   needs a media query of its own. */
.mb-screen {
    container-type: inline-size;
    position: relative;
    aspect-ratio: 16 / 10;
    /* Sub-8px optical finish, same class of exception as the mock browser's 2px
       corners — the glass inside a bezel, not a layout radius. */
    border-radius: 3px;
    overflow: hidden;
    background-color: #0B0C0E;
}

/* Deck. Wider than the lid because that is how a MacBook is built, with the
   hinge line picked out along the top edge and the corners rounded only at the
   front. */
.mb-base {
    position: relative;
    width: 106%;
    height: 1.35%;
    margin-left: -3%;
    border-radius: 0 0 var(--radius-sm) var(--radius-sm);
    background: linear-gradient(180deg, #3A3C41 0%, #1D1E21 40%, #0F1012 100%);
    /* A shallow trapezoid — the deck's front edge is nearer the eye than its
       back, so it reads a touch narrower. */
    clip-path: polygon(0 0, 100% 0, 98.8% 100%, 1.2% 100%);
}

/* The thumb notch on the front lip. Small, centred, and the single detail that
   most reliably says "MacBook" rather than "laptop". */
.mb-notch {
    position: absolute;
    left: 50%;
    bottom: 0;
    transform: translateX(-50%);
    width: 11%;
    height: 55%;
    border-radius: 0 0 40% 40%;
    background-color: rgba(0, 0, 0, 0.55);
}

/* ---- The editor ----

   VS Code "Dark Modern" — the current default — reproduced rather than
   approximated: its chrome greys, its Dark+ token colours, its 2-space-indented
   CSS. This is the one place on the site that carries colour outside the ivory
   accent, and it carries it because a code editor rendered in one hue does not
   read as a code editor. It is a picture OF a screen, not a surface of the page,
   which is what earns it the exemption.

   Every colour is a token here. Dialling the whole thing back to monochrome, or
   swapping in a different theme, is an edit to this block and nothing else. */
.vs {
    /* Chrome — Dark Modern */
    --vs-editor: #1F1F1F;
    --vs-chrome: #181818;
    --vs-rule: #2B2B2B;
    --vs-fg: #CCCCCC;
    --vs-dim: #9D9D9D;
    --vs-line-no: #6E7681;
    --vs-blue: #0078D4;

    /* Syntax — Dark+ token colours */
    --vs-comment: #6A9955;
    --vs-selector: #D7BA7D;
    --vs-prop: #9CDCFE;
    --vs-string: #CE9178;
    --vs-number: #B5CEA8;
    --vs-fn: #DCDCAA;
    --vs-keyword: #CE9178;
    --vs-punct: #D4D4D4;

    position: absolute;
    inset: 0;
    display: flex;
    flex-direction: column;
    background-color: var(--vs-editor);
    color: var(--vs-fg);
    font-family: var(--font-mono);

    /* THE most important declaration in this block. .hero-section sets
       text-align: center for the headline, and it cascaded straight through the
       laptop into the editor — every line of code, every filename in the tree
       and the word EXPLORER were being individually centred. Code has a hard
       left edge; without one it does not read as code at any size, no matter
       how accurate the colours are. This was the single reason the screen did
       not look like VS Code. */
    text-align: left;

    /* The one knob for the CHROME. Everything in the chrome is em off this; the
       code itself steps down from it once, in .vs-code, so the file can be dense
       without shrinking the tabs and status bar into illegibility with it. */
    font-size: 1.15cqw;
    line-height: 1.6;
}

.vs-bar {
    flex: none;
    display: flex;
    align-items: center;
    gap: 0.45em;
    padding: 0.5em 0.85em;
    background-color: var(--vs-chrome);
    border-bottom: 1px solid var(--vs-rule);
}

/* The real traffic lights. They are the one place the mock is allowed to be
   literal — a grey trio reads as "a window", the coloured trio reads as "a Mac". */
.vs-dot {
    width: 0.6em;
    height: 0.6em;
    border-radius: var(--radius-full);
}

.vs-dot:nth-child(1) { background-color: #FF5F57; }
.vs-dot:nth-child(2) { background-color: #FEBC2E; }
.vs-dot:nth-child(3) { background-color: #28C840; }

.vs-bar-title {
    margin: 0 auto;
    /* Sits optically centred against the three dots on the left by being pushed
       from both sides; the right-hand auto margin is the padding-right below. */
    padding-right: 2.4em;
    font-size: 0.8em;
    color: var(--vs-dim);
    white-space: nowrap;
    overflow: hidden;
}

.vs-body {
    flex: 1 1 auto;
    display: flex;
    min-height: 0;
}

.vs-rail {
    flex: none;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 1.1em;
    padding: 0.85em 0.65em;
    background-color: var(--vs-chrome);
    border-right: 1px solid var(--vs-rule);
}

.vs-rail-icon {
    width: 1.2em;
    height: 1.2em;
    fill: none;
    stroke: #858585;
    stroke-width: 1.8;
    stroke-linecap: round;
    stroke-linejoin: round;
}

/* The open view. VS Code marks it with a lit left border and a white icon. */
.vs-rail-icon.is-active {
    stroke: #FFFFFF;
    box-shadow: inset 0.16em 0 0 #FFFFFF;
}

/* ---- Explorer ---- */

.vs-side {
    flex: none;
    /* VS Code's default sidebar is ~250px in a 1440pt window. Matching that
       ratio matters more than it looks: an over-wide sidebar is one of the
       loudest tells that an editor mock was eyeballed. */
    width: 18%;
    min-width: 0;
    padding: 0.75em 0 0;
    background-color: var(--vs-chrome);
    border-right: 1px solid var(--vs-rule);
    overflow: hidden;
}

.vs-side-title {
    padding: 0 0.9em 0.7em;
    font-size: 0.72em;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--vs-dim);
    white-space: nowrap;
}

.vs-tree {
    list-style: none;
}

.vs-node {
    padding: 0.16em 0.9em;
    font-size: 0.78em;
    color: var(--vs-fg);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* Indent steps, matching the tree depth in the markup. */
.vs-d1 { padding-left: 1.7em; }
.vs-d2 { padding-left: 2.6em; }

/* Folders get the disclosure caret; the root is open. */
.vs-node-open,
.vs-node-dir { color: var(--vs-fg); }

.vs-node-open::before,
.vs-node-dir::before {
    content: "▾";
    display: inline-block;
    width: 1em;
    margin-left: -1em;
    color: var(--vs-dim);
}

.vs-node-dir::before { content: "▸"; }

.vs-node.is-active {
    background-color: #04395E;
    color: #FFFFFF;
}

.vs-pane {
    flex: 1 1 auto;
    display: flex;
    flex-direction: column;
    min-width: 0;
}

.vs-tabs {
    flex: none;
    display: flex;
    background-color: var(--vs-chrome);
    border-bottom: 1px solid var(--vs-rule);
}

.vs-tab {
    display: inline-flex;
    align-items: center;
    gap: 0.5em;
    padding: 0.5em 1.05em;
    font-size: 0.8em;
    color: var(--vs-dim);
    border-right: 1px solid var(--vs-rule);
    white-space: nowrap;
}

/* File-type marks. VS Code ships Seti icons; at this size any glyph resolves to
   a smudge, so what survives of them is the COLOUR — and the colour is the part
   that is actually recognisable. A 2px-radius square per language reads as the
   icon set without pretending to draw it. */
.vs-ft {
    flex: none;
    width: 0.72em;
    height: 0.72em;
    border-radius: 2px;
}

.vs-ft-css  { background-color: #42A5F5; }
.vs-ft-html { background-color: #E44D26; }
.vs-ft-js   { background-color: #F0DB4F; }
.vs-ft-dir  { background-color: #6E7681; }

/* The open file. Marked by the editor background bleeding up into the tab and a
   lit top edge — the same way the real thing marks it. */
.vs-tab.is-active {
    color: #FFFFFF;
    background-color: var(--vs-editor);
    box-shadow: inset 0 1.5px 0 var(--vs-blue);
}

/* Breadcrumbs. The strip under the tabs showing the path to whatever the caret
   is inside. Small, easy, and one of the most recognisable parts of the window
   — an editor without it reads as a generic code panel. */
.vs-crumbs {
    flex: none;
    display: flex;
    align-items: center;
    gap: 0.45em;
    padding: 0.4em 1.2em;
    font-size: 0.74em;
    color: var(--vs-dim);
    white-space: nowrap;
    overflow: hidden;
}

.vs-crumb-sep {
    color: #5A5A5A;
}

.vs-crumb-sym {
    color: var(--vs-selector);
}

/* One step down from the chrome. This is what lets the file be dense — nearly
   forty lines on the screen — without dragging the tabs and status bar down to
   the same size. */
.vs-code {
    flex: 1 1 auto;
    display: flex;
    padding-top: 0.5em;
    min-height: 0;
    overflow: hidden;
    font-size: 0.85em;
}

/* Line numbers come from a counter on .ln, not a parallel column of hand-typed
   <i>s. Add or remove a line above and the gutter follows; there is no second
   list to keep in step. */
.vs-lines {
    flex: 1 1 auto;
    counter-reset: vsln;
    min-width: 0;
    padding-right: 0.6em;
    font-family: var(--font-mono);
    font-size: inherit;
    line-height: 1.62;
    color: var(--vs-fg);
    white-space: pre;
    overflow: hidden;
}

/* NOT display: block. Inside white-space: pre the newline between each
   </span> and the next <span> is itself a rendered line break, so making the
   spans blocks as well produced a blank line between every line of code and
   halved how much of the file fit on the screen. The markup's own newlines do
   the breaking; these stay inline. */
.vs-lines .ln {
    counter-increment: vsln;
}

/* Right-aligned in a narrow gutter with one space of air before the code, the
   way the real editor sets it. This was 2.4em wide with 1.4em of margin, which
   opened a four-character gap between the number and the line it belongs to and
   made the gutter read as a separate column of figures. */
.vs-lines .ln::before {
    content: counter(vsln);
    display: inline-block;
    width: 2.1em;
    margin-right: 0.85em;
    text-align: right;
    color: var(--vs-line-no);
    font-variant-numeric: tabular-nums;
}

/* Dark+ token colours. Every <b> is a token span; the weight reset stops the
   browser bolding all of them. */
.vs-lines b { font-weight: 400; }
.vs-lines .c  { color: var(--vs-comment);  font-style: italic; }
.vs-lines .se { color: var(--vs-selector); }
.vs-lines .pr { color: var(--vs-prop); }
.vs-lines .st { color: var(--vs-string); }
.vs-lines .nu { color: var(--vs-number); }
.vs-lines .fn { color: var(--vs-fn); }
.vs-lines .kw { color: var(--vs-keyword); }
.vs-lines .pu { color: var(--vs-punct); }
.vs-lines .hx { color: var(--vs-string); }

/* The colour chip VS Code draws in front of any literal colour value. It is a
   small detail doing a large job here: the four chips on screen are this site's
   actual palette, so the hero's product shot is showing the reader the very
   system the page around it is built from. */
.sw {
    display: inline-block;
    width: 0.85em;
    height: 0.85em;
    margin-right: 0.45em;
    vertical-align: -0.08em;
    border: 1px solid rgba(255, 255, 255, 0.35);
}

.sw-white   { background-color: #FFFFFF; }
.sw-black   { background-color: #000000; }
.sw-ivory   { background-color: #E6E2D3; }
.sw-off     { background-color: #EEEEEE; }
.sw-surface { background-color: #0A0A0A; }

/* The caret. Same idiom as the headline's cursor directly above it — and gated
   the same way, because an indefinite blink is exactly what `reduce` is asking
   to be spared. */
.vs-caret {
    display: inline-block;
    width: 1px;
    height: 1.05em;
    margin-left: 1px;
    vertical-align: text-bottom;
    background-color: var(--color-ivory);
    animation: cursor-blink 1s steps(2, start) infinite;
}

@media (prefers-reduced-motion: reduce) {
    .vs-caret { animation: none; }
}

.vs-map {
    position: relative;
    flex: none;
    display: flex;
    flex-direction: column;
    gap: 0.3em;
    width: 4.2em;
    padding: 0.3em 0.7em 0 0.4em;
}

.vs-map i {
    height: 1.5px;
    border-radius: 1px;
    background-color: #5A5A5A;
}

/* Varied widths on a 7-step cycle, because a column of identical bars reads as
   a texture and a minimap is meant to read as the shape of a file. Seven rather
   than a divisor of the bar count, so the pattern never lines up with itself. */
.vs-map i:nth-child(7n + 1) { width: 82%; }
.vs-map i:nth-child(7n + 2) { width: 44%; }
.vs-map i:nth-child(7n + 3) { width: 94%; }
.vs-map i:nth-child(7n + 4) { width: 66%; }
.vs-map i:nth-child(7n + 5) { width: 88%; }
.vs-map i:nth-child(7n + 6) { width: 34%; }
.vs-map i:nth-child(7n + 7) { width: 72%; }

/* The viewport indicator — the lit block VS Code parks over the part of the file
   currently on screen. Absolute so it sits over the bars rather than joining the
   flex column and pushing them down. Selector is deliberately more specific than
   the width cycle above so it does not need !important to win. */
.vs-map i.vs-map-view {
    position: absolute;
    left: 0;
    right: 0;
    top: 0;
    width: auto;
    height: 62%;
    background-color: rgba(255, 255, 255, 0.06);
    border-radius: 0;
}

.vs-status {
    flex: none;
    display: flex;
    align-items: center;
    gap: 1.5em;
    padding: 0.3em 1em;
    background-color: var(--vs-chrome);
    border-top: 1px solid var(--vs-rule);
    font-size: 0.74em;
    color: var(--vs-dim);
    white-space: nowrap;
    overflow: hidden;
}

/* The source-control entry. An SVG rather than U+2442 — the glyph for a branch
   is not in any font this site loads, so it would fall back to whatever the OS
   supplied, which is the same defect the pricing list's check marks were. */
.vs-status-branch {
    display: inline-flex;
    align-items: center;
    gap: 0.4em;
}

.vs-branch-icon {
    width: 0.95em;
    height: 0.95em;
    fill: none;
    stroke: currentColor;
    stroke-width: 2;
    stroke-linecap: round;
    stroke-linejoin: round;
}

.vs-status-end { margin-left: auto; }

/* ==========================================================================
   HERO STAGE — PHONES AND TABLETS

   On desktop the machine is 1040px wide inside a viewport half again as wide,
   so it is both large and cut off by the fold, and that crop IS the
   composition: the object continues, the light just runs out. Neither half
   held below 900px. A 16/10 lid constrained to 94vw is only 228px tall at
   375px wide, so it finished 130px ABOVE the fold with dead black underneath
   it, and the 140px bottom fade spent itself dimming the lower third of a
   laptop that was nowhere near the edge. It read as a small rectangle floating
   in a hole rather than as a machine running off the page.

   The geometry is the whole problem and it does not negotiate. To reach the
   fold from under the copy on a 375x812 screen the lid has to be about 790px
   tall, and a 16/10 lid that tall is about 1220px wide. There is no width that
   keeps all four lid edges on a portrait phone AND crosses the fold — so it
   stops trying to be a whole laptop and becomes a close-up of one.

   Two changes do it. The lid is sized off viewport HEIGHT, because height is
   what the crop is measured against; and it is anchored to the LEFT edge
   rather than centred. Anchoring left is the load-bearing half: centred, an
   853px lid inside a 375px viewport shows the middle 44%, which means no line
   numbers and every line both starting and ending mid-token — a texture, not
   code. Left-anchored, what stays on screen is the part carrying the claim
   (the rail, the gutter, and every line from its number rightward) and what
   leaves is empty bezel.
   ========================================================================== */

@media not all and (min-width: 900px) {
    /* The copy floats in the space the band leaves instead of sitting on the
       top padding.

       Capping the machine at 36vh hands its surplus back to the hero, and all
       of it was landing in one place: a 223px void between the subheadline and
       the lid on a 375x812 screen, with the headline still jammed against the
       top edge. Same total emptiness, all of it in the worst spot.

       Auto margins on both sides split it instead. Three auto margins share the
       free space — one above the copy, one below it, one over the band — so the
       gaps come out even (147px above the headline, 148 between copy and lid at
       375x812) and the hero reads as a composition rather than as content that
       ran out. The `justify-content: flex-start` on the base rule is untouched:
       auto margins are resolved before it, so it never gets anything to
       distribute. */
    .hero-has-stage > .site-container {
        margin-top: auto;
        margin-bottom: auto;
    }

    /* Left, not centre. See above — this is the rule the legibility rests on. */
    .hero-has-stage .hero-stage {
        justify-content: flex-start;

        /* How much of the screen the machine gets, stated directly.

           This was a top MARGIN, which sets the share only by implication: with
           the lid cropped, everything below the margin is laptop, so the share
           came out as (viewport - copy - margin). The copy's height is the
           problem in that expression — it does not scale with the viewport, it
           jumps by a whole line whenever the headline rewraps, so the same
           margin bought 50% of one phone and 62% of another and nothing named
           the number being chosen.

           A height on the band names it. margin-top: auto parks the band on the
           fold, the lid starts at its top edge and overflows out of the bottom,
           so what is on screen IS this height and the share is 36% wherever it
           is read. Everything left over becomes air above it, which is the right
           place for slack to land: a taller phone gets more room around the
           headline rather than more laptop.

           Floored and capped in px for the extremes — a landscape phone where
           36vh is under 140px and the editor stops being legible, and a tall
           tablet where it would run past 400. */
        flex: none;
        margin-top: auto;
        height: clamp(170px, 36vh, 400px);

        /* Required once the band has a height of its own. The default `stretch`
           resolves the lid's cross size to the band's, overriding the height it
           derives from its own aspect-ratio and squashing the screen out of
           16/10. flex-start lets it keep its intrinsic height and hang out of
           the bottom, which is the crop. */
        align-items: flex-start;
    }

    /* This sets how much MACHINE exists, not how much of it you see — the margin
       above already fixed that by deciding where the lid starts. All this has to
       do is guarantee the lid is tall enough to still reach the fold from there,
       so the crop survives; any excess simply falls off the bottom edge and costs
       nothing on screen.

       0.646 is the lid's own aspect once bezels and deck are counted, so 96vh of
       width lands ~62vh of machine against the ~50vh the margin leaves for it.
       Sized off the WORST case, which is a tall tablet: the margin caps at 132px
       there, so the lid starts high and has the furthest to fall. At 82vh it
       cleared every phone and finished 58px short at 768x1024 — the same
       size-nobody-checks failure the 1040px cap below caused.

         320x568  crop 156px      375x667  crop  88px
         375x812  crop 102px      768x1024 crop  31px

       max() with the original 94vw is a floor, not a preference: a landscape
       phone has more width than height and must not come out of this rule with
       a SMALLER laptop than it had before.

       The cap is 1240px rather than desktop's 1040px, and it has to be. 1040 is
       the width the DESKTOP composition is drawn at, where the viewport is wider
       still; held here it clipped a portrait tablet's lid and left it finishing
       above the fold at 768x1024 — the exact dead-space problem this block exists
       to remove, surviving at the one size nobody checks. */
    .hero-has-stage .macbook {
        width: min(1240px, max(94vw, 96vh));
        /* Load-bearing. .hero-stage is a flex container and .macbook is a flex
           item, so the default `flex-shrink: 1` treats the width above as a basis
           to be negotiated away — it resolved straight back to the 375px of the
           container and nothing cropped at all. The whole point here is a box
           deliberately larger than the space it is in. */
        flex: none;
    }

    /* The hero's scroll cue is retired here, and the arithmetic is why.

       On desktop the cue sits 24px off the bottom, which is deep inside the
       140px fade where it is 85-97% black — that is what makes a label legible
       over code. Below 768px the dock is parked at the bottom, so the same rule
       pushes the cue to 128px off the bottom, and at 203px (its top edge) it
       needs the fade to still be at 0.85 to read. Solving 1 - 203/H >= 0.68
       gives H >= 634px: a 634px fade on an 812px screen dims the whole laptop,
       which is the one thing this pass exists to make legible. The cue and the
       code cannot both be readable in the same band, and the code wins.

       Nothing is lost by dropping it. A machine cut off by the bottom of the
       screen is already the strongest "there is more below" signal on the page —
       it is the exact job the crop does on desktop, per .hero-has-stage::after's
       own comment.

       Scoped to the hero's cue by .hero-has-stage. The transition section used to
       carry a second cue built from these same classes, which is why the scoping
       was needed; that one is gone now, but the scoping stays correct and cheap. */
    .hero-has-stage .scroll-hint { display: none; }

    /* Back to roughly desktop's depth now it only has to soften the crop and give
       the dock some ground to sit on, rather than trying to hide a label. */
    .hero-has-stage::after {
        height: calc(var(--dock-clear-bottom) + 140px);
    }

    /* Type stops being a pure ratio of the lid and takes a floor.
       `1.15cqw` is correct on desktop, where the container is 1000px+ — below
       900px it was resolving to 8.8px of code at 375px wide, 7.5px at 320, and
       6.3px at 700 (the worst number on the site: the 3cqw rescue below stopped
       at 639px, so tablets and every resized desktop window fell through it and
       came out smaller than a phone). Nothing about a 6px glyph reads as code.

       max() keeps the desktop ratio wherever it is already the larger of the
       two, so this only ever raises a size, never lowers one. */
    .vs { font-size: max(1.15cqw, 14px); }

    /* One step down from the chrome instead of two. The code is the subject
       here and the chrome is the frame, so on a screen where only part of the
       window is visible the code takes the larger share: 12.9px of mono against
       11.2px tabs and 10.4px status. */
    .vs-code { font-size: 0.92em; }

    /* The minimap sits at the far right of a lid that now runs off the right
       edge, so it is off-screen at every width in this range. It is laid out
       and painted purely to never be seen. */
    .vs-map { display: none; }
}

/* A short-screen override for the stage's top margin used to sit here, correcting
   a share that came out wrong on a 568px-tall phone. The band's height states the
   share directly now, and states it in vh, so it is already the same fraction on
   every screen — there is nothing left for a height query to correct, and a
   margin rule here would only fight the `margin-top: auto` that parks the band on
   the fold. */

/* A little more room over the headline, on phones only.

   The dock is at the BOTTOM below 768px, so --dock-clear is 0 and the base
   rule's max() resolves to a flat 64px — a value chosen as a floor for the case
   where the dock is overhead, not as a considered amount of air above the
   largest type on the page. On a tall phone there is room to give the headline
   a little more of the top of the screen; on a short one there is not, which is
   what the clamp's floor is protecting. Deliberately small: every pixel here is
   one fewer pixel of laptop, and the margin above is doing the real work. */
@media (max-width: 767px) {
    .hero-centered { padding-top: clamp(var(--space-64), 9vh, 88px); }
}

/* ---- Where the hero's empty space goes, on phones only ----

   The auto-margin split above distributes the hero's slack evenly, and it does
   that correctly — measured at 375x812 the two gaps came out 163px and 180px.
   The even split was never the problem. The AMOUNT was: 343px of the 812 on
   screen, 42%, was black with nothing in it, and the reason is that the band
   only claimed 36% and the copy is short, so everything left over had nowhere to
   go but into the two gaps.

   36 -> 46vh is the whole fix, and it buys two things at once. The gaps come back
   to 136 and 126 (32% rather than 42%), and — the more valuable half — the
   editor gets legible. The bottom fade is `--dock-clear-bottom + 140`, a fixed
   244px, so it eats a constant slab off a band that was only 292px tall: there
   were 48px of undimmed code on a 375x812 screen, which is why the product shot
   read as a cropped screenshot rather than as a machine. At 46vh there are 130.

   Not further. 50vh measured better still on the two numbers above (28%, 162px)
   and is the wrong trade: the surplus on a tall phone belongs to the air around
   the headline, per the band's own comment, and past ~46% the composition stops
   being a hero with a machine under it and becomes a machine with a caption.

   Floor and cap move with it, same reasoning as the original pair — 190px keeps
   the editor legible on a landscape phone (the section is min-height: 560 there,
   so this is 34% of it, not of the 375px viewport), and 440 is the old 400
   scaled by the same ratio so a portrait tablet is not newly capped.

   Bounded at 767.98 rather than edited into the `not all and (min-width: 900px)`
   block it overrides: 768-899 is a band where the dock has already moved
   overhead and --dock-clear-bottom is 0, so the fade arithmetic above does not
   apply there and neither should the correction for it. The bound is also what
   makes "desktop untouched" checkable rather than hoped for.

   The 767.98 fraction is the dock block's, for the dock block's reason: a
   viewport can land on 767.5 under density scaling, and it must match this
   query or the one at 768, never neither. ---- */
@media (max-width: 767.98px) {
    .hero-has-stage .hero-stage {
        height: clamp(190px, 46vh, 440px);
    }

    /* The rag, which centred type makes unforgiving.

       The lead ran to --measure-lead's 48ch, which no phone is wide enough to
       reach, so the container was setting the measure and the breaks fell
       wherever 364px happened to land: at 412 that was "...businesses. No /
       drag-and-drop..." — a break one word past a full stop, with the next
       sentence's first word stranded on the line above it — closing on a
       "the proof." orphan at 375. Every one of those is visible because the
       block is centred: a ragged right edge hides a bad break, two ragged edges
       point at it.

       32ch is the measure at which this particular sentence stops breaking one
       word past its own full stop. `balance` then evens what is left: 256/244/242
       at 375, against 323/307/65 before it. Both are needed — balance alone kept
       the "No" break, because at the container's width the greedy and balanced
       solutions were the same three lines.

       It replaces `pretty` for this element at these widths only. pretty tunes
       the last line and leaves the others where the greedy pass put them, which
       is the pass that produced the break being fixed; balance is the one that
       reconsiders all of them. Above 768px pretty stays — the lead has room for
       its real measure there and only the orphan needs guarding. */
    .hero-subheadline {
        max-width: 32ch;
        text-wrap: balance;
    }
}

/* ---- Small screens ----

   Type is handled by the floor above. What changes here is how much of the
   chrome survives: the lid is wide now, but the VIEWPORT is not, and the
   explorer would take 150px of the ~375px actually on screen to render a file
   tree beside 190px of code. The rail stays — four icons still read as an IDE
   at any size, and it costs 34px. */
@media (max-width: 639px) {
    /* The explorer and the window title go. The title is centred in an 834px
       bar and lands off-screen regardless; the explorer is the real saving.

       Breadcrumbs are NOT in this list any more. They were hidden when they
       rendered at 7.6px, which nobody could resolve; at 10.4px they are legible,
       they span the visible width rather than hiding off the right edge, and
       they are one of the most recognisable strips in the window. */
    .vs-side,
    .vs-bar-title { display: none; }

    /* Line numbers survive but lose their generous gutter. */
    .vs-lines .ln::before {
        width: 1.6em;
        margin-right: 0.7em;
    }

    /* Only the branch and the language survive in the status bar. */
    .vs-status-item:not(.vs-status-branch):not(:last-child) { display: none; }
    .vs-status-item:last-child { margin-left: auto; }
}

/* ==========================================================================
   SECTION TRANSITION
   ========================================================================== */

/* Tall parent + sticky child, same technique as the journey section: it holds the
   band still for a beat without ScrollTrigger's pin, so neither section's trigger
   math can disturb the other's. The held distance is (height - 100vh). */
.transition-section {
    position: relative;
    /* 100vh is spent sticky; the remainder is the scrub runway, and the runway is
       the ONLY thing that sets this sweep's speed — the timeline in main.js has no
       duration of its own, it is scrubbed against this height. Longer section,
       slower line. That is the whole trade: pace is bought with page length.

       Measured on a 1440×800 viewport, not estimated: the line is 2907px wide, so
       the readable beat carries 1467px of travel. At 200vh of runway that beat got
       1120px of scroll — 1.31px of type moving per pixel scrolled, which still read
       as too fast. 300vh of runway gives it 1680px and brings it to 0.87.

       To re-derive after any change to the line, the type size or the 1.5/7/1.5
       split: travel is (track.scrollWidth - innerWidth), the beat gets 0.7 of
       (height - 100vh), and pace is the first divided by the second. Lower is
       slower. Measure it, don't guess at it. */
    height: 400vh;
    background-color: var(--color-black);
}

.transition-pin {
    position: sticky;
    top: 0;
    height: 100vh;
    height: 100dvh;
    display: flex;
    align-items: center;
}

/* overflow:hidden keeps the track from widening the page while it is off-screen.
   No rules top or bottom: they boxed the line into a visible rectangle, which fought
   the idea of the type simply passing through open space. */
.transition-band {
    position: relative;
    width: 100%;
    height: 38vh;
    min-height: 220px;
    display: flex;
    align-items: center;
    justify-content: center;   /* x:0 is now the centred resting position */
    overflow: hidden;
}

.marquee-track {
    display: inline-block;
    flex: none;                /* never shrink, so scrollWidth is the true line width */
    white-space: nowrap;
    font-family: var(--font-heading);
    /* Oversized on purpose. Wider than the viewport, so it is read by scanning rather
       than all at once — see the three-phase timeline in main.js, which slows the middle
       of the sweep to reading pace instead of stopping it on half a sentence. */
    font-size: clamp(3rem, 11vw, 10rem);
    font-weight: 700;          /* Instrument Sans tops out here */
    line-height: 1;
    letter-spacing: -0.035em;  /* big type needs tighter tracking to read as one mass */
    color: var(--color-text-sub);
    will-change: transform;
}

.transition-emphasis {
    color: var(--color-ivory);
}

/* The line sweeps only when there is room to sweep it AND the visitor hasn't asked
   for stillness. Both exceptions land on the same wrapped, stationary layout, so it
   is written once. As two separate blocks they had drifted to different measures
   (20ch and 24ch) and the reduced-motion one, being later, silently won on phones. */
/* `not all and (min-width: 640px)` rather than `max-width: 639px`, because it is the
   exact complement of the query main.js builds the sweep from. CSS viewport widths
   are fractional — 639.333px was measured here — and 639.333 matches neither
   `max-width: 639px` nor `min-width: 640px`. In that gap the line took the oversized
   sweeping size with no timeline to move it, and sat clipped by the band. */
@media not all and (min-width: 640px), (prefers-reduced-motion: reduce) {
    .transition-section { height: auto; }

    .transition-pin {
        position: relative;
        height: auto;
    }

    .transition-band {
        height: auto;
        min-height: 0;
        padding: var(--space-96) var(--container-padding);
        /* With no sweep to contain, overflow:hidden can only cut the headline in
           half — which is what it was doing: 147px off each side at 360px, and
           ~500px each side on a desktop with reduced motion. */
        overflow: visible;
    }

    .marquee-track {
        white-space: normal;
        text-align: center;
        /* The sweeping size has a 3rem floor because it is meant to overflow. Once
           the line wraps and stands still it has to fit instead, so it is resized
           rather than merely re-wrapped. */
        font-size: clamp(1.75rem, 8vw, 3.5rem);
        /* Whichever is smaller: the reading measure, or the space actually available.
           A bare ch measure is not a constraint when 20ch exceeds the viewport. */
        max-width: min(20ch, 100%);
        text-wrap: balance;
    }
}

/* Only a reader who asked for stillness gets it. This used to sit in the block
   above, which meant it also silenced phones — and a phone landing on this section
   saw two static lines on a black screen with no indication anything followed. */
@media (prefers-reduced-motion: reduce) {
    .marquee-track {
        transform: none !important;
        will-change: auto;
    }
}

/* ---- Phones: the same sweep, through a strip that never owns the screen ----

   The sweep is right for a phone; the staging around it was not. Pinned at 100svh
   with the line centred in it, this section handed the reader a black screen with
   one line floating in the middle and the next section a full viewport below the
   fold. Read cold on a phone that is an ending: nothing on screen continues, so
   the reader stops — and only an extra, unrewarded flick would have revealed that
   the page still had three sections left in it.

   So the pin and the runway both come off. The band collapses to the height of the
   line plus air, and the whole section is short enough that the journey section
   below stays in frame under it. That is the actual fix, and it is why the cue that
   used to live here could be deleted outright: a visible next section is a far
   stronger "there is more" than any label saying so.

   Removing the runway costs the animation nothing, because the phone timeline is
   scrubbed against the band's own travel through the viewport rather than against
   section height — see main.js. It plays over (viewport + band) of scroll either
   way, and now the readable middle of it happens while the band is actually on
   screen instead of half a viewport above it.

   The paired query is the exact complement of the desktop one, `not all and
   (min-width: 640px)`, for the fractional-width reason given above; and it is
   nested inside `no-preference` so a reduced-motion phone keeps the flat, static
   layout rather than a strip with nothing moving through it. */
@media (prefers-reduced-motion: no-preference) {
 @media not all and (min-width: 640px) {
    /* No runway to buy pace with, so no height to set. */
    .transition-section { height: auto; }

    /* No pin either — just the band and the air around it. */
    .transition-pin {
        position: relative;
        height: auto;
        padding: var(--space-48) 0;
    }

    /* Height comes from the line now, not from the viewport — that height was
       the "space" the reader was reading as an ending. overflow:hidden comes
       back on (the wrap block above turns it off) so the sweep is clipped
       instead of widening the page. */
    .transition-band {
        height: auto;
        min-height: 0;
        padding: var(--space-32) 0;
        overflow: hidden;
    }

    /* Undoes the wrap block above: back to one unbroken oversized line, because
       the sweep needs the line wider than the screen or there is nothing to scan. */
    .marquee-track {
        white-space: nowrap;
        text-align: initial;
        font-size: clamp(3rem, 11vw, 10rem);
        max-width: none;
    }
 }
}

/* There was a "Keep scrolling" cue here, phones only, because this section used to
   be the place the page looked most finished — a pinned black screen with one line
   in it and the next section a viewport below the fold. It has been removed along
   with the emptiness that made it necessary: the section is now the height of its
   own line, and the journey section's frame arrives as it enters rather than waiting
   to be pinned (see the arrival trigger in main.js), so there is always something
   visible below that is plainly still going. A label telling the reader to do what
   the page is already showing them is furniture. */

@media (prefers-reduced-motion: reduce) {
    .transition-pin { flex-direction: column; }
}

/* ==========================================================================
   BUTTONS
   ========================================================================== */

.btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-8);
    min-height: var(--target-min);
    font-family: var(--font-body);
    font-weight: 500;
    font-size: 0.875rem;
    padding: var(--space-12) var(--space-20);
    border-radius: var(--radius-md);
    text-decoration: none;
    transition: background-color 200ms ease, color 200ms ease, border-color 200ms ease, transform 200ms ease;
}

.btn-primary {
    background-color: var(--color-ivory);
    color: var(--color-black);
    border: 1px solid var(--color-ivory);
    font-weight: 600;
}

/* Gated behind (hover: hover) like .dock-link above: without the guard, :hover
   latches after a tap on touch and leaves the button lifted and white until an
   unrelated tap elsewhere clears it — the exact bug the dock comment already
   warns about, which had been missed here. */
@media (hover: hover) {
    .btn-primary:hover {
        background-color: var(--color-white);
        border-color: var(--color-white);
        transform: translateY(-1px);
    }

    .btn-secondary:hover {
        border-color: var(--color-ivory);
        color: var(--color-ivory);
        transform: translateY(-1px);
    }

    .btn-secondary:hover .btn-arrow {
        transform: translateX(3px);
    }
}

/* :active is unguarded and is what touch relies on for feedback instead — it
   clears the instant the finger lifts, so it carries none of :hover's latching
   risk. Settling to translateY(0) and dimming opacity reads as "pressed in"
   whether or not the hover lift ran first. Transform and opacity only, so it
   stays compositor-only like every other motion on the site. */
.btn-primary:active {
    transform: translateY(0);
    opacity: 0.85;
}

.btn-secondary {
    background-color: transparent;
    color: var(--color-white);
    border: 1px solid var(--color-rule);
}

.btn-secondary:active {
    border-color: var(--color-ivory);
    color: var(--color-ivory);
    transform: translateY(0);
    opacity: 0.85;
}

.btn-arrow {
    transition: transform 200ms ease;
}

.btn-secondary:active .btn-arrow {
    transform: translateX(3px);
}

/* ==========================================================================
   SECTION 2 — CUSTOMER JOURNEY
   Every rule below describes the FINISHED state. The scrubbed timeline in
   main.js animates *from* a start state, so if GSAP fails to load, or motion
   is reduced, the visitor still gets a complete mock site rather than an
   empty frame.
   ========================================================================== */

.journey-section {
    position: relative;
    background-color: var(--color-black);
}

/* Tall parent + sticky child is what gives the scrub its scroll runway.
   Only applied where there's room; see the mobile rules further down. */
.journey-pin {
    position: relative;
    overflow: hidden;
    padding: var(--space-64) 0;
}

/* Same cell size and weight as the hero's canvas grid, so the two sections
   read as one continuous surface with the marquee band passing over it. */
.journey-grid {
    position: absolute;
    inset: 0;
    background-image:
        repeating-linear-gradient(to right,  rgba(230, 226, 211, 0.12) 0 1px, transparent 1px 46px),
        repeating-linear-gradient(to bottom, rgba(230, 226, 211, 0.12) 0 1px, transparent 1px 46px);
    pointer-events: none;
}

/* Same container as every other section. An earlier pass widened this to 1140px to
   buy the frame more room, which worked and cost more than it bought: it moved the
   section's left edge 70px outboard of the footer's and the contact block's, so the
   one section on the page with a left-aligned column was the one that did not line
   up with anything. The frame gets its width back from .journey-stage below
   instead — by overhanging to the RIGHT, into gutter that is empty anyway, which
   leaves the left edge where the rest of the site expects it. */
.journey-inner {
    position: relative;
    z-index: 1;
    width: 100%;
    max-width: var(--container-max-width);
    margin: 0 auto;
    padding: 0 var(--container-padding);
    display: flex;
    flex-direction: column;
    gap: var(--space-32);
}

.journey-eyebrow {
    font-family: var(--font-mono);
    font-size: 0.6875rem;
    font-weight: 600;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--color-ivory);
    margin-bottom: var(--space-12);
}

.journey-heading {
    font-family: var(--font-heading);
    font-size: clamp(1.75rem, 1.2rem + 1.8vw, 2.5rem);
    font-weight: 600;
    line-height: var(--leading-tight);
    letter-spacing: -0.02em;
    color: var(--color-white);
    max-width: 16ch;
    margin-bottom: var(--space-24);
}

/* Steps */
.journey-steps { list-style: none; margin: 0; padding: 0; }

.journey-step {
    position: relative;
    display: grid;
    grid-template-columns: auto 1fr;
    gap: var(--space-16);
    align-items: start;
    padding: var(--space-12) 0 var(--space-12) var(--space-16);
    border-top: 1px solid var(--color-rule);
}
.journey-step:first-child { border-top: 0; }

/* The accent bar for the active step. Reserved on every step and revealed by
   opacity, so walking down the list never reflows it. */
.journey-step::before {
    content: '';
    position: absolute;
    left: 0;
    top: var(--space-8);
    bottom: var(--space-8);
    width: 2px;
    border-radius: var(--radius-full);
    background-color: var(--color-ivory);
    opacity: 0;
}

/* Highlighting is opt-in: main.js adds .is-live only once the scrubbed timeline is
   actually driving the steps. Without JS every step stays in its resting state.

   This used to dim inactive steps to opacity 0.32, which put the step title at
   2.67:1 and its body text at 1.54:1 against black — both far under the 4.5:1 floor,
   and all five steps sat dimmed at once whenever no step was active. Opacity cannot
   be the signal here: the muted body copy needs ~0.81 opacity just to hold 4.5:1,
   by which point there is no visible dimming left to read. So the active step is
   marked rather than the others hidden — every step stays fully legible, and the
   state is carried by a bar and a surface tint, not by contrast the reader needs. */
.journey-steps.is-live .journey-step {
    transition: background-color 200ms ease;
}

.journey-steps.is-live .journey-step::before {
    transition: opacity 200ms ease;
}

.journey-steps.is-live .journey-step.is-active {
    background-color: rgba(230, 226, 211, 0.05);
}

.journey-steps.is-live .journey-step.is-active::before { opacity: 1; }

.journey-step-num {
    font-family: var(--font-mono);
    font-size: 0.75rem;
    font-weight: 500;
    color: var(--color-ivory);
    font-variant-numeric: tabular-nums;
    padding-top: 2px;
}

.journey-step-body { display: block; }

.journey-step-title {
    display: block;
    font-family: var(--font-heading);
    font-size: 1rem;
    font-weight: 600;
    color: var(--color-white);
    letter-spacing: -0.01em;
    margin-bottom: var(--space-4);
    /* 42ch on the body text below sets the column's natural measure; the title is
       short enough that it never reaches it. */
}

.journey-step-text {
    display: block;
    font-family: var(--font-body);
    font-size: 0.875rem;
    line-height: 1.5;
    color: var(--color-text-muted);
    max-width: 42ch;
}

/* ---- The stage: one frame, and five things that happen to it ---- */

.journey-stage {
    position: relative;
    width: 100%;
    align-self: center;
}

/* The frame's contents, stacked and crossfaded. .jw-body used to own the padding
   and the column layout directly; both moved onto .jlayer, because an absolutely
   positioned child resolves against its container's *padding* box — padding left
   on .jw-body would have been ignored, and every layer would have run edge to
   edge where the old single layout was inset. */
.jlayer {
    position: absolute;
    inset: 0;
    display: flex;
    flex-direction: column;
    padding: 4% 4.5%;
    opacity: 0;
}

/* The finished state, and the only layer that renders without JS or under reduced
   motion: once the site exists, it is what the frame contains. Chat and quote are
   moments the timeline passes through, not resting states — which is why they
   default to transparent and the site layer does not.

   This one is deliberately NOT capped by .jlayer-measure below: it is a website
   filling a browser window, so it should reach the frame's edges. The layers that
   depict a document or a message thread should not. */
.jlayer-site {
    gap: 4%;
    opacity: 1;
}

/* A measure for the layers that depict something held rather than something
   rendered. Without it, every row in them stretched to the frame's full width, so
   a quote line item became a short label and a short amount separated by 400px of
   nothing — the layer read as sparse and stretched at exactly the sizes the frame
   was widened to. Both margins must be explicit: an absolutely positioned box with
   left AND right pinned to 0 is over-constrained, and the browser resolves that by
   dropping `right` rather than by centring, unless the margins say otherwise. */
.jlayer-chat,
.jlayer-quote {
    max-width: 480px;
    margin-left: auto;
    margin-right: auto;
}

/* ---- 01 — You message us ---- */

.jlayer-chat {
    justify-content: center;
    gap: 6%;
}

.jchat {
    display: flex;
    flex-direction: column;
    justify-content: center;
    gap: 16%;
    padding: 3.5% 4%;
    border-radius: var(--radius-sm);
}

/* vw-based rather than a percentage: these sit in a column flex box whose height
   is content-derived, so a percentage height would resolve against a size that
   depends on this element's own height. */
.jchat i {
    display: block;
    height: clamp(4px, 0.9vw, 7px);
    border-radius: 2px;
    transform-origin: 0 50%;
}

.jchat-in {
    align-self: flex-start;
    width: 52%;
    background-color: rgba(230, 226, 211, 0.14);
}
.jchat-in i { background-color: rgba(230, 226, 211, 0.5); }

/* The reply is the solid one — the studio answering is the beat that matters. */
.jchat-out {
    align-self: flex-end;
    width: 38%;
    background-color: var(--color-ivory);
}
.jchat-out i { background-color: rgba(0, 0, 0, 0.5); }

/* ---- 02 — Fixed quote, fixed date ----

   A quote sheet. Amounts are set hard right against the labels' left, which is
   the one layout that reads as priced work at any size, and the heaviest fill in
   the layer is the total — sat next to a literal "RM" so there is no ambiguity
   about what the bar is standing in for. Every text size here is fixed rather
   than proportional: these are labels on a document, not shapes, and a label
   that scales with the frame stops looking like type. */

/* Gaps land on the 8pt scale at both ends of the clamp; only the fluid middle is
   free. Same rule applied to every gap and padding added in this section — the
   bar heights and box sizes are left off-scale on purpose, because those are
   drawing dimensions inside a miniature, not layout spacing. */
.jlayer-quote {
    justify-content: center;
    gap: clamp(16px, 2.8vw, 32px);
}

.jq-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
}

.jq-eyebrow,
.jq-when-label,
.jq-currency,
.jq-stamp {
    font-family: var(--font-mono);
    font-weight: 600;
    letter-spacing: 0.14em;
    text-transform: uppercase;
    white-space: nowrap;
}

.jq-eyebrow {
    font-size: 0.625rem;
    color: rgba(230, 226, 211, 0.55);
}

/* Stamped rather than set: a couple of degrees off-square is the whole difference
   between "a word in a box" and something pressed onto an agreed document. */
.jq-stamp {
    font-size: 0.625rem;
    color: var(--color-ivory);
    padding: 4px 10px;
    border: 1px solid rgba(230, 226, 211, 0.55);
    border-radius: var(--radius-full);
    transform: rotate(-6deg);
    transform-origin: 50% 50%;
}

.jq-lines {
    display: flex;
    flex-direction: column;
    gap: clamp(12px, 2.2vw, 24px);
}

/* space-between, not a gap: the amount is pinned to the right edge the way a
   quote sets it, whatever the label in front of it is doing. */
.jrow {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 6%;
}

.jrow i {
    height: clamp(4px, 0.8vw, 7px);
    border-radius: 2px;
    transform-origin: 0 50%;
}

.jrow-k { background-color: rgba(230, 226, 211, 0.3); }
.jrow-v {
    background-color: rgba(230, 226, 211, 0.6);
    transform-origin: 100% 50%;
}

/* Uneven line items — three identical rows read as a placeholder, three unequal
   ones read as scope that was actually costed. Labels run to roughly half the row:
   at the 30/42/24% they started at, the gutter between description and amount was
   57% of the row, which is not a document with air in it, it is two small marks at
   opposite ends of an empty line. */
.jrow:nth-child(1) .jrow-k { width: 44%; }
.jrow:nth-child(1) .jrow-v { width: 14%; }
.jrow:nth-child(2) .jrow-k { width: 58%; }
.jrow:nth-child(2) .jrow-v { width: 17%; }
.jrow:nth-child(3) .jrow-k { width: 36%; }
.jrow:nth-child(3) .jrow-v { width: 12%; }

.jquote-rule {
    width: 100%;
    height: 1px;
    background-color: rgba(230, 226, 211, 0.25);
    transform-origin: 0 50%;
}

/* The two fixed things, one on each end of the closing row: the date on the left,
   the price on the right. */
.jq-total {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-16);
}

.jq-when {
    display: inline-flex;
    align-items: center;
    gap: var(--space-8);
}

.jq-when-label {
    font-size: 0.5625rem;
    color: rgba(230, 226, 211, 0.55);
}

/* A calendar at postage-stamp size: a binding bar and two rows of days, one of
   them filled. Anything more detailed than this stops resolving at 60px wide. */
.jcal {
    display: block;
    width: clamp(34px, 5vw, 46px);
    padding: 3px;
    border: 1px solid rgba(230, 226, 211, 0.35);
    border-radius: 3px;
}

/* 2px on bars, 3px on panels — the same two-value radius system the rest of the
   mock uses. These were 1px, which made the calendar the only element in the frame
   with a radius of its own. */
.jcal-bind {
    display: block;
    height: 2px;
    margin-bottom: 3px;
    border-radius: 2px;
    background-color: rgba(230, 226, 211, 0.45);
}

.jcal-grid {
    display: grid;
    grid-template-columns: repeat(5, 1fr);
    gap: 2px;
}

.jcal-grid i {
    display: block;
    aspect-ratio: 1;
    border-radius: 2px;
    background-color: rgba(230, 226, 211, 0.22);
}

/* Qualified as `.jcal-grid i.jcal-day`, not a bare `.jcal-day`: the day cells are
   already matched by `.jcal-grid i` at (0,1,1), which a plain class at (0,1,0)
   loses to however late it is declared. Same trap the review markers fell into. */
.jcal-grid i.jcal-day { background-color: var(--color-ivory); }

.jq-amount {
    display: inline-flex;
    align-items: center;
    gap: var(--space-8);
}

.jq-currency {
    font-size: 0.75rem;
    letter-spacing: 0.06em;
    color: var(--color-ivory);
}

/* The heaviest fill in the layer, and the only solid one — the number itself. */
.jq-amount-bar {
    display: block;
    width: clamp(74px, 13vw, 128px);
    height: clamp(13px, 1.9vw, 19px);
    border-radius: 2px;
    background-color: var(--color-ivory);
    transform-origin: 0 50%;
}

.jw {
    position: relative;
    width: 100%;
    aspect-ratio: 16 / 10;
    display: flex;
    flex-direction: column;
    border: 1px solid rgba(230, 226, 211, 0.45);
    border-radius: var(--radius-sm);
    background-color: rgba(230, 226, 211, 0.05);
    overflow: hidden;
}

.jw-chrome {
    flex: 0 0 9%;
    display: flex;
    align-items: center;
    gap: 5px;
    padding: 0 3%;
    border-bottom: 1px solid rgba(230, 226, 211, 0.3);
}

.jw-dot {
    width: 6px;
    height: 6px;
    flex: none;
    border-radius: var(--radius-full);
    background-color: var(--color-ivory);
}

/* The certificate, which only exists once the domain is actually serving. Sits
   where a browser puts it — left of the address bar, not inside it — and arrives
   on the same beat as the URL filling in. */
.jw-lock {
    flex: none;
    width: clamp(8px, 0.95vw, 11px);
    height: clamp(9px, 1.1vw, 13px);
    margin-left: var(--space-12);
    stroke: var(--color-ivory);
    stroke-width: 1.2;
    stroke-linecap: round;
    stroke-linejoin: round;
}

/* The empty address bar is present the whole way through — the site has somewhere
   to live before it has anything in it. .jw-url-fill is what completes at launch. */
.jw-url {
    position: relative;
    flex: 1 1 auto;
    height: 42%;
    margin-left: var(--space-8);
    border-radius: 2px;
    background-color: rgba(230, 226, 211, 0.18);
    overflow: hidden;
}

/* The domain going live, drawn as the bar filling in. Default scaleX(1) so the
   no-JS resting state is a site that is already up. */
.jw-url-fill {
    position: absolute;
    inset: 0;
    display: block;
    background-color: var(--color-ivory);
    transform-origin: 0 50%;
}

/* Padding and column layout moved to .jlayer — see the comment there. What stays
   is the box the layers are positioned against. */
.jw-body {
    position: relative;
    flex: 1 1 auto;
}

/* The page inside the frame. Every fill here is a tint of the one accent — the
   heaviest thing on the page is the hero headline bar, and it is a single short
   run, not a slab. An earlier version filled 40% of the frame's height with two
   full-bleed rectangles, one solid ivory and one pure white; those are the two
   heaviest fills the palette can produce, side by side, at the largest size in
   the mock, so the frame read as a pair of paint swatches instead of a website.
   Weight now goes where a real page puts it: a short headline, one button, and
   card surfaces that are mostly empty. */
.jw-nav { display: flex; align-items: center; gap: 3%; height: 5%; }
.jw-nav i {
    display: block;
    width: 9%;
    height: 100%;
    border-radius: 2px;
    background-color: rgba(230, 226, 211, 0.35);
    transform-origin: 0 50%;
}
/* The nav's call to action — the one solid mark above the fold, pushed to the
   far end the way a real nav does it. */
.jw-nav-cta {
    width: 14%;
    margin-left: auto;
    background-color: var(--color-ivory);
}

/* Inner padding, gaps and bar heights are px-clamped rather than percentages,
   for the reason already noted on .jchat i: a percentage PADDING or GAP resolves
   against the containing block's inline size, not the box's own — so `padding: 8%`
   inside a card sitting in the 483px-wide .jw-blocks row resolved to 38.6px on
   every side of an 88px-tall card, and the two content bars were flattened to
   1px and 0.7px. Widths stay proportional; anything that reads as a thickness
   is a clamp. */
.jw-hero {
    position: relative;
    height: 26%;
    display: flex;
    flex-direction: column;
    justify-content: center;
    gap: clamp(4px, 0.7vw, 9px);
    padding: 0 clamp(10px, 2vw, 22px);
    border-radius: 3px;
    background-color: rgba(230, 226, 211, 0.09);
    transform-origin: 0 50%;
}
.jw-hero > i {
    display: block;
    border-radius: 2px;
    transform-origin: 0 50%;
}
.jw-hero-line-a { width: 58%; height: clamp(6px, 1.15vw, 11px); background-color: var(--color-ivory); }
.jw-hero-line-b { width: 42%; height: clamp(4px, 0.75vw, 7px); background-color: rgba(230, 226, 211, 0.4); }
.jw-hero-btn {
    width: 18%;
    height: clamp(6px, 1.1vw, 10px);
    margin-top: clamp(2px, 0.4vw, 5px);
    border-radius: var(--radius-full);
    background-color: rgba(230, 226, 211, 0.7);
}

/* Three equal cards rather than two unequal slabs. Content sits at the foot of
   each card, so the empty upper two thirds reads as the image well. */
.jw-blocks { display: flex; gap: 3.5%; height: 34%; }
.jw-block {
    position: relative;
    flex: 1 1 0;
    display: flex;
    flex-direction: column;
    justify-content: flex-end;
    gap: clamp(3px, 0.5vw, 6px);
    padding: clamp(6px, 1.1vw, 12px);
    border-radius: 3px;
    background-color: rgba(230, 226, 211, 0.09);
    transform-origin: 0 50%;
}
.jw-block > i {
    display: block;
    height: clamp(3px, 0.55vw, 5px);
    border-radius: 2px;
    transform-origin: 0 50%;
}
.jw-block > i:first-child { width: 68%; background-color: var(--color-ivory); }
.jw-block > i:nth-child(2) { width: 92%; background-color: rgba(230, 226, 211, 0.32); }

.jw-foot {
    position: relative;
    height: 6%;
    margin-top: auto;
    border-radius: 2px;
    background-color: rgba(230, 226, 211, 0.18);
    transform-origin: 0 50%;
}

/* ---- 04 — You review it ----

   The site stays where it is and the checklist comes forward over it. The dimming
   is the layer's own background rather than a scrim child, because .jlayer carries
   percentage padding and an absolutely positioned child would resolve against the
   padding box and leave an undimmed border all the way round. */

.jlayer-review {
    justify-content: center;
    align-items: center;
    background-color: rgba(0, 0, 0, 0.66);
}

.jqc {
    width: 74%;
    max-width: 470px;
    padding: clamp(12px, 1.9vw, 20px);
    border: 1px solid rgba(230, 226, 211, 0.3);
    border-radius: var(--radius-sm);
    background-color: rgba(12, 12, 12, 0.92);
}

.jqc-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-16);
    padding-bottom: clamp(8px, 1.2vw, 12px);
    margin-bottom: clamp(8px, 1.2vw, 12px);
    border-bottom: 1px solid rgba(230, 226, 211, 0.18);
}

.jqc-title,
.jqc-pass {
    font-family: var(--font-mono);
    font-size: 0.625rem;
    font-weight: 600;
    letter-spacing: 0.14em;
    text-transform: uppercase;
    white-space: nowrap;
}

.jqc-title { color: rgba(230, 226, 211, 0.6); }

/* The outcome, and the only inverted mark on the stage — it lands once every line
   has ticked, so the step resolves on a verdict rather than just stopping. */
.jqc-pass {
    padding: 3px 9px;
    border-radius: var(--radius-full);
    color: var(--color-black);
    background-color: var(--color-ivory);
}

.jqc-list {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: clamp(8px, 1.3vw, 12px);
}

.jqc-item {
    display: flex;
    align-items: center;
    gap: clamp(8px, 1.2vw, 12px);
}

.jqc-box {
    position: relative;
    flex: none;
    display: grid;
    place-items: center;
    width: clamp(13px, 1.8vw, 17px);
    height: clamp(13px, 1.8vw, 17px);
    border: 1px solid rgba(230, 226, 211, 0.45);
    border-radius: 3px;
    /* Transparent rather than unset: the timeline tweens this to ivory, and GSAP
       needs a colour on both ends to interpolate between. */
    background-color: rgba(230, 226, 211, 0);
}

.jqc-tick {
    width: 78%;
    height: 78%;
    stroke: var(--color-black);
    stroke-width: 1.9;
    stroke-linecap: round;
    stroke-linejoin: round;
    /* Longer than the path (~11.8 units) so a full offset clears it completely.
       The tick is drawn on by tweening the offset back to 0. */
    stroke-dasharray: 14;
}

.jqc-bar {
    display: block;
    height: clamp(4px, 0.7vw, 6px);
    border-radius: 2px;
    background-color: rgba(230, 226, 211, 0.28);
    transform-origin: 0 50%;
}

.jqc-item:nth-child(1) .jqc-bar { width: 62%; }
.jqc-item:nth-child(2) .jqc-bar { width: 78%; }
.jqc-item:nth-child(3) .jqc-bar { width: 54%; }
.jqc-item:nth-child(4) .jqc-bar { width: 70%; }

/* ---- 05 — We launch ----

   The publish, not just its result. Marks lift off the page and out of the top of
   the frame while a bar fills along the bottom; the address bar and the padlock
   complete on the same beat. No background on this layer — the finished site has
   to stay fully visible underneath, because it is the thing being sent. */

.jlayer-deploy {
    justify-content: flex-end;
    align-items: center;
}

/* Three marks leaving the page. Positioned against the layer's padding box, which
   is what puts them over the middle of the site rather than its edge. */
.jup {
    position: absolute;
    bottom: 26%;
    display: block;
    height: 3px;
    border-radius: 2px;
    background-color: var(--color-ivory);
    opacity: 0;
}
.jup:nth-of-type(1) { left: 38%; width: 12%; }
.jup:nth-of-type(2) { left: 47%; width: 7%;  }
.jup:nth-of-type(3) { left: 55%; width: 10%; }

.jdeploy {
    position: relative;
    display: flex;
    align-items: center;
    gap: clamp(8px, 1.1vw, 12px);
    /* Capped for the same reason the quote sheet is: at 84% of a widened frame the
       progress track alone ran past 380px, which made a publish look like a file
       transfer dialog from 2003. */
    width: 84%;
    max-width: 420px;
    padding: clamp(8px, 0.9vw, 12px) clamp(12px, 1.5vw, 16px);
    border: 1px solid rgba(230, 226, 211, 0.3);
    border-radius: var(--radius-full);
    background-color: rgba(12, 12, 12, 0.92);
}

.jdeploy-icon {
    flex: none;
    width: clamp(12px, 1.5vw, 15px);
    height: clamp(12px, 1.5vw, 15px);
    stroke: var(--color-ivory);
    stroke-width: 1.4;
    stroke-linecap: round;
    stroke-linejoin: round;
}

.jdeploy-label {
    flex: none;
    font-family: var(--font-mono);
    font-size: 0.5625rem;
    font-weight: 600;
    letter-spacing: 0.14em;
    text-transform: uppercase;
    color: rgba(230, 226, 211, 0.7);
    white-space: nowrap;
}

.jdeploy-track {
    flex: 1 1 auto;
    height: 3px;
    border-radius: var(--radius-full);
    background-color: rgba(230, 226, 211, 0.18);
    overflow: hidden;
}

.jdeploy-fill {
    display: block;
    width: 100%;
    height: 100%;
    border-radius: var(--radius-full);
    background-color: var(--color-ivory);
    transform-origin: 0 50%;
}

/* The frame catching light as it goes live. Sits behind .jw and outside it, so it
   is unaffected by the frame's own overflow clip. Absent at rest — a site that has
   been up for a year does not glow. */
.jw-glow {
    position: absolute;
    inset: -9% -7%;
    display: block;
    /* No radius: the shape is a blurred radial gradient that fades to nothing well
       inside its own box, so a corner radius here was a fifth distinct radius value
       in the stylesheet that could never be seen. */
    background: radial-gradient(58% 58% at 50% 52%, rgba(230, 226, 211, 0.22), rgba(230, 226, 211, 0) 72%);
    filter: blur(20px);
    opacity: 0;
    pointer-events: none;
}

/* Payoff marker for the launch step, pinned below the frame's lower-right corner. */
.jw-live {
    position: absolute;
    right: 0;
    bottom: -38px;
    display: inline-flex;
    align-items: center;
    gap: var(--space-8);
    padding: 5px 11px;
    border: 1px solid rgba(230, 226, 211, 0.4);
    border-radius: var(--radius-full);
    background-color: rgba(230, 226, 211, 0.07);
    font-family: var(--font-mono);
    font-size: 0.6875rem;
    font-weight: 600;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--color-ivory);
}
.jw-live::before {
    content: '';
    width: 6px;
    height: 6px;
    border-radius: var(--radius-full);
    background-color: var(--color-ivory);
    animation: live-pulse 2s ease-in-out infinite;
}

@keyframes live-pulse {
    0%, 100% { opacity: 1; transform: scale(1); }
    50%      { opacity: 0.35; transform: scale(0.8); }
}

/* Same reasoning as the headline cursor: an indefinite pulse is exactly what
   `reduce` is asking to be spared. The dot still renders, it just holds still. */
@media (prefers-reduced-motion: reduce) {
    .jw-live::before { animation: none; }
}

/* ---- Desktop: pinned, copy beside the stage ---- */
@media (min-width: 900px) {
    /* Scroll runway is pinned to the timeline's real length: 14.1 units, read off
       gsap.timeline().duration() in the console rather than counted by hand, since
       it is the sum of every act's fades and internal build and never lands on a
       round number. Re-derive it that way if the timeline changes again.

       Pace is the runway — (height - 100vh) — divided by those 14.1 units. At
       535.8vh that was 31vh of scroll per unit, which read as too fast: the mock
       assembled itself faster than the eye could follow one act finishing before
       the next began. 730vh puts it at 44.7vh per unit, a ~1.45× slowdown.

       This is an expensive knob. The section is the single tallest thing on the
       page and every extra unit of pace is paid for in screens of scrolling, so it
       is worth watching the whole act play through before reaching for more. */
    .journey-section { height: 730vh; }

    /* Centred in the space BELOW the dock, not in the raw viewport. Border-box, so
       the pin still measures exactly one screen and the sticky maths is unchanged;
       only the band the content is centred within gets shorter. */
    .journey-pin {
        position: sticky;
        top: 0;
        height: 100vh;
        height: 100dvh;
        display: flex;
        align-items: center;
        padding: var(--dock-clear) 0 var(--space-32);
    }

    .journey-inner {
        flex-direction: row;
        align-items: center;
        gap: var(--space-48);
    }

    /* Tighter rhythm once the list is a column beside the stage rather than a
       full-width block: the five steps have to fit the band left over after the
       dock's headroom, and at 12px top and bottom they did not on a ~700px-tall
       laptop viewport. 8px keeps every step's own text block intact and only
       closes the air between them. */
    .journey-step { padding-top: var(--space-8); padding-bottom: var(--space-8); }

    /* Floored in px rather than left as a pure percentage: below about 300px the
       step text wraps to three lines each and the column grows tall enough to run
       back under the dock, which is the collision this whole pass is fixing. */
    .journey-copy { flex: 0 0 clamp(300px, 34%, 360px); }

    /* The frame takes back the width the container gave up, by overhanging into the
       right-hand gutter — but only as much gutter as actually exists, and never more
       than 120px, so it stops growing on ultrawide displays instead of becoming a
       billboard. The extra 16px keeps the bleed clear of the scrollbar, which 100vw
       counts and the visible viewport does not. */
    .journey-stage {
        flex: 1 1 auto;
        margin-right: calc(-1 * clamp(0px,
            (100vw - var(--container-max-width)) / 2 - var(--container-padding) - 16px,
            120px));
    }
}

/* ---- Short desktop viewports ----

   A laptop with browser chrome open lands around 620–680px of viewport, and the
   pinned band is that minus the dock's 104px of headroom and 32px at the foot. At
   the full-size rhythm the five-step column measures 552px against a 504px band on
   a 640px-tall window: it still cleared the dock by 3px, but only because an
   over-tall flex item overflows a centred container in both directions at once —
   3px is not clearance, it is a coincidence, and one more line of wrapped step text
   would have taken it negative.

   Only the air is removed. Every step keeps its title and its full description; the
   heading keeps its two lines. Height-based rather than width-based because height
   is what actually ran out. ---- */
@media (min-width: 900px) and (max-height: 780px) {
    .journey-eyebrow { margin-bottom: var(--space-8); }

    .journey-heading {
        font-size: 1.5rem;
        margin-bottom: var(--space-16);
    }

    .journey-step {
        padding-top: var(--space-4);
        padding-bottom: var(--space-4);
    }

    .journey-step-text { line-height: 1.45; }
}

/* ---- Mobile: the section pins and the step list becomes one swapping slot ----

   The previous version here stuck the frame under the top edge and gave every step
   `min-height: 46vh` so the list itself was the scrub's runway. It kept the frame on
   screen, which was the point, and it paid for that with 373px-tall list rows holding
   ~85px of text: on a 375×812 phone the section ran 2362px with roughly 1400px of it
   empty, and the active step's tint painted a 373px grey slab around two lines of
   copy. It read as broken rather than airy, which is exactly what it looked like.

   The runway and the layout were the same thing, so the space could not be closed
   without also destroying the pace. They are separated here: the runway moves onto
   the SECTION (400vh of scroll, same technique as desktop), and the list stops being
   a list at all — the five steps stack into one slot and cross-fade, so only the step
   the frame is currently showing is visible. Nothing on screen is empty, the whole
   composition is one centred screen, and the scrub gets more runway than it had.

   No new JS drives this: main.js already toggles .is-active on exactly one step from
   the timeline's real act boundaries, so the slot follows the frame for free.

   Everything is scoped to .is-scrubbed, which main.js adds only when it has actually
   built the timeline. Without JS, or under reduced motion, none of the below applies
   and the section is what it should be with nothing moving: a finished frame and a
   plain, compact five-step list, as tall as its own content.

   Desktop is untouched: it pins the whole section and sits the two columns side by
   side, and has room to show all five steps at once. ---- */
@media not all and (min-width: 900px) {
    .journey-inner { gap: var(--space-24); }

    /* The frame leads; the copy reads underneath it. */
    .journey-stage {
        order: -1;
        /* Full width is right on a phone and too much on a tablet, where the
           container is 672px wide and the frame's 16/10 would make it 420px tall —
           taller than everything else in the section put together. */
        max-width: 560px;
    }

    /* The launch marker, brought back inside the frame's lower edge.

       `bottom: -38px` is right on desktop, where the frame has a whole column of
       gutter under it. Here the stage sits directly on top of the copy with only
       the inner's 24px gap between them, so a 28px badge hanging 38px below the
       frame finished 22px INSIDE the copy block — level with the "How it works"
       eyebrow, sharing its line, reading as a stray tag that had come loose from
       something. Measured at 320x568; 17px of the same overlap at 375.

       The overhang has to fit inside .journey-inner's gap, and that gap is not
       one number: it is 24px normally and 16px under the short-phone query
       below, which is exactly where the composition is tightest. 12px clears
       both — 12px of air at 375, 4px at 320x568 — where 18px cleared only the
       first and left 2px of overlap on the short phone. Nothing gains height;
       the alternative was reserving 34px of padding, and on a 320x568 screen the
       pinned composition has 37px of slack in total.

       The fill goes near-opaque because the badge now lands on the mock site's
       footer bar rather than on bare black, and 7% ivory over that is unreadable. */
    .jw-live {
        bottom: -12px;
        background-color: rgba(10, 10, 10, 0.92);
    }
}

/* ---- Scrubbed: the pinned, one-step-at-a-time layout ----

   Gated on height as well as width. The composition — frame, heading and one step —
   measures ~425px, and the band it is centred in is the viewport less the dock's
   headroom and 24px of air, so it needs roughly 550px of screen. A landscape phone
   has 375. Below the floor the section simply does not pin: the steps stay a plain
   list, the section stays as tall as its content, and the build plays as it passes.
   Every rule that changes the layout is in here, so falling out of the query falls
   all the way back to that resting state rather than to a half-applied version.

   Nested rather than written as one query, and it has to be: `not` negates the WHOLE
   query, so `not all and (min-width: 900px) and (min-height: 560px)` reads "not (wide
   AND tall)" — which is TRUE on the 812×375 landscape phone this floor exists to
   exclude, and the section pinned itself to a screen a third too short. Nesting keeps
   the negation on the width alone. The `not all` idiom is kept over a max-width pair
   for the reason main.js gives: it is the exact complement of the desktop query, with
   no gap or overlap at fractional viewport widths. ---- */
@media (min-height: 560px) {
 @media not all and (min-width: 900px) {
    /* The runway, and the one number to change if the pace needs tuning. 340vh less
       the pinned screen is 240vh of scroll for a 14.1-unit timeline — ~17vh per
       unit. Narrow screens keep a faster pace than desktop's 44.7 on purpose: a
       phone's viewport height is the unit here, and matching desktop's number would
       mean thumbing through eleven screens for one section. Everything else is
       structural.

       Down from 430vh (itself down from 540). This was still the single largest
       block of "scrolling with nothing new arriving" on the page — 4.3 screens for
       five short steps, most of it spent holding on a centred composition with a
       lot of black around it. The five acts still each get real time to play in;
       what is gone is more of the dwell, on a device where the reader is paying for
       it with their thumb. Together with the marquee's trim below it the page comes
       back to about 7.6 screens on a 375x812 phone, down from 8.7.

       The timeline is scrubbed against this section's own top and bottom with
       invalidateOnRefresh, so this number is the only thing that sets the pace —
       no JS changes with it. */
    .journey-section.is-scrubbed { height: 340vh; }

    .journey-section.is-scrubbed .journey-pin {
        position: sticky;
        top: 0;
        /* svh, not dvh: dvh grows as the address bar retracts, which re-lays out the
           pinned screen mid-scroll — the one moment the reader is watching it. svh is
           the smallest the viewport ever gets, so the composition is measured once
           and never moves. */
        height: 100vh;
        height: 100svh;
        display: flex;
        align-items: center;
        /* Exactly one of these tokens is non-zero: the dock is at the bottom below
           768px and at the top above it, and this section straddles that line. */
        padding: calc(var(--dock-clear) + var(--space-24)) 0
                 calc(var(--dock-clear-bottom) + var(--space-24));
    }

    /* One slot. Every step is placed in the same grid cell, so the slot is as tall as
       the tallest step and swapping between them never reflows anything below. */
    .journey-section.is-scrubbed .journey-steps { display: grid; }

    .journey-section.is-scrubbed .journey-step {
        grid-area: 1 / 1;
        align-content: center;
        /* The rules separated items in a list. There is no list here any more. */
        border-top: 0;
        opacity: 0;
        /* Opacity rather than display/visibility on purpose: the four hidden steps
           stay in the accessibility tree and in source order, so a screen reader still
           gets all five in one pass instead of one step per 80vh of scrolling. They
           are only taken out of the pointer's way. */
        pointer-events: none;
    }

    /* 220ms of pure opacity was a dissolve: two steps both half-visible in the same
       place for an eighth of a second, which is what made the swap read as soft. The
       transform is the thing that gives it a click — the outgoing step is thrown to
       the peek slot on an overshooting curve while the incoming one lands from the
       other side, and the opacity is cut faster than the movement so the two are
       never both legible at once. */
    .journey-section.is-scrubbed .journey-steps.is-live .journey-step {
        transition: opacity 150ms ease,
                    transform 260ms cubic-bezier(0.22, 1.2, 0.36, 1);
    }

    .journey-section.is-scrubbed .journey-step.is-active {
        opacity: 1;
        pointer-events: auto;
    }

    /* The surface tint marks the active step among four inactive ones. With one step
       on screen there is nothing for it to distinguish it from, and it reads as a grey
       box drawn around the text for no reason. The accent bar stays — it is an
       alignment mark, not a state. */
    .journey-section.is-scrubbed .journey-steps.is-live .journey-step.is-active {
        background-color: transparent;
    }
 }
}

/* ---- The wheel: a peek at the step behind and the step ahead ----

   One step alone in a slot said nothing about where it sat in a sequence of five —
   the number was doing all of that work, and a number is a label, not a position. So
   the neighbours come back, clipped to their first line and dimmed: enough to read
   what is coming without competing with what is on screen.

   Clipped rather than shown whole, and that is a measurement, not taste. The room
   available is the pinned band (viewport less the dock clearance and 24px) minus the
   composition, measured at four sizes: 812 tall leaves 223px, 667 leaves 107, 640
   leaves 90, 568 leaves 22. Two full 94px neighbours need 188 and only clear the
   first of those. A 44px clip — the number, the title and the first sliver of the
   description under a fade — costs 52px a side, which is what this padding reserves.

   The padding is on the list rather than anywhere else because the peeks are painted
   INTO it: the peek above would otherwise land on the heading's last line (the gap
   there is only 16-23px), and reserving the same below is what keeps the composition
   optically centred — the flex centring above measures boxes, and without the bottom
   half the box would stop 52px above the ink and sit the whole section 26px high.
   Measured with it: 60px of clearance top and bottom at 812.

   680px is where that stops fitting, and the floor is set from the measurement rather
   than a round number. The band is the viewport less 152px, and the ink comes to
   511px on a screen short enough for the trims below (which stop at 700) and 541px
   without them — so 680 leaves 18px, 701 leaves 4, and 812 leaves 60. A 667 screen
   leaves 2px, which is a fit with nothing left for a longer step title or a reader's
   larger type, so it is excluded on purpose: below this floor the section keeps the
   single-step slot, which has no such dependency.

   Both neighbours are transformed out of the shared grid cell rather than laid out,
   so the cell stays the height of the tallest step and nothing in the composition
   moves when the step changes. `align-self` is what makes the two offsets the same
   number: the previous step is pinned to the top of the cell and thrown up by its own
   clip height plus the gap, the next is pinned to the bottom and thrown down by the
   same, and neither depends on how tall the active step happens to be. ---- */
@media (min-height: 680px) {
 @media not all and (min-width: 900px) {
    .journey-section.is-scrubbed .journey-steps {
        padding-top: 3.25rem;
        padding-bottom: 3.25rem;
    }

    .journey-section.is-scrubbed .journey-step.is-prev,
    .journey-section.is-scrubbed .journey-step.is-next {
        opacity: 0.26;
        /* Overriding the `align-content: center` the single-step slot sets, and it is
           not cosmetic: centring keeps the step's full 94px of content centred inside
           a box clipped to 44, so the clip takes 25px off the TOP — measured with it
           on, the title's own top 8px were cut and the number went with them. Anchored
           to the start, the clip falls where it is meant to, past the title. */
        align-content: start;
        /* 44px is the number and the title (they end 36px in, measured) plus the top
           few pixels of the description. The mask fades the cut so it reads as text
           continuing past the edge rather than text with its legs cut off, and the
           fade is held back to the last 20% for a reason: at 58% it started eating the
           title's descenders, which is the one thing in the peek that has to be
           readable. */
        max-height: 2.75rem;
        overflow: hidden;
        -webkit-mask-image: linear-gradient(to bottom, #000 80%, transparent 100%);
        mask-image: linear-gradient(to bottom, #000 80%, transparent 100%);
    }

    .journey-section.is-scrubbed .journey-step.is-prev {
        align-self: start;
        transform: translateY(-3.25rem);
    }

    .journey-section.is-scrubbed .journey-step.is-next {
        align-self: end;
        transform: translateY(3.25rem);
    }

    /* The steps two or more away come from and leave towards the same side as their
       neighbours, so the whole set reads as one column moving past a window rather
       than items appearing out of nowhere just off the edge. They are at opacity 0
       the entire time; this only sets which direction they are hiding in. */
    .journey-section.is-scrubbed .journey-step.is-before {
        transform: translateY(-6rem);
    }

    .journey-section.is-scrubbed .journey-step.is-after {
        transform: translateY(6rem);
    }
 }
}

/* ---- Short phones ----
   Same idea as the short-desktop query above: on a 320×568 screen the composition
   is 7px taller than the band it has to centre in, so only the air comes out. The
   frame keeps its size, the step keeps its full description. ---- */
@media (min-height: 560px) and (max-height: 700px) {
 @media not all and (min-width: 900px) {
    .journey-section.is-scrubbed .journey-inner { gap: var(--space-16); }
    .journey-section.is-scrubbed .journey-eyebrow { margin-bottom: var(--space-8); }

    .journey-section.is-scrubbed .journey-heading {
        font-size: 1.5rem;
        margin-bottom: var(--space-16);
    }
 }
}

/* ==========================================================================
   SECTION 3 — ABOUT / PROOF

   Deliberately the quietest section on the page, and the only one that does not
   move on its own. The hero types, the marquee crosses, the journey builds — by
   the time the reader arrives here they have been shown three things in a row.
   This one is a block of text and a picture that sits still until it is looked
   at, because the job here is to be believed rather than to impress.

   Note what is NOT here: no pin, no sticky, no scrub. The page already spends
   ~8.4 screens of scroll with the viewport pinned (300vh of marquee, 535.8vh of
   journey); a third scroll-jacked section would have put nearly the whole page
   under animation control, and a hero loop has no discrete steps for a scrub to
   map onto anyway. The video simply plays when it comes into view.
   ========================================================================== */

.about-section {
    position: relative;
    background-color: var(--color-black);
    border-top: 1px solid var(--color-rule);
    padding: var(--space-96) 0;
}

.about-inner {
    width: 100%;
    max-width: var(--container-max-width);
    margin: 0 auto;
    padding: 0 var(--container-padding);
    display: flex;
    flex-direction: column;
    gap: var(--space-48);
}

.about-heading {
    font-family: var(--font-heading);
    font-size: clamp(1.75rem, 1.2rem + 1.8vw, 2.5rem);
    font-weight: 600;
    line-height: var(--leading-tight);
    letter-spacing: -0.02em;
    color: var(--color-white);
    max-width: 16ch;
    margin-bottom: var(--space-24);
    text-wrap: balance;
}

.about-text {
    font-family: var(--font-body);
    font-size: var(--text-lead);
    line-height: var(--leading-body);
    color: var(--color-text-sub);
    max-width: var(--measure-lead);
    text-wrap: pretty;
    opacity: 0.9;
}

.about-text + .about-text { margin-top: var(--space-16); }

/* ---- The proof ---- */

.about-proof {
    margin: 0;
    display: flex;
    flex-direction: column;
    gap: var(--space-16);
    min-width: 0;
}

/* The frame carries the media's own shape here, and only here — the journey's copy
   of .jw keeps its 16/10, because what it holds is a mock drawn to fit it.

   This one holds a real screenshot, and 16/10 was cropping it: `object-fit: cover`
   fills the box and throws away whatever does not fit, which on a 2.002:1 capture in
   a 1.6 box was 20% of the width — the client's logo off one edge and their nav off
   the other. Matching the box to the file is the only way to show the page as it
   actually is; `contain` would show all of it too but inside two black bars, which
   is a letterbox pretending to be a browser.

   The ratio goes on the BODY, not on .jw, and that distinction is the whole fix. The
   frame is a flex column of a chrome bar and a body, and the picture only ever fills
   the body — put 1894/946 on the frame and the body inherits the frame's height minus
   the ~16px of chrome, which measured 2.21:1 and went straight back to cropping, just
   off the top and bottom this time instead of the sides. Sizing the body directly and
   letting the frame take whatever height that plus its chrome comes to is what makes
   the box the picture actually lands in the same shape as the file.

   Written as the asset's real pixel dimensions rather than a rounded 2/1 so the two
   cannot drift: if the screenshot is ever retaken at a different size, this line is
   the one that has to change with it, and it says what it is waiting for. */
.about-proof .jw {
    aspect-ratio: auto;
}

.about-proof .jw-body {
    flex: 0 0 auto;
    aspect-ratio: 1894 / 946;
}

/* .jw-body is the positioning context (position: relative, from the journey
   block). Filling it by inset rather than width/height keeps the video out of
   the flex sizing entirely — .jw owns the box, and the video is painted
   into whatever that box turns out to be. */
.about-video {
    position: absolute;
    inset: 0;
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
    /* Visible in the gap between the poster loading and the first painted frame,
       and it is what the empty frame reads as until the media files land. */
    background-color: rgba(230, 226, 211, 0.05);
}

.about-caption {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: var(--space-8) var(--space-12);
}

.about-client {
    font-family: var(--font-heading);
    font-size: 0.9375rem;
    font-weight: 600;
    letter-spacing: -0.01em;
    color: var(--color-white);
}

.about-meta {
    font-family: var(--font-mono);
    font-size: 0.6875rem;
    font-weight: 500;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--color-text-muted);
}

/* Pushed to the far end of the caption row on one line, and dropped to its own
   line by the flex-wrap once there is no room — so it never crowds the client
   name, which is the part carrying the evidence. */
.about-visit {
    margin-left: auto;
    display: inline-flex;
    align-items: center;
    /* The link's ink is 20px tall, which is a fine size to read and a poor one to
       hit with a thumb. Padding can't fix it: the underline is this link's
       border-bottom, and padding would drop that rule away from the text. The
       pseudo-element below extends the touch target to 44px without moving a
       single pixel of what is drawn. */
    position: relative;
    gap: var(--space-4);
    font-family: var(--font-body);
    font-size: 0.8125rem;
    font-weight: 500;
    color: var(--color-ivory);
    text-decoration: none;
    border-bottom: 1px solid rgba(230, 226, 211, 0.3);
    transition: border-color 200ms ease;
}

.about-visit::after {
    content: "";
    position: absolute;
    inset: -12px -8px;
}

@media (hover: hover) {
    .about-visit:hover { border-bottom-color: var(--color-ivory); }
}

.about-visit:active { color: var(--color-white); }

/* Two columns from the same 900px the journey splits at, so the page changes
   shape once rather than twice on the way down. Copy leads here — the journey
   puts its stage first on mobile because the build IS the content, but this
   section's payload is the sentence "an independent studio in Malaysia", and the video
   is corroboration for it. */
@media (min-width: 900px) {
    .about-inner {
        flex-direction: row;
        align-items: center;
        gap: var(--space-48);
    }

    .about-copy { flex: 0 0 clamp(300px, 38%, 400px); }
    .about-proof { flex: 1 1 auto; }
}

/* ==========================================================================
   PRICING

   This page used to be ~35 inline style attributes and no stylesheet rules at
   all — its own `class="pricing-section"` matched nothing. That is why it was
   the only page on the site with type sizes off the ladder (1.75rem, 2.25rem),
   raw pixel offsets (-12px, 2px) and check marks typed as text: an inline
   style has nowhere to inherit a decision from, so every value gets re-decided
   at the point of use. Everything below comes from the token contract.

   The three cards are a grid, not three flex columns, so the price row and the
   call to action land on the same baseline across all three regardless of how
   long each tier's blurb and feature list run. Nothing here is optically
   nudged; alignment is a consequence of the row template.
   ========================================================================== */

/* ---- One section, one screen ----

   The documented exception to the desktop lock in CLAUDE.md. The lock says
   layout changes live in mobile media queries unless the request is explicitly
   about desktop; this one was, and it was scoped to this page. Hence
   `.page-pricing > *` rather than `main > section` — index, journey and contact
   share `.page-content` and must not pick any of this up.

   Why the page wanted it: the eight sections here were separated by a hairline
   and 64px of padding, which is the same separation two paragraphs get. Read
   end to end that is one 5,159px column of price-shaped text, and the reader
   has no way to tell where "what you pay" stops and "what you pay for later"
   starts. A screen each is the cheapest way to say those are different
   questions — it costs no new colour, no new rule and no decoration.

   svh, not vh. On a phone `vh` resolves against the viewport at its TALLEST —
   toolbar collapsed — so a 100vh section is ~100px taller than what is actually
   on screen when the page loads, and every section starts life with its last
   line pushed under the fold. `svh` is the smallest state, which is the one
   that has to fit. `dvh` would fit too but re-lays out the whole page every
   time the toolbar slides, which on a page of full-height sections is visible
   as the content jumping while you scroll.

   The padding is max()'d against BOTH dock tokens rather than set to a flat
   value because this dock moves: it is at the top from 768px up and at the
   bottom below it, and only one of --dock-clear / --dock-clear-bottom is
   non-zero at a time. Centring content in a full-height box without both would
   slide the first or last line under the pill at exactly one of the two
   widths. */
.page-pricing > section,
.page-pricing > .pricing-screen {
    display: flex;
    flex-direction: column;
    justify-content: center;
}

/* The hero keeps its screen at every width — a landing hero that stops short of
   the fold reads as a page that failed to load. */
.page-pricing > .pricing-screen {
    min-height: 100svh;
}

/* But the CONTENT sections only take a screen each from 900px up.

   The argument above is a wide-screen argument: eight sections separated by a
   hairline read as one undifferentiated column, and a screen each tells the
   reader where one question ends. Below 900px that reasoning inverts, because
   below 900px `.pricing-band` is still a single column — a 62ch list under a
   heading, which is nowhere near a screenful. Measured at 390px the add-ons
   band was 514px inside an 844px box, so the floor was adding ~160px of black;
   at 768px it was 230px. Nothing fills that space. It is slack the centring
   spreads above and below the content, which strands a hairline rule in the
   middle of an empty screen and makes a short list read as a fragment of a
   section rather than the whole of one.

   900px, not 768px, because 900px is where the band becomes two columns — the
   width at which the content actually has a screenful to fill and the "one
   section, one screen" decision starts paying for itself. Tying the floor to
   the same breakpoint as the layout that justifies it means the two can't
   drift apart. It is also already a site breakpoint (the journey section
   switches there), so this invents nothing.

   Narrower than that, a phone or tablet already separates sections by
   scrolling them out of view; the hairline plus 64px is what every other page
   on the site uses and it is enough. */
@media (min-width: 900px) {
    .page-pricing > section {
        min-height: 100svh;
    }
}

.page-pricing > section {
    padding-top: max(var(--space-64), var(--dock-clear));
    padding-bottom: max(var(--space-64), var(--dock-clear-bottom));
}

/* The wrapper adds no padding of its own — the hero inside it already carries
   the top dock clearance. Only the bottom dock needs clearing, and only below
   768px, where --dock-clear-bottom is the non-zero one. */
.page-pricing > .pricing-screen {
    padding-bottom: var(--dock-clear-bottom);
}

/* Fallback for browsers without svh (Safari < 15.4, Chrome < 108). They get the
   old vh behaviour, which is imperfect on a phone but never shorter than the
   viewport — the failure mode is a little extra air, not a clipped section. */
@supports not (min-height: 100svh) {
    .page-pricing > .pricing-screen {
        min-height: 100vh;
    }

    /* Same 900px gate as the svh rule above. Without it this fallback would hand
       every old phone back the full-height sections the media query exists to
       remove — and on vh rather than svh, so ~100px MORE dead space than the
       bug being fixed. */
    @media (min-width: 900px) {
        .page-pricing > section {
            min-height: 100vh;
        }
    }
}

/* Two sections cannot be one screen and are not asked to be: the three tiers
   stacked on a phone measure ~2,200px and the twenty included rows ~1,300px.
   `min-height` rather than `height` is the whole reason that is a non-event —
   the box grows past the floor, the centring becomes a no-op because there is
   no slack left to centre in, and nothing is clipped or scrolled inside
   anything. Everything else on the page still lands on a screen boundary.

   ---- The first screen's air is fluid, and has to be ----

   This was written when the hero shared the screen with the offer band, and the
   two of them were a fixed 714px tall at 1440 wide — taller than a laptop at
   1334x684, so the band's closing rule sat under the fold. The band is gone and
   the cliff with it: a headline, four lines of lead and nothing else clears any
   viewport worth the name.

   The clamps stay anyway, because what they do is spend air only when there is
   air to spend, and that is still right for a short window. Each value clamps a
   vh term between two tokens, so at a tall viewport the screen keeps the
   spacing it was designed with and at a short one it gives that back rather
   than pushing content past the edge. The tokens are the bounds, so nothing
   here lands off the 8-point scale — this is the same shape as the
   `clamp(--space-16, 2.5vh, --space-48)` already used on the home hero.

   padding-top is deliberately untouched: it is `max(--space-64, --dock-clear)`
   and at >=768px that resolves to the dock's 104px. Anything fluid there would
   dip under it on a short window and slide the eyebrow beneath the pill. */
.pricing-screen .hero-compact {
    padding-bottom: clamp(var(--space-24), 4vh, var(--space-64));
}

/* The wrapper's own 20px gap between eyebrow, title and lead is the last thing
   to give, and it gives least — it is the spacing that holds the eyebrow to the
   heading it labels, so it bottoms out at 12px rather than collapsing. */
.pricing-screen .hero-center-wrapper {
    gap: clamp(var(--space-12), 2vh, var(--space-20));
}

/* A sub-page hero. .hero-centered is 100vh because the home page's hero IS the
   first screen and has a canvas behind it; a visitor who clicked "Pricing"
   asked for prices, and giving them a full screen of headline first puts every
   card below the fold. Sized to its content with generous air instead, and
   floored well under a viewport so it never becomes the wall it replaced.

   This was `style="min-height: 70vh; padding-top: var(--space-96)"` inline —
   which also overrode the `max(64px, --dock-clear)` above it with a flat 96px,
   8px short of the dock's 104px, re-opening the exact collision that comment
   describes. Overriding padding-top here would do the same, so this rule does
   not touch it. */
.hero-compact {
    min-height: 0;
    padding-bottom: var(--space-64);
}

/* ---- The page's ground ----

   The home hero has the gravity grid behind it — a canvas the cursor bends.
   This page had nothing, which at a full screen is a headline floating on flat
   black with no sense of a surface under it.

   It is the same visual family and deliberately the quieter member: the same
   ivory hairline the canvas strokes with, on a larger cell, and it does not
   move. Reusing the canvas itself would have given two pages the same signature
   and put a rAF loop behind a page whose job is to be read; a static grid says
   "there is a surface here" and stops.

   Drawn with repeating-linear-gradient rather than a background image so it
   costs no request and no raster, and coloured through --color-ivory-rgb, which
   exists for exactly this — a gradient that needs a runtime alpha.

   The mask is what keeps it from reading as a box. Without it the grid stops
   dead at the section's edges and the section becomes a tiled panel; faded from
   an ellipse behind the copy it dissolves before it reaches any edge, so what
   the eye gets is a ground that falls away rather than a border.

   ---- Why this is one rule and not one per section ----

   It started as `.pricing-screen .hero-compact::before` and already carried a
   note warning that restating the radial-gradient twice is how two copies drift
   apart. Once the content screens wanted a ground too that stopped being a
   warning and became four copies. So the gradient, the mask and the stacking
   are written once here and the three things that actually differ per section —
   cell, alpha, ellipse — are custom properties the opting-in element sets.

   Purely decorative, so it is a pseudo-element and not markup, it takes
   pointer-events: none, and nothing here is announced. `inset: 0` on a
   positioned parent cannot exceed that parent's box, so no ancestor needs
   `overflow: hidden` to contain it — which matters, because the matrix section
   has a sticky table header that an overflow clip would strand. */
/* ---- Three layers, because one was not a surface ----

   The grid alone was not enough and the reason is worth writing down: a grid is
   a DRAWING on a background, not a background. It gives the eye a coordinate
   system and leaves the black between the lines exactly as flat as it found it,
   so a screen with a grid on it still reads as type floating in a void — which
   is precisely the note this got twice.

   What a surface actually needs is the two things the grid cannot supply:

     BLOOM — a suggestion of light falling on the page, so the black has a
             brightest point and a falloff instead of being one value
             everywhere. This is the layer that does most of the work.
     GRAIN — a fine achromatic noise, so the black has a material at all.
             Pure #000 across 1400px is the flattest thing a screen can
             display; a few percent of speckle is the difference between
             "unpainted" and "painted very dark".

   Both stay inside the contract. The bloom is ivory — the one accent — resolved
   through --color-ivory-rgb, which the token file documents as existing for
   exactly this, a gradient needing a runtime alpha. The grain has no hue at all
   (feColorMatrix saturate 0), so it introduces no colour; it is a finish, in
   the same sense the dock's backdrop blur is documented as a finish. Neither is
   a shadow and neither adds a radius.

   Three layers, three homes, so each is tunable without disturbing the others:

     element background   the bloom      (unmasked — it IS the falloff)
     ::before             the grid       (masked, so it dissolves before an edge)
     ::after              the grain      (tiled, opacity-controlled)
     children             the content    (z-index 1, above all three)

   Named .surface-ground rather than .pricing-ground because journey's held
   stage now uses it too. */
.surface-ground {
    position: relative;

    /* The quiet default, for the content screens. Half the hero's alpha on a
       cell half again as large: at 96px a 1440px screen gets fifteen columns of
       line so faint it reads as grain rather than as graph paper. */
    --ground-cell: var(--space-96);
    --ground-alpha: 0.05;
    --ground-mask: ellipse 76% 62% at 50% 50%;

    /* Bloom and grain both start near their floor here and are turned up on the
       two heroes below. A content screen that glows as hard as an opening one
       flattens the page's whole hierarchy — every screen equally lit is the
       same problem as every screen equally dark. */
    --ground-bloom: 0.045;
    --ground-bloom-shape: ellipse 62% 48% at 50% 46%;
    --ground-grain: 0.035;

    background-image:
        radial-gradient(
            var(--ground-bloom-shape),
            rgba(var(--color-ivory-rgb), var(--ground-bloom)) 0%,
            rgba(var(--color-ivory-rgb), calc(var(--ground-bloom) * 0.35)) 45%,
            rgba(var(--color-ivory-rgb), 0) 78%
        );
}

/* Content over ground. The hero's .site-container is already z-index 1 through
   .hero-centered; this covers the sections that are not heroes. */
.surface-ground > * {
    position: relative;
    z-index: 1;
}

.surface-ground::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: 0;
    pointer-events: none;
    background-image:
        repeating-linear-gradient(
            to right,
            rgba(var(--color-ivory-rgb), var(--ground-alpha)) 0 1px,
            transparent 1px var(--ground-cell)
        ),
        repeating-linear-gradient(
            to bottom,
            rgba(var(--color-ivory-rgb), var(--ground-alpha)) 0 1px,
            transparent 1px var(--ground-cell)
        );
    -webkit-mask-image: radial-gradient(var(--ground-mask), #000 0%, #000 38%, transparent 76%);
    mask-image: radial-gradient(var(--ground-mask), #000 0%, #000 38%, transparent 76%);
}

/* The grain.

   An inline SVG data URI rather than a raster: it costs no request, no file in
   the repo, and it is resolution-independent, so it does not turn into visible
   pixel mush on a 2x display the way a tiled PNG of noise does.

   feColorMatrix saturate 0 is not optional. feTurbulence generates noise in all
   three channels independently, which is COLOUR static — magenta and green
   speckle on a page whose whole argument is four colours. Desaturating collapses
   it to luminance and keeps the palette honest.

   160px tile, baseFrequency 0.9, two octaves. Two rather than three because the
   extra octave adds structure the eye starts to read as a pattern once it tiles;
   at this size and alpha nobody can see the repeat. */
.surface-ground::after {
    content: "";
    position: absolute;
    inset: 0;
    z-index: 0;
    pointer-events: none;
    opacity: var(--ground-grain);
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23g)'/%3E%3C/svg%3E");
    background-size: 160px 160px;
    /* The grain has to be faded off top and bottom for the same reason the grid
       is masked, and it is the layer that actually exposes the problem: the grid
       is already dissolving into nothing well inside its box and the bloom is a
       radial that reaches zero at 78%, but uniform noise carried to the edge of
       a section draws a visible horizontal seam against the flat black of the
       next one. Vertical only — the left and right edges are the window's, so
       there is nothing there to seam against. */
    -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 6%, #000 94%, transparent 100%);
    mask-image: linear-gradient(to bottom, transparent 0, #000 6%, #000 94%, transparent 100%);
}

/* ---- The two openings ----

   Both are the first screen of a page and both get the loud setting: the grid at
   the 0.12 the gravity canvas strokes with, and roughly triple the bloom of a
   content screen. This is the site's one place for a lit surface — the screens
   below whisper the same thing so that these two can say it.

   The pricing hero's ellipse is offset to 42% because the optical centre of the
   copy sits above the geometric centre of the section. Journey's stage carries
   its heading at the top and a 16:10 frame under it, so its bloom sits higher
   still and spreads wider — it is lighting the margins around a picture rather
   than the space behind a sentence. */
.pricing-screen,
.arc-stage {
    --ground-cell: var(--space-64);
    --ground-alpha: 0.12;
    --ground-bloom: 0.13;
    --ground-grain: 0.06;
}

/* The ground belongs to the SCREEN, not to the headline block inside it.
   .hero-compact is `min-height: 0` and sized to its own three elements — about
   400px of a 900px screen — so texturing it lit the copy and left the bottom
   half of the first screen as the flat black this was meant to fix, with a hard
   seam across the middle where the noise stopped. .pricing-screen is the box
   that is actually 100svh. */
.pricing-screen {
    --ground-mask: ellipse 70% 60% at 50% 42%;
    --ground-bloom-shape: ellipse 58% 46% at 50% 40%;
}

/* And the hero inside it has to stop painting over the thing it is standing on.
   .hero-centered carries `background-color: var(--color-black)` — correct on the
   home page, where it is the page's own backdrop — but here the hero is a child
   of the textured screen and .surface-ground > * lifts it to z-index 1, so that
   opaque black covered the middle 460px of the ground exactly. What it looked
   like: two horizontal bands of texture with a flat black stripe between them,
   which is a worse result than having no texture at all. */
.pricing-screen > .hero-section {
    background-color: transparent;
}

.arc-stage {
    --ground-mask: ellipse 82% 66% at 50% 44%;
    --ground-bloom-shape: ellipse 74% 52% at 50% 34%;
}

/* ---- The pricing hero's proof field ----

   Two rows of real screenshots, one above the copy and one below it, running
   off both edges of the screen. See the markup note in pricing.html for what
   the files are and why they are downscaled copies.

   ---- Why two rows at the edges and not a collage behind the type ----

   The obvious version — three frames scattered around the headline — was drawn
   first and thrown away. The copy on this screen is centred and fluid: the
   headline wraps at two lines on a laptop and three on a phone, and the lead is
   four lines of prose. Anything placed "beside" it is placed beside a box whose
   height changes with the viewport, so on some window it lands under a
   descender. Bands pinned to the top and bottom of the screen cannot: they are
   anchored to the two edges the copy is never at, and the space between them is
   exactly the dead ground this was added to fill (~190px above the eyebrow and
   ~290px below the lead, measured at 390x844).

   ---- Three layers of protection for the type, and all three are needed ----

     opacity   on the shots themselves, not on .proof-field, because the scrim
               below is a child of the field and would be faded with them.
     grayscale the shots are of OTHER SITES — a red hero, a purple one, a blue
               one — and this page has four colours. Desaturated they read as
               screens; left alone they read as somebody else's brand palette
               leaking through the background.
     scrim     a radial of the page's own background, painted OVER the rows and
               opaque in the middle. This is what guarantees the headline's
               contrast rather than hoping the opacity was low enough.

   The grain (.surface-ground::after) sits above all of it in DOM order at the
   same z-index, which is the right way round: the noise falls across the
   photographs as well as the black, and that is most of what stops them looking
   like images pasted onto a background rather than part of one. */
.proof-field {
    position: absolute;
    inset: 0;
    /* Not `1`. `.surface-ground > *` lifts every child to z-index 1 to clear the
       ground layers, and this is a ground layer — it belongs under the content,
       above the grid, and (by DOM order at the same index) under the grain. */
    z-index: 0;
    overflow: hidden;
    pointer-events: none;
}

.proof-row {
    position: absolute;
    left: 50%;
    display: flex;
    gap: var(--space-24);
    /* max-content, so the row is as wide as its four shots want to be and runs
       off both edges rather than squeezing them to fit. Four is what makes that
       true at 1920 as well as at 390 — three at the clamp's ceiling stop 200px
       short of a wide screen and the row reads as a strip with ends. */
    width: max-content;
}

/* The two rows are offset from centre in opposite directions so the repeated
   files do not stack into columns. */
.proof-row-top {
    top: calc(var(--dock-clear) + var(--space-16));
    transform: translateX(-56%);
}

.proof-row-bottom {
    bottom: calc(var(--dock-clear-bottom) + var(--space-16));
    transform: translateX(-44%);
}

/* Small enough to read as a wall of screens rather than as a slideshow. At the
   first size this was drawn at — 34vw, ceiling 560 — a single ChillBox capture
   was 560px wide behind the headline, which is not a texture, it is a second
   page showing through the first. Around a quarter of the viewport is the width
   at which the eye takes the row as one object and stops trying to read any
   individual frame in it. */
.proof-shot {
    display: block;
    flex: none;
    width: clamp(220px, 26vw, 480px);
    aspect-ratio: 900 / 455;
    overflow: hidden;
    border: 1px solid var(--color-rule);
    border-radius: var(--radius-md);
    opacity: 0.34;
}

.proof-shot img {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
    /* Top-anchored: these are captures of whole pages, and the half worth
       showing at this size is the hero, not the footer. */
    object-position: 50% 0;
    filter: grayscale(0.85);
}

/* The scrim. Opaque at the centre and gone by the corners, so the copy sits on
   flat page background and the shots are only ever legible out at the edges. */
.proof-field::after {
    content: "";
    position: absolute;
    inset: 0;
    background-image: radial-gradient(
        ellipse 72% 46% at 50% 46%,
        rgba(var(--color-black-rgb), 1) 0%,
        rgba(var(--color-black-rgb), 0.96) 45%,
        rgba(var(--color-black-rgb), 0.7) 75%,
        rgba(var(--color-black-rgb), 0.45) 100%
    );
}

/* On a phone the rows are the only thing in the top and bottom thirds of the
   screen, so they can afford to be read a little more clearly — and they have
   to be, because at 240px wide a screenshot at desktop's opacity is a grey
   smudge rather than a recognisable page. The scrim tightens to match: a
   narrower ellipse keeps the same protection over a headline that is now three
   lines instead of two. */
@media (max-width: 767px) {
    .proof-shot { opacity: 0.6; }

    .proof-field::after {
        background-image: radial-gradient(
            ellipse 92% 30% at 50% 46%,
            rgba(var(--color-black-rgb), 1) 0%,
            rgba(var(--color-black-rgb), 0.94) 46%,
            rgba(var(--color-black-rgb), 0.6) 76%,
            rgba(var(--color-black-rgb), 0.34) 100%
        );
    }
}

/* The line that names the field. Mono and muted, which on this site is the
   voice used for a label rather than for a sentence — it is captioning the
   screen, not adding a third paragraph to it. The margin is its own because
   .hero-center-wrapper's flex gap is sized to separate an eyebrow from a
   headline from a lead, and this belongs to the lead above it. */
.hero-proof-note {
    margin: calc(var(--space-8) * -1) 0 0;
    font-family: var(--font-mono);
    font-size: 0.6875rem;
    font-weight: 500;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    text-align: center;
}

.hero-proof-link {
    color: var(--color-text-muted);
    text-decoration: none;
    /* Drawn as a border rather than an underline so it can be dimmed
       independently of the text — an underline at full strength under muted
       type is a link shouting louder than its own label. */
    border-bottom: 1px solid var(--color-rule);
    padding-bottom: 2px;
    transition: color 0.2s ease, border-color 0.2s ease;
}

@media (hover: hover) {
    .hero-proof-link:hover {
        color: var(--color-accent);
        border-bottom-color: var(--color-accent);
    }
}

.hero-proof-link:focus-visible {
    color: var(--color-accent);
    border-bottom-color: var(--color-accent);
}

/* Nothing here is gated on reduced motion, because nothing here moves. It is
   deliberately a still field: a drifting or fading wall of screenshots behind a
   price is the kind of movement that makes a reader distrust the number in
   front of it, and this screen is the one place on the site that cannot afford
   that. The scrim is a plain radial-gradient rather than a mask, so it also
   survives the no-mask fallback further down intact. */

/* 70% of 1440px is nine cells and reads as a ground. 70% of 390px is four, and
   a four-by-four patch of lines behind the headline reads as a swatch someone
   left there. Wider than the section and flatter, so on a phone the grid runs
   off both sides and fades vertically instead — same effect, no visible edge.
   The content screens need the same correction for the same reason.

   The grain goes UP on a phone, not down. It is a fixed 160px tile, so on a
   390px screen each tile is a much larger share of what is on display and the
   speckle reads coarser and therefore weaker per unit area; matching the desktop
   alpha there leaves the phone looking like the flat black this was meant to
   fix — which is the screen most of this site's readers are on. */
@media (max-width: 767px) {
    .surface-ground {
        --ground-mask: ellipse 140% 54% at 50% 50%;
        --ground-grain: 0.05;
    }

    .pricing-screen,
    .arc-stage {
        --ground-grain: 0.075;
    }

    .pricing-screen { --ground-mask: ellipse 130% 52% at 50% 38%; }
    .arc-stage { --ground-mask: ellipse 150% 58% at 50% 40%; }
}

/* Without a mask, both masked layers become hard-edged tiled panels — which is
   worse than not having them. The grid stops dead at the section's edges and the
   grain draws exactly the horizontal seam its own mask was added to remove, so
   a browser that cannot mask drops both rather than being handed the failure
   this whole block is designed around.

   The bloom is not in here on purpose. It is a radial-gradient that reaches
   fully transparent at 78% under its own steam, so it has no edge to hide and
   needs no mask — which means the fallback is still a lit surface with a
   falloff, not the flat black this started as. */
@supports not ((mask-image: radial-gradient(#000, transparent)) or (-webkit-mask-image: radial-gradient(#000, transparent))) {
    .surface-ground::before,
    .surface-ground::after { display: none; }
}

/* ---- Founding client offer ----

   The band that announced it is gone from the markup, and every rule that
   styled it went with it: .offer-band, .offer-inner, .offer-heading,
   .offer-text, .offer-timer and .offer-deadline. It had already lost its
   countdown clock to the argument that a ticking deadline reads as a pressure
   device rather than a date; what removing the band says is that the strip
   under the headline was making the same claim more slowly. The discount is
   still on the page — it is in the cards, as a struck list price with the
   founding price beside it, which is where a price belongs.

   ---- Once the deadline has passed ----

   main.js sets [data-offer="closed"] on <html> after comparing the deadline on
   #pricing-plans against the visitor's clock, and every card falls back to its
   list price — the struck figure was always the real one, so nothing has to be
   rewritten for it to become the price again.

   An offer that keeps advertising itself after it has closed is the thing that
   actually cheapens a page, because it tells the reader the deadline was never
   real. This is the whole reason both figures are in the markup. */
:root[data-offer="closed"] .pricing-price-now,
:root[data-offer="closed"] .pricing-price-note {
    display: none;
}

:root[data-offer="closed"] .pricing-price-was {
    font-size: inherit;
    font-weight: inherit;
    color: var(--color-white);
    text-decoration: none;
}

.pricing-section {
    padding-top: var(--space-64);
    padding-bottom: var(--space-64);
}

.pricing-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(272px, 1fr));
    gap: var(--space-24);
    align-items: stretch;
}

/* Six rows, fixed in order: eyebrow, name, blurb, price, features, action.
   The features row takes the slack (1fr), which is what pins the button to the
   bottom of every card; the four rows above it are content-sized and therefore
   identical height across cards, which is what aligns the prices. */
.pricing-card {
    display: grid;
    grid-template-rows: auto auto auto auto 1fr auto;
    background-color: var(--color-surface);
    border: 1px solid var(--color-rule);
    border-radius: var(--radius-md);
    padding: var(--space-32);
    position: relative;
    transition: border-color 200ms ease;
}

@media (hover: hover) {
    .pricing-card:hover { border-color: rgba(230, 226, 211, 0.28); }
}

/* The one card carrying the recommendation. It is marked by border and badge
   only — no scale, no lift, no shadow. The site has no shadow scale and adding
   one here to make a card "pop" is exactly the decision this page was full of. */
.pricing-card-featured {
    border-color: rgba(230, 226, 211, 0.45);
}

@media (hover: hover) {
    .pricing-card-featured:hover { border-color: var(--color-accent); }
}

/* Straddles the card's top edge, so it reads as attached to the card rather
   than floating inside it. Offset is half the badge's own height rather than a
   guessed pixel value, so it stays centred on the rule if the type changes. */
.pricing-badge {
    position: absolute;
    top: 0;
    right: var(--space-24);
    transform: translateY(-50%);
    background-color: var(--color-accent);
    color: var(--color-black);
    font-family: var(--font-mono);
    font-size: 0.6875rem;
    font-weight: 600;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    padding: var(--space-4) var(--space-12);
    border-radius: var(--radius-full);
}

.pricing-eyebrow {
    font-family: var(--font-mono);
    font-size: 0.6875rem;
    font-weight: 500;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--color-accent);
}

.pricing-name {
    font-family: var(--font-heading);
    font-size: 1.25rem;
    font-weight: 600;
    letter-spacing: -0.01em;
    color: var(--color-white);
    margin-top: var(--space-8);
}

.pricing-blurb {
    font-family: var(--font-body);
    font-size: 0.875rem;
    line-height: 1.5;
    color: var(--color-text-muted);
    margin-top: var(--space-8);
}

/* Four lines reserved whether the sentence needs them or not — that is the
   longest any of the three blurbs runs at the width a card takes in a
   multi-column grid, so reserving it puts every price on the same line and
   every feature list on the same line under it. Without it the shortest blurb
   pulls its whole card up by one line and the row stops reading as a row,
   which is the most visible symptom of a card grid nobody aligned.

   It was three when the blurbs were the ones this page shipped with. The
   descriptions now come from the client catalogue and two of them run to four
   lines in a 285px card, so the reservation follows the copy. This number is
   measured, not chosen: if the blurbs are rewritten, measure again.

   Gated at 640px because below it the grid is a single column: there is no
   neighbouring card to align to, and the reservation would only be an empty
   line under two of the three. 640px is the breakpoint the marquee already
   switches on rather than a new one. */
@media (min-width: 640px) {
    .pricing-blurb { min-height: calc(4 * 1.5 * 0.875rem); }
}

/* The price, what it was, and the two lines qualifying it are ONE grid child.
   The card's six-row template is what puts every feature list and every button
   on the same line across three cards; adding the discount as three more
   siblings would have made it a nine-row card next to two six-row ones.

   The 24px either side of it was the same gap the eyebrow, name and blurb use
   between each other, so the price — the one thing a reader came to this card
   for — sat in the middle of an evenly spaced stack with nothing marking it
   out. 32px is the next step on the scale and is enough to read the block as
   its own unit without adding a rule or a fill to say so. */
.pricing-price-block {
    margin: var(--space-32) 0;
}

/* The two figures stack rather than sitting on one baseline. Baseline was the
   first shape and it does read better — but a card in the three-column grid is
   285px wide, and "From RM9,800 RM7,350" measures within a pixel of the 221px
   left inside the padding. It wrapped, the third card's price row became two
   lines where the others were one, and the row stopped aligning. A stack is one
   line plus one line in every card at every width, so there is nothing left to
   wrap. */
.pricing-price {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: var(--space-4);
    font-family: var(--font-mono);
    /* The same clamp the section headings use. A price is display type and gets
       a display step — it does not get a step of its own. */
    font-size: clamp(1.75rem, 1.2rem + 1.8vw, 2.5rem);
    font-weight: 600;
    line-height: 1.1;
    letter-spacing: -0.02em;
    font-variant-numeric: tabular-nums;
    color: var(--color-white);
}

.pricing-currency {
    font-size: 0.875rem;
    font-weight: 500;
    letter-spacing: 0.08em;
    color: var(--color-text-muted);
}

/* The list price, struck, on the line above. Body scale rather than a display
   one, and muted: it is the figure being replaced, not a second price competing
   with the first. The size difference is the whole signal — set at the same
   scale, two prices on two lines read as a range.

   The strike is drawn at a hairline in the muted colour rather than left to the
   UA default, which inherits the 600 weight above and lands as a heavy bar
   through the middle of the figure. */
.pricing-price-was {
    font-size: 1rem;
    font-weight: 500;
    letter-spacing: 0;
    color: var(--color-text-muted);
    text-decoration: line-through;
    text-decoration-thickness: 1px;
    text-decoration-color: var(--color-text-muted);
}

/* Inside the struck figure the currency is part of the same small unit, so it
   does not take the separate small-caps treatment it gets on the live price. */
.pricing-price-was .pricing-currency {
    font-size: inherit;
    letter-spacing: inherit;
    color: inherit;
}

.pricing-price-now {
    display: inline-flex;
    align-items: baseline;
}

/* "From" appears on both figures because both are from-prices — once they are
   stacked rather than sharing a baseline, a single prefix at the top qualifies
   only the line it sits on. A word, not a figure, so it leaves the mono face
   the numerals need. The strike on the line above propagates to it, which is
   correct: the whole of the old price is what was withdrawn. */
.pricing-price-prefix {
    font-family: var(--font-body);
    font-size: 0.875rem;
    font-weight: 500;
    letter-spacing: 0;
    color: var(--color-text-muted);
    /* Sits alongside a real space in the markup, which is there so the figure is
       announced and copied as "From RM9,800" rather than "FromRM9,800". */
    margin-right: var(--space-4);
}

/* The only ivory on the card below the eyebrow. The saving is the one thing
   here that is genuinely temporary, and ivory is how this site marks the thing
   that matters — a second colour for "discount" is what the whole palette rule
   exists to prevent. */
.pricing-price-note {
    font-family: var(--font-mono);
    font-size: 0.6875rem;
    font-weight: 500;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--color-accent);
    margin-top: var(--space-12);
}

.pricing-meta {
    font-family: var(--font-body);
    font-size: 0.875rem;
    line-height: 1.5;
    color: var(--color-text-muted);
    margin-top: var(--space-8);
}

/* 16, not 12. With five lines in a 285px card the list was a paragraph with
   ticks in it — the rows had less air between them than the line spacing inside
   a row that wrapped, so a two-line feature and two one-line features read as
   one block of three lines. At three items there is room to let the rows breathe
   and the list becomes countable at a glance, which is the only job it has in a
   card someone is comparing against two others. */
.pricing-features {
    list-style: none;
    display: flex;
    flex-direction: column;
    gap: var(--space-16);
    margin-bottom: var(--space-32);
}

/* flex-start, not center: a feature that wraps to two lines would otherwise
   centre its tick against the block of text instead of the first line. */
.pricing-feature {
    display: flex;
    align-items: flex-start;
    gap: var(--space-8);
    font-family: var(--font-body);
    font-size: 0.875rem;
    line-height: 1.5;
    color: var(--color-text-sub);
}

/* An SVG, because every other icon on this site is an SVG. These were bare
   U+2713 characters, which render in whatever fallback font the OS supplies —
   a different weight and baseline on every platform, and the one glyph on the
   page nobody chose. flex: none stops it being squeezed by a long line. */
.pricing-tick {
    flex: none;
    width: 16px;
    height: 16px;
    margin-top: 0.15em;
    stroke: var(--color-accent);
    stroke-width: 2;
    stroke-linecap: round;
    stroke-linejoin: round;
    fill: none;
}

/* "Everything in <the tier below>". Not a feature, so it takes no tick and no
   tick-sized indent — it is the line that tells the reader the three cards are
   cumulative rather than three separate menus, which is the one thing a trimmed
   list could otherwise lose. Set in the muted colour at the mono labelling
   voice, above a hairline, so it reads as a note on the list rather than a
   fourth item in it. */
.pricing-feature-carry {
    font-family: var(--font-mono);
    font-size: 0.6875rem;
    font-weight: 500;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--color-text-muted);
    padding-top: var(--space-16);
    border-top: 1px solid var(--color-rule);
    /* .pricing-feature is a flex row sized around a tick that is not here. */
    display: block;
    /* The list fills the card's 1fr row, so this drops the carry line to the
       bottom of that space — level across the two cards that have one, and
       directly above the button rather than floating mid-card. */
    margin-top: auto;
}

.pricing-cta {
    width: 100%;
}

/* ---- The four content bands between the cards and the reassurance strip ----

   One padding and one rule for all of them, so the page reads as a stack of
   equal-weight sections rather than four blocks that each decided their own
   spacing. The reassurance strip below already uses the same pair. */
.pricing-included,
.pricing-addons,
.pricing-process,
.pricing-renewals {
    padding: var(--space-64) 0;
    border-top: 1px solid var(--color-rule);
}

/* ---- Two-column bands: add-ons, process, renewals ----

   These three are lists capped at 62ch. Stacked under a heading in a 1000px
   container that was already a bit lopsided; given a full screen each it became
   a ribbon of text down the left with a third of the width and 300px of height
   empty around it, which reads as a section that ran out of content rather than
   one composed of air.

   Two columns fixes it with nothing added — the heading was always going to be
   read first and the list second, so putting them side by side changes the
   shape and not the order. 900px is where the journey section already switches
   from stacked to two-column, so it is the same decision at the same width
   rather than a new breakpoint.

   Placement is explicit rather than left to auto-flow because the renewals band
   has a footnote after its list: on auto-flow that lands in column one under
   the heading, which is the one place it does not belong. */
@media (min-width: 900px) {
    .pricing-band {
        display: grid;
        grid-template-columns: 2fr 3fr;
        /* Column gap only. A row gap would also apply between the renewals list
           and the footnote stacked under it in column two — and because row one
           is as tall as the list, that gap lands on top of the footnote's own
           margin and pushes it half a screen clear of the figures it qualifies. */
        column-gap: var(--space-48);
        align-items: start;
    }

    .pricing-band-head {
        grid-column: 1;
        grid-row: 1;
        /* ---- The spine ----

           Two columns of text with 48px of black between them are two blocks
           that happen to be beside each other; the reader has nothing telling
           them the heading on the left governs the list on the right. One
           hairline does, and it is the same hairline the lists themselves are
           ruled with — this section adds no device, it extends the one already
           on screen through the axis the layout turns on.

           It needs `stretch` to be worth drawing. Under the `align-items: start`
           above, the head box is only as tall as its own three lines, so the
           rule stopped a third of the way down and read as a stray tick rather
           than as the division between two columns. Set on the head alone
           rather than by changing the shared `align-items`, which would also
           stretch the list and the footnote in column two for no reason. */
        align-self: stretch;
        border-right: 1px solid var(--color-rule);
        padding-right: var(--space-48);
    }

    .pricing-band > :not(.pricing-band-head) {
        grid-column: 2;
    }

    /* Both exist to open a gap before the list below. In two columns there is
       nothing below them — the gap becomes a dangling tail under the heading. */
    .pricing-band .journey-heading,
    .pricing-band .pricing-included-lead {
        margin-bottom: 0;
    }

    /* Except that it is not true of the heading in the one band that has a lead:
       the add-ons head is eyebrow, heading, lead, so zeroing the heading's
       margin closed the gap between the heading and its own lead and the two
       set solid. Reopened from below rather than by exempting the heading,
       because only the head that actually has a lead should get the space —
       process and renewals have nothing after their heading and the rule above
       is right for them. */
    .pricing-band-head .pricing-included-lead {
        margin-top: var(--space-16);
    }

    /* The cap is what kept a 1000px-wide row from stranding its label and its
       figure at opposite ends. The column is already narrower than the cap, so
       it now only stops the list filling the column it was given. */
    .pricing-band .pricing-addon-list,
    .pricing-band .pricing-renewal-list {
        max-width: none;
    }

    /* Sits under the list in column two, not against it. */
    .pricing-band .pricing-footnote {
        margin-top: var(--space-32);
    }

    /* ---- Rhythm that scales with the screen the band was given ----

       These three bands are short: four add-ons, three renewals, three steps.
       Given a whole screen each by the rule at the top of this block, they were
       a ~300px huddle centred in 900px of black — which does not read as
       composure, it reads as a section that ran out of things to say.

       The fix is not more content, it is spending the height on the content
       that is there. Each row's padding clamps a vh term between two tokens, so
       a tall window buys air between the rows and a short one gives it straight
       back rather than pushing the band past the fold. It is the same shape as
       the `clamp(--space-16, 2.5vh, --space-48)` the home hero already uses,
       and both bounds are tokens, so nothing here lands off the 8-point scale.

       Only inside `.pricing-band`, and only from 900px. Below that the sections
       size to their content and the compact rhythm is the correct one. */
    .pricing-band .pricing-addon {
        padding: clamp(var(--space-16), 2.4vh, var(--space-32)) 0;
    }

    .pricing-band .pricing-renewal {
        padding: clamp(var(--space-20), 2.8vh, var(--space-48)) 0;
    }

    /* The steps are ruled the same way the two price lists are, so they take the
       same treatment. The extra top weighting is the step number's: it sits on
       the first line of the title, and padding split evenly leaves the numeral
       looking high in its own row. */
    .pricing-band .journey-step {
        padding-top: clamp(var(--space-12), 2.4vh, var(--space-32));
        padding-bottom: clamp(var(--space-12), 2vh, var(--space-24));
    }
}

.pricing-included-lead {
    font-family: var(--font-body);
    font-size: var(--text-lead);
    line-height: var(--leading-body);
    color: var(--color-text-sub);
    max-width: var(--measure-lead);
    margin-bottom: var(--space-32);
    text-wrap: pretty;
}

/* ---- The matrix section's head ----

   The three bands below this one turn their heading into a left column because
   a 62ch list given a whole screen leaves the right of it empty. This section
   has the opposite shape — the table under it is full width — but the same
   problem above the table: eyebrow, heading and lead stacked at 16ch and 48ch
   respectively occupied the left 45% of a 1000px container and left the top
   right corner of the screen blank, directly above the widest element on the
   page.

   So the head splits on the same 2fr/3fr the bands use, which puts the lead
   over the columns it is talking about and starts the table's width one block
   earlier. No spine here: the table's own header rule is already drawing the
   horizontal division a few pixels below, and a vertical hairline crossing it
   would read as the corner of a box neither of them is in.

   Aligned to `end` rather than `start` because the heading is display type and
   the lead is body — hung from a common top they part company immediately, and
   the only line they can share is their last one. */
.pricing-included-head {
    margin-bottom: var(--space-32);
}

/* The head now owns the gap to the table, so the lead inside it does not. */
.pricing-included-head .pricing-included-lead {
    margin-bottom: 0;
}

@media (min-width: 900px) {
    .pricing-included-head {
        display: grid;
        grid-template-columns: 2fr 3fr;
        column-gap: var(--space-48);
        align-items: end;
    }

    /* Same reason as the bands: the gap under it existed to open space before
       the thing below, and in two columns there is nothing below it. */
    .pricing-included-head .journey-heading {
        margin-bottom: 0;
    }
}

/* ---- The comparison matrix ----

   Replaces the three stacked "adds" lists. See the note above the table in
   pricing.html for why the shape changed; this block is only about how it is
   drawn.

   `separate` rather than `collapse`, which is the unusual choice here and a
   deliberate one: a collapsed border belongs to the table, not to the cell, and
   a collapsed border on a `position: sticky` cell is left behind by the cell
   when it detaches. Both the header row and the feature column stick, so every
   rule below is drawn on the cell and the spacing is zeroed by hand. */
.pricing-matrix {
    width: 100%;
    border-collapse: separate;
    border-spacing: 0;
    /* Below this the feature column and three tick columns cannot both stay
       legible, so the table stops shrinking and the wrapper scrolls instead.
       Measured against the longest unbreakable run in column one — "48-hour
       response for 30 days after launch" — at the 192px that 40% leaves it. */
    min-width: 480px;
    /* Fixed, so the three plan columns are the same width whatever lands in
       them. On auto layout the widest cell in a column sets it, and the column
       holding "Payment gateways — iPay88, Fiuu, eGHL…" would have taken a third
       of the table to hold a tick. */
    table-layout: fixed;
}

/* Only a scroller where it needs to be one — see the min-width block at the
   bottom, which turns this off. Two reasons it is not left on everywhere:
   `overflow-x: auto` forces overflow-y to `auto` as well, which makes this box
   a scroll container and kills the sticky header inside it; and a scroll
   container that never scrolls still clips the focus ring on anything in it. */
.pricing-matrix-scroll {
    overflow-x: auto;
    overscroll-behavior-x: contain;
    /* Not decoration and not a micro-optimisation: without it the 480px table
       widens the layout viewport on a phone even though this box scrolls and
       visibly clips it, and the whole page — the fixed dock included — ends up
       75px wider than the screen with a horizontal scrollbar under it. Measured
       at 390px: documentElement.scrollWidth 465 without, 390 with. Paint
       containment is what makes the clip real to layout rather than only to the
       eye. Switched off at 768px with the scroller. */
    contain: paint;
}

/* ---- The cut edge ----

   A mask rather than an overlay, because there is nowhere to put an overlay: the
   only element between the container and the table is this one, and anything
   absolutely positioned INSIDE a scroll container scrolls with its content, so a
   gradient parked over the right edge would slide off it on the first swipe.
   Masking the scroller's own box keeps the fade where the edge is.

   Applied to whichever side still has content behind it — see the readEdges
   block in main.js for why that is measured rather than assumed. The fade is
   short (8%, ~28px at 342) and reaches only ~55% transparency: enough that the
   clipped heading reads as continuing past the edge rather than as a word cut in
   half, and not so much that a tick in the last column is dimmed into doubt.

   No fallback needed for a browser without mask support: it gets the table
   exactly as it is today, with the written hint underneath. */
.pricing-matrix-scroll[data-edge="end"] {
    -webkit-mask-image: linear-gradient(to right, #000 92%, rgba(0, 0, 0, 0.45) 100%);
    mask-image: linear-gradient(to right, #000 92%, rgba(0, 0, 0, 0.45) 100%);
}

.pricing-matrix-scroll[data-edge="start"] {
    -webkit-mask-image: linear-gradient(to left, #000 92%, rgba(0, 0, 0, 0.45) 100%);
    mask-image: linear-gradient(to left, #000 92%, rgba(0, 0, 0, 0.45) 100%);
}

.pricing-matrix-scroll[data-edge="both"] {
    -webkit-mask-image: linear-gradient(to right, rgba(0, 0, 0, 0.45) 0%, #000 8%, #000 92%, rgba(0, 0, 0, 0.45) 100%);
    mask-image: linear-gradient(to right, rgba(0, 0, 0, 0.45) 0%, #000 8%, #000 92%, rgba(0, 0, 0, 0.45) 100%);
}

/* 40/20/20/20. The proportion is set once, on the header row, because
   `table-layout: fixed` reads the first row and ignores the rest. */
.pricing-matrix-corner { width: 40%; }
.pricing-matrix-plan { width: 20%; }

.pricing-matrix-plan {
    vertical-align: bottom;
    text-align: center;
    padding: 0 var(--space-8) var(--space-16);
}

/* The header rule runs the full width, empty corner included. It reads as a
   considered omission while the table sits still, but the header sticks: with
   the rule ending where the first plan column starts, rows slide under an edge
   that is drawn for two thirds of its length and cut off for the other third. */
.pricing-matrix-plan,
.pricing-matrix-corner {
    border-bottom: 1px solid var(--color-rule);
}

/* The name is a card title and takes the card title's step. It is the same
   string as the <h3> on the tier above, so it reads at the same size. */
.pricing-matrix-plan-name {
    display: block;
    font-family: var(--font-heading);
    font-size: 1rem;
    font-weight: 600;
    line-height: 1.3;
    letter-spacing: -0.01em;
    color: var(--color-text-main);
    text-wrap: balance;
}

/* Each tier's eyebrow, repeated from its card. It is what stops the empty cell
   on "A multi-page site" reading as an omission — the column has already said
   it is a one-page build. */
.pricing-matrix-plan-note {
    display: block;
    margin-top: var(--space-4);
    font-family: var(--font-mono);
    font-size: 0.6875rem;
    font-weight: 500;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--color-text-muted);
}

/* The one column that is marked out, and only by its label. A fill or a border
   down the featured column would make the ticks in it look like a different
   kind of tick. */
.pricing-matrix-plan-featured .pricing-matrix-plan-note {
    color: var(--color-accent);
}

/* Subject headings. Same voice and colour as the three list headings they
   replace — the grouping moved from tier to subject, the typography did not.
   The top padding is what separates one group from the next; the first group
   sits under the header rule and does not need it. */
.pricing-matrix-group {
    text-align: left;
    padding: var(--space-32) 0 var(--space-12);
    font-family: var(--font-mono);
    font-size: 0.6875rem;
    font-weight: 500;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--color-accent);
}

.pricing-matrix tbody:first-of-type .pricing-matrix-group {
    padding-top: var(--space-24);
}

/* 400, not the UA's bold: a row label is body copy that happens to be a <th>,
   and bolding it would be the "bold it to emphasise it" move the type rules
   exist to prevent. */
.pricing-matrix-feature {
    text-align: left;
    font-family: var(--font-body);
    font-size: 0.875rem;
    font-weight: 400;
    line-height: 1.5;
    color: var(--color-text-sub);
    padding: var(--space-12) var(--space-16) var(--space-12) 0;
    border-top: 1px solid var(--color-rule);
    /* Pinned while the table is swiped sideways, so a tick is never stranded
       from the row it belongs to. Inert at widths where nothing scrolls. The
       opaque background is what the scrolled columns pass under. */
    position: sticky;
    left: 0;
    background-color: var(--color-bg);
    z-index: 1;
}

.pricing-matrix-cell {
    text-align: center;
    padding: var(--space-12) var(--space-8);
    border-top: 1px solid var(--color-rule);
}

/* The tick is the same sprite the cards use. The 0.15em nudge it carries there
   aligns it to the first line of a wrapping list item; in a centred cell it is
   just 2px of drop. */
.pricing-matrix-cell .pricing-tick {
    margin-top: 0;
}

/* "Not included" is a hairline, not an em dash. Column one is full of em dashes
   — "Domain, hosting and SSL — year one" — and a dash in the tick column read
   as punctuation that had escaped. A bar reads as the absence of a mark. */
.pricing-matrix-off {
    display: inline-block;
    width: 12px;
    height: 1px;
    background-color: var(--color-text-muted);
    vertical-align: middle;
}

/* Row highlight, because the thing this table is for is reading across a row
   1000px wide. Colour only, and gated behind a real pointer: on touch the
   highlight latches on the last row tapped and stays there.

   Painted as a gradient rather than a background-color because the feature cell
   already spends its background-color on being opaque for the sticky column —
   a translucent tint set there would replace the opacity rather than sit on it. */
@media (hover: hover) {
    .pricing-matrix tbody tr:hover .pricing-matrix-feature,
    .pricing-matrix tbody tr:hover .pricing-matrix-cell {
        background-image: linear-gradient(rgba(var(--color-ivory-rgb), 0.05),
                                          rgba(var(--color-ivory-rgb), 0.05));
    }
}

/* The swipe affordance, shown only where the table actually scrolls. */
.pricing-matrix-hint {
    margin-top: var(--space-16);
    font-family: var(--font-mono);
    font-size: 0.6875rem;
    font-weight: 500;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--color-text-muted);
}

/* From 768px the container is at least 672px wide against a 480px table, so
   nothing overflows and the scroller can be switched off — which is what lets
   the header row stick to the top of the viewport instead of to a scroll box
   that never scrolls. 22 rows is more than a screen, and a tick column with its
   heading scrolled away is three columns of anonymous ticks. */
@media (min-width: 768px) {
    .pricing-matrix-scroll {
        overflow-x: visible;
        /* Paint containment would clip the sticky header the moment it left this
           box, and there is nothing to contain once nothing overflows. */
        contain: none;
    }

    .pricing-matrix-feature {
        position: static;
    }

    .pricing-matrix-plan,
    .pricing-matrix-corner {
        position: sticky;
        /* The dock is overhead from 768px up and this is what it costs. */
        top: var(--dock-clear);
        background-color: var(--color-bg);
        z-index: 2;
    }

    .pricing-matrix-plan {
        padding-top: var(--space-16);
    }

    .pricing-matrix-hint {
        display: none;
    }
}

/* ---- Add-ons and renewals ----

   Both are label-left, figure-right rows, and both are capped well short of the
   1000px container. A row that spans the full width puts 400px of black between
   "A third language" and the number that belongs to it, and the eye stops
   pairing them — the rule under the row becomes the only thing joining the two,
   which is how a price list turns into two unrelated columns. 62ch is where the
   longest name and the widest figure still sit in one readable span. */
.pricing-addon-list,
.pricing-renewal-list {
    max-width: 62ch;
}

/* Grid rather than wrapping flex, and the same `1fr auto` two-column shape
   .pricing-renewal uses 60 lines below — one pattern for the two price lists on
   this page instead of two.

   What flex did wrong: `flex-wrap: wrap` + `justify-content: space-between`
   only holds the label-left / figure-right shape while both fit one line. On a
   phone (342px of content) they do not. "Booking / appointment system" pushed
   its figure onto a second line, and space-between with a single item on that
   line left-aligns it — so the price landed under the LEFT edge, directly below
   the name, while the two short rows kept their figure out at the right. Four
   rows, two different shapes, alternating: the list stopped reading as a list.

   Grid fixes it without a media query. The name owns column one and wraps
   inside it however many lines it needs; the figure is pinned to column two of
   row one and cannot migrate. Every row is the same shape at every width, which
   is what the desktop rendering already looked like — so this changes phones
   only, and changes nothing at 1440. */
.pricing-addon {
    display: grid;
    grid-template-columns: 1fr auto;
    align-items: baseline;
    gap: var(--space-8) var(--space-24);
    padding: var(--space-16) 0;
    border-top: 1px solid var(--color-rule);
}

.pricing-addon-name { grid-column: 1; grid-row: 1; }
.pricing-addon-price { grid-column: 2; grid-row: 1; }

/* The list is read as a block, so it is closed at the bottom. Only the last row
   carries it, rather than every row carrying a border-bottom and the stack
   doubling its rules. */
.pricing-addon:last-child {
    border-bottom: 1px solid var(--color-rule);
}

.pricing-addon-name {
    font-family: var(--font-body);
    font-size: 0.875rem;
    line-height: 1.5;
    color: var(--color-text-sub);
    max-width: 48ch;
}

.pricing-addon-price {
    font-family: var(--font-mono);
    font-size: 0.875rem;
    font-weight: 500;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    font-variant-numeric: tabular-nums;
    color: var(--color-accent);
    /* "from RM1,200" breaking after "from" would leave a figure alone on a line
       at the right edge of a row whose left half is already wrapping. */
    white-space: nowrap;
}

/* Two columns and two rows per renewal entry: the plan and its figure share the
   top line, and what the figure covers runs the full width beneath. A
   three-column table would have put a sentence in a cell 200px wide.

   Placement is explicit on all three children rather than left to auto-flow,
   because the price is a <dd> that has to sit on the <dt>'s row — the one thing
   source order cannot express here. */
.pricing-renewal {
    display: grid;
    grid-template-columns: 1fr auto;
    gap: var(--space-4) var(--space-24);
    padding: var(--space-20) 0;
    border-top: 1px solid var(--color-rule);
}

.pricing-renewal:last-child {
    border-bottom: 1px solid var(--color-rule);
}

.pricing-renewal-plan {
    grid-column: 1;
    grid-row: 1;
    font-family: var(--font-heading);
    font-size: 1rem;
    font-weight: 600;
    letter-spacing: -0.01em;
    color: var(--color-white);
}

.pricing-renewal-price {
    grid-column: 2;
    grid-row: 1;
    font-family: var(--font-mono);
    font-size: 1rem;
    font-weight: 600;
    letter-spacing: -0.01em;
    font-variant-numeric: tabular-nums;
    color: var(--color-accent);
    white-space: nowrap;
}

.pricing-renewal-period {
    font-size: 0.6875rem;
    font-weight: 500;
    letter-spacing: 0.08em;
    color: var(--color-text-muted);
}

.pricing-renewal-covers {
    grid-column: 1 / -1;
    grid-row: 2;
    font-family: var(--font-body);
    font-size: 0.875rem;
    line-height: 1.5;
    color: var(--color-text-muted);
    max-width: 48ch;
}

/* Small print, in the sense that it qualifies a figure above it — not in the
   sense of being hidden. Same size as the body copy it sits under, muted one
   step. */
.pricing-footnote {
    font-family: var(--font-body);
    font-size: 0.875rem;
    line-height: var(--leading-body);
    color: var(--color-text-muted);
    max-width: 48ch;
    margin-top: var(--space-32);
}

/* ---- Reassurance strip ---- */

.pricing-assurance {
    padding: var(--space-64) 0;
    border-top: 1px solid var(--color-rule);
}

.pricing-assurance-heading {
    font-family: var(--font-heading);
    font-size: clamp(1.75rem, 1.2rem + 1.8vw, 2.5rem);
    font-weight: 600;
    line-height: var(--leading-tight);
    letter-spacing: -0.02em;
    color: var(--color-white);
    max-width: 16ch;
    margin-bottom: var(--space-32);
    text-wrap: balance;
}

.pricing-facts {
    list-style: none;
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
    gap: var(--space-32);
}

.pricing-fact-term {
    font-family: var(--font-mono);
    font-size: 0.6875rem;
    font-weight: 500;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--color-accent);
    margin-bottom: var(--space-8);
}

.pricing-fact-text {
    font-family: var(--font-body);
    font-size: 0.875rem;
    line-height: var(--leading-body);
    color: var(--color-text-sub);
    max-width: 42ch;
}

/* ==========================================================================
   CONTACT / ENQUIRY
   One action, the promise restated, and a line telling the reader what they
   are committing to by tapping it.

   This used to close the home page as well. It does not any more — the block
   was removed from index.html and contact.html is the only thing that renders
   these rules now, which is why they stay here rather than being deleted with
   it. Every "Start a project" and "Contact" link on the home page points at
   that page.
   ========================================================================== */

.contact-section {
    position: relative;
    background-color: var(--color-black);
    border-top: 1px solid var(--color-rule);
    padding: var(--space-96) 0;
    text-align: center;
}

/* The same block used as a whole page rather than as a closing band. Two
   differences, both structural: there is nothing above it, so the top rule
   would be drawing a line under nothing — and its own 96px of padding is 8px
   short of the dock's 104px clearance at >=768px, which is precisely the
   collision --dock-clear exists to prevent. `max()` keeps the 96px on phones,
   where the dock is at the bottom and --dock-clear is 0. */
.contact-section-page {
    border-top: none;
    padding-top: max(var(--space-96), calc(var(--dock-clear) + var(--space-32)));
    /* No --dock-clear-bottom here: the footer always follows this section and
       already carries the clearance the bottom-docked pill needs. Adding it
       twice opened 104px of dead space between the enquiry and the footer on
       every phone. */
    padding-bottom: var(--space-96);
    min-height: 70vh;
    display: flex;
    align-items: center;
}

/* One channel per row rather than two side by side. A phone number and an email
   address are not alternatives to weigh against each other — they are the same
   ask twice, and a row of two equal buttons makes the reader choose before they
   have decided to get in touch at all. */
.contact-channels {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--space-12);
    width: 100%;
    max-width: 320px;
    margin-top: var(--space-8);
}

.contact-channels .btn { width: 100%; }

.contact-channel-icon {
    flex: none;
    width: 18px;
    height: 18px;
    fill: none;
    stroke: currentColor;
    stroke-width: 1.8;
    stroke-linecap: round;
    stroke-linejoin: round;
}

/* What we will ask for, said before the reader has to ask for it. The single
   most common reason an enquiry button is not pressed is not knowing how much
   work is on the other side of it. */
.contact-expect {
    list-style: none;
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
    gap: var(--space-24);
    width: 100%;
    max-width: 720px;
    margin-top: var(--space-48);
    padding-top: var(--space-32);
    border-top: 1px solid var(--color-rule);
    text-align: left;
}

.contact-expect-term {
    font-family: var(--font-mono);
    font-size: 0.6875rem;
    font-weight: 500;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--color-accent);
    margin-bottom: var(--space-8);
}

.contact-expect-text {
    font-family: var(--font-body);
    font-size: 0.875rem;
    line-height: var(--leading-body);
    color: var(--color-text-sub);
    max-width: 42ch;
}

.contact-inner {
    width: 100%;
    max-width: var(--container-max-width);
    margin: 0 auto;
    padding: 0 var(--container-padding);
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--space-20);
}

.contact-heading {
    font-family: var(--font-heading);
    font-size: clamp(1.75rem, 1.2rem + 1.8vw, 2.5rem);
    font-weight: 600;
    line-height: var(--leading-tight);
    letter-spacing: -0.02em;
    color: var(--color-white);
    max-width: 18ch;
    text-wrap: balance;
}

.contact-lead {
    font-family: var(--font-body);
    font-size: var(--text-lead);
    line-height: var(--leading-body);
    color: var(--color-text-sub);
    max-width: var(--measure-lead);
    text-wrap: pretty;
    opacity: 0.9;
}

.contact-actions {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: var(--space-12);
    margin-top: var(--space-8);
}

/* The risk reducer. Says what happens after the tap, so the button is not a
   leap into an unknown sales process. */
.contact-reassure {
    font-family: var(--font-mono);
    font-size: 0.6875rem;
    font-weight: 500;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--color-text-muted);
    margin-top: var(--space-8);
}

/* ==========================================================================
   FOOTER
   ========================================================================== */

.site-footer {
    background-color: var(--color-black);
    border-top: 1px solid var(--color-rule);
    padding: var(--space-64) 0;
    /* Clears the dock, which floats over the bottom of the page below 768px. */
    padding-bottom: var(--space-96);
}

.footer-inner {
    width: 100%;
    max-width: var(--container-max-width);
    margin: 0 auto;
    padding: 0 var(--container-padding);
    display: flex;
    flex-direction: column;
    gap: var(--space-48);
}

.footer-grid {
    display: grid;
    grid-template-columns: 1fr;
    gap: var(--space-32);
}

@media (min-width: 768px) {
    .footer-grid {
        grid-template-columns: 2fr 1fr 1fr;
        gap: var(--space-48);
    }
    .site-footer {
        padding-bottom: var(--space-48);
    }
}

/* ---- Phones: the two link columns sit side by side ----

   The footer is the one block on this page whose height does not answer to the
   viewport at all — it measured 996px on a 375×812 phone and 996px again on a
   320×568 one, because it is three stacked columns of fixed content. On the tall
   phone that is 1.2 screens; on the short one it is 1.75, and it is the last
   thing between the reader and the end of an already 10.6-screen page.

   Navigation carries six links and Connect three, and neither is anywhere near
   wide enough to earn a full 327px row — at 0.875rem the longest label in either
   list ("WhatsApp Inquiry") sets in about 120px. Sitting them beside each other
   spends horizontal room the single-column stack was throwing away and takes
   roughly 240px off the footer, without dropping a single link or shrinking a
   touch target: .footer-link keeps its 44px min-height, so the rows stay just as
   thumbable, there are simply two of them per line instead of one.

   The brand column still spans, because the tagline wants its measure and a
   147px-wide paragraph would wrap to seven lines and give back everything the
   two columns just saved.

   Floored at 360px rather than run to the full mobile range, and the floor is
   read off the longest label rather than picked as a round number. Column width
   here is (viewport - 48px of container padding - 20px of gutter) / 2, and
   "WhatsApp Inquiry" sets at 128px with its external-link icon. That needs about
   332px of viewport to clear; 360 is the nearest common device width above it
   (Galaxy S-series, Pixel), and it was measured holding at 375 too — 154px
   columns, nothing wrapped, every link still 44px tall.

   Below the floor is the 320px iPhone SE, where the columns come out ~126px and
   both "WhatsApp Inquiry" and "ST Pro Aluminium" break to two lines with their
   icons stranded on the second. The row count stops falling at that point and
   the footer just gets scrappier, so those screens keep the single-column
   stack. ---- */
@media (min-width: 360px) and (max-width: 767px) {
    .footer-grid {
        grid-template-columns: 1fr 1fr;
        /* Column gap tightened from the 32px the stack used: that value was
           separating full-width blocks vertically, and as a gutter between two
           narrow lists it is width the labels need more. */
        gap: var(--space-32) var(--space-20);
    }

    .footer-brand-col { grid-column: 1 / -1; }
}

/* The link rows already clear 44px vertically, which is the dimension that
   matters in a stacked list — but the shortest labels ("Home" at 38px, "Pricing"
   at 43px) were narrower than they were tall, so the target failed the 44x44
   floor on the one axis nobody checks. The rows do not move; the shrink-to-fit
   inline-flex box just stops shrinking below the floor. */
@media (max-width: 767px) {
    .footer-link { min-width: var(--target-min); }
}

.footer-brand-col {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: var(--space-12);
}

.footer-logo {
    display: inline-flex;
    align-items: center;
    gap: var(--space-8);
    min-height: var(--target-min);
    font-family: var(--font-heading);
    font-weight: 700;
    font-size: 1rem;
    letter-spacing: 0.04em;
    color: var(--color-white);
    text-decoration: none;
    transition: color 200ms ease;
}

.footer-logo:active {
    color: var(--color-ivory);
}

.footer-tagline {
    font-family: var(--font-body);
    font-size: 0.875rem;
    color: var(--color-text-muted);
    line-height: var(--leading-body);
    max-width: 38ch;
}

.footer-col-heading {
    font-family: var(--font-mono);
    font-size: 0.75rem;
    font-weight: 600;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--color-accent);
    margin-bottom: var(--space-12);
}

.footer-links-list {
    list-style: none;
    display: flex;
    flex-direction: column;
    gap: var(--space-4);
}

.footer-link {
    display: inline-flex;
    align-items: center;
    gap: var(--space-4);
    min-height: var(--target-min);
    font-family: var(--font-body);
    font-size: 0.875rem;
    color: var(--color-text-sub);
    text-decoration: none;
    transition: color 200ms ease;
}

@media (hover: hover) {
    .footer-link:hover {
        color: var(--color-ivory);
    }
}

.footer-link:active {
    color: var(--color-ivory);
}

.footer-bottom {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--space-8);
    padding-top: var(--space-24);
    border-top: 1px solid var(--color-rule);
    text-align: center;
}

@media (min-width: 640px) {
    .footer-bottom {
        flex-direction: row;
        justify-content: space-between;
        text-align: left;
    }
}

.footer-legal {
    font-family: var(--font-body);
    font-size: 0.875rem;
    color: var(--color-text-muted);
}

.footer-location {
    font-family: var(--font-mono);
    font-size: 0.75rem;
    color: var(--color-text-muted);
}

/* Marks a link that leaves the site. Sized to the text it sits beside rather than
   set in px, so it tracks the label at every viewport. */
.external-icon {
    width: 0.75em;
    height: 0.75em;
    flex: none;
    stroke: currentColor;
    fill: none;
    stroke-width: 2.5;
    stroke-linecap: round;
    stroke-linejoin: round;
}

/* ==========================================================================
   ACCESSIBLE PRIVACY & ACCESSIBILITY MODAL DIALOG
   ========================================================================== */

body.modal-open {
    overflow: hidden;
}

.modal-overlay {
    position: fixed;
    top: 0;
    left: 0;
    width: 100vw;
    height: 100vh;
    z-index: 2000;
    background-color: rgba(0, 0, 0, 0.85);
    /* See .dock-container. Same prefix pair, and it matters more here: this is the
       backdrop the privacy dialog is read against, and 0.85 alone leaves the page
       behind it legible enough to compete with the policy text. */
    -webkit-backdrop-filter: blur(8px);
            backdrop-filter: blur(8px);
    display: flex;
    align-items: center;
    justify-content: center;
    padding: var(--space-16);
    opacity: 0;
    visibility: hidden;
    pointer-events: none;
    transition: opacity 200ms ease, visibility 200ms ease;
}

/* Two ways in, because this one is a legal document.
   `[aria-hidden="false"]` is the JS route: main.js flips the attribute and calls
   preventDefault on the trigger, so with scripting on the hash is never set and
   :target below never matches.
   `:target` is the fallback. Everything else on this site renders without JS —
   the privacy policy was the single exception, because the trigger's href was
   `#privacy` and nothing on the page carried that id, so with scripting off the
   link went nowhere at all. The href now points at the dialog's real id and CSS
   alone opens it. Close, in that mode, is the browser's Back button. */
.modal-overlay[aria-hidden="false"],
.modal-overlay:target {
    opacity: 1;
    visibility: visible;
    pointer-events: auto;
}

.modal-container {
    background-color: var(--color-surface);
    border: 1px solid var(--color-rule);
    border-radius: var(--radius-md);
    width: 100%;
    max-width: 640px;
    max-height: 85vh;
    display: flex;
    flex-direction: column;
    box-shadow: 0 16px 48px rgba(0, 0, 0, 0.9);
    transform: scale(0.95);
    transition: transform 200ms ease-out;
}

.modal-overlay[aria-hidden="false"] .modal-container,
.modal-overlay:target .modal-container {
    transform: scale(1);
}

.modal-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: var(--space-20) var(--space-24);
    border-bottom: 1px solid var(--color-rule);
}

.modal-title {
    font-family: var(--font-heading);
    font-size: 1.25rem;
    font-weight: 600;
    color: var(--color-white);
}

/* The one control that dismisses this modal, and on a phone it was a 30×32 box
   holding a glyph. Sized to the same 44px floor the dock links use — centring the
   × with flex rather than padding keeps the glyph optically where it was. */
.modal-close-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: none;
    min-width: var(--target-min);
    min-height: var(--target-min);
    background: transparent;
    border: none;
    color: var(--color-text-muted);
    font-size: 1.5rem;
    line-height: 1;
    cursor: pointer;
    padding: 0;
    border-radius: var(--radius-sm);
    transition: color 150ms ease, background-color 150ms ease;
}

@media (hover: hover) {
    .modal-close-btn:hover {
        color: var(--color-white);
        background-color: rgba(238, 238, 238, 0.1);
    }
}

.modal-body {
    padding: var(--space-24);
    overflow-y: auto;
    display: flex;
    flex-direction: column;
    gap: var(--space-20);
}

.policy-section h3 {
    font-family: var(--font-mono);
    font-size: 0.8125rem;
    font-weight: 600;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--color-accent);
    margin-bottom: var(--space-8);
}

.policy-section p,
.policy-section ul {
    font-family: var(--font-body);
    font-size: 0.875rem;
    color: var(--color-text-sub);
    line-height: var(--leading-body);
}

.policy-section ul {
    margin-top: var(--space-8);
    padding-left: var(--space-20);
    display: flex;
    flex-direction: column;
    gap: var(--space-4);
}

/* .modal-body's flex gap separates the sections from each other; it does nothing
   between two paragraphs INSIDE one, and the reset has stripped their margins. */
.policy-section p + p,
.policy-section ul + p { margin-top: var(--space-12); }

/* The browser default here is whatever monospace the OS ships, which is the one
   typeface on the page that was never chosen. */
.policy-section code {
    font-family: var(--font-mono);
    font-size: 0.9em;
    color: var(--color-ivory);
}

/* Sits above the first section, so the reader can see how current this is before
   reading any of it — which is the first thing anyone checks in a policy. */
.policy-updated {
    font-family: var(--font-mono);
    font-size: 0.6875rem;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--color-text-muted);
}

.policy-link {
    color: var(--color-ivory);
    text-decoration: underline;
}

.modal-footer {
    padding: var(--space-16) var(--space-24);
    border-top: 1px solid var(--color-rule);
    display: flex;
    justify-content: flex-end;
}

/* ==========================================================================
   NO-MOTION FALLBACK
   The scroll runways collapse when nothing is driving them.

   .transition-section is 400vh and .journey-section 730vh because a scrubbed
   timeline has no duration of its own — pace is bought with page length, as both
   sections' own comments say. Those heights were unconditional and the pins under
   them are plain CSS sticky, so whenever main.js did not build the timelines the
   runway survived and the pin survived and the animation did not: ~11 screens of
   scrolling past two frozen sections, on a page that is four sections long.

   Three ordinary ways to land there, none of them exotic:
     · prefers-reduced-motion: reduce — buildJourney() is skipped outright
     · js/vendor/gsap.min.js missing or failing to parse — the `typeof gsap` guard
       fails. Much rarer since the pair stopped being fetched from a CDN, because
       a same-origin script cannot be blocked by a content blocker, a tracking
       prevention setting or a DNS filter — but a bad deploy that ships the HTML
       without the vendor directory lands here exactly the same way
     · JavaScript disabled

   The reduced-motion rules further up already retired the marquee's runway, which
   is why only the journey ever showed this on a preference change — but neither
   section was ever protected against GSAP simply not arriving, which is the failure
   mode a visitor is far more likely to actually meet.

   main.js adds .motion-ready to <html> at exactly the point where both timelines
   are about to be built. Anything else falls through to here.

   Last in the file and prefixed with html:not(...) for specificity, not just order:
   the narrow-viewport rules are written at .journey-section.is-scrubbed (0,2,0) and
   would outrank a bare .journey-section reset however late it was declared.
   ========================================================================== */

html:not(.motion-ready) .transition-section { height: auto; }

html:not(.motion-ready) .transition-pin {
    position: relative;
    height: auto;
    flex-direction: column;
}

/* With no sweep to contain, overflow:hidden can only cut the headline in half —
   the same reasoning as the reduced-motion block above the marquee. */
html:not(.motion-ready) .transition-band {
    height: auto;
    min-height: 0;
    padding: var(--space-96) var(--container-padding);
    overflow: visible;
}

/* The oversized size has a 3rem floor because it is meant to overflow. Standing
   still it has to fit instead, so it is resized rather than merely re-wrapped. */
html:not(.motion-ready) .marquee-track {
    transform: none;
    white-space: normal;
    text-align: center;
    font-size: clamp(1.75rem, 8vw, 3.5rem);
    max-width: min(20ch, 100%);
    text-wrap: balance;
    will-change: auto;
}


html:not(.motion-ready) .journey-section { height: auto; }

/* Back to the base rule's padding. --dock-clear existed to centre the composition
   in the band below the dock while it was pinned; in normal flow the fixed dock
   simply floats over the section like it does over every other one. */
html:not(.motion-ready) .journey-pin {
    position: relative;
    height: auto;
    padding: var(--space-64) 0;
}
