/* ================================================================ fonts === */
/*
 * Self-hosted, latin subset only, straight from Google's own woff2 builds.
 * Not <link>ed from fonts.googleapis.com: that costs a DNS lookup, a TLS
 * handshake and a blocking stylesheet round-trip before the first glyph can
 * even be requested, on a store whose product pages already make more
 * third-party requests than they should.
 *
 * Archivo and Plex Sans are single VARIABLE files covering the whole weight
 * range each - one request buys 400 through 700, so asking for a heavier
 * heading later costs nothing. Plex Mono has no variable build, hence two
 * static cuts; it is used only for figures, so it is also the only family
 * here that is not needed for first paint.
 *
 * font-display:swap over `optional` deliberately: the fallback stacks below
 * are metrically distant enough that `optional` would leave a returning
 * visitor on Segoe UI for the whole session on a slow connection.
 */
@font-face {
    font-family: "Archivo";
    src: url("../fonts/archivo-latin-var.woff2") format("woff2-variations"),
         url("../fonts/archivo-latin-var.woff2") format("woff2");
    font-weight: 400 700;
    font-style: normal;
    font-display: swap;
}
@font-face {
    font-family: "Plex Sans";
    src: url("../fonts/plex-sans-latin-var.woff2") format("woff2-variations"),
         url("../fonts/plex-sans-latin-var.woff2") format("woff2");
    font-weight: 400 600;
    font-style: normal;
    font-display: swap;
}
@font-face {
    font-family: "Plex Mono";
    src: url("../fonts/plex-mono-latin-400.woff2") format("woff2");
    font-weight: 400;
    font-style: normal;
    font-display: swap;
}
@font-face {
    font-family: "Plex Mono";
    src: url("../fonts/plex-mono-latin-500.woff2") format("woff2");
    font-weight: 500;
    font-style: normal;
    font-display: swap;
}

/* =============================================================== tokens === */
:root {
    /*
     * Cold-precision palette. Every ratio below is computed from these exact
     * hex values, not estimated - same standard the warm palette held itself
     * to. Re-compute if any value here changes.
     */
    --ink: #101418;
    --paper: #F4F4F2;
    --tile: #E6E7E4;
    --tile-hover: #DCDEDA;
    --muted: #5D6570;       /* 5.36:1 on paper, 4.75:1 on tile */
    --line: #D8DAD6;

    /*
     * The accent is hue-neutral in NAME on purpose. The old tokens were
     * called --amber-*, so this palette change would have left every rule in
     * the file claiming a colour it no longer had. Names describe the job.
     */
    --accent-ink: #1F5FD9;      /* accent TEXT - 5.16:1 on paper, 4.58:1 on tile */
    --accent: #4E86E8;          /* decoration only - 3.56:1 on white, never small light text */
    --accent-strong: #17469F;   /* hover - 7.90:1 on paper, 8.70:1 under white */

    /*
     * Semantic colours are NOT the accent, and are deliberately still warm.
     * Under the old palette the brand accent and the "needs attention"
     * colour were both amber, which made status-paid and status-pending
     * nearly the same chip in the admin orders list - the one place the
     * difference means "money landed" versus "money might be lost".
     */
    --warn-ink: #8F4207;    /* 6.46:1 on paper, 5.95:1 on --warn-soft */
    --warn-soft: #FCE8CC;
    --ok-ink: #1E6B3C;      /* 5.43:1 on --ok-soft */
    --ok-soft: #DCEFE2;
    --danger: #B00020;      /* 6.65:1 on paper, 6.30:1 on --danger-soft */
    --danger-soft: #FBEAEA;

    /*
     * Body was 15px, which is small for a page asking for a card number.
     * 16px throughout, and the scale steps around it are ratios rather than
     * round pixel values so headings stay proportional as they clamp.
     */
    --fs-h1: clamp(2rem, 3.6vw, 2.75rem);
    --fs-h2: clamp(1.3125rem, 2.2vw, 1.625rem);
    --fs-h3: 1.0625rem;
    --fs-body: 1rem;
    --fs-meta: 0.875rem;
    --fs-micro: 0.75rem;
    --fs-price: 1.125rem;

    --s-1: 4px;  --s-2: 8px;  --s-3: 12px; --s-4: 16px;
    --s-5: 24px; --s-6: 32px; --s-7: 48px; --s-8: 64px;
    --s-9: 96px;

    /*
     * ONE radius system, and it is square. The old file mixed 6/10/14px
     * surfaces with 999px pills on every control, which reads consumer-soft;
     * a flight instrument does not. The documented rule is simply: nothing
     * on this site is rounder than 4px, and larger surfaces get the larger
     * step. No pills anywhere - if a new component wants one, the rule
     * changes here first, not locally.
     */
    --r-sm: 2px; --r-md: 3px; --r-lg: 4px;
    --container: 1200px;

    /* Height of the sticky .site-header, for scroll-margin-top on anything a
       same-page link targets - without it a #anchor lands underneath the
       header. Measured, not guessed, and re-measured for this palette: 67px
       from 700px up, 103px below that where the search takes its own row
       (see the responsive block at the foot of the file). Both values are
       rounded UP by 3px here, because overshooting scroll-margin-top only
       adds whitespace above the target while undershooting hides it. */
    --header-h: 70px;

    /*
     * Three roles, not one. Inter was doing all of them at a single weight,
     * which is why nothing on the page had a voice. Fallbacks are named
     * per-role rather than sharing one stack: a mono fallback that lands on
     * a proportional face would break the figure alignment the mono is here
     * for in the first place.
     */
    --font-display: "Archivo", "Helvetica Neue", Arial, sans-serif;
    --font: "Plex Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
    --font-mono: "Plex Mono", ui-monospace, SFMono-Regular, Consolas, monospace;
}

/* ================================================================= base === */
*, *::before, *::after { box-sizing: border-box; }

body {
    margin: 0;
    background: var(--paper);
    color: var(--ink);
    font-family: var(--font);
    font-size: var(--fs-body);
    line-height: 1.5;
    -webkit-font-smoothing: antialiased;
}

h1, h2, h3 {
    margin: 0 0 var(--s-4);
    font-family: var(--font-display);
    font-weight: 700;
    line-height: 1.08;
    letter-spacing: -.025em;
    /* Headings are short and set large; balanced wrapping stops a two-word
       orphan on the second line. Unsupported browsers just fall back to
       normal wrapping, so this needs no guard. */
    text-wrap: balance;
}
h1 { font-size: var(--fs-h1); }
h2 { font-size: var(--fs-h2); }
h3 { font-size: var(--fs-h3); letter-spacing: -.012em; line-height: 1.3; }
p { margin: 0 0 var(--s-3); }

a { color: var(--accent-ink); }
img { display: block; max-width: 100%; }

:focus-visible {
    outline: 2px solid var(--accent-ink);
    outline-offset: 2px;
    border-radius: var(--r-sm);
}

.sr-only {
    position: absolute; width: 1px; height: 1px;
    padding: 0; margin: -1px; overflow: hidden;
    clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

.skip-link { position: absolute; left: -9999px; }
.skip-link:focus {
    left: var(--s-4); top: var(--s-4); z-index: 100;
    background: var(--ink); color: #fff;
    padding: var(--s-2) var(--s-4); border-radius: var(--r-sm);
}

input[type="text"], input[type="number"], input[type="password"], input[type="email"], select {
    font: inherit; color: var(--ink); background: #fff;
    border: 1px solid var(--line); border-radius: var(--r-sm);
    padding: var(--s-2) var(--s-3);
}
/* Quantities and prices typed into a field line up with the ones printed
   beside them only if both are tabular. */
input[type="number"] { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }

button, .btn {
    font: inherit; font-weight: 600; cursor: pointer;
    background: var(--accent-ink); color: #fff;
    border: 0; border-radius: var(--r-sm);
    padding: var(--s-2) var(--s-5);
    letter-spacing: -.005em;
    transition: background .15s ease;
}
/* .btn is worn by <a>, which the base `a` rule colours with the accent. */
.btn { display: inline-block; text-decoration: none; }
button:hover, .btn:hover { background: var(--accent-strong); color: #fff; }
/* A control that cannot be pressed down does not feel like a control. 1px
   is the whole gesture; anything more reads as the layout shifting. */
button:active, .btn:active { transform: translateY(1px); }

.price {
    margin: 0;
    font-family: var(--font-mono);
    font-size: var(--fs-price); font-weight: 500;
    color: var(--accent-ink);
    font-variant-numeric: tabular-nums;
    letter-spacing: -.02em;
}

/* =============================================================== chrome === */
.site-header {
    position: sticky; top: 0; z-index: 50;
    display: flex; align-items: center; gap: var(--s-5);
    padding: var(--s-3) var(--s-5);
    /* Must track --paper. A literal is unavoidable (rgba() cannot take a hex
       token and add alpha on the browsers this ships to), so the pair is
       kept adjacent in the file rather than hoping they drift together. */
    background: rgba(244, 244, 242, .85);
    backdrop-filter: blur(10px);
    border-bottom: 1px solid var(--line);
}

.logo {
    font-family: var(--font-display);
    font-size: 1.125rem; font-weight: 700; letter-spacing: -.03em;
    color: var(--ink); text-decoration: none;
}
.logo .dot { color: var(--accent-ink); }

.site-header .search { margin-left: auto; display: flex; gap: var(--s-2); }

.container {
    max-width: var(--container); margin: 0 auto;
    /* Vertical rhythm scales with the viewport, horizontal padding does not:
       on a phone the gutter is a fixed safe margin, while the space between
       stacked blocks is what makes the page feel unhurried. */
    padding: clamp(var(--s-6), 5vw, var(--s-8)) var(--s-5);
}

/*
 * The footer is now a real one, so it gets a ground of its own rather than
 * a centred line floating on --paper. --tile is the same surface the product
 * thumbs sit on, which keeps it inside the page's one light theme (no
 * section on this site flips to an inverted palette) while still reading as
 * the floor of the document.
 *
 * Mobile-first: single column here, columns added at a min-width below. The
 * responsive block at the FOOT of this file is deliberately not used for
 * these components - a min-width query written immediately after its own
 * base rule cannot lose a specificity tie to it, which is the failure mode
 * that silently dropped declarations the last time this file grew.
 */
.site-footer {
    margin-top: var(--s-7);
    border-top: 1px solid var(--line);
    background: var(--tile);
    color: var(--muted);
    font-size: var(--fs-meta);
}

.footer-inner {
    max-width: var(--container);
    margin: 0 auto;
    padding: var(--s-8) var(--s-5) var(--s-7);
    display: grid;
    grid-template-columns: 1fr;
    gap: var(--s-7) var(--s-5);
}

.footer-brand .logo { display: inline-block; margin-bottom: var(--s-3); }
.footer-brand p { margin: 0; max-width: 34ch; }

/*
 * Footer column headings are plain sans, NOT the tracked-out mono uppercase
 * used by .detail-heading and the table headers. That treatment means one
 * specific thing in this system - "this is a data label on a record" - and
 * spending it on navigation would leave the page with a small-caps label
 * above every group of anything.
 */
.footer-col h2 {
    margin: 0 0 var(--s-3);
    font-family: var(--font);
    font-size: var(--fs-meta);
    font-weight: 600;
    letter-spacing: 0;
    line-height: 1.4;
    color: var(--ink);
}
.footer-col ul { margin: 0; padding: 0; list-style: none; }
.footer-col li + li { margin-top: var(--s-2); }
.footer-col a { color: var(--muted); text-decoration: none; }
.footer-col a:hover { color: var(--accent-ink); text-decoration: underline; }

.footer-payment p { margin: 0 0 var(--s-3); max-width: 32ch; }
.footer-payment p:last-child { margin-bottom: 0; }
.footer-payment a { color: var(--accent-ink); }

.footer-legal {
    max-width: var(--container);
    margin: 0 auto;
    padding: var(--s-4) var(--s-5) var(--s-7);
    border-top: 1px solid var(--line);
    display: flex; flex-wrap: wrap; gap: var(--s-2) var(--s-5);
    font-size: var(--fs-micro);
}
.footer-legal p { margin: 0; }

@media (min-width: 700px) {
    .footer-inner { grid-template-columns: 1.6fr 1fr 1fr; }
    /* The payment column carries prose, so it takes a full row of its own
       below the three nav columns rather than being squeezed to a quarter
       of the width and wrapping every three words. */
    .footer-payment { grid-column: 1 / -1; padding-top: var(--s-5); border-top: 1px solid var(--line); }
    .footer-payment p { max-width: 60ch; }
}

@media (min-width: 1000px) {
    .footer-inner { grid-template-columns: 1.6fr 1fr 1fr 1.4fr; }
    .footer-payment { grid-column: auto; padding-top: 0; border-top: 0; }
    .footer-payment p { max-width: 32ch; }
}

/* ======================================================== empty states === */
.empty { padding: var(--s-9) 0; text-align: center; }
.empty h2 { margin-bottom: var(--s-3); }
.empty p { color: var(--muted); }

/* ====================================================== listings/cards === */
.collection-head {
    display: flex; align-items: flex-end; flex-wrap: wrap; gap: var(--s-4);
    margin-bottom: var(--s-7);
    /* The hero's "Shop drones" CTA scrolls here. .site-header is sticky, so
       anything less than its height parks this heading UNDERNEATH the header
       - see --header-h in :root. */
    scroll-margin-top: calc(var(--header-h) + var(--s-4));
}
.collection-head h1, .collection-head h2 { margin: 0; font-size: var(--fs-h1); }
.collection-head .count {
    margin: var(--s-2) 0 0; color: var(--muted);
    font-family: var(--font-mono); font-size: var(--fs-micro);
    letter-spacing: .06em; font-variant-numeric: tabular-nums;
}
.collection-head .sort {
    margin-left: auto; display: flex; align-items: center; gap: var(--s-2);
    color: var(--muted); font-size: var(--fs-meta);
}

.grid { display: grid; grid-template-columns: 1fr; gap: var(--s-7) var(--s-5); }
@media (min-width:  520px) { .grid { grid-template-columns: repeat(2, 1fr); } }
@media (min-width:  900px) { .grid { grid-template-columns: repeat(3, 1fr); } }
@media (min-width: 1200px) { .grid { grid-template-columns: repeat(4, 1fr); } }

.card { display: block; color: inherit; text-decoration: none; }

.card .thumb {
    position: relative;
    display: flex; align-items: center; justify-content: center;
    aspect-ratio: 1; padding: var(--s-5);
    background: var(--tile); border-radius: var(--r-md); overflow: hidden;
    /*
     * The hover was a lift: translateY plus a drop shadow plus a scaling
     * image, three things moving for one pointer. At DENSITY 3 that reads
     * busy. What is left is the tile darkening and a hairline drawing
     * itself, which says "this is the one you are pointing at" without the
     * page appearing to flinch. box-shadow is still transitioned because
     * .card:focus-visible has no other affordance.
     */
    box-shadow: inset 0 0 0 1px transparent;
    transition: background .18s ease, box-shadow .18s ease;
}
/* contain, never cover - cover crops product photography */
.card .thumb img { width: 100%; height: 100%; object-fit: contain; }

.card:hover .thumb {
    background: var(--tile-hover);
    box-shadow: inset 0 0 0 1px var(--accent);
}

.card .name {
    margin: var(--s-4) 0 var(--s-2);
    font-size: var(--fs-meta); font-weight: 500;
    letter-spacing: -.005em; line-height: 1.35;
    display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.card:hover .name { color: var(--accent-ink); }

.badge {
    position: absolute; top: var(--s-3); left: var(--s-3); z-index: 2;
    background: var(--accent); color: var(--ink);   /* 5.20:1 */
    font-family: var(--font-mono);
    font-size: .625rem; font-weight: 500; letter-spacing: .1em; text-transform: uppercase;
    padding: 2px var(--s-2); border-radius: var(--r-sm);
}

/* ============================================== filters & pagination === */
.filters {
    display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-2);
    background: #fff; border: 1px solid var(--line); border-radius: var(--r-md);
    padding: var(--s-2); margin-bottom: var(--s-6);
}
.filters input { border: 1px solid transparent; }
.filters input:focus-visible { border-color: transparent; }
.filters input[type="number"] { width: 120px; }
.filters button { margin-left: auto; }

.pagination {
    display: flex; justify-content: center; flex-wrap: wrap; gap: var(--s-2);
    margin-top: var(--s-7);
}
.pagination a, .pagination span {
    min-width: 40px; padding: var(--s-2); border-radius: var(--r-sm);
    text-align: center; text-decoration: none; color: var(--ink);
    font-family: var(--font-mono); font-size: var(--fs-meta);
    font-variant-numeric: tabular-nums;
}
.pagination a:hover { background: var(--tile); }
.pagination .current { color: var(--accent-ink); font-weight: 500; box-shadow: inset 0 -2px 0 var(--accent-ink); }
.pagination .disabled { color: var(--muted); }

/* ========================================================= product page === */
.product { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--s-6); }

@media (min-width: 900px) {
    /* minmax(0, …) not plain fr: a grid item's automatic minimum size is its
       min-content size, so the gallery track would otherwise stretch to the
       main image's intrinsic width and push the page sideways. */
    .product { grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); gap: var(--s-7); }
    .product .gallery { position: sticky; top: 96px; align-self: start; }
}

.gallery .main {
    display: flex; align-items: center; justify-content: center;
    aspect-ratio: 1; padding: var(--s-5);
    background: var(--tile); border-radius: var(--r-lg);
}
.gallery .main img { width: 100%; height: 100%; object-fit: contain; }

.thumbs {
    display: flex; gap: var(--s-2); margin-top: var(--s-3);
    overflow-x: auto; scroll-snap-type: x mandatory; padding-bottom: var(--s-2);
}
.thumbs button {
    flex: 0 0 72px; aspect-ratio: 1; scroll-snap-align: start;
    padding: var(--s-1); background: var(--tile);
    border: 1px solid transparent; border-radius: var(--r-sm);
}
.thumbs button:hover { background: var(--tile-hover); }
.thumbs button[aria-current="true"] { border-color: var(--accent-ink); }
.thumbs img { width: 100%; height: 100%; object-fit: contain; }

/*
 * Section labels inside the product column. Set in mono rather than as a
 * bolder sans: at this size the tracked-out uppercase is doing the work, and
 * mono keeps it from competing with the Archivo <h1> directly above it.
 */
.detail-heading {
    font-family: var(--font-mono);
    font-size: var(--fs-micro); font-weight: 500; text-transform: uppercase;
    letter-spacing: .12em; color: var(--muted);
    margin: var(--s-7) 0 var(--s-3);
}

.variants { display: flex; flex-wrap: wrap; gap: var(--s-2); margin: 0; padding: 0; list-style: none; }
.variants li {
    padding: var(--s-1) var(--s-3);
    border: 1px solid var(--line); border-radius: var(--r-sm);
    font-size: var(--fs-meta);
}
.variants li.oos { color: var(--muted); }

.specs {
    display: grid; grid-template-columns: auto 1fr; gap: var(--s-3) var(--s-6);
    margin: 0; font-size: var(--fs-meta);
}
.specs dt { color: var(--muted); }
/* A weight, a range and an order number are measurements. Set them as
   measurements so they align down the column instead of drifting. */
.specs dd {
    margin: 0; overflow-wrap: anywhere;
    font-family: var(--font-mono); font-variant-numeric: tabular-nums;
}

.description img { border-radius: var(--r-sm); margin: var(--s-4) 0; }
.description p { margin: 0 0 var(--s-3); }

/* ================================================================ admin === */
.admin-form { display: flex; gap: var(--s-2); margin-bottom: var(--s-5); max-width: 480px; }
.admin-table { width: 100%; border-collapse: collapse; }
.admin-table th, .admin-table td { border-bottom: 1px solid var(--line); padding: var(--s-3) var(--s-2); text-align: left; vertical-align: middle; }
.admin-table th {
    font-family: var(--font-mono);
    font-size: var(--fs-micro); font-weight: 500;
    text-transform: uppercase; letter-spacing: .1em; color: var(--muted);
    border-bottom-color: var(--ink);
}
.admin-table form.inline { display: inline; }
.actions { display: flex; gap: var(--s-2); }
/* The name is the one admin field that is edited rather than read: CJ ships
   keyword-stuffed titles ("2024 New Mini Drone 4K HD Camera Foldable RC...")
   and the storefront needs a human one. The input gets the room, and the
   link to the live page drops underneath so the row still leads to it. */
.name-cell { width: 34%; min-width: 18rem; }
.admin-table form.name-form { display: flex; gap: var(--s-2); }
.name-cell input[type="text"] { flex: 1 1 auto; min-width: 0; }
/* A sell price is at most seven characters; the browser's default number
   input is wide enough to push the Save button onto a second line once the
   name column takes its share of the row. */
.admin-table input[type="number"] { width: 6.5rem; }
.name-cell .view-link { display: inline-block; margin-top: var(--s-1); font-size: var(--fs-micro); }

.error { color: var(--danger); font-weight: 500; }

/* Orders: this banner is the operator's only alarm for a webhook that never
   arrived - an amount_total mismatch or a misconfigured endpoint both leave
   orders stuck at 'pending' with nothing else surfacing it, so it needs to
   read as urgent, not as routine list-page chrome. */
.alert-warning {
    background: var(--danger-soft); border: 1px solid var(--danger); color: var(--danger);
    border-radius: var(--r-md); padding: var(--s-4); margin-bottom: var(--s-5);
}
.alert-warning a { color: var(--danger); }
.alert-warning code {
    background: rgba(176, 0, 32, .08); border-radius: var(--r-sm);
    padding: 1px var(--s-1); font-size: .875em;
}

.status-badge {
    display: inline-block; padding: 2px var(--s-2); border-radius: var(--r-sm);
    font-family: var(--font-mono);
    font-size: var(--fs-micro); font-weight: 500; text-transform: uppercase; letter-spacing: .08em;
    background: var(--tile); color: var(--muted);   /* 4.75:1 */
}
.status-badge.status-paid { background: var(--accent); color: var(--ink); }        /* 5.20:1, same pairing as .badge */
.status-badge.status-fulfilled { background: var(--ok-soft); color: var(--ok-ink); }
.status-badge.status-cancelled, .status-badge.status-failed { background: var(--danger-soft); color: var(--danger); }
/* 'pending' used to fall through to the base rule above (grey on grey) - the
   quietest badge on the page - even though a stuck 'pending' row is the one
   status that can mean "charged but the webhook never confirmed it". It needs
   a caution colour distinct from both status-paid and status-cancelled.
   Under the warm palette that was hard: 'paid' was amber and 'pending' was a
   second amber, the two rows an operator most needs to tell apart. Now that
   the accent is blue and the caution colour is the only warm chip in the
   list, the distinction carries at a glance instead of on inspection. */
.status-badge.status-pending { background: var(--warn-soft); color: var(--warn-ink); }   /* 5.95:1 */

/* Stale-pending rows on the list page (see OrderRepository::recentSummaries)
   get both a full-row tint and an explicit marker chip next to the age, so
   "this one might be a lost sale" doesn't depend on noticing the timestamp. */
.admin-table tr.stale-row { background: var(--danger-soft); }
.stale-marker {
    display: inline-block; margin-left: var(--s-1); padding: 1px 6px;
    border-radius: var(--r-sm); background: var(--danger); color: #fff;
    font-size: .6875rem; font-weight: 700; letter-spacing: .04em;
}

/* The shipping address is what the operator copies into the CJ order form -
   default <address> italics read as decorative, not as data to be copied. */
address { font-style: normal; line-height: 1.6; }

/* ========================================================== email popup === */
/*
 * Opt-in dialog, opened by assets/js/email-popup.js after a dwell or a
 * scroll. It ships with `hidden` on it and only script removes that, so a
 * visitor without JavaScript never meets a dialog they cannot close.
 *
 * Fixed, not absolute, and the scrim is a sibling rather than a ::before on
 * the panel: the scrim has to take the click that dismisses the dialog, and
 * a pseudo-element on the panel would sit inside the thing it is meant to
 * cover.
 */
.email-popup {
    position: fixed; inset: 0; z-index: 200;
    display: flex; align-items: center; justify-content: center;
    padding: var(--s-4);
}
.email-popup[hidden] { display: none; }

.email-popup-scrim {
    position: absolute; inset: 0;
    background: rgba(16, 20, 24, .55);
    opacity: 0;
    transition: opacity .18s ease;
}
.email-popup.is-open .email-popup-scrim { opacity: 1; }

.email-popup-panel {
    position: relative;
    width: 100%; max-width: 27rem;
    background: var(--paper);
    border: 1px solid var(--line);
    border-radius: var(--r-lg);
    /* The only elevation on the site. A dialog is the one element that is
       genuinely in front of the page rather than part of it. */
    box-shadow: 0 24px 60px rgba(16, 20, 24, .28);
    padding: var(--s-6);
    opacity: 0;
    transform: translateY(8px);
    transition: opacity .18s ease, transform .18s ease;
}
.email-popup.is-open .email-popup-panel { opacity: 1; transform: none; }

.email-popup-close {
    position: absolute; top: var(--s-2); right: var(--s-2);
    width: 32px; height: 32px; padding: 0;
    display: flex; align-items: center; justify-content: center;
    background: transparent; color: var(--muted);
    font-size: 1.5rem; line-height: 1;
}
.email-popup-close:hover { background: var(--tile-hover); color: var(--ink); }

.email-popup-eyebrow {
    margin: 0 0 var(--s-2);
    font-family: var(--font-mono);
    font-size: var(--fs-micro); text-transform: uppercase; letter-spacing: .12em;
    color: var(--muted);
}
.email-popup-panel h2 { font-size: var(--fs-h2); margin-bottom: var(--s-3); }
.email-popup-blurb { color: var(--muted); font-size: var(--fs-meta); margin-bottom: var(--s-5); }

/* Stacked, not side by side: at 27rem an input and a button on one line
   leave the input too short to read back a typed address. */
.email-popup-form { display: flex; flex-direction: column; gap: var(--s-3); }
.email-popup-form input[type="email"] { width: 100%; padding: var(--s-3); }
.email-popup-form button { padding: var(--s-3) var(--s-5); }

/* Off-screen rather than display:none - some bots skip fields they can see
   are unrendered, and this one is meant to look fillable. */
.email-popup-trap {
    position: absolute; left: -9999px;
    width: 1px; height: 1px; overflow: hidden;
}

.email-popup-status {
    margin: var(--s-3) 0 0;
    font-size: var(--fs-meta); color: var(--ok-ink); min-height: 1.25rem;
}
.email-popup-status.is-error { color: var(--danger); }

.email-popup-fine {
    margin: var(--s-4) 0 0;
    font-size: var(--fs-micro); color: var(--muted);
}

/* After a successful submit the form is gone and the status line is the
   whole message - keeping a filled-in form on screen invites a second send. */
.email-popup-panel.is-done .email-popup-form,
.email-popup-panel.is-done .email-popup-blurb { display: none; }

@media (max-width: 480px) {
    /* A bottom sheet on a phone: a centred dialog puts the input under the
       on-screen keyboard the moment it is focused. */
    .email-popup { align-items: flex-end; padding: 0; }
    .email-popup-panel {
        max-width: none;
        border-radius: var(--r-lg) var(--r-lg) 0 0;
        border-bottom: 0;
        padding: var(--s-5) var(--s-4) var(--s-6);
        transform: translateY(16px);
    }
}

@media (prefers-reduced-motion: reduce) {
    .email-popup-scrim, .email-popup-panel { transition: none; }
    .email-popup-panel { transform: none; }
}

/* ================================================================ cart === */
.site-header .cart-link {
    display: flex; align-items: center; gap: var(--s-1);
    color: var(--ink); text-decoration: none; font-weight: 600;
}
.site-header .cart-link:hover { color: var(--accent-ink); }
.site-header .cart-link .count {
    display: inline-flex; align-items: center; justify-content: center;
    min-width: 20px; height: 20px; padding: 0 6px;
    background: var(--accent-ink); color: #fff;
    border-radius: var(--r-sm); font-family: var(--font-mono);
    font-size: var(--fs-micro); font-weight: 500;
}

.add-to-cart {
    display: flex; align-items: center; gap: var(--s-2);
    margin-bottom: var(--s-5);
}
.add-to-cart input[type="number"] { width: 72px; }

/*
 * The cart used to wear .admin-table, i.e. the exact styling of the internal
 * orders report, on the last page before a customer types a card number.
 * Same markup shape, its own presentation, and its own mobile behaviour.
 *
 * Mobile-first, and the mobile form is NOT a table: four columns plus a
 * thumbnail cannot hold together at 320px, and a horizontally scrolling cart
 * hides the total. Each row becomes a stacked block whose cells label
 * themselves from the data-label attribute in the markup; the table proper
 * is restored at 620px, where it fits.
 */
.cart-table { width: 100%; border-collapse: collapse; margin-bottom: var(--s-6); }
.cart-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.cart-table tr { display: block; padding: var(--s-4) 0; border-bottom: 1px solid var(--line); }
.cart-table tr:first-child { padding-top: 0; }
.cart-table td { display: flex; align-items: center; justify-content: space-between; gap: var(--s-4); padding: var(--s-1) 0; }
.cart-table td::before {
    content: attr(data-label);
    color: var(--muted); font-size: var(--fs-meta);
}
/* The product cell leads the block: it gets no repeated label and the image
   and name take the full width rather than being pushed to one side. */
.cart-table td[data-label="Product"] { display: block; padding-bottom: var(--s-3); }
.cart-table td[data-label="Product"]::before { content: none; }
.cart-table td.num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }

@media (min-width: 620px) {
    .cart-table thead { position: static; width: auto; height: auto; overflow: visible; clip: auto; }
    .cart-table tr { display: table-row; padding: 0; border-bottom: 0; }
    .cart-table th {
        font-family: var(--font-mono);
        font-size: var(--fs-micro); font-weight: 500;
        text-transform: uppercase; letter-spacing: .1em; color: var(--muted);
        text-align: left; padding: 0 var(--s-2) var(--s-3);
        border-bottom: 1px solid var(--ink);
    }
    .cart-table td {
        display: table-cell; padding: var(--s-4) var(--s-2);
        border-bottom: 1px solid var(--line); vertical-align: middle;
    }
    .cart-table td::before { content: none; }
    .cart-table th.num, .cart-table td.num { text-align: right; }
}
/* .cart-product is an inner <div>, not the <td> itself - a flex display on
   the <td> directly overrides its default `display: table-cell`, which
   drops the .admin-table td rule's vertical-align and skews that column's
   width/border behaviour relative to the others. */
.cart-product { display: flex; align-items: center; gap: var(--s-3); }
.cart-product img { border-radius: var(--r-sm); background: var(--tile); }
.cart-table input[type="number"] { width: 72px; border-radius: var(--r-sm); }

.cart-totals {
    display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end;
    gap: var(--s-6); margin-top: var(--s-6);
}
.cart-totals dl {
    display: grid; grid-template-columns: auto auto; gap: var(--s-1) var(--s-5);
    margin: 0; font-size: var(--fs-body);
}
.cart-totals dt { color: var(--muted); }
.cart-totals dd {
    margin: 0; text-align: right;
    font-family: var(--font-mono); font-variant-numeric: tabular-nums;
}
.cart-totals dt.total, .cart-totals dd.total { color: var(--ink); font-weight: 600; font-size: var(--fs-h2); }
.cart-totals dt.total { font-family: var(--font-display); letter-spacing: -.02em; }
.cart-actions { display: flex; flex-wrap: wrap; gap: var(--s-2); justify-content: flex-end; }

/*
 * Continue shopping on one side, the totals and both buttons on the other.
 * The way back into the catalogue was previously the masthead logo or the
 * Back button, on the page where a shopper is most likely to want one more
 * item before paying.
 */
.cart-foot {
    display: flex; flex-wrap: wrap; align-items: flex-start;
    justify-content: space-between; gap: var(--s-5);
}
.cart-foot .cart-totals { margin-top: 0; flex-direction: column; align-items: flex-end; }

/* A shipping line that says only "$9.95" invites the question this answers. */
.cart-totals .note {
    display: block; color: var(--muted);
    font-size: var(--fs-micro); font-weight: 400;
}

/* Directly under the Checkout button, because that is where the hesitation
   is. The claim is literally true: checkout is a hosted Stripe Session, so
   this application never renders a card field. */
.cart-secure {
    margin: var(--s-3) 0 0; max-width: 40ch; text-align: right;
    color: var(--muted); font-size: var(--fs-meta);
}

/* =================================================================== hero === */
/*
 * Container-width, not viewport-width. Edge-to-edge from inside
 * <main class="container"> needs `width:100vw; margin-left:calc(50% - 50vw)`,
 * and 100vw ignores the scrollbar on Windows - that trick buys a horizontal
 * scrollbar on the developer's own platform.
 */
.hero {
    position: relative;
    margin-bottom: var(--s-7);
    border-radius: var(--r-lg);
    overflow: hidden;
    background: var(--tile);
}

/*
 * height:auto is load-bearing, not tidying. The <img> carries width/height
 * attributes (which reserve space before the image loads), and those become
 * presentational hints for BOTH dimensions - with a resolved height,
 * aspect-ratio is ignored entirely and every rule below is dead. The
 * attributes stay: with height:auto they still do their layout-shift job,
 * through aspect-ratio rather than around it.
 */
.hero img {
    width: 100%;
    height: auto;
    aspect-ratio: 21 / 9;
    object-fit: cover;
}

/*
 * The copy sits on this gradient, never on raw image pixels, so contrast
 * holds whichever generated candidate is chosen. The stops are set from
 * measurement, not from the darkest stop: the text runs well past 0% of the
 * gradient, so what matters is the alpha where the LAST character sits.
 *
 * RE-MEASURED 2026-08-21 against the cold hero (Phase 2) and against these
 * literals, which are now the current --ink (16,20,24) rather than the old
 * (20,22,26) they were parked at while the warm image was still in place.
 * Method: composite this gradient over the real decoded pixels in a canvas at
 * the rendered size, then take the LOWEST ratio found anywhere inside each
 * text element's box - an average hides the one word that fails.
 *
 *            headline   paragraph
 *    320px     5.79        7.98     (bottom-up stops, see the 699px block)
 *    400px     5.77        8.51
 *    700px     5.95        4.84     <- worst on the page, still clear of AA
 *   1280px     7.24        8.23
 *   1536px     7.24        8.23
 *
 * Every figure clears WCAG AA (4.5:1) and most clear AAA. The cold image is
 * darker where the copy sits than the golden-hour one was, so mobile improved
 * most: the headline was 4.55:1 at 400px and is now 5.77:1.
 *
 * Re-measure the whole table if the image, the stops, or the copy's length
 * changes. Do not eyeball it - the previous hero measured 2.83:1 while
 * looking perfectly legible in a screenshot.
 */
.hero-copy {
    position: absolute;
    inset: 0;
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    justify-content: center;
    gap: var(--s-3);
    padding: var(--s-5);
    color: #fff;
    background: linear-gradient(90deg,
        rgba(16, 20, 24, .82) 0%,
        rgba(16, 20, 24, .58) 50%,
        rgba(16, 20, 24, 0) 85%);
}

.hero-copy h1 { margin: 0; max-width: 15ch; }
.hero-copy p { margin: 0; max-width: 34ch; color: rgba(255, 255, 255, .88); }

/* Roomier padding as the crop gets taller, in two steps rather than one. A
   single jump to --s-7 at 700px overflows: the 21:9 crop is only 273px tall
   there while the copy plus 48px of padding top and bottom needs 277px, and
   the overflow is clipped by .hero{overflow:hidden}. The band closes on its
   own by ~760px, so the wider padding just waits for 900px. */
@media (min-width: 700px) {
    .hero-copy { padding: var(--s-6); }
}

@media (min-width: 900px) {
    .hero-copy { padding: var(--s-7); }
}

/* Narrow screens: a 21:9 crop is a letterbox slit, and a left-hand scrim has
   no room, so the image gets taller and the scrim rotates bottom-up. */
@media (max-width: 699px) {
    .hero img { aspect-ratio: 4 / 3; }
    /* The scrim has to reach much further up here than the desktop one does
       sideways: a 4:3 crop at 400px is 253px tall and the copy fills 229px of
       it, so a fade that has run out by 78% leaves the headline on raw sky.
       Re-measured against the cold hero, 2026-08-21: worst case behind the
       headline is 5.77:1 at 400px and 5.79:1 at 320px, up from 4.55:1 and
       4.58:1 under the golden-hour image. The full table lives with the
       desktop stops above; these bottom-up stops are what those two rows
       were measured through. */
    .hero-copy {
        justify-content: flex-end;
        background: linear-gradient(0deg,
            rgba(16, 20, 24, .88) 0%,
            rgba(16, 20, 24, .66) 55%,
            rgba(16, 20, 24, .52) 95%,
            rgba(16, 20, 24, .30) 100%);
    }
}

/* Very narrow screens: the copy is taller than the crop it sits on. Because
   .hero-copy is justify-content:flex-end inside .hero{overflow:hidden}, the
   excess escapes UPWARD and is clipped - at 320px the headline's top sits
   above the hero box entirely. Measured at 320px: the copy needs 229px of
   the 193px a 4:3 crop gives it. Both halves of the gap are closed here -
   the copy gets shorter (tighter padding/gap, headline at --fs-h2, which a
   257px-wide box wants anyway) and the crop gets taller (5:4) - so there is
   real headroom rather than a fit that one extra wrapped line would undo. */
@media (max-width: 399px) {
    .hero img { aspect-ratio: 5 / 4; }
    .hero-copy { padding: var(--s-4); gap: var(--s-2); }
    .hero-copy h1 { font-size: var(--fs-h2); }
}

/* ================================================ trust & funnel === */
/*
 * Everything below is mobile-first: the base rule is the small-screen form
 * and every change up is a min-width query written immediately after it. The
 * responsive section at the foot of this file is for overriding rules that
 * live far away from it; a component that states its own breakpoints in
 * place cannot lose a specificity tie to itself.
 */

/* A text link that is not a button and not body copy: the secondary way out
   of a decision point (Continue shopping, Keep looking, Read the policy). */
.link-quiet {
    color: var(--muted); font-size: var(--fs-meta);
    text-decoration: underline; text-underline-offset: 3px;
}
.link-quiet:hover { color: var(--accent-ink); }

/* Update cart must not compete with Checkout. Outlined, not filled, so the
   pair reads as one primary action and one maintenance action rather than
   two equal choices at the moment of payment. The :hover rule carries two
   classes so it beats the base `button:hover`, which would otherwise put the
   accent fill straight back. */
.btn-secondary { background: #fff; color: var(--ink); box-shadow: inset 0 0 0 1px var(--line); }
.btn-secondary:hover { background: var(--tile); color: var(--ink); box-shadow: inset 0 0 0 1px var(--muted); }

/* -------------------------------------------------------------- trust --- */
/*
 * The three reassurances, in two arrangements from one partial: a band
 * across the page (home, cart) and a stack inside the buy box (product).
 *
 * Not cards. Hairlines and space group these, which is the same decision the
 * product grid makes - a bordered box around every promise would read as
 * three badges bolted on, and badges are exactly what a sceptical shopper
 * discounts. The whole item is the link, so the target is a full row rather
 * than a four-word phrase.
 */
.trust { list-style: none; margin: 0; padding: 0; }
.trust a { display: block; color: inherit; text-decoration: none; }
.trust strong {
    display: block; color: var(--ink);
    font-size: var(--fs-meta); font-weight: 600; letter-spacing: -.005em;
}
.trust span { display: block; margin-top: var(--s-1); color: var(--muted); font-size: var(--fs-meta); }
.trust a:hover strong { color: var(--accent-ink); }
.trust a:hover span { color: var(--ink); }

.trust-band {
    margin: 0 0 var(--s-7);
    border-top: 1px solid var(--line);
    border-bottom: 1px solid var(--line);
}
.trust-band li { border-top: 1px solid var(--line); }
.trust-band li:first-child { border-top: 0; }
.trust-band a { padding: var(--s-4) 0; }

@media (min-width: 760px) {
    .trust-band { display: grid; grid-template-columns: repeat(3, 1fr); }
    /* Vertical hairlines between the three, none on the outside: the top and
       bottom rules already close the band. */
    .trust-band li { border-top: 0; border-left: 1px solid var(--line); }
    .trust-band li:first-child { border-left: 0; }
    .trust-band a { padding: var(--s-5); }
    .trust-band li:first-child a { padding-left: 0; }
    .trust-band li:last-child a { padding-right: 0; }
}

/* Inside the buy box, where it answers "what does postage cost" and "what if
   I hate it" without the shopper leaving the page to find out. */
.trust-stack { margin-top: var(--s-5); border-top: 1px solid var(--line); }
.trust-stack li { border-top: 1px solid var(--line); }
.trust-stack li:first-child { border-top: 0; }
.trust-stack a { padding: var(--s-3) 0; }
.trust-stack span { font-size: var(--fs-micro); }

/* --------------------------------------------------------- breadcrumb --- */
.breadcrumb {
    display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-2);
    margin-bottom: var(--s-5);
    font-family: var(--font-mono); font-size: var(--fs-micro);
    letter-spacing: .04em; color: var(--muted);
}
.breadcrumb a { color: var(--muted); text-decoration: none; }
.breadcrumb a:hover { color: var(--accent-ink); text-decoration: underline; }
.breadcrumb .sep { color: var(--line); }
/*
 * The product name can be long; it is the page title, not a link, so it
 * truncates rather than wrapping the crumb onto three lines.
 *
 * max-width is 100%, NOT a ch value. A nowrap element with `max-width: 46ch`
 * cannot shrink below about 331px of mono, which is wider than the content
 * box of a 320px phone - it would have put a horizontal scrollbar on every
 * product page, the exact bug the header had before Phase 1. min-width:0 is
 * what actually lets a flex item shrink past its content.
 */
.breadcrumb [aria-current="page"] {
    min-width: 0; max-width: 100%;
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* ------------------------------------------------------------- buybox --- */
/*
 * Price, quantity, button and reassurances as one object. They were four
 * loose things stacked down the column, so the decision the page exists to
 * support was spread across half a metre of scroll. White on --paper lifts
 * it without a shadow, which suits a palette this cold.
 */
.buybox {
    margin: var(--s-5) 0 var(--s-6);
    padding: var(--s-5);
    background: #fff;
    border: 1px solid var(--line);
    border-radius: var(--r-lg);
}
.buybox .price { font-size: var(--fs-h2); }
.buybox .add-to-cart { margin: var(--s-4) 0 0; }
.buybox .add-to-cart button { flex: 1 1 auto; }

/* Confirmation after POST /cart/add redirects back to the product. Uses the
   semantic ok pair, not the brand accent: this is "that worked", which is a
   different thing from "this is a Sky Pilot Gear button". */
.added {
    margin: var(--s-4) 0 0;
    padding: var(--s-4);
    background: var(--ok-soft);
    border-left: 3px solid var(--ok-ink);
    border-radius: var(--r-sm);
}
.added-line { margin: 0 0 var(--s-3); color: var(--ok-ink); font-size: var(--fs-meta); }
.added-line strong { display: block; font-size: var(--fs-body); }
.added-actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-4); }

/* ------------------------------------------------------------ related --- */
/* A product page that a visitor has just decided against needs somewhere to
   go that is not the Back button. */
.related {
    margin-top: var(--s-9);
    padding-top: var(--s-7);
    border-top: 1px solid var(--line);
}
.related h2 { margin-bottom: var(--s-6); }

/* ------------------------------------------------------- policy pages --- */
/*
 * Shipping, returns, contact, privacy. Measure-constrained prose: at the
 * 1200px container these would run to about 160 characters a line and nobody
 * would read the paragraph that says who they are buying from.
 */
.policy, .confirmation { max-width: 68ch; }
.policy h2, .confirmation h2 {
    margin: var(--s-7) 0 var(--s-3);
    font-size: var(--fs-h3);
    font-family: var(--font-display);
    letter-spacing: -.012em;
}
.policy p, .confirmation p { margin: 0 0 var(--s-4); }
.policy-lede {
    color: var(--muted);
    font-size: 1.0625rem;
    max-width: 52ch;
}

.contact-address {
    margin: var(--s-6) 0;
    padding: var(--s-5);
    background: #fff;
    border: 1px solid var(--line);
    border-radius: var(--r-lg);
    font-family: var(--font-mono);
    font-size: 1.0625rem;
    overflow-wrap: anywhere;
}

/* -------------------------------------------------- order confirmation --- */
/* The highest-trust moment in the funnel, and previously three centred
   sentences in .empty, the component that otherwise means "nothing here". */
.order-number {
    display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--s-2) var(--s-4);
    margin: var(--s-5) 0;
    padding: var(--s-4) var(--s-5);
    background: #fff;
    border: 1px solid var(--line);
    border-radius: var(--r-lg);
}
.order-number span { color: var(--muted); font-size: var(--fs-meta); }
.order-number strong {
    font-family: var(--font-mono); font-size: var(--fs-price);
    font-variant-numeric: tabular-nums; letter-spacing: -.01em;
}

.next-steps { counter-reset: step; list-style: none; margin: 0 0 var(--s-6); padding: 0; }
.next-steps li {
    counter-increment: step;
    position: relative;
    padding-left: var(--s-6);
    margin-bottom: var(--s-5);
    color: var(--muted);
}
.next-steps li::before {
    content: counter(step);
    position: absolute; left: 0; top: 1px;
    font-family: var(--font-mono); font-size: var(--fs-meta);
    font-variant-numeric: tabular-nums;
    color: var(--accent-ink);
}
.next-steps strong { display: block; color: var(--ink); font-weight: 600; }

.confirmation-actions {
    display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-4);
    margin-top: var(--s-6);
}

/* ============================================ responsive overrides === */
/*
 * The header did not actually wrap, it OVERFLOWED. A flex item's default
 * min-width is auto, so the search <input> refused to shrink below its
 * intrinsic width and pushed the cart link off the side: at a 320px viewport
 * the document measured 447px wide, i.e. every phone visitor could scroll
 * the whole site sideways. The old 114px header height was that overflow
 * being absorbed, not a deliberate two-row layout.
 *
 * Below 700px the search now takes a row of its own, on purpose: logo and
 * cart on the first row, full-width search on the second. min-width:0 on the
 * input is the part that actually fixes the overflow; the rest is what makes
 * the resulting two rows look intentional.
 */
@media (max-width: 699px) {
    /* Two rows here, so the anchor offset has to grow with them. */
    :root { --header-h: 106px; }

    .site-header { flex-wrap: wrap; gap: var(--s-3) var(--s-4); }
    /* min-width:0 is needed on BOTH the form and the input. The form is a
       flex item of the header AND a flex container itself; leaving its own
       automatic minimum size at `auto` lets its content push it past the
       100% basis, which is what kept a 320px viewport 11px too wide even
       after the input was allowed to shrink. */
    .site-header .search { order: 3; flex: 1 1 100%; min-width: 0; margin-left: 0; }
    .site-header .search input { flex: 1 1 auto; min-width: 0; }
    .site-header .cart-link { margin-left: auto; }
}

/* ==================================================== reduced motion === */
@media (prefers-reduced-motion: reduce) {
    *, *::before, *::after {
        transition-duration: .001s !important;
        animation-duration: .001s !important;
    }
    .card:hover .thumb, .card:hover .thumb img { transform: none; }
}
