/* ==========================================================================
   Teluria — Styles (mobile-first)

   Base = mobile (< 720px). `@media (min-width: …)` steps up to tablet/desktop.

   This file was rewritten from scratch at checkpoint 2. The previous version was
   inherited wholesale from another site and carried its brand palette (#CC6600
   orange) plus its hero/band/modal/cart/admin/workshop sections and copy — none
   of which belong to Teluria.

   DELIBERATE PLACEHOLDER: the palette below is neutral greys on white. No
   Teluria brand colour has been invented — the owner is a jewellery atelier and
   a speculative hue would be worse than none. Swap the tokens in §1 when he
   decides on a colour.

   Sections
     0. Web fonts (self-hosted Lora + Montserrat)
     1. Tokens
     2. Reset & accessibility
     3. Base typography
     4. Layout containers
     5. Masthead
     6. Buttons & links
     7. Badges (stock / featured)
     8. Catalog grid + card
     9. Product detail (breadcrumb, gallery, details, prev/next)
    10. Home
    11. 404
    12. Footer
    13. Cart panel + checkout form
         13b. Payment outcome pages (/gracias/, /pago-no-completado/)
         14. Tablet ≥ 600px
    15. Desktop ≥ 900px
   ========================================================================== */

/* ── 0. WEB FONTS — Lora (headings) + Montserrat (everything else),
   self-hosted ──
   The pairing is the owner's (2026-10-26): a moderate-contrast transitional
   serif for headings over a wide geometric sans for body copy. It is the
   classic atelier combination — the serif carries the editorial voice, the
   sans disappears into price lists, labels and nav — and it replaces the
   inherited Inter 4.1, which was a single utilitarian family doing both jobs.

   WHY LORA AND NOT PLAYFAIR (owner decision 2026-10-26). Playfair Display was
   the first heading face and was replaced the same day — on LEGIBILITY, not on
   taste: the owner read it as "a bit on the hard-to-read side". The reason is
   WHERE this face is used. It is not a headline-only face. It also sets real UI
   headings at **16.8px** — the cart's "Tu pedido:", the checkout's "Ingresa tus
   datos" and "Revisa tu pedido", the admin drawer's "Editar producto" — and
   page titles at 20-37px (h1/h2). Playfair is a display cut whose hairlines
   were drawn for large sizes, so it thins out exactly where panel text has to
   be scanned rather than admired. Lora is a text-first serif — large x-height,
   moderate contrast ratio — so it holds at 16.8px as well as it carries the
   75px hero. The Playfair masters stay in `docs/fonts-pre-subset/playfair-display/`
   as provenance.

   (Card titles are Montserrat 600, NOT this face — they are 15.2px labels in a
   grid, and a display serif there would be neither readable nor tabular. Do not
   "promote" them.)

   TWO FAMILIES, ONE JOB EACH. `--font-display` is headings only (see §3);
   `--font-body` is every other glyph on the page. Do not reach for the display
   face for prices, buttons, badges or the store numerals: Lora has no `tnum`
   either, so `font-variant-numeric: tabular-nums` is a no-op there, and prices
   have to line up in a column.

   Both families are SIL OFL-1.1. The licence texts are PUBLISHED next to the
   files (`LICENSE-Lora.txt`, `LICENSE-Montserrat.txt`) because OFL requires
   that when the fonts are redistributed.

   The served files are SUBSET, static WOFF2 cuts, generated from the full
   variable masters in `docs/fonts-pre-subset/<family>/` by
   `scripts/subset-fonts.py`. A subset is lossy: if new copy needs a glyph the
   subset table dropped, widen `CHARSET` in that script and re-run it — never
   hand-edit the .woff2. The eight files below are the only cuts declared, and
   each one is selectable by this stylesheet:

     400 body · 600 nav/labels/prices · 700 headings · 400/600 italic

   Italic exists for 400/600 because author HTML in `products.json` may use
   <em>/<strong>. Lora has no 600 (no heading asks for it) and no italic
   700 (nothing asks for bold italic headings): a cut declared but never
   selected is bytes every visitor pays for.

   GLYPHS THE FAMILIES DO NOT SHIP: ✓ ✗ ⚠ ▾ ⠿ ⟲ Σ. No subset can add them, so
   they fall back to the system stack — they are the admin panel's status marks
   and the nav's decorative caret, never body copy. (The arrows ← ↑ → ↓ ↔ ↕,
   − ≥ ≈ and the typographic quotes ARE in both families.) */

@font-face {
    font-family: "Lora";
    font-style: normal;
    font-weight: 400;
    font-display: swap;
    src: url("/public/fonts/Lora-Regular.woff2") format("woff2");
}

@font-face {
    font-family: "Lora";
    font-style: normal;
    font-weight: 700;
    font-display: swap;
    src: url("/public/fonts/Lora-Bold.woff2") format("woff2");
}

@font-face {
    font-family: "Lora";
    font-style: italic;
    font-weight: 400;
    font-display: swap;
    src: url("/public/fonts/Lora-Italic.woff2") format("woff2");
}

@font-face {
    font-family: "Montserrat";
    font-style: normal;
    font-weight: 400;
    font-display: swap;
    src: url("/public/fonts/Montserrat-Regular.woff2") format("woff2");
}

@font-face {
    font-family: "Montserrat";
    font-style: normal;
    font-weight: 600;
    font-display: swap;
    src: url("/public/fonts/Montserrat-SemiBold.woff2") format("woff2");
}

@font-face {
    font-family: "Montserrat";
    font-style: normal;
    font-weight: 700;
    font-display: swap;
    src: url("/public/fonts/Montserrat-Bold.woff2") format("woff2");
}

@font-face {
    font-family: "Montserrat";
    font-style: italic;
    font-weight: 400;
    font-display: swap;
    src: url("/public/fonts/Montserrat-Italic.woff2") format("woff2");
}

@font-face {
    font-family: "Montserrat";
    font-style: italic;
    font-weight: 600;
    font-display: swap;
    src: url("/public/fonts/Montserrat-SemiBoldItalic.woff2") format("woff2");
}

/* ── 1. TOKENS ── */

:root {
    /* Neutral placeholder palette — see header note. */
    --bg: #ffffff;
    --text: #1a1a1a;
    --muted: #6b6b6b;
    --line: #e2e2e2;
    --sub: #f6f6f4;   /* tinted band behind a card/section */
    --card: #ffffff;
    --on-dark: #ffffff;
    --dark: #1a1a1a;

    /* Type stacks — see §0. `display` is headings only; `body` is everything
       else. The fallbacks are chosen by SHAPE, not by licence: Georgia is a
       serif of similar proportion, so a Lora that fails to load still reads as
       a heading instead of collapsing into the sans. */
    --font-display: "Lora", Georgia, "Times New Roman", serif;
    --font-body: "Montserrat", system-ui, -apple-system, "Segoe UI", sans-serif;

    /* THE MARK STACKS. Neither family ships ✓ ✗ ⚠ ▾ ⠿ ⟲ Σ, so those glyphs
       have always been drawn by whatever the OS offered next — and on Linux
       and macOS that is a COLOUR EMOJI font, which is why the admin's ⚠ used
       to appear as an amber emoji sticker in the middle of a status pill.
       Naming a monochrome symbol face BEFORE `system-ui` fixes it: fallback is
       per-glyph, so Montserrat still draws every letter and only the missing
       mark resolves further down the list.

       `--font-body-mark` is for strings that MIX text and marks (pills,
         upload status, drag handles); `--font-symbol` is for elements that can
         only ever hold a mark (the nav's ▾ caret, icon-only buttons), where
         the body family is deliberately absent. */
    --font-body-mark: "Montserrat", "Segoe UI Symbol", "Apple Symbols",
        "DejaVu Sans", "Noto Sans Symbols 2", system-ui, sans-serif;
    --font-symbol: "Segoe UI Symbol", "Apple Symbols", "DejaVu Sans",
        "Noto Sans Symbols 2", sans-serif;

    --space-1: 0.4rem;
    --space-2: 0.75rem;
    --space-3: 1rem;
    --space-4: 1.5rem;
    --space-5: 2.5rem;
    --space-6: 4rem;

    --radius: 2px;

    /* Measured height of `.masthead` — mobile base is the two-row layout
       (brand/actions over nav): 8px pad-top + 46px brand row + 12px gap +
       23px nav row + 8px pad-bottom + 1px border = 98px. Single source of
       truth for the hero's viewport math (`100svh - var(--header-h)` lands
       the hero's bottom edge on the viewport bottom), so it must track the
       masthead's real height per layout; the ≥480px three-column masthead
       (79px) overrides it in the masthead section below. */
    --header-h: 98px;

    /* 1px hairline. The catalog is a portfolio of objects on a light table —
       flat hairlines keep the photography, not the chrome, in charge. */
    --hairline: 1px solid var(--line);
}

/* ── 2. RESET & ACCESSIBILITY ── */

*,
*::before,
*::after {
    box-sizing: border-box;
}

html {
    -webkit-text-size-adjust: 100%;
}

body {
    margin: 0;
}

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

.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;
    top: -100px;
    left: 0;
    z-index: 100;
    padding: 0.6rem 1rem;
    background: var(--dark);
    color: var(--on-dark);
    text-decoration: none;
}

.skip-link:focus {
    top: 0;
}

:focus-visible {
    outline: 2px solid var(--text);
    outline-offset: 2px;
}

/* ── 3. BASE TYPOGRAPHY ── */

body {
    font-family: var(--font-body);
    font-size: clamp(1rem, 0.97rem + 0.15vw, 1.0625rem);
    line-height: 1.6;
    color: var(--text);
    background: var(--bg);
    -webkit-font-smoothing: antialiased;
}

/* Headings are the ONE place the display face is used (§0). h4 carries the
   family but not the 700 weight below: it is the smallest level and only
   appears inside author-supplied `desc` HTML, where 400 reads better. */
h1,
h2,
h3,
h4 {
    font-family: var(--font-display);
}

h1,
h2,
h3 {
    font-weight: 700;
    /* 1.28, not the 1.25 that was tuned for Playfair. Lora's accented-capital
       to descender span is 1.207em (Playfair's was 1.124em), so 1.25 would
       leave 0.04em of clearance — under a pixel at the smallest heading size.
       1.28 is the font's own hhea box (1.280em), which is the leading Lora was
       drawn to sit in. Do not reduce it without re-measuring that span. */
    line-height: 1.28;
    margin: 0 0 var(--space-3);
    letter-spacing: -0.01em;
}

h1 {
    font-size: clamp(1.6rem, 1.35rem + 1.2vw, 2.4rem);
}

h2 {
    font-size: clamp(1.25rem, 1.12rem + 0.6vw, 1.6rem);
}

h3 {
    font-size: 1.125rem;
}

/* Body copy stays narrow-measured — generous whitespace, the photos carry the
   page. */
p {
    margin: 0 0 var(--space-3);
    max-width: 68ch;
}

a {
    color: inherit;
}

/* Author-supplied product `desc` HTML (<p> <ul> <ol> <strong> <em> <h2>-<h4>
   <hr> <table>). Scoped so it can never leak into the site chrome. */
.product-desc {
    max-width: 68ch;
}

.product-desc > :first-child {
    margin-top: 0;
}

/* The description now closes the introduction (owner decision 2026-10-26:
   title → store line → description → price → control), so its author HTML
   drops its trailing margin. The gap above the price is then the price's own
   `margin-top` — one number, not two that silently add up. */
.product-desc > :last-child {
    margin-bottom: 0;
}

.product-desc h2 {
    font-size: 1.15rem;
    margin-top: var(--space-4);
}

.product-desc h3 {
    font-size: 1.05rem;
    margin-top: var(--space-3);
}

.product-desc table {
    width: 100%;
    border-collapse: collapse;
    margin: 0 0 var(--space-3);
    font-size: 0.95rem;
}

.product-desc th,
.product-desc td {
    border-bottom: var(--hairline);
    padding: 0.5rem 0.6rem;
    text-align: left;
    vertical-align: top;
}

.product-desc hr {
    border: 0;
    border-top: var(--hairline);
    margin: var(--space-4) 0;
}

/* Spec list (`features`) as its own section below the description. Same hairline
   / flat treatment as the rest of the page — no card, no fill. The heading is
   inside the section and the markup is omitted entirely when there are no rows,
   so there is never a heading with an empty list under it. */
.product-extra {
    max-width: 68ch;
    margin-top: var(--space-4);
}

.product-extra-title {
    font-size: 1.05rem;
    margin: 0 0 var(--space-2);
}

.product-extra-list {
    margin: 0;
    padding-left: 1.15rem;
}

.product-extra-list li {
    margin-bottom: var(--space-1);
}

/* ── 4. LAYOUT CONTAINERS ── */

.container {
    width: 100%;
    margin: 0 auto;
    padding: var(--space-4) var(--space-3) var(--space-6);
}

/* Opt-in full-bleed: zero padding so children like .sobre-media can go
   edge-to-edge. Set `fullBleed: true` in a page's front matter. */
.container--bleed {
    padding: 0;
}

/* ── 5. MASTHEAD ── */

.masthead {
    display: grid;
    grid-template-columns: 1fr auto;
    grid-template-areas:
        "brand actions"
        "nav    nav";
    align-items: center;
    gap: var(--space-2);
    /* The vertical padding is NOT free: it is what the brand mark's extra
       height is paid out of. The mark is 46px tall (the owner's call), the
       brand row grows with it, and the header must NOT grow — so the padding
       gives back exactly what the mark takes (16px, 8px per side). The
       masthead's height is unchanged: 8px pad-top + 46px brand row + 12px
       gap + 23px nav row + 8px pad-bottom + 1px border = 98px. */
    padding: 8px var(--space-4);
    border-bottom: var(--hairline);
    background: var(--bg);
}

.masthead nav {
    display: flex;
    gap: var(--space-3);
    font-size: 0.9rem;
    /* Left-aligned on mobile — the menu start matches the brand's left edge. */
    justify-content: flex-start;
}
.masthead .brand { grid-area: brand; justify-self: start; }
.masthead .menu-right { grid-area: actions; }

@media (min-width: 480px) {
    :root {
        /* Three-column masthead: 16px pad-top + 46px row + 16px pad-bottom
           + 1px border = 79px. Kept next to the layout that sets it. */
        --header-h: 79px;
    }
    .masthead {
        grid-template-columns: 1fr auto 1fr;
        grid-template-areas: "nav brand actions";
        /* The same trade as the mobile layout: the mark's 46px comes out of
           the vertical padding (24px -> 16px), so the header keeps its
           height and the mark simply fills more of it. */
        padding: 16px var(--space-4);
    }
    /* Restore centered brand in the 3-column layout (the global .brand rule
       is overridden by .masthead .brand above on mobile). */
    .masthead .brand { justify-self: center; }
}

.masthead nav a {
    text-decoration: none;
    color: var(--muted);
}

.masthead nav a:hover {
    color: var(--text);
}

/* Catálogo dropdown — CSS-only hover. The trigger is a real link to
   /productos/ so clicking navigates; hovering reveals category links. */
.masthead .dropdown {
    position: relative;
}

/* Bridge the dead zone between the trigger and the submenu. The .dropdown
   stretches taller than its trigger link (flex stretch + the trailing arrow
   box), so `top: 100%` detaches the menu by a few px — crossing that strip
   lost :hover and the menu vanished mid-selection. This invisible strip is a
   child of .dropdown, so hovering the gap keeps the menu open. */
.masthead .dropdown::after {
    content: "";
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
    height: 14px;
}

.masthead .dropdown-trigger {
    cursor: pointer;
    /* The trailing " ▾" arrow must never wrap below the label — at narrow
       widths it did, doubling the nav row height (and inflating the masthead
       past --header-h, which the hero's viewport math depends on). */
    white-space: nowrap;
}

.masthead .dropdown-trigger::after {
    content: " ▾";
    /* ▾ is not in Montserrat. Pinned to the symbol stack so the caret is
       drawn monochrome and at a predictable size, instead of whatever the OS
       fallback picks (a colour-emoji font would render a blue triangle). */
    font-family: var(--font-symbol);
    font-size: 0.7rem;
    vertical-align: middle;
}

.masthead .dropdown-menu {
    display: none;
    position: absolute;
    top: 100%;
    left: 0;
    z-index: 100;
    min-width: 12ch;
    padding: var(--space-1) 0;
    margin-top: var(--space-1);
    background: var(--bg);
    border: var(--hairline);
    border-radius: 6px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}

.masthead .dropdown-menu a {
    display: block;
    padding: var(--space-1) var(--space-3);
    white-space: nowrap;
}

.masthead .dropdown-menu a:hover {
    background: var(--sub);
}

.masthead .dropdown:hover .dropdown-menu,
.masthead .dropdown:focus-within .dropdown-menu {
    display: block;
}

/* ── 5b. CATEGORY PAGE ── */
.category-page h1 {
    margin-bottom: var(--space-1);
}
.category-intro {
    font-size: 0.9rem;
    color: var(--muted);
    margin-bottom: var(--space-4);
}
.category-empty {
    color: var(--muted);
    font-style: italic;
}

/* ── 5c. CATALOGUE PAGE ── */
/* The catalogue header states the piece count in the SAME muted style as the
   category pages (owner decision 2026-10-21): both grid views read the same,
   and neither states availability twice. */
.catalogo-head h1 {
    margin-bottom: var(--space-1);
}
.catalogo-intro {
    font-size: 0.9rem;
    color: var(--muted);
    margin-bottom: var(--space-4);
}

/* ── 5d. TIENDA PAGE HEADER ── */
/* Title → the shop's own subtitle → the piece count. The subtitle is the
   `description` field from stores.json (author HTML: two sentences separated
   by a <br>), so it keeps body size and takes the muted secondary colour; the
   count below it reads exactly like the catalogue's and the category pages'
   (same size, same colour, same bottom gap). Neither line is decorative — the
   subtitle says what the shop is, the count says what the page holds. */
.tienda-page h1 {
    margin-bottom: var(--space-1);
}

.tienda-subtitle {
    color: var(--muted);
    margin-bottom: var(--space-2);
}

.tienda-intro {
    font-size: 0.9rem;
    color: var(--muted);
    margin-bottom: var(--space-4);
}

.tienda-empty {
    color: var(--muted);
    font-style: italic;
}

/* ── 6. WORDMARK ── */
.brand {
    justify-self: center;
    text-decoration: none;
    color: var(--text);
    /* Only reachable while `site.logoExists` is false — with the artwork in
       place the mark is an <img> (below). It still asks for the display face:
       a serif wordmark is nearer the owner's calligraphic mark than a
       geometric sans, so the degraded state degrades in the right direction. */
    font-family: var(--font-display);
    font-weight: 600;
    font-size: 1.15rem;
    letter-spacing: 0.14em;
    text-transform: uppercase;
}

/* The brand mark — the owner's calligraphic "Teluria" wordmark
   (src/public/brand/teluria-logo2.svg, 2.25:1). A FIXED height, not
   max-height: the mark is a script with ascenders and descenders, and a
   fixed height keeps its ink from resizing with the viewport. `width: auto`
   keeps the ratio, so the mark scales down with the viewport instead of
   being squeezed. `display: block` kills the inline baseline gap under it.

   46px is the owner's call (it was 30px): the mark is the piece of brand
   that has to read. The masthead does NOT grow for it — the vertical padding
   gives the 16px back (see § MASTHEAD), so the header keeps its height and
   the hero's viewport math with it. */
.brand img {
    height: 46px;
    width: auto;
    max-width: 100%;
    display: block;
}

.menu-right {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    justify-self: end;
}

.social {
    display: flex;
    align-items: center;
    gap: var(--space-2);
}

.social img {
    width: 20px;
    height: 20px;
    display: block;
}

/* ── 6. BUTTONS & LINKS ── */

.btn {
    display: inline-block;
    padding: 0.6rem 1.2rem;
    background: var(--dark);
    color: var(--on-dark);
    text-decoration: none;
    border: 1px solid var(--dark);
    border-radius: var(--radius);
    font-size: 0.95rem;
    line-height: 1.2;
}

.btn:hover {
    background: #000;
    border-color: #000;
}

.btn--ghost {
    background: transparent;
    color: var(--text);
    border-color: var(--line);
}

.btn--ghost:hover {
    background: var(--sub);
    border-color: var(--text);
}

.more-link {
    font-size: 0.95rem;
}

/* ── 7. BADGES ── */

.badge {
    display: inline-block;
    padding: 0.15rem 0.45rem;
    font-size: 0.7rem;
    font-weight: 600;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    border: var(--hairline);
    border-radius: var(--radius);
    background: var(--bg);
    color: var(--muted);
    white-space: nowrap;
}

/* Sold-out is STATED, never hidden: these are one-off works and the catalog is
   the portfolio. Outlined, not filled, so it reads as a state rather than an
   alarm and the piece stays the focus. */
.badge--soldout {
    border-color: var(--text);
    color: var(--text);
}

/* NO `.badge--featured` rule: the "Destacado" badge is not displayed on any
   grid (owner decision 2026-10-21). The card carries ONE badge — the sold
   state — so there is nothing to style here, and no dead CSS to mislead the
   next change. The `featured` flag lives on in the data and in the admin
   panel. */

/* ── 8. CATALOG GRID + CARD ── */

.grid {
    display: grid;
    gap: var(--space-4);
    padding: 0;
    margin: 0;
    list-style: none;
}

.catalog-grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
}

.card-item {
    position: relative;
}

.card-link {
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
    text-decoration: none;
    color: inherit;
    height: 100%;
}

/* No fixed frame: photos are 1:1 originals, so each card shows the full
   image at its natural ratio (owner decision 2026-10-09). */
.card-media {
    position: relative;
    display: block;
    overflow: hidden;
    border: var(--hairline);
    background: var(--sub);
}

.card-media picture,
.card-media img {
    width: 100%;
    height: 100%;
}

.card-media img {
    object-fit: cover;
    transform-origin: center;
    transition: transform 0.5s cubic-bezier(0.22, 0.61, 0.36, 1);
}

/* Photo zoom on card hover — the IMAGE scales inside the `.card-media` frame
   (which already has `overflow: hidden`), never the card itself. The card keeps
   its exact box: no border growth, no layout shift, no grid reflow. 6% is
   deliberately subtle — enough to say "this is clickable", not enough to make
   the catalogue feel animated. Guarded by `(hover: hover)` so a tap on a phone
   does not leave the photo stuck zoomed; keyboard focus gets the same cue. */
@media (hover: hover) {
    .card-link:hover .card-media img {
        transform: scale(1.06);
    }
}

.card-link:focus-visible .card-media img {
    transform: scale(1.06);
}

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

.card-media .badge {
    position: absolute;
    top: 0.5rem;
    left: 0.5rem;
}

.card-body {
    display: flex;
    flex-direction: column;
    gap: 0.15rem;
}

/* NO `.card-flags`: the wrapper existed only to hold the "Destacado" badge,
   which is no longer displayed (owner decision 2026-10-21). The sold-out
   badge lives in `.card-media`, so the card body no longer needs a flag row —
   and an empty wrapper is a block that says nothing. */

.card-title {
    font-weight: 600;
    font-size: 0.95rem;
    line-height: 1.3;
}

.card-price {
    font-size: 0.95rem;
    color: var(--muted);
    font-variant-numeric: tabular-nums;
}

/* Teaser renders ONLY when the product has copy — gated in card.njk, so the 28
   products without a `desc` show no teaser box at all. */
.card-teaser {
    font-size: 0.875rem;
    color: var(--muted);
    line-height: 1.45;
}

/* Sold-out: the photo desaturates a little so the grid still reads as one
   catalogue, but the piece is plainly marked. */
.card-item.is-soldout .card-media img {
    filter: grayscale(0.55);
    opacity: 0.75;
}

/* ── 8b. TIENDA PAGE CARD — store ID on the left ───────────────────────
   On /tienda/<store>/ pages every product has a storeLocation, so the
   storeId is always present. It is the primary in-store identifier —
   displayed large to the left of the title/price. The wrapper only renders
   when storeLocation is set (defensive: on this page it always is). */

.tienda-page .card-store-row {
    display: flex;
    gap: var(--space-3);
    align-items: flex-start;
}

.tienda-page .card-store-id {
    font-size: 1.8rem;
    font-weight: 700;
    line-height: 1;
    color: var(--muted);
    flex: 0 0 auto;
    min-width: 2.5ch;
    font-variant-numeric: tabular-nums;
    text-align: right;
}

.tienda-page .card-store-info {
    flex: 1;
    min-width: 0;
    display: flex;
    flex-direction: column;
    gap: 0.15rem;
}

/* On the product detail page the store id and the store link form ONE row
   under the <h1> (owner decision 2026-10-26): the piece number and the shop it
   lives in are the same fact, so they are read together. Baseline alignment
   keeps the bracket numeral on the link's line; `wrap` lets a long shop name
   fall under the number on a narrow column instead of overflowing. */
.product-store {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 0 var(--space-2);
    margin-bottom: var(--space-2);
}

.product-store-id {
    font-size: 1.15rem;
    font-weight: 700;
    color: var(--muted);
    font-variant-numeric: tabular-nums;
}

/* The link itself carries no paragraph margin inside the row — the row owns
   the spacing, so the number and the link sit on one line. */
.product-store .store-link {
    margin: 0;
}

/* ── 9. PRODUCT DETAIL ── */

.breadcrumb {
    margin-bottom: var(--space-4);
    font-size: 0.85rem;
    color: var(--muted);
}

.breadcrumb ol {
    display: flex;
    flex-wrap: wrap;
    gap: 0.4rem;
    list-style: none;
    margin: 0;
    padding: 0;
}

.breadcrumb li + li::before {
    content: "/";
    margin-right: 0.4rem;
    color: var(--line);
}

.breadcrumb a {
    color: var(--muted);
    text-decoration: none;
}

.breadcrumb a:hover {
    color: var(--text);
    text-decoration: underline;
}

.product-main {
    display: grid;
    /* minmax(0,1fr): the implicit auto track grows to the zoom Swiper's
       min-content width (the srcset-picked image's intrinsic width — 640/1024w
       on high-DPR phones), blowing the gallery column out to a huge value on
       mobile. minmax(0,…) caps the track so it can shrink instead. */
    grid-template-columns: minmax(0, 1fr);
    gap: var(--space-4);
}

/* aspect-ratio is set INLINE per figure from the real width/height, so every
   frame reserves its own space and the page never reflows on load. The 2x2
   grid lives on .product-gallery-grid; .product-gallery is just the wrapper
   that swaps grid ↔ zoomed viewer. */
.product-gallery {
    position: relative;
    /* Grid item default min-width:auto lets the zoom Swiper's min-content
       width (intrinsic image size) blow the column out to a huge value on
       mobile — allow it to shrink so the track stays at the viewport width. */
    min-width: 0;
}

.product-gallery-grid {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: var(--space-3);
}

/* A gallery of ONE photo has nothing to arrange: the figure spans the column,
   so the photo uses the width the two-up grid would have wasted (owner decision
   2026-10-26). The width matches the Swiper slide's — the viewer opens at load
   on these pages (gallery-zoom.js) and the frame it replaces is exactly this
   box, so nothing shifts on screen when it does. */
.product-gallery-single .product-gallery-grid {
    grid-template-columns: minmax(0, 1fr);
}

.product-gallery-grid[hidden] {
    display: none;
}

/* ── 9b. GALLERY ZOOM (Swiper) ────────────────────────────────────────
   The expanded viewer replaces the grid in its own slot (in-flow, not
   full-screen). Each slide is a square frame (every gallery photo is 1:1)
   holding a `.swiper-zoom-container`, which is what Swiper's zoom module
   scales and pans. The square aspect-ratio is what gives the container a
   definite height — the module's own CSS is `height: 100%`, which collapses
   against an auto-height slide. */
.gallery-zoom[hidden] {
    display: none;
}

.gallery-zoom {
    position: relative;
}

.gallery-zoom-close,
.gallery-zoom-toggle {
    position: absolute;
    top: var(--space-2);
    z-index: 10;
    width: 2.25rem;
    height: 2.25rem;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 1.5rem;
    line-height: 1;
    color: var(--text);
    background: var(--bg);
    border: var(--hairline);
    border-radius: 50%;
    cursor: pointer;
}

.gallery-zoom-close {
    right: var(--space-2);
}

/* The 2x toggle sits opposite the close button. The icon itself is inline
   SVG in the template; the + / − swap is driven by aria-pressed, so the
   script only flips one attribute. */
.gallery-zoom-toggle {
    left: var(--space-2);
    padding: 0;
}

.gallery-zoom-toggle svg {
    display: block;
    width: 1.15rem;
    height: 1.15rem;
}

.gallery-zoom-icon-out {
    display: none;
}

.gallery-zoom-toggle[aria-pressed="true"] .gallery-zoom-icon-in {
    display: none;
}

.gallery-zoom-toggle[aria-pressed="true"] .gallery-zoom-icon-out {
    display: block;
}

.gallery-zoom-close:hover,
.gallery-zoom-toggle:hover {
    background: var(--sub);
}

.gallery-swiper .swiper-slide {
    display: flex;
    align-items: center;
    justify-content: center;
    background: var(--bg);
    border: var(--hairline);
    /* Clip the 2x photo to its own frame: without this the scaled image
       spills over the neighbouring slide. */
    overflow: hidden;
    /* The vendor sets `height: 100%`; state `auto` so the aspect ratio is what
       gives the slide (and therefore the zoom container) a definite height,
       with no percentage circularity against the auto-height wrapper. */
    height: auto;
    aspect-ratio: 1 / 1;
}

/* Swiper's bundle styles `> img/svg/canvas` but NOT `> picture`, and our
   clone is a <picture> (it carries the srcset). Size the box here so the
   module's scale() is relative to the fitted photo, not to nothing. */
.gallery-swiper .swiper-zoom-container picture {
    display: block;
    width: 100%;
    height: 100%;
}

.gallery-swiper .swiper-zoom-container img {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: contain;
}

/* Swiper control colours follow the site tokens (Swiper's defaults are blue). */
.gallery-swiper .swiper-button-prev,
.gallery-swiper .swiper-button-next {
    color: var(--text);
}

.gallery-swiper .swiper-pagination-bullet-active {
    background: var(--text);
}

/* Zoom chrome fades in/out around the fly animation — visible only once the
   image has landed, hidden while a ghost is flying. */
.gallery-zoom .gallery-zoom-close,
.gallery-zoom .gallery-zoom-toggle,
.gallery-zoom .swiper-button-prev,
.gallery-zoom .swiper-button-next,
.gallery-zoom .swiper-pagination {
    transition: opacity 0.2s ease, visibility 0.2s ease;
}

.gallery-zoom.is-animating .gallery-zoom-close,
.gallery-zoom.is-animating .gallery-zoom-toggle,
.gallery-zoom.is-animating .swiper-button-prev,
.gallery-zoom.is-animating .swiper-button-next,
.gallery-zoom.is-animating .swiper-pagination,
.gallery-zoom.is-animating .gallery-swiper {
    opacity: 0;
    visibility: hidden;
}

/* Swiper writes the zoom transition duration INLINE (300 ms). Dropping the
   zoom on close must land in the same frame as the ghost's flight, so the
   reset class outranks the inline style with !important. */
.gallery-zoom.is-resetting .swiper-zoom-container,
.gallery-zoom.is-resetting .swiper-zoom-container picture {
    transition-duration: 0ms !important;
}

@media (prefers-reduced-motion: reduce) {
    .swiper-zoom-container,
    .swiper-zoom-container picture {
        transition-duration: 0ms !important;
    }
}

.product-figure {
    margin: 0;
    border: var(--hairline);
    background: var(--sub);
}

/* Custom zoom cursor on clickable gallery images (self-hosted 32px PNGs,
   hotspot at the lens centre; native zoom-in/out as fallback for UAs that
   reject custom cursors). */
.product-figure {
    cursor: url("/public/icons/cursor-zoom-in.png") 16 16, zoom-in;
}

/* Fit view: clicking the photo zooms in. Zoomed view: Swiper adds
   `.swiper-slide-zoomed`, which sets `cursor: move` + `touch-action: none`;
   this selector outranks it (ours is more specific), so the grab states are
   stated here rather than inherited from the bundle. */
.gallery-swiper .swiper-slide:not(.swiper-slide-zoomed) {
    cursor: url("/public/icons/cursor-zoom-in.png") 16 16, zoom-in;
}

.gallery-swiper .swiper-slide-zoomed {
    cursor: grab;
}

.gallery-swiper .swiper-slide-zoomed:active {
    cursor: grabbing;
}

.product-figure picture,
.product-figure img {
    width: 100%;
    height: 100%;
}

.product-figure img {
    object-fit: contain;
}

.product-figure-empty {
    display: flex;
    align-items: center;
    justify-content: center;
    aspect-ratio: 3 / 4;
    color: var(--muted);
    font-size: 0.9rem;
}

/* The price opens the purchase block, after the description. The sibling rule
   only matches when a `desc` is actually rendered, so a piece without prose
   keeps the store row's own bottom margin above the price — unchanged. */
.product-desc + .product-price {
    margin-top: var(--space-4);
}

.product-price {
    font-size: 1.25rem;
    font-weight: 600;
    font-variant-numeric: tabular-nums;
    margin-bottom: var(--space-2);
}

/* No visible SKU on the piece page (owner decision 2026-10-26): the piece
   number is an internal reference, so the line and its rule are gone. There is
   no availability line either — the add-to-cart control states the state, and
   the note (when sold) says why. */

.product-note {
    font-size: 0.95rem;
    color: var(--muted);
    border-left: 2px solid var(--line);
    padding-left: var(--space-3);
}

/* The "store pickup" line that used to sit under the store link is gone (owner
   decision 2026-10-26) — the checkout still offers the pickup, but the product
   page no longer advertises it. */

.product-nav {
    display: grid;
    gap: var(--space-3);
    margin-top: var(--space-6);
    padding-top: var(--space-4);
    border-top: var(--hairline);
}

.product-nav-link {
    display: flex;
    flex-direction: column;
    gap: 0.15rem;
    text-decoration: none;
    color: inherit;
}

.product-nav-next {
    text-align: right;
}

.product-nav-label {
    font-size: 0.8rem;
    color: var(--muted);
}

.product-nav-title {
    font-weight: 600;
    font-size: 0.95rem;
}

.product-nav-link:hover .product-nav-title {
    text-decoration: underline;
}

/* ── 10. HOME ── */

/* Hero: full-bleed editorial image with the headline overlaid. The frame is
   sized from the VIEWPORT, never from the image: `min-height: 100svh - the
   masthead - the container's top padding`, with the negative top margin
   cancelling that padding so the hero starts flush under the header and its
   bottom edge lands exactly on the viewport bottom. `100vh` first, `100svh`
   ahead of it, so a phone with a URL bar does not overrun. The scrim is a
   neutral black gradient, not a brand colour: the placeholder palette stays in
   charge. */
.home-hero {
    position: relative;
    min-height: calc(100vh - var(--header-h));
    min-height: calc(100svh - var(--header-h));
    margin: calc(-1 * var(--space-4)) calc(-1 * var(--space-3)) 0;
    background: var(--dark);
    overflow: hidden;
}

/* Both layers are absolutely positioned in the same box, so the image and the
   text are the same box by construction and neither can size the hero: the
   `min-height` above is the only thing that sets it. In flow the image would
   fall back to its intrinsic ratio (`height: 100%` cannot resolve against an
   indefinite-height parent) and make the hero as tall as its width demands. */
.hero-media,
.hero-body {
    position: absolute;
    inset: 0;
    width: 100%;
}

.hero-media,
.hero-media picture {
    display: block;
    width: 100%;
    height: 100%;
}

/* Shopify framed this exact crop; pass it through so the responsive pipeline
   keeps the subject in frame at every width. The image fills the box instead of
   setting it — no `aspect-ratio`, so height comes from the viewport, and
   `cover` does the cropping. */
.hero-media img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    object-position: 24.0212% 36.4044%;
}

.hero-body {
    display: flex;
    flex-direction: column;
    /* The copy block is anchored to the BOTTOM-LEFT of the hero: the headline
       is the statement, it sits on the bottom edge of the frame, and the
       sub + CTA row sits under it. `justify-content: flex-end` pushes the
       whole block down; `align-items: flex-start` keeps it on the left edge
       (the box is the full-bleed frame, so its left padding IS the margin). */
    justify-content: flex-end;
    align-items: flex-start;
    padding: var(--space-5) var(--space-3);
    text-align: left;
    color: var(--on-dark);
    /* The scrim is heaviest under the copy, where the big white headline sits
       over the photograph: a neutral black gradient, not a brand colour. */
    background: linear-gradient(to top, rgba(0, 0, 0, 0.72) 0%, rgba(0, 0, 0, 0.38) 38%, rgba(0, 0, 0, 0) 72%);
}

.hero-title {
    /* Overrides the generic h2 size: this is the front page's headline, and
       it is deliberately oversized — it is the statement of the page. */
    font-size: clamp(2.4rem, 1.55rem + 4.25vw, 4.2rem);
    font-weight: 700;
    /* Lora's accented-capital to descender span is 1.207em, so Playfair's
       1.12 would let the two collide over a wrapped headline; 1.16 leaves a
       little air without loosening the stack. */
    line-height: 1.16;
    letter-spacing: -0.01em;
    max-width: 24ch;
    margin: 0 0 var(--space-3);
    /* A soft shadow keeps the big white type legible over the photograph
       without a hard outline. */
    text-shadow: 0 2px 20px rgba(0, 0, 0, 0.35);
}

/* The sub and the CTA stack under the headline: the H3 first, the button
   below it, both on the left edge of the frame. */
.hero-foot {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: var(--space-3);
    width: 100%;
    max-width: 68rem;
}

.hero-sub {
    margin: 0;
    /* Never wider than the frame: the H3 wraps instead of running off. */
    max-width: 100%;
    /* A DECK, NOT A HEADING (owner decision 2026-10-26). It is an <h3> for the
       document outline, but typographically it is the line of copy under the
       headline — so it takes the body face. A display serif at this size
       competes with the headline instead of supporting it, and against a
       photograph the thin strokes go ghostly whatever the family. */
    font-family: var(--font-body);
    font-weight: 400;
    color: var(--on-dark);
    opacity: 0.85;
    text-shadow: 0 1px 12px rgba(0, 0, 0, 0.35);
    /* The deck is a sentence, not a line to be filled: greedy wrapping left its
       last word alone on the second line on a phone ("…hecho / eterno"). Balance
       splits it evenly instead. Progressive — browsers without it keep the old
       wrapping, and `max-width: 100%` still means it can never run off. */
    text-wrap: balance;
}

.hero-cta {
    margin: 0;
}

.home-hero .btn {
    background: var(--on-dark);
    color: var(--dark);
    border-color: var(--on-dark);
}

/* About: image + the owner's text. Full width on mobile, 40% beside the copy
   from 900px up (Shopify's `--size-style-width: 40%`). */
.home-about {
    display: grid;
    gap: var(--space-5);
    margin-top: var(--space-6);
}

.home-about-media {
    overflow: hidden;
    /* 20px comes from the original Shopify design. */
    border-radius: 20px;
}

.home-about-media img {
    width: 100%;
    /* --ratio 0.8396887159533074 == 1079 / 1285 */
    aspect-ratio: 1079 / 1285;
    object-fit: cover;
}

/* This is the "about the atelier" copy people actually sit down and read, so it
   gets body-copy treatment rather than the card UI treatment: a comfortable
   ~65ch measure, a size up from the product cards, and looser leading. Shopify's
   original used 1.5rem with a loose line-height and text-wrap: balance. */
.home-about-text {
    max-width: 65ch;
    font-size: 1.0625rem;
    line-height: 1.7;
    text-wrap: pretty;
}

.home-about-text p {
    max-width: none;
    margin: 0 0 var(--space-3);
}

.home-about-lead {
    font-size: 1.125rem;
}

/* The owner's emphasis: weight, not colour — it should read as emphasis without
   shouting. */
.home-about-text strong {
    font-weight: 700;
}

.home-section {
    margin-top: var(--space-6);
}

/* ── 10b. SOBRE TELURIA ───────────────────────────────────────────────── */

/* The atelier page: one long read, one photograph. The image sits below the
   text on mobile, and becomes a full-cover backdrop on tablet/desktop. */

.sobre {
    position: relative;
    overflow: hidden;
    padding: 0;
    display: flex;
    flex-direction: column-reverse;
}

/* On mobile the image is in normal flow below the content. */
.sobre-media {
    margin: 0;
    line-height: 0;
}

.sobre-media img {
    width: 100%;
    height: auto;
    display: block;
}

/* Translucent white background so the photo shows through faintly
   behind the text while keeping the prose readable. */
.sobre-content {
    position: relative;
    z-index: 2;
    background: rgba(255,255,255,0.95);
    padding: var(--space-5);
    corner-shape: scoop;
    border-radius: 16px;
}

/* The page's <h1>. The masthead's sr-only wordmark is suppressed on this page
   (see `suppressMastheadH1`), so this is the single h1 in the document. */
.sobre-title {
    font-size: clamp(1.75rem, 1.5rem + 1.5vw, 2.5rem);
    margin: 0 0 var(--space-5);
}

/* The measure — set from MEASURED line lengths, not from the nominal `ch`
   unit.

   `ch` is the advance of "0", which in Inter is wider than the average Spanish
   letter, so the `ch` number always over-promises. Counting real characters
   per rendered line (Range rects per character, scripts/verify-sobre-render.py):
   65ch → up to 83 chars/line, 56ch → up to 83 as well (the box was already
   pinned at the container width), 43ch → 65 (measured max, 768px and up).

   43ch is the value that actually lands inside the 60–70 character band for
   this Spanish prose at every breakpoint ≥ 600px; below that the viewport, not
   this rule, sets the measure (390px viewport → 51 chars/line, which is the
   narrowest a phone can offer and still readable).

   Owner set 60ch (2026-10-09) — wider than the earlier 43ch, still below
   .home-about-text's 65ch; this page is the one read end to end. */
.sobre-text {
    max-width: 60ch;
    font-size: 1.0625rem;
    line-height: 1.75;
    text-wrap: pretty;
}

.sobre-text p {
    max-width: none;   /* the measure is set once on .sobre-text */
    margin: 0 0 var(--space-3);
}

/* The lead paragraph — the owner's "Teluria no nació como marca…" opening.
   Weight and size, not colour: same rule as the home page's about block. */
.sobre-lead {
    font-size: 1.1875rem;
    font-weight: 700;
    letter-spacing: -0.01em;
}

.sobre-text strong {
    font-weight: 700;
}

/* The blockquote in the source is the author's aside — the hand-made,
   no-serial statement. Hairline left rule only: no fill, no card, no border
   box, matching the page's flat treatment. */
.sobre-aside {
    margin: var(--space-4) 0;
    padding-left: var(--space-3);
    border-left: var(--hairline);
}

.sobre-aside p {
    margin: 0;
}

/* The closing statement. Kept as its own block with a little more air above it
   because it is the page's last word, and the author's own <br> breaks inside
   it are preserved (the line-level composition is deliberate). */
.sobre-closure {
    margin-top: var(--space-5);
}

/* ── 11. 404 ── */

.notfound {
    text-align: center;
    padding: var(--space-6) 0;
}

.notfound h1 {
    font-size: clamp(3rem, 2rem + 6vw, 5rem);
    margin-bottom: var(--space-3);
}

.notfound-msg {
    margin: 0 auto var(--space-4);
    color: var(--muted);
}

.notfound-actions {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-2);
    justify-content: center;
    margin: 0;
}

/* ── 12. FOOTER ── */

.footer {
    border-top: var(--hairline);
    padding: var(--space-4) var(--space-3);
    font-size: 0.85rem;
    color: var(--muted);
    text-align: center;
}

.footer p {
    margin: 0;
    max-width: none;
}

.footer a {
    color: inherit;
}

/* ── Tracking preference page (/tracking/) ── */

.tracking-page {
    max-width: 44rem;
    margin: 0 auto;
    padding: var(--space-5) 0;
}

.tracking-toggle {
    margin: var(--space-4) 0;
    padding: var(--space-4);
    border: var(--hairline);
    border-radius: var(--radius);
}

.tracking-status {
    margin-bottom: var(--space-3);
}

.tracking-message {
    margin-top: var(--space-3);
    font-size: 0.9rem;
    color: var(--muted);
}

/* ── 13. CART PANEL + CHECKOUT ──
   Ported from the reference implementation's cart tail, restyled in Teluria's
   neutral tokens only: no brand colour is invented, the chrome is the same 1px
   hairline and 2px radius as the catalog, and the primary action is the same
   solid --dark button as everywhere else. Mobile-first: the panel is a
   full-height sheet at every width; it only becomes a side drawer ≥ 600px. */

.cart-link {
    display: inline-flex;
    align-items: center;
    gap: 0.3rem;
    text-decoration: none;
    color: inherit;
}

.cart-icon {
    width: 20px;
    height: 20px;
}

.cart-count {
    font-size: 0.8rem;
    font-variant-numeric: tabular-nums;
    color: var(--muted);
    min-width: 1ch;
}

.cart-overlay {
    position: fixed;
    inset: 0;
    background: rgba(26, 26, 26, 0.4);
    z-index: 30;
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.25s;
}

.cart-overlay.open {
    opacity: 1;
    pointer-events: auto;
}

/* Lock page scroll behind the open panel. */
body.cart-open {
    overflow: hidden;
}

.cart-panel {
    position: fixed;
    top: 0;
    right: 0;
    bottom: 0;
    width: 100%;
    background: var(--card);
    border-left: var(--hairline);
    z-index: 31;
    display: flex;
    flex-direction: column;
    /* Off-canvas AND invisible to assistive tech / tab order until opened. */
    visibility: hidden;
    transform: translateX(100%);
    transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), visibility 0s linear 0.3s;
}

.cart-panel.open {
    visibility: visible;
    transform: translateX(0);
    transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), visibility 0s;
}

.cart-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: var(--space-2) var(--space-3);
    border-bottom: var(--hairline);
    background: var(--sub);
}

.cart-header h2 {
    margin: 0;
    font-size: 1.05rem;
}

.cart-close {
    background: none;
    border: none;
    color: var(--text);
    font-size: 1.5rem;
    line-height: 1;
    cursor: pointer;
    padding: 0 0.2rem;
}

/* Transient status line (added / refused). `role="status"` so the refusal is
   announced, not only shown. */
.cart-flash {
    margin: 0;
    padding: var(--space-2) var(--space-3);
    font-size: 0.9rem;
    background: var(--sub);
    border-bottom: var(--hairline);
}

.cart-flash--warn {
    border-left: 2px solid var(--text);
}

.cart-flash[hidden] {
    display: none;
}

.cart-scroll {
    flex: 1 1 auto;
    overflow-y: auto;
    display: flex;
    flex-direction: column;
}

.cart-items {
    padding: var(--space-2) var(--space-3);
}

.cart-empty {
    text-align: center;
    color: var(--muted);
    padding: var(--space-5) 0;
}

/* A sold-out line was removed from the cart — say which one, never do it
   silently. */
.cart-notice {
    font-size: 0.85rem;
    color: var(--muted);
    border-left: 2px solid var(--line);
    padding-left: var(--space-2);
    margin: 0 0 var(--space-3);
}

.cart-item {
    /* Flex row: qty stepper | 48px thumb | info (flex 1). The stepper is
       first in the flow so it reads as a badge flush with the thumbnail. */
    display: flex;
    align-items: center;
    gap: var(--space-2);
    padding: var(--space-2) 0;
    border-bottom: var(--hairline);
}

.cart-item-img {
    width: 48px;
    height: 48px;
    flex: 0 0 48px;
    object-fit: cover;
    border-radius: var(--radius);
    border: var(--hairline);
}

.cart-item-info {
    min-width: 0;
    flex: 1 1 auto;
}

.cart-item-name {
    font-size: 0.95rem;
    line-height: 1.3;
}

.cart-item-meta {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--space-2);
    font-size: 0.82rem;
    color: var(--muted);
}

.cart-item-variant {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.cart-item-price {
    white-space: nowrap;
    font-variant-numeric: tabular-nums;
}

.cart-item-qty {
    display: inline-flex;
    align-items: center;
    flex: 0 0 auto;
    height: 48px;
    background: var(--sub);
    border: var(--hairline);
    border-radius: var(--radius);
    font-size: 0.8rem;
}

.cart-item-qty button {
    background: transparent;
    border: none;
    color: var(--text);
    width: 26px;
    height: 100%;
    cursor: pointer;
    font-size: 1.2rem;
    line-height: 1;
    display: flex;
    align-items: center;
    justify-content: center;
}

.cart-item-qty button:hover:not(:disabled) {
    background: var(--line);
}

/* Every piece is a one-off: the + button is rendered disabled at the cap, not
   hidden, so the control still reads as a stepper and the ceiling is visible. */
.cart-item-qty button:disabled {
    color: var(--line);
    cursor: not-allowed;
}

.cart-item-qty span {
    min-width: 1.4rem;
    text-align: center;
    font-size: 0.85rem;
    font-variant-numeric: tabular-nums;
}

/* Review step: strip the stepper chrome and read the count as "× N". */
.cart-items.items--review .cart-item-qty {
    height: auto;
    background: transparent;
    border: none;
}

.cart-items.items--review .cart-item-qty button {
    display: none;
}

.cart-items.items--review .cart-item-qty span::after {
    content: " ×";
    color: var(--muted);
    font-size: 0.9em;
}

.cart-totals {
    margin-top: var(--space-3);
    font-size: 0.9rem;
}

/* "Pendiente" is a STATE, not a price: the shipping amount is not known until
   the customer says where the order goes (a store pickup, or their region).
   Muted, so it never reads like a figure that was quoted. */
.cart-totals .pending {
    color: var(--muted);
}

.cart-footer {
    padding: var(--space-3);
    border-top: var(--hairline);
    background: var(--bg);
}

.cart-footer.hidden {
    display: none;
}

.cart-checkout {
    width: 100%;
    padding: 0.85rem;
}

.cart-checkout[hidden] {
    display: none;
}

/* Shared 2-column money/detail rows: Subtotal, Envío, Total, and the contact
   fields on the review step. Label column wraps, value stays on one line. */
.row {
    display: grid;
    grid-template-columns: 1fr auto;
    gap: var(--space-2);
    align-items: baseline;
    margin: 0.3rem 0;
}

.row > :last-child {
    text-align: right;
}

.row .row-label {
    color: var(--muted);
}

.row.total {
    font-weight: 700;
    border-top: var(--hairline);
    margin-top: 0.6rem;
    padding-top: 0.5rem;
}

/* Checkout form: a full-bleed band inside the panel, each step carrying its
   own padding. */
.checkout-section {
    display: none;
}

.checkout-section.open {
    display: flex;
    flex-direction: column;
    animation: cartFadeIn 0.25s ease;
}

.checkout-section form {
    display: flex;
    flex-direction: column;
    flex: 1;
    background: var(--sub);
    border-top: var(--hairline);
}

.checkout-step {
    display: none;
    padding: var(--space-3);
}

.checkout-step.active {
    display: block;
}

.checkout-step h3 {
    font-size: 1.05rem;
    margin-bottom: var(--space-2);
}

.checkout-section .form-field {
    margin-bottom: var(--space-3);
}

.checkout-section .form-field label {
    display: block;
    margin-bottom: var(--space-1);
    font-size: 0.9rem;
    font-weight: 600;
}

.checkout-section .form-field .required {
    color: var(--muted);
}

.checkout-section .form-field input,
.checkout-section .form-field textarea,
.checkout-section .form-field select {
    width: 100%;
    background: var(--card);
    color: var(--text);
    border: var(--hairline);
    border-radius: var(--radius);
    padding: var(--space-2) 0.7rem;
    font-family: inherit;
    font-size: 1rem;   /* ≥16px so iOS does not zoom on focus */
}

.checkout-section .form-field textarea {
    resize: vertical;
    min-height: 60px;
}

/* The note under the delivery method: why the store pickup is not on offer
   for this cart. Muted and small — it explains a rule, it does not sell. */
.checkout-section .form-field .form-hint {
    margin: var(--space-2) 0 0;
    font-size: 0.85rem;
    line-height: 1.5;
    color: var(--muted);
}

.checkout-section .form-field input:focus,
.checkout-section .form-field textarea:focus,
.checkout-section .form-field select:focus {
    outline: none;
    border-color: var(--text);
}

.checkout-actions {
    display: flex;
    gap: var(--space-2);
    margin-top: var(--space-3);
}

.checkout-actions .btn-back {
    flex: 0 0 35%;
}

.checkout-actions button:not(.btn-back) {
    flex: 1;
}

.checkout-review {
    margin-top: var(--space-2);
}

.review-contact {
    margin-top: var(--space-2);
}

.review-contact-title {
    font-weight: 600;
    margin: 0 0 var(--space-2);
}

@media (prefers-reduced-motion: reduce) {
    .cart-panel,
    .cart-overlay {
        transition: none;
    }
}

@keyframes cartFadeIn {
    from {
        opacity: 0;
        transform: translateY(8px);
    }
    to {
        opacity: 1;
        transform: none;
    }
}

/* Add-to-cart on a product page. */
.product-actions {
    margin-bottom: var(--space-3);
}

.add-to-cart {
    width: 100%;
    padding: 0.85rem 1.2rem;
    font-size: 1rem;
}

/* Sold-out: stated, never hidden. Muted, outlined, and clearly not actionable. */
.add-to-cart.is-unavailable {
    background: transparent;
    color: var(--muted);
    border-color: var(--line);
    cursor: not-allowed;
    opacity: 1;
}

/* Catalog cards carry NO buy control (owner decision 2026-10-19): the card is a
   single navigational link, and the "No disponible" badge states the sold case. */

.card-item {
    display: flex;
    flex-direction: column;
}

.card-item .card-link {
    flex: 1 1 auto;
}

/* ── 13b. PAYMENT OUTCOME PAGES ────────────────────────────────────────────
   /gracias/ (a genuinely paid return) and /pago-no-completado/ (pending,
   rejected, cancelled, invalid or unverifiable). Both are short, centred
   reading pages: one h1, a short reassurance paragraph, one CTA. The reference
   is muted so it never competes with the copy. */

.order-page {
    padding: var(--space-6) 0;
}

.order-page .page-content {
    max-width: 44rem;
    margin: 0 auto;
    text-align: center;
}

.order-page h1 {
    margin-bottom: var(--space-3);
}

.order-page h2 {
    margin-top: var(--space-5);
    font-size: 1.15rem;
}

.order-page p {
    margin-bottom: var(--space-3);
}

.thanks-ref {
    color: var(--muted);
    font-size: 0.95rem;
}

.thanks-cta {
    margin-top: var(--space-4);
}

/* ── 14. Tablet ≥ 600px ── */

@media (min-width: 600px) {
    .catalog-grid {
        grid-template-columns: repeat(3, minmax(0, 1fr));
    }

    .container {
        padding: var(--space-5) var(--space-4) var(--space-6);
    }

    .container--bleed {
        padding: 0;
    }

    .sobre {
        padding: var(--space-5) var(--space-4) var(--space-6);
        min-height: 70vh;
        display: block;
    }

    /* On tablet/desktop the image becomes a full-cover backdrop. */
    .sobre-media {
        position: absolute;
        inset: 0;
        margin: 0;
        z-index: 0;
        line-height: normal;
    }

    .sobre-media img {
        width: 100%;
        height: 100%;
        object-fit: cover;
        aspect-ratio: auto;
    }

    /* The hero's negative top margin cancels this breakpoint's container top
       padding, so the hero starts flush under the header and the height it
       subtracts (`100svh - --header-h`) lands its bottom edge on the viewport
       bottom. */
    .home-hero {
        margin-top: calc(-1 * var(--space-5));
    }

    .product-nav {
        grid-template-columns: 1fr 1fr;
    }

    /* ≥600px the sheet becomes a side drawer: it must never cover the whole
       viewport, or the customer loses the catalog they are shopping. */
    .cart-panel {
        width: 34vw;
        min-width: 22rem;
        max-width: 30rem;
    }

    .cart-item-qty {
        height: 48px;
    }
}

/* ── 15. DESKTOP ≥ 900px ── */

@media (min-width: 900px) {
    .catalog-grid {
        grid-template-columns: repeat(4, minmax(0, 1fr));
    }

    /* Gallery left, details right, details sticky so price and stock stay
       visible while scrolling a long gallery. */
    .product-main {
        grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
        gap: var(--space-6);
        align-items: start;
    }

    .details {
        position: sticky;
        top: var(--space-4);
    }

    /* Hero breaks out of the 1200px container to the full viewport width. The
       shorthand must carry this breakpoint's container top padding
       (`--space-5`) in its margin-top — a `space-4` there leaves the bottom
       edge 16px below the fold. */
    .home-hero {
        margin: calc(-1 * var(--space-5)) calc(50% - 50vw) 0;
    }

    .hero-body {
        padding: var(--space-6) var(--space-4);
    }

    /* 40% image / 60% copy, matching Shopify's --size-style-width: 40%. */
    .home-about {
        grid-template-columns: 40% minmax(0, 1fr);
        align-items: center;
        gap: var(--space-6);
    }

    .hero-title {
        font-size: clamp(3.2rem, 1rem + 4.6vw, 5.2rem);
    }

    .hero-sub {
        font-size: 1.2rem;
    }

    /* About page: text column on the right with a translucent white panel
       over the backdrop image. Flex right-alignment pushes the content
       to the right side. */
    .sobre {
        display: flex;
        flex-direction: column;
        align-items: flex-end;
    }
}

/* ── 16. ENCARGO MODAL — "quiero una pieza como esta" ──────────────────────
   A sold-out product page states the fact and then offers the way out: the
   shop can make a piece in the same line. The note keeps its hairline rule,
   which now spans BOTH sentences (the wrapper draws it; the paragraph inside
   drops its own so the line does not restart mid-thought), and "escríbenos"
   is a button that reads as a link, because it opens a dialog rather than
   going anywhere.

   The modal is a native <dialog>: ::backdrop and centring come from the UA, so
   there is no overlay div, no z-index ladder and no focus-trap code. Mobile
   first — one column, `min(92vw, 30rem)` on every screen, so it never needs a
   breakpoint. */

.product-soldout {
    display: flex;
    flex-direction: column;
    /* One thought, two sentences: the paragraphs must not keep their own
       margins, or the gap inside the note is larger than the gap from the
       control above it and it reads as two unrelated blocks. */
    gap: 0.25rem;
    border-left: 2px solid var(--line);
    padding-left: var(--space-3);
}

.product-soldout p {
    margin: 0;
}

/* Inside the wrapper the rule belongs to the wrapper, not to each sentence. */
.product-soldout .product-note {
    border-left: 0;
    padding-left: 0;
}

.product-note-extra {
    font-size: 0.95rem;
    color: var(--muted);
    line-height: 1.5;
}

/* A button, styled as the sentence it lives in. */
.encargo-link {
    display: inline;
    padding: 0;
    margin: 0;
    border: 0;
    background: none;
    font: inherit;
    color: var(--text);
    text-decoration: underline;
    text-underline-offset: 2px;
    cursor: pointer;
}

.encargo-link:hover {
    text-decoration-thickness: 2px;
}

.encargo-link:focus-visible {
    outline: 2px solid var(--text);
    outline-offset: 2px;
}

/* The page behind an open modal does not scroll. */
html.encargo-open,
html.encargo-open body {
    overflow: hidden;
}

.encargo-modal {
    width: min(92vw, 30rem);
    max-height: 88svh;
    overflow-y: auto;
    padding: var(--space-4);
    border: var(--hairline);
    border-radius: var(--radius);
    background: var(--bg);
    color: var(--text);
    box-shadow: 0 24px 60px rgba(0, 0, 0, 0.18);
}

.encargo-modal::backdrop {
    background: rgba(0, 0, 0, 0.5);
}

.encargo-modal[open] {
    animation: encargoIn 0.22s ease-out;
}

@keyframes encargoIn {
    from {
        opacity: 0;
        transform: translateY(8px);
    }
    to {
        opacity: 1;
        transform: none;
    }
}

.encargo-head {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: var(--space-3);
    margin-bottom: var(--space-3);
}

.encargo-title {
    margin: 0;
    font-family: var(--font-display);
    font-size: 1.35rem;
    line-height: 1.25;
}

.encargo-close {
    flex: 0 0 auto;
    padding: 0;
    border: 0;
    background: none;
    font-family: var(--font-body);
    font-size: 1.6rem;
    line-height: 1;
    color: var(--muted);
    cursor: pointer;
}

.encargo-close:hover {
    color: var(--text);
}

.encargo-close:focus-visible {
    outline: 2px solid var(--text);
    outline-offset: 2px;
}

.encargo-lead {
    margin: 0 0 var(--space-3);
    font-size: 0.95rem;
    line-height: 1.55;
    color: var(--muted);
}

.encargo-field {
    display: flex;
    flex-direction: column;
    gap: 0.3rem;
    margin-bottom: var(--space-3);
}

.encargo-field label {
    font-size: 0.85rem;
    color: var(--muted);
}

.encargo-field input {
    width: 100%;
    padding: 0.7rem 0.8rem;
    border: var(--hairline);
    border-radius: var(--radius);
    background: var(--bg);
    color: var(--text);
    font-family: var(--font-body);
    /* 16px: anything smaller makes iOS zoom the page when the field focuses. */
    font-size: 1rem;
}

.encargo-field input:focus {
    outline: 2px solid var(--text);
    outline-offset: 1px;
}

/* Honeypot: removed from the visual flow and from the accessibility tree, but
   still reachable by a form-filler that ignores CSS. Never `display: none` —
   some bots skip hidden inputs. */
.encargo-hp {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip: rect(0 0 0 0);
    clip-path: inset(50%);
    white-space: nowrap;
}

.encargo-status {
    min-height: 1.25rem;
    margin: 0 0 var(--space-3);
    font-size: 0.9rem;
    line-height: 1.45;
    color: var(--text);
}

.encargo-status:empty {
    margin-bottom: var(--space-2);
}

.encargo-submit {
    width: 100%;
}

.encargo-alt,
.encargo-done p {
    margin: var(--space-3) 0 0;
    font-size: 0.9rem;
    line-height: 1.5;
    color: var(--muted);
}

.encargo-alt a,
.encargo-done a {
    color: var(--text);
}

.encargo-done {
    font-size: 0.95rem;
}

.encargo-done p:first-child {
    margin-top: 0;
    color: var(--text);
}

/* `hidden` must win over the display values above. */
.encargo-form[hidden],
.encargo-done[hidden] {
    display: none;
}

@media (prefers-reduced-motion: reduce) {
    .encargo-modal[open] {
        animation: none;
    }
}
