/*
 * assets/styles/tokens.css — the single stylesheet this Worker serves
 * (task 4.8; design.md Decision 7, Route/state contract accessibility
 * paragraph, and the Responsive contract).
 *
 * ---------------------------------------------------------------------------
 * PATH
 * ---------------------------------------------------------------------------
 * design.md Decision 7 and tasks.md 4.8 both name this file
 * `web/src/http/styles/tokens.css`. It lives at
 * `web/public/assets/styles/tokens.css` instead, because that is the only
 * place it can be served from: wrangler.jsonc's `assets.directory` is
 * `./public`, so a file under `src/` is never uploaded and never reachable at
 * the `/assets/styles/tokens.css` URL http/templates/layout.ts links to.
 * Keeping a second copy under `src/` was rejected outright — two copies of a
 * token set are two places for it to drift, which is precisely the failure
 * this task's own acceptance criterion (a structural diff against design.md)
 * exists to catch.
 *
 * ---------------------------------------------------------------------------
 * THE `:root` BLOCK IS A MECHANICAL COPY, NOT A TRANSCRIPTION
 * ---------------------------------------------------------------------------
 * Decision 7 states that the complete token set is written out in design.md
 * "so apply is pure transcription". The block below was EXTRACTED from that
 * fenced code block programmatically and inserted here byte-for-byte —
 * including its comments, its two-declarations-per-line spacing rows, and its
 * trailing-comment placement. Nothing in it was retyped, reordered,
 * reformatted, or "tidied", so hand-transcription drift is impossible by
 * construction rather than by care. `test/unit/http/tokens-css.test.ts`
 * re-derives both sides and fails on any divergence, so it stays that way.
 *
 * ---------------------------------------------------------------------------
 * FONTS: SELF-HOSTED, NO THIRD-PARTY REQUEST (task 4.9, DEC-133)
 * ---------------------------------------------------------------------------
 * Decision 7 requires Exo 2 self-hosted as a subsetted woff2. The owner
 * authorised the download and the build toolchain INV-19 had reserved, so the
 * face is now declared below and the file ships in this repository under
 * `web/public/assets/fonts/`, served through the Worker's `/assets/*` route
 * with the Route/state contract's immutable-cache and security headers.
 *
 * The `@font-face` src is a ROOT-RELATIVE path. There is no `@import`, no
 * absolute `https:` URL and no protocol-relative `//host` reference anywhere
 * in this file, which is what keeps INV-8 and Decision 7's "zero third-party
 * font/icon network request" true.
 *
 * `--font-display`/`--font-body` are unchanged and still carry Decision 7's
 * exact declared value, `'Exo 2', system-ui, sans-serif`. No token value moved
 * for this change; the face simply started resolving. `system-ui` remains the
 * fallback that paints while the font loads and that covers any character
 * outside the subset's `unicode-range`.
 *
 * The artifact is reproducible: `scripts/build-exo2-subset.mjs` pins the
 * upstream commit, the source digest, every subsetting flag and the resulting
 * digest, and refuses to run when any of them disagree. The SIL OFL 1.1 text
 * ships beside the binary at `/assets/fonts/OFL.txt`, as the licence requires.
 *
 * ---------------------------------------------------------------------------
 * SCOPE OF THE RULES BELOW THE TOKENS
 * ---------------------------------------------------------------------------
 * Every class defined below is one the task 4.7 templates actually render;
 * the set is closed and is pinned by `test/unit/http/tokens-css.test.ts`
 * against a list typed independently into that test. This file defines no
 * class no template uses, and no template class is left undefined.
 */

/* ===========================================================================
 * EXO 2 — ONE VARIABLE FACE (task 4.9, issue #131, DEC-133)
 *
 * ONE FILE, NOT FOUR. The rules below consume four weights (400, 600, 700,
 * 900). Four static subsets would cost roughly 60-100 KB across four requests;
 * this variable subset is 26 612 bytes in one, and every browser in charter
 * §4.6's support matrix has shipped variable-font support since 2018.
 *
 * `font-weight: 400 900` is the RANGE the shipped file actually carries, not a
 * wish: the `wght` axis was restricted to 400-900 before subsetting, which
 * saved 9 736 bytes against keeping the untouched 100-900 axis. The cost is
 * recorded rather than hidden — `--fw-light` (300) is declared in the token
 * set and consumed by zero rules; a rule that started consuming it would
 * render clamped to 400 until the axis is widened and the artifact rebuilt.
 * The descriptor above is not taken on trust: the stylesheet's test reads the
 * `wght` axis out of the woff2's own `fvar` table and requires this line to
 * equal it exactly, then checks every `--fw-*` the rules consume against THAT
 * axis rather than against this line. Widening the descriptor alone therefore
 * buys nothing, which is what makes the clamp impossible to introduce quietly.
 *
 * `unicode-range` IS NOT DERIVED FROM THE TEMPLATES. Product names, part codes
 * and category names come from D1 at run time, so a subset built from today's
 * markup stops covering the catalog the moment real data lands. What that
 * costs is worth stating precisely, because it is NOT tofu: Chromium's font
 * matching falls through PER CHARACTER, so a glyph missing from the subset is
 * drawn from the next family in the stack instead. Measured here with a
 * digits-only build of this same file: `7` rendered from Exo 2 while `a`, `á`
 * and `ñ` rendered at the fallback's exact advance widths, with no `.notdef`
 * box anywhere. The real defect is a MIXED TYPEFACE INSIDE ONE WORD — a part
 * code in Exo 2 beside a product name in the system face — which is a brand
 * failure on owner content, not a legibility failure, and is correspondingly
 * easy to miss in review. The range below is a general Spanish-Peru range —
 * Basic Latin, the whole Latin-1 Supplement (every accented letter in both
 * cases, plus ¿ ¡ ° º ª « » §), and the six General Punctuation marks the
 * approved copy uses. It is a SUPERSET of what the templates contain, which is
 * the direction that stays correct as the catalog grows.
 *
 * `font-display: swap` — text is readable from the first paint in `system-ui`
 * and is repainted in Exo 2 when the file arrives, so the font can never hide
 * content. The alternative considered was `optional`, which guarantees zero
 * layout shift by refusing a late font outright; it was rejected because it
 * would leave most first-time mobile visitors on the fallback face entirely,
 * which defeats the reason for self-hosting the brand typeface. The swap's
 * cost was measured rather than assumed: see DEC-133.
 * ======================================================================== */

@font-face {
  font-family: 'Exo 2';
  src: url("/assets/fonts/exo2-variable-latin.woff2") format("woff2");
  font-weight: 400 900;
  font-style: normal;
  font-display: swap;
  unicode-range: U+0020-007E, U+00A0-00FF, U+2013-2014, U+2018-2019, U+201C-201D, U+2026;
}

:root {
  /* Color — navy ramp, calibrated on dark background #001430, hue ~213deg */
  --navy-950: #001430;
  --navy-900: #001A3D;
  --navy-800: #01224b;
  --navy-700: #0A3266;
  --navy-600: #144684;
  --navy-300: #8FA6C4;
  --navy-100: #E3E9F2;
  /* Color — signal red; hover/darken pair */
  --red-600: #E30613;
  --red-700: #B80510;
  /* Color — neutrals */
  --gray-100: #F2F4F7;
  --white: #FFFFFF;
  --gray-300: #D4DAE3;
  --gray-500: #7E8899;
  --gray-700: #3F4A5C;
  /* Color — semantic layer (reference-defined) */
  --text-heading: var(--navy-950);
  --text-body: var(--gray-700);
  --text-inverse: var(--white);
  --text-accent: var(--red-600);
  --text-muted: var(--gray-500);
  --surface-page: var(--white);
  --surface-subtle: var(--gray-100);
  --surface-dark: var(--navy-950);
  --surface-dark-raised: var(--navy-900);
  --surface-card: var(--white);
  --surface-brand: var(--navy-800);
  --border-default: var(--gray-300);  /* card border: 1px solid var(--border-default) */
  --border-strong: var(--navy-700);
  --border-on-dark: rgba(255, 255, 255, .14);
  --action-primary: var(--red-600);
  --action-primary-hover: var(--red-700);
  --action-secondary: var(--navy-800);
  --action-secondary-hover: var(--navy-700);
  --focus-ring: 0 0 0 3px rgba(1, 34, 75, .35);
  /* Color — status */
  --ok: #1E8E3E;
  --warn: #B26A00;
  --danger: var(--red-600);
  /* Typography — Exo 2, self-hosted; weights and scale per reference */
  --font-display: 'Exo 2', system-ui, sans-serif;
  --font-body: 'Exo 2', system-ui, sans-serif;
  --fw-light: 300;
  --fw-regular: 400;
  --fw-semibold: 600;
  --fw-bold: 700;
  --fw-black: 900;
  --fs-display: 48px;
  --fs-h1: 34px;
  --fs-h2: 26px;
  --fs-h3: 20px;
  --fs-body: 16px;
  --fs-small: 14px;
  --fs-caption: 12px;
  --lh-tight: 1.1;
  --lh-heading: 1.2;
  --lh-body: 1.55;
  --ls-caps: .14em; /* ALL-CAPS labels/slogans (reference value) */
  --ls-wide: .06em;
  /* Spacing — reference: 4px base step */
  --sp-1: 4px;  --sp-2: 8px;  --sp-3: 12px;  --sp-4: 16px;
  --sp-5: 24px; --sp-6: 32px; --sp-7: 48px;  --sp-8: 64px;
  /* Radii — reference: controls 4-8px, cards 14px, pills for badges only */
  --radius-sm: 4px;
  --radius-md: 8px;
  --radius-lg: 14px;
  --radius-pill: 999px;
  /* Elevation */
  --shadow-card: 0 2px 8px rgba(0, 20, 48, .10);
  --shadow-raised: 0 8px 24px rgba(0, 20, 48, .16);
  /* Motion — reference: 120-200ms, slide/fade from left, no bounces */
  --speed-fast: 120ms;
  --speed-base: 200ms;
  --ease-brand: cubic-bezier(.2, .7, .3, 1);
  --skew: -14deg;
  /* Interaction — this design's own decision, absent from the reference's
     token files (DEC-131) */
  --press-scale: 0.98;
}

/* ===========================================================================
 * USAGE RULES (Decision 7, "binding on templates/CSS")
 *
 *   headings         Bold/Black, often uppercase with --ls-caps
 *   hover            darkens: --red-600 -> --red-700, navy -> --navy-700
 *   press            scales to --press-scale
 *   red accent       at most ONE per element cluster
 *   page background  white or --gray-100
 *   dark sections    --navy-950 / --navy-800
 *   motion           slide/fade FROM THE LEFT only, never a bounce, always
 *                    within --speed-fast/--speed-base and --ease-brand, and
 *                    fully disabled under prefers-reduced-motion
 * ======================================================================== */

/* --- Reset kept to the minimum the templates need ----------------------- */

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

/* THE ONE PIECE OF THE FULL-BLEED MECHANISM THAT CANNOT LIVE ON `.band`.
 *
 * Landing 5a is a stack of bands whose BACKGROUNDS reach the viewport edges
 * while their CONTENT stays on a 1060px measure. `.band` (below) escapes the
 * content measure with `margin-inline: calc(50% - 50vw)`, which is the only
 * formula available: percentages resolve against the containing block, so a
 * child cannot ask for "the viewport" any other way.
 *
 * `50vw` is half the viewport INCLUDING the classic scrollbar, while the
 * layout width excludes it, so every full-bleed band is exactly one
 * scrollbar-width too wide — a real horizontal scrollbar on every desktop
 * page, measured at 15px before this rule existed. `clip` (not `hidden`)
 * removes that overflow without turning the root into a scroll container,
 * and — unlike `hidden` — it is defined as a non-scrollable clip, so nothing
 * about programmatic or anchor scrolling changes.
 *
 * `position: fixed` is unaffected: the floating WhatsApp affordance's
 * containing block is the viewport, not the root element's box, so root
 * clipping cannot reach it. Verified in a browser at 390/1024/1280 px. */
html {
  overflow-x: clip;
}

/* --- Document ----------------------------------------------------------- */

.page {
  margin: 0;
  background-color: var(--surface-page);
  color: var(--text-body);
  font-family: var(--font-body);
  font-size: var(--fs-body);
  font-weight: var(--fw-regular);
  line-height: var(--lh-body);
  /* Opt out of the mobile-Safari text-size adjustment that would otherwise
     override the type scale on rotation. */
  -webkit-text-size-adjust: 100%;
}

/* Headings: Bold/Black per the usage rule. Uppercase + --ls-caps is applied
   to the label-like headings (section and handoff titles), not to the
   headings that carry product and category NAMES — uppercasing a raw approved
   product or category name would render owner content in a form the owner did
   not approve. The split is by what a heading CONTAINS, not by its level:
   home's `h1` is `.category-section__title`, since `Categorías` is interface
   copy. `.page-title` went with its last emitter, home's old `h1`, which
   repeated the brand link directly above it. */
/* `.category-page__title` LEFT THIS LIST in adopt-results-view: the category
   route renders the shared results composition now, whose `<h1>` sits on the
   navy header band and carries its own rule. */
.product__name,
.search-state__title,
.error-page__title {
  margin: 0 0 var(--sp-4);
  color: var(--text-heading);
  font-family: var(--font-display);
  font-weight: var(--fw-black);
  font-size: var(--fs-h1);
  line-height: var(--lh-heading);
}

/* `.pd-related__title` JOINED (adopt-product-detail Slice 3, design.md §8):
   the related-products heading opens a body of content exactly like
   `.category-section__title` and `.handoff__title` do, so it takes their
   shared treatment rather than a near-duplicate rule. The physical rule
   under it is `renderBandHeading`'s own shipped `.band-heading__rule`
   (`subrayado-titulo-l3.svg`), unchanged. */
.category-section__title,
.handoff__title,
.pd-related__title {
  margin: 0 0 var(--sp-4);
  color: var(--text-heading);
  font-family: var(--font-display);
  font-weight: var(--fw-bold);
  font-size: var(--fs-h2);
  line-height: var(--lh-heading);
  text-transform: uppercase;
  letter-spacing: var(--ls-caps);
}

/* --- Visible focus (Route/state contract accessibility paragraph) -------- */

/* `:focus-visible`, not `:focus`: a mouse click on a button must not leave a
   ring behind, but every keyboard arrival must show one. The ring is drawn
   twice on purpose — an `outline` that survives forced-colors/high-contrast
   mode, plus the token `--focus-ring` shadow that gives it the brand halo. */
:focus-visible {
  outline: 3px solid var(--border-strong);
  outline-offset: 2px;
  box-shadow: var(--focus-ring);
}

/* `.on-dark` (design.md §1 Decision; task 2.3): a SURFACE rule, not a
   header/footer rule. Landing 5a inverts the chrome — the header is now the
   site's one white section, and the utility bar and footer (plus, from U3,
   the pillar and CTA bands) are the dark ones — so this is applied to each
   dark BAND rather than fixed to two selectors that would silently stop
   covering a third dark section someone adds later. A navy outline is
   invisible against navy, which is what every `.on-dark` band shares. */
.on-dark :focus-visible {
  outline-color: var(--white);
}

/* --- Touch targets: the two techniques, declared once -------------------- */
/*
 * design.md §5.5 ends with "Minimum touch target 44px throughout", and task
 * 4.2 repeats it. U2/U3/U4 met it on everything they built (category row 73px,
 * featured card 82px, CTA action 49px, mode label 46px); SIX chrome controls
 * from U1 did not, and — because the chrome is on every document — they missed
 * it on every route. This section states how they were raised, so the pattern
 * is read once instead of six times.
 *
 * WHAT WAS *NOT* DONE, AND WHY. The mock is authoritative on how these
 * controls LOOK: the utility bar's 16px social glyphs, the header's 36px
 * lockup and the footer's 40px social squares are the design's specified
 * VISUAL sizes. Scaling the artwork up to reach the floor would answer an
 * ergonomics requirement by changing the design, which is the owner's call and
 * not this unit's. Only the interactive area grows.
 *
 * TECHNIQUE A — `min-height` on the control itself. Used where the extra
 * height costs no layout movement, and it is this repository's existing idiom
 * (`.footer__link`, `.site-header__cta`, `.search-form__input`). Applied to
 * `.skip-link` (absolutely positioned, so it can never move anything) and
 * `.site-nav__link` (the header row is already 44px tall because
 * `.site-header__cta` floors it there, so the nav growing into that height
 * changes nothing above 900px).
 *
 * TECHNIQUE B — an absolutely positioned `::after` hit area. Used where
 * technique A WOULD move something: growing `.site-brand` or `.footer__brand`
 * in flow pushes their neighbours down, and growing `.footer__social-link`
 * enlarges a square whose translucent background is VISIBLE artwork rather
 * than an invisible hit box. An out-of-flow `::after` leaves the element's own
 * box — and therefore the layout and the artwork — untouched while the
 * activatable region reaches the floor. Generated content belongs to its
 * originating element, so a pointer inside it activates the same link; that is
 * asserted empirically by `test/e2e/accessibility.spec.ts`'s target-size guard
 * (`document.elementFromPoint`), never assumed from these rules.
 *
 * Each `::after` uses `inset` values derived from the control's own measured
 * size, and each one is written next to the control it belongs to rather than
 * as a shared utility class: the numbers differ per control and a shared class
 * would hide which control is how far from the floor.
 */

/* --- Skip link ---------------------------------------------------------- */

/* Off-screen until focused, never `display: none` — a hidden-by-display
   element is not focusable at all, which would silently delete the skip
   link for the keyboard users it exists for.
   Technique A: measured 244.8x40.8, so only the height was short. Absolutely
   positioned, so the reserved 44px box moves nothing. */
.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  z-index: 2;
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  padding: var(--sp-2) var(--sp-4);
  background-color: var(--surface-page);
  color: var(--text-heading);
  font-weight: var(--fw-bold);
  border-radius: var(--radius-sm);
  text-decoration: underline;
}

.skip-link:focus {
  left: var(--sp-3);
  top: var(--sp-3);
}

/* --- Header (light section; design.md §1 inverts the OLD dark header) --- */
/*
 * Landing 5a's header is FOUR stacked bands, all inside the ONE
 * `<header class="site-header">` landmark (see layout.ts's `renderDocument`
 * doc comment for why): the navy utility bar, the white brand/nav/Cotizar
 * row, the gray beta notice, and — temporarily, until task 2.2 moves it to
 * the hero — a navy wrapper around the pre-existing search form. `.site-
 * header` itself is a bare positioning context; each band paints its own
 * background so the visual banding costs nothing extra structurally.
 */

.site-header {
  position: relative;
}

/* Utility bar: navy, location left, DEC-149's three social icons right
   (design.md §2 "Utility bar"; task 2.1). `.on-dark` (task 2.3) keeps the
   focus ring visible against `--surface-brand`. */
.utility-bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-3);
  padding: var(--sp-2) var(--sp-4);
  background-color: var(--surface-brand);
  color: var(--text-inverse);
}

.utility-bar__location {
  color: var(--navy-100);
  font-size: var(--fs-caption);
}

.utility-bar__social {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  margin: 0;
  padding: 0;
  list-style: none;
}

/* THE ONE CONTROL THAT DOES NOT REACH 44px, RECORDED RATHER THAN GLOSSED.
   Measured 16x16 glyphs on a 12px (`--sp-3`) pitch (28px centre-to-centre),
   inside a band whose whole RENDERED height is ~40.8px (~41). A 44px square
   target is still geometrically unavailable without a VISUAL change the mock
   does not authorise — widening the icon gap past 28px, or growing the band
   itself — neither of which is this unit's to make.
 *
 * OWNER DECISION D, 2026-08-13, SUPERSEDING U5's 24x24. The hit area now
 * uses the room that IS available without any visual change: the band's own
 * full height. Each glyph sits 10px below the bar's top edge and 14.8px
 * above its bottom edge (measured in a real browser, not assumed from the
 * padding math), so extending the hit area by exactly those amounts reaches
 * the bar's top and bottom edges precisely — no target spills outside the
 * bar. Widened 6px each side (16 + 2x6 = 28) at the unchanged 28px pitch,
 * neighbouring hit areas meet edge to edge with zero gap and no overlap.
 * The 16px glyph and the 12px visual gap between glyphs are UNCHANGED — only
 * the interactive area grows. `test/e2e/accessibility.spec.ts`'s target-size
 * guard measures the largest centred SQUARE that lands on the control, which
 * this rectangle's narrower (28px) dimension bounds; its exception table
 * carries the resulting measured floor, this exact reason, and fails if the
 * exception ever stops being load-bearing. */
.utility-bar__social-link {
  position: relative;
  display: inline-flex;
  color: var(--text-inverse);
}

/* Technique B, 28 wide (16 + 2x6) x ~40.8 tall (10px above the glyph + 16 +
   14.8px below) — the utility bar's own full rendered height. */
.utility-bar__social-link::after {
  content: "";
  position: absolute;
  inset-inline: -6px;
  inset-block-start: -10px;
  inset-block-end: -14.8px;
}

.utility-bar__social-link:hover {
  color: var(--text-accent);
}

/* The white brand/nav/Cotizar row. */
.site-header__row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-4);
  padding: var(--sp-4);
  background-color: var(--surface-page);
  border-bottom: 1px solid var(--border-default);
}

.site-brand {
  position: relative;
  display: inline-flex;
  align-items: center;
  color: var(--text-heading);
  text-decoration: none;
}

/* Technique B, 36 + 2x4 = 44. `min-height` was rejected here: the lockup is
   36px because README §2 says the header logo is 36px, and an in-flow 44px box
   around it would push the whole white row 8px taller at every width where the
   row is not already floored by `.site-header__cta`. The 4px of overflow lands
   in `.site-header__row`'s own 16px padding, so it reaches no neighbouring
   control. */
.site-brand::after {
  content: "";
  position: absolute;
  inset-block: -4px;
  inset-inline: 0;
}

/* The LIGHT-surface lockup (design.md §1.1) draws its own full wordmark, so
   — unlike the old dark isotype-only mark — nothing else inside `.site-brand`
   repeats the brand name; `alt` on the `<img>` itself is the link's whole
   accessible name (layout.ts's `BRAND_MARK_LIGHT_HREF` comment). A fixed
   36px block-size (the design's own header-logo height, README §2) plus
   `inline-size: auto` holds the shipped file's real aspect ratio; no
   negative-margin crop is needed because — unlike the old dark mark — this
   file is not padded with empty canvas around the drawing.

   The header now draws `logo-lockup.svg`, the tagline-free mark (the owner
   dropped the strapline). Losing the tagline band changed the aspect ratio
   from 451:152 to 438:90, so the same 36px draws a 175px-wide mark where it
   used to draw a 107px one. Everything at 360px and up absorbs that: at 1280
   the mark ends at x=215 against a nav starting at x=966, and at 390 it ends
   at x=191 inside a 390px viewport, with zero horizontal overflow at either.
   320px does not — see the query below. */
.site-brand__mark {
  display: block;
  block-size: 36px;
  inline-size: auto;
}

/* THE 320px HEADER IS A BUDGET, AND THE WIDER MARK SPENDS IT (owner decision,
   2026-08-13, guarded by `hero-overlay.spec.ts`'s "two rows with the nav on
   one of them"). Below 640px row 1 is `[brand ... Cotizar]` and the nav owns
   row 2. At 320px that row has 288px of content width and a 16px gap, and
   `Cotizar` measures 110.41px in Exo 2 and 113.75px in the `system-ui`
   fallback the `font-display: swap` first paint uses — so the mark may be at
   most 158.25px before the button wraps to a third row and the approved
   two-row header becomes three. The old 107px mark cleared that with 51px to
   spare; the 175px one misses it by 17px, which is a regression this change
   caused and not a pre-existing tightness.

   36px is the design's header-logo height (README §2), so it is kept wherever
   it fits and given up only where it does not: 30px yields 146px, 12px inside
   the tighter fallback-face budget, and the margin is deliberately that wide
   because the two faces measure 3.34px apart and a 2px margin is how the
   nav's own CLS defect happened (see `--font-display`'s note at the 639.98px
   query). The mark is still BIGGER than what shipped before — 27.5px of drawn
   ink at 30px, against 19.5px inside the old 36px box, because the old file
   spent its bottom third on the tagline.

   The boundary is 360px and not 340: the 36px mark needs a 337px viewport in
   the fallback face, and picking the nearest real device width above that
   leaves the step at a width people actually use rather than mid-way through
   one. Scoped to `max-width` deliberately, like the two queries U7 added — it
   must not reach the approved wider compositions. */
@media (max-width: 359.98px) {
  .site-brand__mark {
    block-size: 30px;
  }
}

.site-brand:hover {
  color: var(--text-accent);
}

/* Section nav (design.md §2 "Header"; task 2.1). A SECOND `<nav>` landmark
   coexists on the category page (`.pagination`), so this one carries its own
   `aria-label` (layout.ts's `renderSiteNav`) to satisfy axe-core's
   `landmark-unique` rule, the same reason `FOOTER_CATALOG_LINKS` is a bare
   `<ul>` rather than a second unlabelled `<nav>`. */
.site-nav {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--sp-5);
  /* The design groups the nav WITH `Cotizar` on the right, against the brand
     on the left; `.site-header__row`'s `space-between` alone would spread all
     three evenly and leave the nav stranded in the middle of the row
     (measured at x=463 on a 1280px viewport, against the mock's 690). An auto
     start margin collapses the gap before the nav and pushes both to the
     right. `flex-wrap` on the row still lets the nav drop to its own line
     below 900px, which is §5.5's "nav wraps to a second row" rule. */
  margin-inline-start: auto;
}

/* Technique A, measured 77.6x18.6 — the width already cleared the floor, so
   only the height was raised. `min-height` rather than an `::after` because
   the nav wraps to a second row below 900px (§5.5) and a vertically overflowing
   hit area would then overlap the row above it; an in-flow 44px box stacks
   with `.site-nav`'s own 24px row gap and cannot. Above 900px the row's height
   is already 44px (`.site-header__cta`), so nothing moves there. */
.site-nav__link {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  color: var(--text-heading);
  font-family: var(--font-display);
  font-weight: var(--fw-bold);
  font-size: var(--fs-caption);
  letter-spacing: var(--ls-wide);
  text-transform: uppercase;
  text-decoration: none;
}

.site-nav__link:hover {
  color: var(--text-accent);
}

/* `Cotizar` (design.md §2 "Header"; task 2.1).
   PRIMARY (red) — OWNER RULING, 2026-08-12. U1/U2 demoted both this button
   and the hero's `Ver repuestos` to the navy secondary action, on the
   archived Decision 7 usage rule "at most ONE red accent per element
   cluster" as it was then encoded: exactly one `var(--action-primary)`
   use in the whole stylesheet. The owner ruled that Landing 5a's two
   primary buttons are red, and the rule is now encoded as a DECLARED
   ALLOWLIST of five red usages rather than a count of one — see
   `tokens-css.test.ts`'s "declared red usages" block, which still fails on
   a sixth, undeclared one.
   White on `--red-600` measures 4.88:1, above the 4.5:1 floor this project
   holds itself to at any type size.
   Omitted entirely, not rendered disabled, when `DocumentOptions.
   whatsappHref` has no value — see layout.ts's `renderCotizarButton`. */
.site-header__cta {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 44px;
  padding: var(--sp-2) var(--sp-5);
  background-color: var(--action-primary);
  color: var(--text-inverse);
  font-family: var(--font-display);
  font-size: var(--fs-small);
  font-weight: var(--fw-bold);
  letter-spacing: var(--ls-wide);
  text-transform: uppercase;
  text-decoration: none;
  border-radius: var(--radius-md);
  transition:
    background-color var(--speed-fast) var(--ease-brand),
    transform var(--speed-fast) var(--ease-brand);
}

.site-header__cta:hover {
  background-color: var(--action-primary-hover);
}

/* `.beta-banner` RETIRED WHOLE (adopt-product-detail Slice 6, third review
   round, 2026-08-24): the visible beta-notice band is removed from EVERY
   route's chrome — this is site-wide chrome (`layout.ts`'s `renderDocument`),
   not scoped to the product page. Indexing prevention is UNCHANGED:
   `PUBLIC_INDEXING` still gates `noindex`/`X-Robots-Tag` independently. */

/* ===========================================================================
 * THE HEADER COLLAPSES FROM FOUR ROWS TO TWO WHERE IT CANNOT FIT ONE
 * (owner decision, 2026-08-13, U7; design.md §8.3)
 *
 * WHY THIS FILE GAINS ITS FIRST `max-width` QUERY. Every other breakpoint
 * here is mobile-first `min-width`, and that is still the right default. It
 * cannot express THIS change: the declarations below must apply on the SMALL
 * side of a boundary and nowhere else, and the properties they set (`order`,
 * `flex-basis`, `margin-inline-start`) all have values the base rules
 * already carry for the wide layout. Writing them mobile-first would mean
 * adding a reset for each one inside a `min-width` block — editing an
 * approved composition to say "unchanged". A `max-width` query leaves those
 * blocks untouched, and "untouched" is a stronger guarantee than "reset to
 * the same value".
 *
 * WHY 640px AND NOT 900px. The owner described the defect at phone width and
 * the design's own small-viewport step is <900px, but forcing two rows
 * everywhere below 900px would make the header TALLER between 640 and 899px,
 * where one row already fits and measures 77px against the two-row layout's
 * 137px. The one-row header needs 631.74px of viewport — 32px of gutter plus
 * the brand's 106.81, the nav's 350.52, `Cotizar`'s 110.41 and two 16px gaps,
 * all measured — so 640px, this file's existing step, is the first one above
 * that threshold. Between 632 and 640px the two-row layout is used where one
 * row would just fit; that is 8px of viewport and not worth a fifth
 * breakpoint.
 *
 * WHAT IT REPLACES. Measured at 360x800 the header stacked FOUR rows —
 * logo alone with empty space beside it, then `CATEGORÍAS` + `REPUESTOS
 * DESTACADOS`, then `CONTACTO` wrapped to a line of its own, then `COTIZAR`
 * on a fourth — and stood 359px tall, 45% of the viewport, before any
 * product content. It is now 2 rows: `[logo ......... COTIZAR]` over the
 * three nav items, 239px tall.
 *
 * THE `order` IS THE ONLY WAY TO GET THAT LAYOUT FROM THIS MARKUP, AND IT
 * COSTS SOMETHING. The DOM is brand -> nav -> Cotizar, which is the reading
 * order the header should have and the one the >= 900px layout also paints.
 * Putting `Cotizar` beside the logo means it must paint BEFORE the nav while
 * still coming after it in the document, so below 900px the visible order
 * and the focus order differ by one step: tabbing runs logo -> the three nav
 * links -> `Cotizar`, and that last step moves focus up-and-right to row 1.
 * The alternative — reordering the MARKUP and using `order` at >= 900px
 * instead — moves the identical mismatch onto the approved desktop
 * composition, so it is the same defect somewhere worse. The reading order
 * assistive technology gets is unchanged and is the meaningful one (brand,
 * navigation, then the call to action), which is why this is recorded as a
 * cost rather than treated as a blocker.
 * ======================================================================== */

@media (max-width: 639.98px) {
  .site-nav {
    /* After `Cotizar` visually, so row 1 is `[brand ... Cotizar]`. */
    order: 1;
    /* A full-width flex item cannot share a line, so it takes row 2 on its
       own without depending on how wide the brand and the button happen to
       be at any given viewport. */
    flex-basis: 100%;
    /* The base rule's auto start margin exists to group the nav WITH
       `Cotizar` against the brand at >= 900px. On a row of its own it has no
       free space to absorb and would only strand the items if one ever
       appeared, so it is cancelled explicitly rather than left to luck. */
    margin-inline-start: 0;
    /* The three items spread across the row they now own. `space-between`
       demotes `gap` from "the spacing" to "the MINIMUM spacing": the rendered
       separation is whatever is left over (12.75px at 360px), and `gap` only
       decides the width at which the row gives up and wraps.
       WHICH IS WHY IT IS 4px AND NOT 8 OR 24, AND THE REASON IS CLS, NOT
       TASTE. `--font-display` is served with `font-display: swap` (DEC-133,
       deliberately, so the first paint is not blank), so the labels are laid
       out TWICE: once in the fallback face and once in Exo 2. Measured at
       360px the three labels are 314.1px in the fallback and 302.5px in
       Exo 2, against 328px of row. At `gap: 8px` those two numbers straddle
       the wrap threshold — the header rendered two nav rows, then collapsed
       to one when the font arrived, shrinking 52px and shifting the whole
       page up for a measured CLS of 0.273 against a 0.1 budget
       (`test/e2e/product-media-box.spec.ts`, which pins 360x800, caught it).
       At 4px both faces fit one row at 360px and both wrap at 320px, so the
       swap changes nothing either way.
       The residual is reported rather than claimed away: a ~12px band of
       viewport widths must always exist where the two faces disagree, because
       they disagree about the labels' width by ~12px and the nav genuinely
       cannot fit one row at 320px in either face. At this gap that band is
       343-354px, which no common device width falls in — but its position
       depends on the platform's own `system-ui`, so the invariant is asserted
       at 320/360/390 in `test/e2e/hero-overlay.spec.ts` rather than left to
       hold by luck. The same band existed BEFORE this change at 385-397px
       (the old 24px gap put it there); it was never at a tested width, so
       nothing had ever looked for it. See design.md §8.3. */
    justify-content: space-between;
    gap: var(--sp-1);
  }
}

/* --- Search form -------------------------------------------------------- */

/* TASK 2.2 MOVED THIS FORM OUT OF THE HEADER. It is now the hero's "Por
   código" panel, on the card's WHITE surface, so `.search-form__label` and
   `.search-form__status` below are re-derived for that surface instead of
   carrying the old dark header's `--text-inverse`/`--navy-300` pairing.
   `.site-header__search`, the navy wrapper that used to hold the old
   pairing legible between the gray beta notice and the page, went with the
   markup — it had no other purpose. */

/* Mobile-first: stacked, full-width controls below 640 px. */
.search-form {
  display: grid;
  gap: var(--sp-2);
}

.search-form__label {
  color: var(--text-body);
  font-size: var(--fs-small);
  font-weight: var(--fw-semibold);
}

/* Technique A (see "Touch targets" above), measured 129.5x21.7 on `/buscar`
   — SCOPED TO THIS MODIFIER, not the base `.search-form__label` class. See
   `http/templates/search.ts`'s `renderSearchField` doc comment: the hero's
   copy of this same label is hidden by default (its radiogroup panel is not
   the checked one) and was therefore never a reachable target for the
   target-size guard; `/buscar`'s copy is always visible, and only
   `--standalone` carries this fix, so the hero's panel is untouched. */
.search-form__label--standalone {
  min-height: 44px;
}

.search-form__input {
  width: 100%;
  min-height: 44px; /* pointer target floor */
  padding: var(--sp-2) var(--sp-3);
  background-color: var(--surface-page);
  color: var(--text-body);
  font-family: inherit;
  font-size: var(--fs-body);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-md);
}

/* The submit is the SECONDARY (navy) action, not red: the only red accent in
   this page's action set is the WhatsApp CTA on the detail page. See the
   one-red-accent block below. */
.search-form__submit {
  width: 100%;
  min-height: 44px;
  padding: var(--sp-2) var(--sp-5);
  background-color: var(--action-secondary);
  color: var(--text-inverse);
  font-family: var(--font-display);
  font-size: var(--fs-body);
  font-weight: var(--fw-bold);
  letter-spacing: var(--ls-wide);
  text-transform: uppercase;
  border: 1px solid var(--border-on-dark);
  border-radius: var(--radius-md);
  cursor: pointer;
  transition:
    background-color var(--speed-fast) var(--ease-brand),
    transform var(--speed-fast) var(--ease-brand);
}

/* ---------------------------------------------------------------------------
 * THE SAME FORM ON A NAVY SURFACE (adopt-results-view design.md §1/§8)
 * ---------------------------------------------------------------------------
 * The results view renders THIS form as its refine field inside the navy
 * search header, so three of its parts need a surface-specific colour and
 * nothing else does. Each is a MODIFIER, never a change to the base class:
 * the hero card's copy of this form sits on white and must render
 * byte-for-byte unchanged, which is the same discipline
 * `--standalone` already applies to the label's touch target.
 *
 * THE SUBMIT IS RED (owner ruling, correction round). It shipped white-on-navy
 * on the argument that design.md §8 assigned this change's only red growth to a
 * later slice, so a third red entering here would be an unreviewed one. That
 * argument was about the ALLOWLIST, and the approved design answers it about
 * the CONTROL: the handoff draws this button as
 * `<x-import ... variant="primary" ...>Buscar<`, and the design system's own
 * manifest defines `--action-primary` as `var(--red-600)`. Red is what the mock
 * specifies for it, not an inference from the surrounding page. The allowlist
 * objection is answered by growing the allowlist deliberately with that reason
 * recorded in it, which is what a reviewed gate is for; painting a control
 * off-brand to avoid editing the gate is the gate deciding the design.
 *
 * TWO MEASUREMENTS PREVIOUSLY RECORDED HERE WERE WRONG and are removed rather
 * than reworded, because both were relayed to the owner as reasons to decide:
 * that the no-match state "one search away already renders a red submit", and
 * that this was therefore the site's only non-red primary action. Neither
 * holds. `http/handlers/search.ts` sends every non-`results` kind — the
 * no-match state included — through `renderSearchField`, which calls
 * `renderSearchForm(value, false)`; those states render the BASE class, which
 * is `--action-secondary` navy. The hero card's copy renders navy for the same
 * reason. Only `kind === "results"` reaches `renderSearchForm(value, true)`
 * and this modifier. The ruling stands on the mock above; do not restore the
 * page-level argument.
 *
 * IT ALSO REPAIRS THE PAGE'S WEAKEST FOCUS INDICATOR. Inside this band
 * `.on-dark` makes `:focus-visible` draw a WHITE 3px outline at a 2px offset.
 * On a white button that is white, then a 2px gap of white, then white — the
 * ring was legible only where it happened to cross the navy beyond the
 * button's edge. On the red fill the same white ring reads at full strength.
 *
 * Navy-on-navy was the third option and is unusable either way
 * (`--action-secondary` #01224b on `--surface-dark` #001430 is 1.1:1 of
 * surface separation). White on `--action-primary` measures 4.88:1, which
 * clears AA for its 16px/700 label without relying on the large-text
 * exemption.
 *
 * The status region needs its own colour for the reason the base rule's
 * comment already records in reverse: `--text-body` was derived for a white
 * card and measures 2.2:1 here. `--navy-100` measures 15.8:1.
 * ------------------------------------------------------------------------ */

.search-form__label--on-dark {
  color: var(--text-inverse);
}

.search-form__submit--on-dark {
  background-color: var(--action-primary);
  color: var(--text-inverse);
  /* The border is kept and painted the fill's own colour rather than dropped:
     the base class declares a 1px border, and removing it here would shrink
     the control by 2px in each axis against the white-surface copy of the
     same form for no visual gain. */
  border-color: var(--action-primary);
}

.search-form__status--on-dark {
  color: var(--navy-100);
}

/* The `aria-live` region enhance.js writes "Buscando…" into. It reserves no
   height while empty, so nothing shifts when it fills — the text replaces
   nothing and pushes nothing above it.

   `--text-body`, not the old `--navy-300`: on the hero card's white surface
   `--navy-300` (#8FA6C4) measures 2.24:1, well under the 4.5:1 this project
   holds itself to. It was legible only against the navy wrapper task 2.2
   removed. */
.search-form__status {
  margin: 0;
  min-height: 1.25em;
  color: var(--text-body);
  font-size: var(--fs-small);
}

/* --- Main --------------------------------------------------------------- */

.site-main {
  display: block;
  padding: var(--sp-5) var(--sp-4) var(--sp-8);
}

/* `tabindex="-1"` on <main> makes the skip link move real keyboard focus, but
   it also makes the element focusable by click, where a ring would be noise.
   `:focus-visible` above already covers the keyboard case. */
.site-main:focus {
  outline: none;
}

/* ===========================================================================
 * LANDING: THE BAND STACK (adopt-landing-5a; design.md §5.5; task 4.2)
 *
 * ---------------------------------------------------------------------------
 * WHY THIS WHOLE LANDING SECTION CARRIES PIXEL LITERALS
 * ---------------------------------------------------------------------------
 * The `:root` block above is a byte-for-byte copy of the ARCHIVED v1
 * design's Decision 7 token set, structurally diffed by
 * `tokens-css.test.ts`, so no token can be added for a value Landing 5a
 * introduces. Where 5a's own scale (README §4/§"Design Tokens") has no token
 * equivalent — the 40px display heading, the 58%/52%/36%/22% hero geometry,
 * the 1060px band measure, the 40px band padding — the design's literal is
 * used, the same way `44px`, `40px` and `#25D366` already appear in this
 * file. Where a token DOES carry the value, the token wins.
 *
 * ---------------------------------------------------------------------------
 * FULL BLEED (README §"Layout general": "una pila vertical de bandas
 * full-bleed; cada banda define su propio fondo")
 * ---------------------------------------------------------------------------
 * Every band's BACKGROUND reaches the viewport edges; every band's CONTENT
 * stays on the design's 1060px measure with 40px of padding. Those are two
 * different boxes, which is why there are two classes: `.band` escapes the
 * content measure, `.band__inner` re-establishes it.
 *
 * The bands render inside `<main class="site-main">`, which pads its content
 * and, at >= 1024px, centres it in a 1200px measure — so `.band` has to
 * escape a container narrower than the viewport. `calc(50% - 50vw)` is the
 * only formula that can: a percentage resolves against the containing
 * block, so no other unit can name the viewport. Its known cost — `50vw`
 * counts the scrollbar and the layout width does not — is paid once by
 * `html { overflow-x: clip }` at the top of this file rather than by every
 * band, and U2's deliberate deviation ("the band stops at the content
 * measure above 1200px") is retired with it.
 * ======================================================================== */

.band {
  margin-inline: calc(50% - 50vw);
  /* 16px below 900px, the design's 40px above it (see the 900px block). A
     40px gutter on each side of a 390px phone leaves 310px of content, which
     is where the two longest category names start wrapping to three lines;
     the design is resolved at 1180px and says nothing about the small end,
     so this is decided here rather than inherited by accident. The chrome
     bands use the SAME two values at the same breakpoint, so the header, the
     footer and the bands share one gutter at every width. */
  padding-inline: var(--sp-4);
}

.band__inner {
  max-width: 1060px;
  margin-inline: auto;
}

/* ===========================================================================
 * LANDING: HERO BAND (adopt-landing-5a; design.md §3, §4.2, §5.5; tasks
 * 3.1-3.3, 4.2)
 * ======================================================================== */

.hero {
  display: block;
  margin-block-start: calc(-1 * var(--sp-5));
  margin-block-end: 0;
  padding-inline: 0; /* the hero's own inner carries the 40px, so the
                        photograph can reach the band's edge */
  background-color: var(--surface-page);
}

/* THE LCP ELEMENT. `width`/`height` in the markup carry the encode's real
   1672x941, so the box is reserved before decode; below 900px the band is
   stacked (design.md §5.5: "image on top at aspect-ratio: 16/9, text
   below"), so the reserved box is overridden to that ratio here rather than
   left at the source's 16:9-ish 1.777:1 by luck. `object-position: 38% 50%`
   is the design's own framing, which keeps the delivery unit in frame as the
   box narrows. */
.hero__image {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 9;
  object-fit: cover;
  object-position: 38% 50%;
}

/* The design's fusion gradient, which dissolves the photograph into the text
   column instead of ending it on a hard vertical edge. Purely decorative and
   empty, so it carries no accessible name; it exists only at the >= 900px
   layout, where the photograph is beside the text rather than above it. */
.hero__blend {
  display: none;
}

.hero__content {
  padding: var(--sp-5) var(--sp-4) var(--sp-6);
}

.hero__column {
  width: 100%;
}

/* THE BRAND'S SPEED MARKER IS A SHIPPED ASSET AGAIN.
   U2 drew it in CSS — one skewed 18x4 bar — arguing that a rectangle was
   not worth a network request. `DEC-153` and design.md §4.1 say otherwise:
   this is brand artwork from `data/assets/40-kit-grafico/`, and an
   approximation of brand artwork is a redrawing of it. What ships is light
   variant 3 of the `marcador-seccion` specimen sheet, cropped to its own
   ink box (`marcador-seccion-l3.svg`, 2 400 B) — the navy-plus-red
   combination the design puts here. `--skew` is no longer referenced by
   anything and stays in `:root` only because the token set is a frozen
   structural copy. */
/* Inside the `<h1>` since 2026-08-26 (the heading is one sentence), which is
   why `line-height` is PINNED here. As a sibling of the title it inherited
   `--lh-body` from `body`; as a descendant of `.hero__title` it would inherit
   `--lh-tight` instead, and its rendered height would fall from 18.6px to
   13.2px at the caption size. That height is not cosmetic: the `< 900px`
   `.hero__title` clamp derives its budget by subtracting "the eyebrow's own
   30.59px", which is exactly this 18.6px plus the 12px margin below. Letting
   the heading's line-height reach it would silently spend clearance the
   overlay reserves over the photograph's safe region.

   The other inherited values are harmless and stay inherited: `--sp-2` and
   `--sp-3` are absolute px, `--ls-caps` is an `em` that resolves against the
   eyebrow's OWN font-size, and the marker is sized `block-size: 14px`, so
   none of them scale with the 40px heading around them. */
.hero__eyebrow {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  margin: 0 0 var(--sp-3);
  line-height: var(--lh-body);
}

.hero__eyebrow-marker {
  display: block;
  flex: none;
  block-size: 14px;
  inline-size: auto;
}

.hero__eyebrow-text {
  color: var(--text-heading);
  font-family: var(--font-display);
  font-size: var(--fs-caption);
  font-weight: var(--fw-black);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
}

/* `text-wrap: pretty` is the design's own request. Unsupported engines
   ignore the declaration and wrap normally; nothing depends on it. */
.hero__title {
  margin: 0 0 var(--sp-3);
  color: var(--text-heading);
  font-family: var(--font-display);
  font-size: var(--fs-h1); /* 40px at the mock's width; see the 900px block */
  font-weight: var(--fw-black);
  line-height: var(--lh-tight);
  text-wrap: pretty;
}

.hero__lead {
  margin: 0 0 var(--sp-5);
  max-width: 400px;
  color: var(--text-body);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
}

/* The eyebrow + `<h1>` wrapper (`templates/landing/hero.ts`). At >= 900px it
   is deliberately INERT: no padding, no border, no display change, so the
   `<h1>`'s bottom margin still collapses through it to `.hero__lead` and the
   approved desktop composition does not move by a pixel. Everything it
   actually does happens in the `< 900px` block below. */
.hero__headline {
  margin: 0;
}

/* ===========================================================================
 * < 900 px: THE HEADING PAINTS ON THE PHOTOGRAPH
 * (owner decision, 2026-08-13, U7; design.md §8.4)
 *
 * The eyebrow and the `<h1>` leave the flow and sit over the photograph's
 * white left region; the photograph moves right to clear that region; the
 * lead paragraph and the search card stay in flow below the image, which the
 * owner ruled on explicitly ("it is secondary text and it stays where it
 * is"). Same `max-width` reasoning as the header block above: these
 * declarations must not reach the approved >= 900px composition, and several
 * of them (`object-position`, `margin-inline-start`, `width`) would need a
 * reset inside that block if they were written mobile-first.
 *
 * ---------------------------------------------------------------------------
 * THE PHOTOGRAPH MOVES WITHOUT OVERFLOWING, AND WITHOUT BEING RESCALED
 * ---------------------------------------------------------------------------
 * The obvious way to nudge an image right — a start margin on a 100%-wide
 * box — pushes 8% of it past the band and needs `overflow: hidden` on
 * `.hero` to clip it. That is not free here: `.search-card` sits 16px from
 * the band's edge and its `--shadow-raised` is a 24px blur, so clipping the
 * band would also cut the card's shadow. The same result is available with
 * no overflow at all:
 *
 *   width 92% + margin-inline-start 8%   the box occupies the RIGHT 92% of
 *                                        the band; nothing leaves it
 *   aspect-ratio 1472/900                92 : 56.25 — the box keeps EXACTLY
 *                                        the height the full-width 16/9 box
 *                                        had, so the band does not get
 *                                        shorter and the LCP box is reserved
 *                                        at the same size as before
 *   object-position 0% 50%               `cover` now has 8% of horizontal
 *                                        slack (the source is 1.7768:1
 *                                        against a 1.6356:1 box); anchoring
 *                                        it left renders the photograph at
 *                                        the SAME scale as before and drops
 *                                        the right 8% instead of scaling the
 *                                        whole frame down
 *
 * What is lost is the rightmost 8% of the frame — far-right speed lines and
 * the outer sliver of the cardboard stack. `HERO_IMAGE_ALT_ES` describes "a
 * Lutricon delivery unit on route", which is untouched by that crop, so the
 * alt text still says what the image shows.
 *
 * ---------------------------------------------------------------------------
 * WHERE THE TEXT MAY GO — MEASURED, NOT ASSUMED
 * ---------------------------------------------------------------------------
 * The photograph's left region is NOT flat white: it carries gradients and
 * pale speed lines, and a dark navy road crosses it diagonally. Sampling the
 * decoded source against `--text-heading` (#001430) on a 1% grid, the
 * largest top-left-anchored rectangle that holds 4.5:1 everywhere inside it
 * is 36% of the frame's width by 60% of its height, worst case 13.0:1. Wider
 * or taller than that and the van's roof or the road's edge enters the box:
 * at 65% of the height the safe width has already collapsed to 21%.
 *
 * So the headline box is `8% (the vacated strip, which is the page's own
 * white at 18.4:1) + 36% (the frame's safe region) = 44%` of the band, and
 * the type is sized to fit 60% of the image's height. The rendered pixels
 * under the real glyph boxes are asserted at 320/360/390/899px by
 * `test/e2e/hero-overlay.spec.ts`, which samples the composited image rather
 * than trusting these numbers.
 * ======================================================================== */

@media (max-width: 899.98px) {
  /* The containing block for the out-of-flow headline. `overflow` is
     deliberately NOT set here — see the shadow-clipping note above. */
  .hero {
    position: relative;
  }

  .hero__image {
    width: 92%;
    margin-inline-start: 8%;
    aspect-ratio: 1472 / 900;
    object-position: 0% 50%;
    /* THE VACATED STRIP NEEDS A DISSOLVE, NOT A BUTT JOINT.
       The frame's upper-left is near-white, so its new left edge is almost
       invisible up there (#FFFFFF meeting #F7F7F7, measured). Its LOWER left
       is not: the navy road and the red speed lines reach the frame's edge,
       and against the page's white they rendered as a razor-straight
       vertical line that reads as a clipping bug rather than a composition.
       This is the same problem `.hero__blend` solves at >= 900px — its own
       comment calls it "dissolves the photograph into the text column
       instead of ending it on a hard vertical edge" — so it gets the same
       answer, applied to the image itself rather than to a second element
       whose box would have to be pinned to the image's height. 32px is
       roughly one line of the road's own diagonal at phone width: enough to
       read as a fade, short enough not to eat the white region the heading
       needs. An engine without mask support ignores both declarations and
       gets the hard edge back — a cosmetic degradation, never a broken
       layout, and never a contrast one (the strip it reveals is the page's
       own white). */
    -webkit-mask-image: linear-gradient(90deg, transparent 0, #000 32px);
    mask-image: linear-gradient(90deg, transparent 0, #000 32px);
  }

  /* 44% = the 8% vacated strip plus the frame's measured 36% safe region.
     The start padding is the band's own 16px gutter, so the heading aligns
     with the lead and the card below it. */
  .hero__headline {
    position: absolute;
    inset-block-start: 0;
    inset-inline-start: 0;
    inline-size: 44%;
    padding-block-start: var(--sp-4);
    padding-inline-start: var(--sp-4);
  }

  /* THE EYEBROW IS ALLOWED OUT OF THE 44% COLUMN, AND ONLY THE EYEBROW.
     It measures 179.73px intrinsically (a 40.84px marker, an 8px gap and
     130.89px of tracked caps), which does not fit the title's column until
     the viewport reaches 445px. Wrapping it instead costs 18.6px of height
     at the TOP of the block, which pushes the whole `<h1>` down past the
     60%-height line where the road's edge enters the frame — the wrap was
     measured doing exactly that, putting the title's last line on dark
     pixels. Letting it run on is safe for the opposite reason the title's
     column is narrow: at the eyebrow's own height (7.9%-17.1% of the frame)
     the van's roof has not entered yet and the measured safe width is far
     wider than 44%. `test/e2e/hero-overlay.spec.ts` samples the pixels under
     these glyphs rather than taking that on trust. */
  .hero__eyebrow-text {
    white-space: nowrap;
  }

  /* THE TITLE IS RESPONSIVE BECAUSE THE SAFE REGION IS.
     The region is 60% of the image's height, the image is 56.25% of the
     band's width, so the vertical budget is 0.3375 x viewport minus the
     16px inset and the eyebrow's own 30.59px. Three lines at `--lh-tight`
     consume 3.3 x the font size, and solving that for the 320px and 390px
     ends gives a `10vw` slope. The constant is 15px rather than the 13.5px
     that exactly exhausts the budget, which buys ~5px of clearance above
     the 60% line at every tested width — the boundary is a diagonal road
     edge, not a step, so sitting flush against it is a defect waiting for a
     font metric to change. That yields 18px at 320, 21px at 360, 24px at
     390, reaching the mock's 34px at 490px and holding there. The ceiling
     is the token the >= 900px block raises to 40px, which this query never
     reaches. */
  .hero__title {
    font-size: clamp(18px, 10vw - 15px, var(--fs-h1));
  }
}

/* --- Landing: the dual search card (design.md §3) ------------------------ */

.search-card {
  max-width: 520px;
  background-color: var(--surface-card);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-raised);
  overflow: hidden;
}

/* The two mode radios: VISUALLY hidden, never `display: none`, for the same
   reason `.skip-link` is positioned off-screen rather than hidden — a
   display-hidden control is not focusable at all, which would delete the
   keyboard path to the mode switch. Their focus ring is forwarded to the
   visible label below. */
.search-card__mode-input {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  margin: 0;
  opacity: 0;
  pointer-events: none;
}

.search-card__modes {
  display: flex;
  background-color: var(--surface-subtle);
  border-bottom: 1px solid var(--border-default);
}

/* The label IS the pointer target, so it carries the 44px floor rather than
   relying on the 1px input it names. `--text-body` rather than the design's
   `--gray-500`: 12.5px/700 is not large text, and `--gray-500` on
   `--gray-100` measures 3.42:1 against the 4.5:1 this project holds itself
   to — the same defect, and the same correction, the `--text-muted` round
   already applied to `.product__field-label`. */
.search-card__mode {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 44px;
  padding: var(--sp-3) var(--sp-5);
  color: var(--text-body);
  font-family: var(--font-display);
  font-size: var(--fs-caption);
  font-weight: var(--fw-bold);
  letter-spacing: var(--ls-wide);
  text-transform: uppercase;
  cursor: pointer;
  border-bottom: 3px solid transparent;
  transition:
    background-color var(--speed-fast) var(--ease-brand),
    color var(--speed-fast) var(--ease-brand);
}

.search-card__mode:hover {
  color: var(--text-accent);
}

.search-card__panel {
  display: none;
  padding: var(--sp-4);
}

/* THE WHOLE MODE SWITCH, IN TWO RULES AND NO JAVASCRIPT. The radios precede
   both the label row and the panels in source order, which is what lets the
   general sibling combinator reach them. */
.search-card__mode-input--vehiculo:checked ~ .search-card__panel--vehiculo,
.search-card__mode-input--codigo:checked ~ .search-card__panel--codigo {
  display: block;
}

.search-card__mode-input--vehiculo:checked ~ .search-card__modes .search-card__mode--vehiculo,
.search-card__mode-input--codigo:checked ~ .search-card__modes .search-card__mode--codigo {
  background-color: var(--surface-card);
  color: var(--text-heading);
  /* One of the five DECLARED red usages (tokens-css.test.ts, "declared red
     usages"). `--action-primary`, not the raw `--red-600` it used to name:
     this is the active STATE of an action, so it belongs to the token the
     allowlist governs rather than sitting outside it as an unguarded
     literal. Same colour, same pixel — different accountability. */
  border-bottom-color: var(--action-primary);
}

/* Focus forwarded from the off-screen radio to the label a visitor can see.
   Without this the mode switch would be operable by keyboard and invisible
   while being operated, which is the worst of both. `outline-offset` is
   negative so the ring is drawn inside the card's clipped corners. */
.search-card__mode-input--vehiculo:focus-visible ~ .search-card__modes .search-card__mode--vehiculo,
.search-card__mode-input--codigo:focus-visible ~ .search-card__modes .search-card__mode--codigo {
  outline: 3px solid var(--border-strong);
  outline-offset: -3px;
  box-shadow: var(--focus-ring);
}

/* --- Landing: the vehicle panel (DEC-152) -------------------------------- */

.vehicle-search {
  display: grid;
  gap: var(--sp-4);
}

/* Responsive contract §5.5: selects stacked below 900px, three columns at or
   above it. */
.vehicle-search__fields {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--sp-2);
}

.vehicle-search__field {
  display: flex;
  flex-direction: column;
  gap: var(--sp-1);
}

/* `--text-body`, not the design's `--gray-500`: 11px/700 is nowhere near the
   large-text threshold and `--gray-500` on white measures 3.58:1. */
.vehicle-search__label {
  color: var(--text-body);
  font-size: var(--fs-caption);
  font-weight: var(--fw-bold);
  letter-spacing: var(--ls-wide);
  text-transform: uppercase;
}

/* `--fs-small` (14px), not `--fs-body`: the design's own select type is
   14.5px, and the difference is load-bearing rather than cosmetic. At the
   narrow end of the side-by-side layout the text column is 52 % of 900px and
   the three columns are ~113px each, where 16px clips `Mercedes-Benz` to
   `Mercedes-B…`. The full make and model are still stated in full in the
   coverage note directly below, so nothing is lost either way — but a
   control that has ONE option should be able to show it. */
.vehicle-search__select {
  width: 100%;
  min-height: 44px; /* pointer target floor */
  padding: var(--sp-2);
  background-color: var(--surface-page);
  color: var(--text-heading);
  font-family: inherit;
  font-size: var(--fs-small);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-md);
}

.vehicle-search__footer {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-3);
}

/* The coverage note that REPLACED the prototype's fitment footnote (task
   3.3). Same contrast correction as the two rules above. */
.vehicle-search__coverage {
  margin: 0;
  color: var(--text-body);
  font-size: var(--fs-small);
}

/* `Ver repuestos`: PRIMARY (red) — OWNER RULING, 2026-08-12, and the same
   ruling that repainted `.site-header__cta` above. U2 demoted this to the
   navy secondary action because the archived Decision 7 usage rule was
   encoded as "exactly one `var(--action-primary)` use in the whole file".
   The rule is now a DECLARED ALLOWLIST of five red usages
   (`tokens-css.test.ts`, "declared red usages"), of which this button and
   `Cotizar` are one; a sixth, undeclared red still fails.
   White on `--red-600` measures 4.88:1, above the 4.5:1 floor. The border
   goes with the demotion: `--border-on-dark` was there to lift a navy
   button off a white card, and a red one needs no help. */
.vehicle-search__submit {
  width: 100%;
  min-height: 44px;
  padding: var(--sp-2) var(--sp-5);
  background-color: var(--action-primary);
  color: var(--text-inverse);
  font-family: var(--font-display);
  font-size: var(--fs-body);
  font-weight: var(--fw-bold);
  letter-spacing: var(--ls-wide);
  text-transform: uppercase;
  border: 0;
  border-radius: var(--radius-md);
  cursor: pointer;
  transition:
    background-color var(--speed-fast) var(--ease-brand),
    transform var(--speed-fast) var(--ease-brand);
}

.vehicle-search__submit:hover,
.vehicle-search__submit:focus-visible {
  background-color: var(--action-primary-hover);
}

/* --- Landing: the floating WhatsApp affordance --------------------------- */

/* `position: fixed` at EVERY viewport (design.md §5.5), 16px from the edges
   below 900px, 24px at/above it — UNCHANGED by the shape change below.
   `#25D366` is WhatsApp's own official green, outside this project's token
   set on the same terms `.footer__social-link--whatsapp` already declares it.
 *
 * OWNER DECISION, 2026-08-13: ICON-ONLY CIRCLE, SUPERSEDING THE PILL.
 * The 148x50 pill read "Escríbanos" and, measured at 360x800, covered 30.6%
 * of `.cta-band__action` within a ~124px scroll window, visibly cutting the
 * word "DISPONIBILIDAD" in half with the bubble painting on top. 56x56 is
 * a circle (`border-radius: 50%`) carrying only the glyph; both controls
 * still open the same WhatsApp conversation, and 56px already clears the
 * 44px touch-target floor `accessibility.spec.ts`'s `target-size` guard
 * enforces.
 *
 * THE ACCESSIBLE NAME MOVED, NOT DISAPPEARED. With the visible word gone,
 * the anchor carries `aria-label="${WHATSAPP_BUBBLE_ES}"` — the SAME
 * approved Spanish string ("Escríbanos"), not a new one — so the control's
 * accessible name is unchanged for anyone who already depends on it. The
 * glyph inside stays `aria-hidden`.
 *
 * WHITE-ON-GREEN, RE-RULED FOR THE ICON-ONLY SHAPE — OWNER RULING,
 * 2026-08-12 (contrast) confirmed unchanged by the 2026-08-13 shape change.
 * `.whatsapp-bubble` no longer paints any TEXT, so axe's `color-contrast`
 * rule (which only scans text nodes) no longer has a node to flag here —
 * verified empirically, not assumed; see the removed exception in
 * `accessibility.spec.ts` and `tokens-css.test.ts`, on the same terms
 * `.site-brand` already established (a rule that paints no text is not a
 * text-painting rule). SC 1.4.11 Non-Text Contrast (3:1 for graphical
 * objects) is the criterion that now actually applies to the glyph, but
 * axe-core 4.12.1 ships NO rule for it at all — verified against
 * `node_modules/axe-core/axe.js`, the same gap this codebase already
 * documented for SC 2.4.11 and SC 3.3.8. Measured by hand instead: the
 * white glyph on `#25D366` is the same 1.98:1 pair as before (white is
 * `currentColor`, unchanged), under the 3:1 floor SC 1.4.11 sets. The owner
 * chose WhatsApp's own pairing with that number in hand (2026-08-12) and
 * the icon-only shape does not reopen it — no code guard enforces this
 * criterion because axe cannot, so it is recorded here rather than silently
 * passing.
 *
 * The footer's icon-only WhatsApp square carries the identical pairing and
 * was always unaffected by the pill's contrast exception, which only ever
 * named this one node. */
.whatsapp-bubble {
  position: fixed;
  z-index: 2;
  right: var(--sp-4);
  bottom: var(--sp-4);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 56px;
  height: 56px;
  background-color: #25D366;
  color: var(--white);
  border-radius: 50%;
  box-shadow: var(--shadow-raised);
  transition:
    background-color var(--speed-fast) var(--ease-brand),
    transform var(--speed-fast) var(--ease-brand);
}

.whatsapp-bubble:hover {
  background-color: #1EB855;
}

/* --- Footer (dark section; DEC-149) -------------------------------------- */

/* One of `.on-dark`'s two remaining chrome consumers (task 2.3; the utility
   bar above is the other): a navy outline is invisible on this navy
   background, same as it always was, now expressed as the surface rule
   rather than a rule pinned to this one selector. */
.site-footer {
  display: flex;
  flex-direction: column;
  gap: var(--sp-4);
  padding: var(--sp-5) var(--sp-4);
  background-color: var(--surface-dark);
  color: var(--text-inverse);
  border-top: 1px solid var(--border-on-dark);
}

/* Brand column (left) and the `SÍGANOS` social block (right); design.md §2
   "Footer". */
.footer__row {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: var(--sp-6);
}

.footer__primary {
  display: flex;
  flex-direction: column;
  gap: var(--sp-3);
}

.footer__brand {
  position: relative;
  display: inline-block;
}

/* Technique B, 25 + 2x9.5 = 44. The link's own box is 25px tall because
   `.footer__brand-mark` crops the file's padded canvas with negative margins
   (see below); a `min-height` here would re-open exactly that padding and push
   `.footer__business-name` 19px down the footer. The 9.5px of overflow stays
   inside `.footer__primary`'s 12px column gap, so the hit area never reaches
   the business name — which is a `<p>`, not a control, and must not become
   clickable by accident. */
.footer__brand::after {
  content: "";
  position: absolute;
  inset-block: -9.5px;
  inset-inline: 0;
}

/* The DARK-surface isotype (design.md §1.1): mark only, no drawn wordmark,
   so `alt=""` and the adjacent `.footer__business-name` text carries the
   name instead — the identical pairing the OLD header used before this
   change, moved here because the footer is now the one surface that still
   needs it. `em` sizing plus the negative margins crop the file's own
   padding: the isotype is drawn at 713x521 from (354, 259) inside a
   1448x1086 viewBox, so half the box is empty and a naively sized mark
   would render with visible dead space around it. The geometry is safe to
   hardcode only because brand-assets.test.ts pins that file's bytes. */
.footer__brand-mark {
  display: block;
  block-size: 2em;
  inline-size: auto;
  margin-block: -0.477em -0.564em;
  margin-inline: -0.652em -0.702em;
  font-size: var(--fs-h2);
}

.footer__business-name {
  margin: 0;
  color: var(--text-inverse);
  font-family: var(--font-display);
  font-weight: var(--fw-bold);
  font-size: var(--fs-h3);
  line-height: var(--lh-heading);
}

.footer__hours {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-1) var(--sp-2);
  margin: 0;
}

.footer__hours-label {
  color: var(--navy-300);
  font-size: var(--fs-small);
  font-weight: var(--fw-semibold);
}

.footer__hours-value {
  margin: 0;
  color: var(--text-inverse);
  font-size: var(--fs-small);
}

.footer__links {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-1) var(--sp-4);
  margin: 0;
  padding: 0;
  list-style: none;
}

/* 44px min-height, this repository's own convention rather than a cited
   requirement — the charter's bar is WCAG 2.2 AA, whose SC 2.5.8 asks for
   24x24 CSS px, and 44x44 is 2.1 AAA (SC 2.5.5), which this project does not
   claim. An earlier version of this comment attributed the floor to the
   Route/state contract's accessibility paragraph; that paragraph names
   lang, the skip link, landmarks, visible focus, accessible names, contrast
   and reduced motion, and no target size at all. Note that min-height alone
   floors the HEIGHT: a short label still renders narrower than 44px, which
   clears 2.5.8 but not the AAA reading of this comment's own number.
   Met the same way .search-form__input/__submit meet it: min-height on the
   interactive element itself, centering the text inside that reserved box
   rather than relying on padding alone to reach it. */
.footer__link {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  color: var(--text-inverse);
  font-size: var(--fs-small);
  font-weight: var(--fw-semibold);
  text-decoration: underline;
}

.footer__link:hover {
  color: var(--navy-300);
}

/* `SÍGANOS` social block (design.md §2 "Footer"; task 2.1): the three
   DEC-149 profiles plus WhatsApp, as 40px icon-only squares — a different
   shape from `.footer__link`'s underlined text links above, which stay for
   the site's own two routes plus `Contacto`. */
.footer__social {
  display: flex;
  flex-direction: column;
}

.footer__social-heading {
  margin: 0 0 var(--sp-3);
  color: var(--navy-300);
  font-size: var(--fs-caption);
  font-weight: var(--fw-bold);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
}

.footer__social-links {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2);
  margin: 0;
  padding: 0;
  list-style: none;
}

.footer__social-link {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  inline-size: 40px;
  block-size: 40px;
  color: var(--text-inverse);
  background-color: rgba(255, 255, 255, .08);
  border-radius: var(--radius-sm);
  transition: background-color var(--speed-fast) var(--ease-brand);
}

/* Technique B, 40 + 2x2 = 44. The 40px square is not an invisible hit box —
   it paints a translucent-white (or, for WhatsApp, a solid green) background,
   so it is visible artwork at the size the design specifies, and growing the
   element itself would enlarge that artwork. The 2px of overflow leaves 4px
   between neighbouring hit areas out of the list's 8px gap, so no two of the
   four squares are ambiguous to tap. */
.footer__social-link::after {
  content: "";
  position: absolute;
  inset: -2px;
}

.footer__social-link:hover {
  background-color: var(--text-accent);
}

/* The one WhatsApp-official-green button in this set (design.md §2
   "Footer"): `#25D366` is the brand's own official WhatsApp color, outside
   this project's token set on the same terms `--danger`'s sibling colors
   are (Decision 7's usage-rule table names only navy/red/neutral surfaces).
   Declared here, once, rather than as a bare literal at each call site. */
.footer__social-link--whatsapp {
  background-color: #25D366;
}

.footer__social-link--whatsapp:hover {
  background-color: #1EB855;
}

.footer__copyright {
  margin: 0;
  padding-top: var(--sp-4);
  color: var(--navy-300);
  font-size: var(--fs-caption);
  border-top: 1px solid var(--border-on-dark);
}

/* --- Landing: the category band (task 3.5; README §6) -------------------- */

/* The band paints white and carries the design's speed texture. `position:
   relative` + `overflow: hidden` are what let the texture be an absolute
   layer that stops at the band's edges. */
.category-section {
  position: relative;
  overflow: hidden;
  /* `margin-block`, never the `margin` shorthand: the shorthand would reset
     `.band`'s `margin-inline: calc(50% - 50vw)` and quietly un-bleed the
     band. Measured: it did exactly that, and the three <section> bands sat
     at the 1168px content measure while the two <div> ones reached the
     viewport. */
  margin-block: 0;
  padding-block: 46px 52px;
  background-color: var(--surface-page);
}

/* README §6: `background-size: 720px; opacity: .45` on an absolute layer.
   The opacity is on the TEXTURE, never on the section — putting it on the
   section would fade the rows with it. Empty and `aria-hidden`, so it adds
   nothing to the accessibility tree. */
.category-section__texture {
  position: absolute;
  inset: 0;
  background-image: url("/assets/kit/pattern-velocidad.svg");
  background-size: 720px;
  opacity: .45;
  pointer-events: none;
}

/* Above the texture. Both are positioned, so source order decides and this
   one is last — no z-index needed, the same trick `.hero__content` uses. */
.category-section .band__inner {
  position: relative;
}

/* THE ROWS ARE ROWS, NOT CARDS (README §6). Unit 4C's grid drew 18 bordered,
   shadowed cards; the design draws a three-column list of rows separated by
   a hairline, with a transparent left rule that turns red on hover. Same
   markup, same classes, same one-link-per-category contract — only the
   painting changed. Responsive contract §5.5: 1 column below 900px. */
.category-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: 0 32px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.category-grid__item {
  display: block;
}

.category-card {
  display: flex;
  align-items: center;
  gap: var(--sp-4);
  min-height: 44px; /* pointer target floor */
  padding: 14px 6px;
  color: var(--text-heading);
  text-decoration: none;
  border-bottom: 1px solid var(--border-default);
  /* Reserved at every state so the hover does not reflow the row by 3px. */
  border-left: 3px solid transparent;
  transition:
    border-left-color var(--speed-fast) var(--ease-brand),
    padding-left var(--speed-fast) var(--ease-brand);
}

/* One of the five DECLARED red usages (tokens-css.test.ts, "declared red
   usages"). `:focus-visible` gets the same treatment as `:hover` so the
   keyboard path shows the same affordance the pointer path does. */
.category-card:hover,
.category-card:focus-visible {
  border-left-color: var(--action-primary);
  padding-left: 12px;
}

/* The duotone crop of the category's own 4:1 sheet (design.md §4.3). A
   fixed 44px box with `inline-size: auto` would let a fragment with a
   different intrinsic ratio change the row height, so both axes are pinned:
   every cell in every sheet is square. */
.category-card__icon {
  display: block;
  flex: none;
  inline-size: 44px;
  block-size: 44px;
}

.category-card__name {
  flex: 1;
  font-family: var(--font-display);
  font-size: var(--fs-small);
  font-weight: var(--fw-semibold);
  line-height: var(--lh-heading);
  text-wrap: pretty;
}

.category-card__arrow {
  display: block;
  flex: none;
  block-size: 13px;
  inline-size: auto;
}

/* --- Landing: the pillar band (task 3.4; README §5) --------------------- */

/* One of the four dark surfaces design.md §1's band table names, so it
   carries `.on-dark` — a navy focus ring is invisible here. */
.pillar-band {
  padding-block: 20px;
  background-color: var(--surface-dark);
  color: var(--text-inverse);
}

/* Responsive contract §5.5: 2 columns below 900px, 3 at 900-1199, 5 at
   >= 1200. */
.pillar-band__list {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--sp-4);
  /* `margin-block`, never the `margin` shorthand — the same reason
     `.category-section` states it, one box further in. This element is also a
     `.band__inner`, and the shorthand resets that class's `margin-inline: auto`
     to zero, which pinned the five pillars against the band's left gutter and
     left the whole measure's slack as empty navy on the right. The `<ul>`'s own
     user-agent block margin is what has to go; its inline centring is not. */
  margin-block: 0;
  padding: 0;
  list-style: none;
}

.pillar {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
}

/* README §5's 40px inverted brand icon (task 1.3). A fixed square box, like
   `.category-card__icon`: the vector's own 1254x1254 viewBox is already 1:1,
   so `inline-size`/`block-size` alone pin the rendered size without
   distorting it. */
.pillar__icon {
  display: block;
  flex: none;
  inline-size: 40px;
  block-size: 40px;
}

.pillar__label {
  color: var(--text-inverse);
  font-family: var(--font-display);
  font-size: var(--fs-caption);
  font-weight: var(--fw-bold);
  letter-spacing: var(--ls-wide);
  line-height: 1.25;
  text-transform: uppercase;
}

/* --- Landing: a band's heading cluster (README §6, reused by §7) --------- */

.band-heading {
  margin-block-end: var(--sp-5);
}

.band-heading__row {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  margin-block-end: var(--sp-1);
}

/* The `<h2>` inside carries the site-wide heading rule's 16px bottom margin,
   which inside a centred flex row pushes the marker 8px below the text's
   optical centre instead of beside it. Measured before this rule: marker at
   y=843 in a row spanning 812-887. */
.band-heading__row > * {
  margin-block: 0;
}

/* README §6: the band headings are 22px, not the token scale's 26px `--fs-h2`
   (which the product/search headings still use). One of the places design.md
   §5 anticipates a 5a literal where the frozen token set has no equivalent.

   Declared PER HEADING rather than as one two-selector rule. The category
   title inherits everything else from the site-wide section-heading rule it
   shares with `.handoff__title`, so it needs this one override; the featured
   title has a rule of its own and carries the literal there. A combined rule
   also lost to that own rule's `font-size: var(--fs-h2)`, which is how the
   featured heading shipped at 26px while this comment claimed 22. */
.category-section__title {
  font-size: 22px;
}

.band-heading__marker {
  display: block;
  flex: none;
  block-size: 13px;
  inline-size: auto;
}

/* The tricolor rule. `object-fit: contain` + `object-position: left` are the
   design's own (README §6): the crop is 1031x30 and the design draws it in a
   240x9 box, so the rule scales down and stays left-aligned under the
   heading instead of stretching. */
.band-heading__rule {
  display: block;
  inline-size: 240px;
  block-size: 9px;
  object-fit: contain;
  object-position: left;
}

/* --- Landing: the featured-parts band (task 3.6; README §7) ------------- */

.featured-section {
  margin-block: 0;
  padding-block: 44px 52px;
  background-color: var(--surface-subtle);
  border-top: 1px solid var(--border-default);
}

.featured-section__title {
  margin: 0;
  color: var(--text-heading);
  font-family: var(--font-display);
  font-weight: var(--fw-black);
  font-size: 22px;
  line-height: var(--lh-heading);
  text-transform: uppercase;
  letter-spacing: var(--ls-caps);
}

/* Responsive contract §5.5: 1 column below 900px, 2 above. */
.featured-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: 14px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.featured-grid__item {
  display: block;
}

/* The brand's angular corner (README §7, "Corte angular (rasgo de marca)").
   `clip-path` rather than a background image or a pseudo-element: it clips
   the border and the shadow with the box, which is what makes the notch read
   as the card's own shape rather than as something drawn on top of it. */
.featured-card {
  display: flex;
  align-items: center;
  gap: var(--sp-5);
  height: 100%;
  padding: var(--sp-4) 22px var(--sp-4) 18px;
  background-color: var(--surface-card);
  color: var(--text-heading);
  text-decoration: none;
  border: 1px solid var(--border-default);
  box-shadow: var(--shadow-card);
  clip-path: polygon(0 0, 100% 0, 100% 100%, 4% 100%, 0 82%);
  transition: background-color var(--speed-base) var(--ease-brand);
}

.featured-card:hover,
.featured-card:focus-visible {
  background-color: var(--navy-100);
}

/* THE CARD SHADOW MUST NOT EAT THE FOCUS HALO. `.featured-card` declares
   `box-shadow: var(--shadow-card)` at (0,1,0) and sits LATER in this file
   than the bare `:focus-visible` rule, which is also (0,1,0) — so without
   this rule a keyboard-focused card paints the card shadow and the halo
   disappears. That is the exact defect `.category-card` and `.product-card`
   shipped before (see `tokens-css.test.ts`'s focus-ring cascade block, and
   the measured `rgba(0, 20, 48, 0.1) 0px 2px 8px 0px` it records); it is
   pre-empted here rather than rediscovered. `.category-card` no longer needs
   the same rule: Landing 5a's rows carry no shadow at all. */
.featured-card:focus-visible {
  box-shadow: var(--focus-ring);
}

.featured-card__illustration {
  display: block;
  flex: none;
  inline-size: 72px;
  block-size: auto;
}

.featured-card__body {
  flex: 1;
  display: block;
}

/* One of the five DECLARED red usages. 10.5px/700 uppercase red on white
   measures 4.88:1, above the 4.5:1 floor. */
.featured-card__category {
  display: block;
  color: var(--action-primary);
  font-family: var(--font-display);
  font-size: 10.5px;
  font-weight: var(--fw-bold);
  letter-spacing: .12em;
  text-transform: uppercase;
}

.featured-card__name {
  display: block;
  margin-block-start: var(--sp-1);
  font-family: var(--font-display);
  font-size: var(--fs-body);
  font-weight: var(--fw-bold);
  line-height: 1.25;
}

.featured-card__arrow {
  display: block;
  flex: none;
  block-size: 14px;
  inline-size: auto;
}

/* --- Landing: the consultation CTA band (task 3.7; README §8) ----------- */

/* The fourth dark surface in design.md §1's band table — dark in the sense
   that matters here, which is that a navy focus ring would vanish on it, so
   it carries `.on-dark` like the other three. One of the five DECLARED red
   usages: this background IS the band. */
.cta-band {
  /* The last band before the footer, so it also swallows `.site-main`'s
     bottom padding — the mock has no white gap between the red band and the
     navy footer. */
  margin-block: 0 calc(-1 * var(--sp-8));
  padding-block: var(--sp-6);
  background-color: var(--action-primary);
  color: var(--text-inverse);
}

/* Responsive contract §5.5: stacked with a full-width button below 900px,
   a space-between row above. */
.cta-band__row {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: var(--sp-5);
}

.cta-band__body {
  display: flex;
  align-items: center;
  gap: 18px;
}

.cta-band__marker {
  display: block;
  flex: none;
  block-size: 22px;
  inline-size: auto;
}

.cta-band__text {
  display: block;
}

.cta-band__title {
  margin: 0;
  color: var(--text-inverse);
  font-family: var(--font-display);
  font-size: 21px;
  font-weight: var(--fw-black);
  line-height: var(--lh-heading);
}

/* The design sets `opacity: .9` here. Dropped, and the reason is measured
   rather than stylistic: white at 90% over `--red-600` composites to
   #FCE6E7, which is 4.46:1 — under the 4.5:1 floor — while solid white is
   4.88:1. Nothing else about the line changes. */
.cta-band__lead {
  margin: var(--sp-1) 0 0;
  color: var(--text-inverse);
  font-size: var(--fs-small);
  line-height: var(--lh-body);
}

/* SECONDARY (navy) on the red band, exactly as the design has it: the band
   has already spent its red, and a red button on red would not be a button.
   White on `--action-secondary` measures 13.4:1. */
.cta-band__action {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--sp-2);
  min-height: 44px;
  padding: var(--sp-3) var(--sp-6);
  background-color: var(--action-secondary);
  color: var(--text-inverse);
  font-family: var(--font-display);
  font-size: var(--fs-body);
  font-weight: var(--fw-bold);
  letter-spacing: var(--ls-wide);
  text-transform: uppercase;
  text-decoration: none;
  border-radius: var(--radius-md);
  transition:
    background-color var(--speed-fast) var(--ease-brand),
    transform var(--speed-fast) var(--ease-brand);
}

.cta-band__action:hover {
  background-color: var(--action-secondary-hover);
}

/* ===========================================================================
 * THE ANGULAR CUT — ONE CLIPPED TWO-LAYER MECHANISM
 * (adopt-results-view design.md §7; the design handoff's own "Tarjeta de
 * resultado (patrón clave)" note)
 *
 * The handoff states the requirement as "el borde debe cerrar la diagonal" —
 * the 1px rule has to follow the cut, not stop at it. `border` cannot do that
 * and neither can `border-image` (a bitmap or vector border cannot hold a
 * crisp 1px rule across the diagonal at arbitrary box sizes and DPRs); a
 * single-element gradient edge was rejected as unmaintainable at fractional
 * pixels. Two clipped layers can, and this is the handoff's own recipe:
 *
 *   outer   background: the border colour; padding: 1px; the polygon
 *   inner   background: the surface; the SAME polygon, computed against the
 *           inner box, which lands its diagonal parallel and 1px inside
 *
 * `--cut` is the ONLY thing a context sets. It is 26px here because the
 * handoff fixes it at 26px in absolute units for result cards, deliberately
 * not a percentage, "para que todas las tarjetas de la grilla se vean
 * idénticas"; a later slice's state blocks set 30px on the same pair.
 *
 * THE CUT IS THE BOTTOM-RIGHT CORNER. design.md §7 says top-left, reading it
 * off the `corte-angular-cards.svg` variant board rather than off the card;
 * the shipped mock's own polygon cuts `100% calc(100% - 26px)` to
 * `calc(100% - 26px) 100%`, which is the bottom-right. The board's top-left
 * corner piece is a SEPARATE ornament and is promoted as one (it sits in the
 * card's image zone). Reported in the apply round rather than silently
 * reconciled.
 *
 * NOTHING FOCUSABLE CARRIES THIS CLASS. `clip-path` clips an element's own
 * outline and box-shadow, so putting it on the card's `<a>` would erase the
 * focus ring the accessibility contract requires. The frame is a span INSIDE
 * the link instead: the link keeps its rectangular focus box, the frame keeps
 * its diagonal.
 * ======================================================================== */

.cut-frame {
  --cut: 26px;
  display: block;
  block-size: 100%;
  padding: 1px;
  background-color: var(--border-default);
  clip-path: polygon(
    0 0,
    100% 0,
    100% calc(100% - var(--cut)),
    calc(100% - var(--cut)) 100%,
    0 100%
  );
  transition: background-color var(--speed-base) var(--ease-brand);
}

.cut-frame__surface {
  display: flex;
  flex-direction: column;
  block-size: 100%;
  background-color: var(--surface-card);
  clip-path: polygon(
    0 0,
    100% 0,
    100% calc(100% - var(--cut)),
    calc(100% - var(--cut)) 100%,
    0 100%
  );
}

/* ===========================================================================
 * THE RESULTS VIEW — ONE COMPOSITION, BOTH RESULT ROUTES
 * (adopt-results-view design.md §1/§8/§10)
 *
 * The navy search header and the gray results body are full-bleed BANDS, so
 * they reuse the shipped `.band`/`.band__inner` pair rather than inventing a
 * second escape mechanism: the design handoff draws both edge-to-edge with
 * their content on the same 1060px measure the landing's bands already use.
 * ======================================================================== */

/* `.band` escapes the content measure with `margin-inline: calc(50% - 50vw)`,
   and that percentage resolves against THIS element's content box. It must
   therefore stay a plain full-width block with no padding or border of its
   own, or both bands stop reaching the viewport edges. Declared rather than
   left to the initial values, because the property is load-bearing. */
.results-view {
  display: block;
  padding: 0;
  border: 0;
}

/* The band swallows `.site-main`'s top padding the way `.hero` does, so the
   navy starts where the chrome ends.

   `overflow: hidden` LEFT WITH THE SPEED ACCENT (correction round). Its only
   job was clipping that ornament to the band; the two remaining decorative
   layers are `inset: 0` on this same box and cannot overflow it, so the
   property had no remaining effect to justify it and `.results-body` — the
   band directly below, with the same shape — never declared one. Removing it
   also removes the one thing in this band that could clip a focus ring if a
   later slice ever placed a control near its edge. */
.results-header {
  position: relative;
  margin-block-start: calc(-1 * var(--sp-5));
  padding-block: var(--sp-5);
  background-color: var(--surface-dark);
  color: var(--text-inverse);
}

/* The blueprint texture, on its own absolute layer so its opacity does not
   fade the heading above it — the same shape `.category-section__texture`
   established for the landing's speed texture. `.45` and the veil's stops are
   the handoff's own numbers. */
.results-header__texture {
  position: absolute;
  inset: 0;
  background-image: url("/assets/hero/hero-blueprint.avif");
  background-position: center;
  background-size: cover;
  opacity: .45;
}

.results-header__veil {
  position: absolute;
  inset: 0;
  background-image: linear-gradient(90deg, rgba(0, 20, 48, .96) 42%, rgba(0, 20, 48, .6));
}

/* `position: relative` puts the content above all three decorative layers
   without a z-index — every one of them is positioned, so source order
   decides and this one is last. */
.results-header__inner {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: var(--sp-4);
}

.results-header__title {
  margin-block: 0;
  color: var(--text-inverse);
  font-family: var(--font-display);
  font-size: var(--fs-h2);
  font-weight: var(--fw-black);
  line-height: var(--lh-heading);
}

/* THE SEARCHED QUERY, NAMED IN THE HEADING (owner ruling, correction round;
   the mock's own treatment). The rest of the heading is white; this span is
   the reference's red, and it is the sixth entry in `tokens-css.test.ts`'s
   declared red allowlist rather than a red that slipped in — the allowlist is
   a reviewed gate, so it grows deliberately or not at all.

   CONTRAST. Nominally 3.77:1 on `--surface-dark`, which clears the 3:1
   large-text threshold and not the 4.5:1 body one — and this text qualifies:
   it is the `<h1>`, 26px at `--fw-black` and 30px from 900px up, both well
   past the 18.66px bold boundary. The declared limit is the same one the
   contrast table records for every other string in this band: the real
   surface is the veil composited over the blueprint texture, not the token
   named, so the painted pixels are the accessibility suite's job.

   `overflow-wrap: anywhere` is not decoration. The value is attacker-
   controlled and bounded only in LENGTH (`MAX_QUERY_LENGTH`, 120), never in
   shape: 120 characters with no space in them would otherwise be one
   unbreakable word running past the band and giving the whole page a
   horizontal scrollbar at the narrowest supported viewport. The
   `.handoff__message` precedent, for the same reason. This element's own
   narrow-viewport font-size step (9e's message-state context only, defect 6
   below) leaves THIS rule — the navy results-header usage's size and its
   own `large: true` 3:1 contrast clearance — completely unchanged. */
.results-header__query {
  color: var(--action-primary);
  overflow-wrap: anywhere;
}

/* The handoff's translucent container around the refine field. `--border-on-dark`
   is this file's own token for a hairline on navy; the handoff draws .18 and
   the token is .14, which is the token winning over a literal per the landing
   section's rule. */
.results-header__search {
  inline-size: 100%;
  padding: var(--sp-2) var(--sp-3);
  background-color: rgba(255, 255, 255, .08);
  border: 1px solid var(--border-on-dark);
  border-radius: var(--radius-sm);
}

/* ---------------------------------------------------------------------------
 * THE HEADER FIELD'S ROWS ARE DECLARED, NOT WRAPPED TO FIT
 * ---------------------------------------------------------------------------
 * MEASURED DEFECT, not a precaution. `.search-form`'s >= 640px layout is
 * `flex-wrap: wrap`, which lets the label, the input and the submit share a
 * line whenever they happen to fit — and whether they fit depends on the
 * label's RENDERED width, which changes when `font-display: swap` repaints it
 * in Exo 2. Observed on `/categorias/{id}` at 1350px with the woff2 delayed
 * 900ms: the box grew from 92px to 148px at the swap and pushed the whole
 * results body 56px down, for a CLS of 0.2812 against the §4.6 budget of 0.1.
 * `npm run budgets` failed on exactly this, on both result surfaces, on the
 * desktop profile only (the mobile profile's narrower band already forced the
 * stacked shape).
 *
 * Three DECLARED rows cannot do that: the label owns a row, the input and the
 * submit share the next one and may not wrap apart, and the status region
 * already had a row of its own. The label's own 1-line/2-line difference is
 * absorbed by `--standalone`'s 44px floor (two 14px lines measure 43.4px), so
 * the swap changes no row count anywhere in the box.
 * ------------------------------------------------------------------------ */

/* The shipped form is a four-row `grid` below 640px. Inside this band it
   keeps the SAME three declared rows at every width instead: on a 360px
   phone the fourth row costs 52px of navy before the first result, and the
   input and the submit fit one row comfortably once the input can shrink. */
.results-header__search .search-form {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--sp-3);
}

.results-header__search .search-form__label {
  flex: 1 0 100%;
}

/* `flex: 1 1 0` with `min-inline-size: 0`, not `auto`: an `<input>`'s
   automatic minimum size is its own intrinsic width, which is wide enough to
   push the submit onto a third row — and whether it does depends on the
   submit's rendered label width, which is the font-swap variable this block
   exists to remove. Basis 0 and a zero minimum let the input absorb every
   change instead. */
.results-header__search .search-form__input {
  flex: 1 1 0;
  min-inline-size: 0;
}

.results-header__search .search-form__submit {
  flex: none;
  inline-size: auto;
}

/* The last band before the footer, so it swallows `.site-main`'s bottom
   padding too — the `.cta-band` precedent; the handoff has no white gap
   between the gray results body and the navy footer. */
.results-body {
  margin-block-end: calc(-1 * var(--sp-8));
  padding-block: var(--sp-5) var(--sp-7);
  background-color: var(--surface-subtle);
}

/* `results-body__hint` RETIRED (adopt-results-view PR 2c): the shipped
   refine hint it drew retires with the numbered pagination below, exactly
   as this comment's own former text already predicted.

   ===========================================================================
   THE RESULTS BAR (adopt-results-view design.md §1/§4/§8-10, PR 2c)
   ===========================================================================
   `resultados.dc.html`'s own results-bar row: the real total/range on the
   left, the disabled sort placeholder on the right. A white box on the gray
   `.results-body` band, the same `.empty-state`/`.pagination__link` pattern
   already established on this band. */
.results-bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-3);
  margin-block-end: var(--sp-4);
  padding: var(--sp-3) var(--sp-4);
  background-color: var(--surface-card);
  border: 1px solid var(--border-default);
}

.results-bar__count {
  color: var(--text-body);
  font-size: var(--fs-small);
}

/* The sort control's own row — `display: flex` (not `.vehicle-search__field`'s
   stacked column), matching the mock's own inline "Ordenar por [select]"
   shape: the results bar is already short on vertical space, unlike the
   vehicle panel's dedicated field grid. */
.results-bar__sort {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
}

.results-bar__sort-label {
  color: var(--text-body);
  font-size: var(--fs-small);
}

/* Same disabled-placeholder shape as `.vehicle-search__select` (design.md
   §1/§9's own "reuses the SAME disabled-placeholder pattern" note) —
   `min-height: 44px` for the same pointer-target floor, even disabled: the
   floor is measured, not conditioned on interactivity. */
.results-bar__sort-select {
  min-height: 44px;
  padding: 0 var(--sp-2);
  background-color: var(--surface-page);
  color: var(--text-heading);
  font-family: inherit;
  font-size: var(--fs-small);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-md);
}

/* ===========================================================================
 * THE LAYOUT SHELL (adopt-results-view PR 2b; task 14.3, "the one measured
 * tax" of the 2a->2b seam)
 * ===========================================================================
 * PR 2a's body container was a single-column `.band__inner`. This wraps the
 * aside and the grid+card markup side by side: `.results-body__inner` is the
 * grid shell (mobile-first single column below 900px, matching the narrow
 * composition design.md §9 authors — the aside's own disclosure IS the
 * mechanism there, so no extra collapse rule is needed below the
 * breakpoint), `236px 1fr` at >=900px; `.results-content` occupies the `1fr`
 * cell and carries exactly what `.band__inner` used to hold directly (the
 * grid/empty-state body plus the route's own footer). The `.product-grid`
 * rule below is untouched — it already reserves the aside's 236px + 24px gap
 * in its own `--card-min` derivation, so nothing about the grid needed to
 * change for the aside to arrive. */
/* `display: flex; flex-direction: column; gap: var(--sp-4)` below 900px —
   NOT `display: block` (correction round, 2026-08-19; owner finding V1):
   `.results-filters` declared no margin of its own and stacked flush
   against `.results-content`, with nothing between the closed filter card
   and the first product card. `gap` is the fix rather than a margin on
   either child, because it is the SAME token (`var(--sp-4)`) the >=900px
   grid step below already uses between the two columns — one value governs
   the space between the aside and the content whether they sit side by
   side or stacked, instead of two separately-tuned numbers that could
   drift apart. */
.results-body__inner {
  display: flex;
  flex-direction: column;
  gap: var(--sp-4);
}

.results-content {
  display: block;
}

/* The >=900px `236px 1fr` step lives in the CONSOLIDATED "RESPONSIVE
   CONTRACT" >= 900px block below, beside `.results-header__inner`'s own
   step — this file's established pattern (every >=900px override shares
   ONE block; `atRuleBody("@media (min-width: 900px)")`, `tokens-css.test.ts`'s
   own helper, finds the FIRST such block by substring, so a second inline
   one here would silently steal every existing responsive assertion that
   relies on it). */

/* ===========================================================================
 * THE NO-JS FILTER ASIDE (adopt-results-view PR 2b; design.md §1, §9)
 * ===========================================================================
 * A plain `<details class="results-filters">` disclosure. Closed by default
 * in markup (no `open` attribute — 18 checkboxes pushing every result below
 * the fold on a phone was rejected, design.md §9). Below 900px the native
 * disclosure IS the drawer: `.results-filters__panel` follows the browser's
 * own open/closed rendering, no CSS override. At >=900px the summary is
 * hidden and the panel is forced visible via the `::details-content`
 * pseudo-element — supported across the FULL `§4.6` browser matrix at
 * measurement time (tasks.md 14.1: Chrome 151/150, Firefox 154/153, Safari
 * 26.6/18.x — every one at or above the feature's `chrome 131` / `firefox
 * 143` / `safari 18.4` floor), so no `open`-by-default fallback is shipped.
 * `::details-content`'s own spec makes this override apply regardless of the
 * native open/closed state, which is the whole mechanism: no script ever
 * toggles anything.
 * ------------------------------------------------------------------------ */
.results-filters {
  position: relative;
  background-color: var(--surface-card);
  border: 1px solid var(--border-default);
  padding: var(--sp-5) var(--sp-4);
  /* Restored (correction round, 2026-08-18) — the mock's own aside carries
     `overflow: hidden`. Removed earlier on a WRONG theory that it was
     clipping 1px off `.results-filters__summary`'s hit area; the real
     cause was unrelated (that rule's own comment records the fix). Not
     purely decorative: it is the second half of `.results-filters__corner`'s
     own fix below, matching the mock's structural pairing rather than
     relying on the ornament's geometry alone to stay inside the box. */
  overflow: hidden;
  /* --------------------------------------------------------------------
   * CAPPED WIDTH BELOW 900px (correction round, 2026-08-19; owner finding
   * V2, "el aside es muy ancho")
   * --------------------------------------------------------------------
   * NO SUB-900px REFERENCE EXISTS for this view — checked the handoff
   * package directly (`design_handoff_internas_lutricon/`) before deciding:
   * no mobile/narrow variant of `resultados.dc.html`, no media query
   * anywhere in it (it is a single-viewport static prototype). design.md
   * §9's own narrow composition was already "authored against the
   * handoff's rules since no mock exists" for the same reason. Craft
   * judgment, per `DEC-157` (2026-08-13) delegating exactly this.
   *
   * WHY A WIDTH CAP, NOT the other two defensible options the owner named.
   * Changing where the aside sits in the stack would put content the
   * visitor has not yet seen (the grid) ahead of the control that narrows
   * it, and design.md §9's own narrow-composition text already describes
   * the closed summary as "one row" near the top — moving it is a bigger
   * change than the reported defect asks for. Aligning the cap to the
   * grid's own `--card-min` geometry was considered and rejected: that
   * custom property is scoped inside `.product-grid`'s own rule (not
   * `:root`, which §8 freezes), so reaching it here would mean either
   * duplicating its literal in two rules that could drift apart, or
   * reaching into a rule this correction has no reason to touch — coupling
   * two independently-tuned components for a resemblance that is not
   * actually meaningful (a filter panel's comfortable width and a product
   * card's minimum width answer different questions).
   *
   * WHY 360px, GROUNDED IN TWO EXISTING REFERENCE POINTS, not invented.
   * `.search-card` (the landing page's own panel-shaped component, same
   * file) already established the pattern this rule follows: cap a
   * card-shaped element's width below the desktop breakpoint rather than
   * let it run full-bleed, with a literal (`max-width: 520px`) rather than
   * a token, because `:root` has none. 520px does not fit HERE: the owner's
   * own reported viewport (~490px) is narrower than 520px, so that cap
   * would never bind at the exact width the defect was observed. 360px is
   * this project's OWN reference narrow-phone width instead —
   * `playwright.config.ts`'s `mobile-360x800` project — comfortably under
   * the ~458px of content width the ~490px report implies (~490px minus
   * the band's 2x16px `--sp-4` gutter), so the cap visibly binds and
   * narrows the panel at the exact viewport the owner drove, while staying
   * fluid (100% width, uncapped) on any phone at or below 392px total
   * viewport width, where nothing narrower would help. Left-aligned by the
   * flex column's own default `align-items: stretch` (no `margin-inline:
   * auto` added) so the aside's own left edge stays flush with the grid's,
   * the same left-aligned rhythm the stacked composition already has.
   *
   * INERT AT >=900px, DELIBERATELY UNGATED. The grid step below fixes this
   * element's track at exactly 236px, well under 360px, so this
   * declaration never binds there — no `@media` guard needed to keep the
   * two out of each other's way. */
  max-inline-size: 360px;
}

.results-filters__summary {
  font-size: var(--fs-caption);
  font-weight: var(--fw-black);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
  color: var(--text-heading);
  cursor: pointer;
  /* 46px, not the bare 44px floor: MEASURED in a real browser
     (`test/e2e/accessibility.spec.ts`'s target-size suite) that a bare 44px
     `min-block-size` on this element's `display: flex` box rendered a
     43px-tall probe-passing area at 360px — a sub-pixel layout rounding
     this file has no other instance of, not a defect in the measurement
     tool. 2px of headroom clears it with margin rather than chasing the
     exact fraction. */
  min-block-size: 46px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-2);
}

/* ---------------------------------------------------------------------------
 * THE DISCLOSURE AFFORDANCE (correction round, 2026-08-18)
 * ---------------------------------------------------------------------------
 * `display: flex` above (needed for the 46px target-size fix) suppresses
 * Chromium's native `<summary>` marker entirely — proven with a control
 * capture of a default (`display: list-item`) summary against this flex
 * one. Below 900px this closed card is the ONLY route to the filters, so a
 * plain word with no triangle, chevron or icon is a real defect, not a
 * cosmetic gap: the project guide requires preserving native interaction
 * behavior, and a disclosure with no visible disclosure affordance fails
 * that. A hand-drawn CSS chevron replaces the suppressed native marker
 * rather than reverting to `display: list-item` (which would reopen the
 * 43px-hit-area bug the flex box exists to fix) — it reflects the SAME
 * `open`/closed state the native marker would, via the `[open]` attribute
 * selector below, and adds no new asset, no script and no hit-area cost of
 * its own (`pointer-events: none`, decorative, sized off the summary's own
 * text baseline rather than a separate target).
 * ------------------------------------------------------------------------ */
/* A STROKED chevron, NOT a filled triangle (correction round, 2026-08-19;
   owner finding V3, "too heavy"). A filled triangle (the border-trick this
   rule used before — three borders, one of them solid, the other two
   transparent) and a stroked chevron are different marks; the owner asked
   for the lighter one. The technique here is the OTHER classic CSS-only
   arrow: a small square carrying only its trailing two sides
   (`border-inline-end` + `border-block-end`), rotated 45deg so the corner
   those two sides share becomes the chevron's point — verified in a real
   browser before shipping (a throwaway two-state fixture, screenshotted)
   because the rotation math is exactly the kind of thing that reads right
   in a comment and renders backwards on screen. `rotate(45deg)` points
   down (closed); the open state below adds another 180deg. Same
   `pointer-events: none`, same transition, same `[open]`-attribute
   mechanism as before — only the shape changed. */
.results-filters__summary::after {
  content: "";
  flex: none;
  inline-size: 8px;
  block-size: 8px;
  border-inline-end: 2px solid var(--text-heading);
  border-block-end: 2px solid var(--text-heading);
  pointer-events: none;
  transform: rotate(45deg);
  transition: transform var(--speed-base) var(--ease-brand);
}

/* Points down when closed (the common "reveals on click" convention) and
   flips to point up once the native `open` attribute is set — the same
   attribute the browser already toggles on click with zero script. */
.results-filters[open] .results-filters__summary::after {
  transform: rotate(225deg);
}

/* The >=900px "hide the summary, force the panel visible via
   `::details-content`" step lives in the CONSOLIDATED "RESPONSIVE CONTRACT"
   >= 900px block below, for the same reason `.results-body__inner`'s own
   step does (this file's established one-block-per-breakpoint pattern). */

.results-filters__panel {
  display: flex;
  flex-direction: column;
  gap: var(--sp-5);
}

.results-filters:not([open]) .results-filters__panel {
  margin-block-start: var(--sp-4);
}

/* The aside's own corner ornament — the red light-panel crop of the same
   kit board the card corner draws from (design.md §11). Decorative,
   `alt=""`.
   ---------------------------------------------------------------------
   CORRECTION (2026-08-18), and the wrong reasoning it replaces recorded
   for anyone re-deriving this.
   ---------------------------------------------------------------------
   An absolutely positioned element's containing block is the PADDING BOX
   of its nearest positioned ancestor — `inset: 0` therefore ALREADY lands
   at `.results-filters`'s own padding-box corner, which is the card's
   real visible corner (offset only by the 1px border). The negative
   `calc(-1 * var(--sp-N))` insets this rule used to carry were based on
   treating that padding-box origin as the CONTENT box instead, which does
   not exist for an absolutely positioned child — cancelling the padding
   pushed the ornament a further 24/16px past the card's own edge, onto
   the gray body behind it (independent visual validation measured this
   directly: 15px left of and 23px above the card's real border box).
   Plain `inset: 0` is correct and needs no offset at all.
   `.product-card__corner` never had this bug because its own containing
   block, `.product-card__media`, carries no padding, so the same plain
   `inset: 0` was already correct there. */
.results-filters__corner {
  position: absolute;
  inset-block-start: 0;
  inset-inline-start: 0;
  inline-size: 52px;
  block-size: auto;
  display: block;
  pointer-events: none;
}

/* `padding-inline-start: 36px` (correction round, 2026-08-18) — NOT
   decoration, and NOT the mock's own 34px literal transcribed unchanged:
   the mock's aside has 18px of its own left padding, so its header row's
   34px lands text at 18+34=52px from the card's edge, exactly the 52px
   ornament width. This card's padding is `var(--sp-4)` (16px), so the
   equivalent clearance is `52 - 16 = 36px` — the arithmetic changes, the
   property (clear the ornament's exact horizontal footprint, not a
   guess) does not. Correcting `.results-filters__corner`'s insets alone
   was NOT enough: a correctly-positioned 52x62 ornament sits directly
   under `FILTROS`/`Limpiar` without this. Scoped to the header row only,
   matching the mock: the ornament's 62px height clears every OTHER row
   in the panel vertically before this row's own height is spent, so
   nothing below the header needs the same treatment. */
.results-filters__header {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-3);
  padding-inline-start: 36px;
}

.results-filters__heading {
  font-size: var(--fs-caption);
  font-weight: var(--fw-black);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
  color: var(--text-heading);
}

/* Declared here (below `tokens-css.test.ts`'s red allowlist entry) rather
   than at `--action-primary`: this is the SAME red-link treatment
   `.results-header__query`'s neighbours already use for an inline action. */
.results-filters__reset {
  font-size: var(--fs-caption);
  color: var(--action-primary);
  min-inline-size: 44px;
  min-block-size: 44px;
  padding-inline: var(--sp-2);
  display: inline-flex;
  align-items: center;
  justify-content: flex-end;
}

.results-filters__group {
  display: block;
}

/* `--text-body`, NOT `--text-muted` (correction round, 2026-08-18: the
   independent visual validator measured `--text-muted` at 3.578:1 here,
   below the 4.5:1 body-size floor — this element is 12px/700, nowhere near
   the 18.66px-bold large-text exemption). `--text-muted` must not paint
   body-size text anywhere in this file (line ~2838's own standing rule,
   already enforced for `.product__field-label`); this is the SAME shape of
   element as `.vehicle-search__label` (an uppercase caption group label),
   which took `--text-body` for the identical reason ("11px/700 is nowhere
   near the large-text threshold and `--gray-500` on white measures
   3.58:1"). Measured: `--text-body` on `--surface-card` (white) is
   8.954:1. */
.results-filters__group-label {
  font-size: var(--fs-caption);
  font-weight: var(--fw-bold);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
  color: var(--text-body);
  margin-block-end: var(--sp-2);
}

.results-filters__group-options {
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
}

/* Real checkboxes on `/buscar`, real navigation links on
   `/categorias/{id}` — the SAME visual treatment either way (design.md §1
   "the aside looks identical"), so one rule covers both `<label>` and `<a>`. */
.results-filters__option {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  font-size: var(--fs-body);
  color: var(--text-heading);
  min-block-size: 44px;
}

/* ---------------------------------------------------------------------------
 * THE CHECKBOX'S HIT AREA IS 44x44; ITS DRAWN BOX STAYS THE MOCK'S 15x15
 * ---------------------------------------------------------------------------
 * MEASURED, not assumed: `padding` on a native `<input type="checkbox">`
 * does NOT enlarge its hit-testing box in Chromium — `getBoundingClientRect`
 * stayed 15x15 regardless (verified directly before choosing this shape).
 * The floor this satisfies is design.md §5.5 / spec "Touch targets meet the
 * floor" (44px), checked by `test/e2e/accessibility.spec.ts`'s target-size
 * suite. The technique: an `appearance: none` `<input>` covers the WHOLE
 * 44x44 wrapper invisibly (still the real, focusable, checked/unchecked
 * control — `:checked` still fires, `:focus-visible` still outlines it, per
 * this file's global `:focus-visible` rule), and a `pointer-events: none`
 * decorative sibling draws the 15x15 box the mock specifies, toggled by the
 * SAME `:checked` state via the sibling combinator.
 * ------------------------------------------------------------------------ */
.results-filters__checkbox {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: none;
  inline-size: 44px;
  block-size: 44px;
  margin-inline: calc(-1 * (44px - 15px) / 2);
}

.results-filters__checkbox input[type="checkbox"] {
  position: absolute;
  inset: 0;
  inline-size: 100%;
  block-size: 100%;
  margin: 0;
  appearance: none;
  -webkit-appearance: none;
  cursor: pointer;
}

/* THE FOCUS RING FRAMES WHAT A PERSON SEES, NOT THE INVISIBLE 44x44 HIT AREA
   (correction round, 2026-08-18). This file's global `:focus-visible` rule
   would otherwise outline the input's own 44x44 box directly — a ring large
   enough to slice through the option's label text while the real 15x15
   painted control sits somewhere inside it, satisfying nothing a person is
   actually looking at. Suppressed here, on the higher-specificity selector
   below, and re-applied to the VISIBLE sibling instead via the SAME
   `:focus-visible` state (never a separate `:focus` fallback, so keyboard
   and mouse/touch focus stay distinguished exactly as the global rule
   intends) — the 44x44 hit area itself is untouched. */
.results-filters__checkbox input[type="checkbox"]:focus-visible {
  outline: none;
  box-shadow: none;
}

.results-filters__checkbox-box {
  inline-size: 15px;
  block-size: 15px;
  background-color: var(--surface-page);
  border: 1px solid var(--border-default);
  pointer-events: none;
}

.results-filters__checkbox input[type="checkbox"]:focus-visible ~ .results-filters__checkbox-box {
  outline: 3px solid var(--border-strong);
  outline-offset: 2px;
  box-shadow: var(--focus-ring);
}

.results-filters__checkbox input:checked ~ .results-filters__checkbox-box {
  background-color: var(--border-strong);
  border-color: var(--border-strong);
}

/* `.results-filters__option[aria-current="page"]` REMOVED, PR 2b2 (owner
   decision, 2026-08-19): the entries it styled are checkboxes now, not
   links, so `aria-current` no longer applies to them at all — see
   `templates/results.ts`'s `ResultsViewModel.filterAside` doc comment for
   the recorded reasoning. The checked-state colour above
   (`.results-filters__checkbox input:checked ~ .results-filters__checkbox-box`)
   is what now marks "this is the current selection," on both routes. */

/* `--text-body`, not `--text-muted` — same contrast correction and the
   same measured 8.954:1 on white as `.results-filters__group-label` above. */
.results-filters__group--declared .results-filters__declared-note {
  font-size: var(--fs-caption);
  color: var(--text-body);
  margin: 0;
}

.results-filters__vehicle {
  border-block-start: 1px solid var(--border-default);
  padding-block-start: var(--sp-4);
}

.results-filters__vehicle-panel {
  background-color: var(--surface-subtle);
  border: 1px solid var(--border-default);
  padding: var(--sp-3);
}

/* `--text-body`, not `--text-muted` — and measured SEPARATELY from the two
   rules above, because this text sits on `.results-filters__vehicle-panel`'s
   `--surface-subtle` (`#F2F4F7`), not on the card's white. `--text-muted`
   there was 3.247:1 (independent validator's own measurement); `--text-body`
   on `#F2F4F7` is 8.126:1 — still well clear of 4.5:1. */
.results-filters__vehicle-note {
  font-size: var(--fs-caption);
  color: var(--text-body);
  margin: 0;
}

.results-filters__form {
  display: flex;
  flex-direction: column;
  gap: var(--sp-5);
}

/* The navy secondary treatment (`.search-form__submit`'s own base colour),
   not red: the mock draws no submit button at all here (a static prototype
   has no no-JS operation to prove), so this control has no owner ruling
   assigning it the red primary treatment the allowlist otherwise requires —
   same discipline this file's own history records ("U1 and U2 demoted
   Cotizar and Ver repuestos to the navy secondary action against the mock"
   before an explicit owner ruling made them red). */
.results-filters__submit {
  min-block-size: 44px;
  background-color: var(--action-secondary);
  color: var(--text-inverse);
  border: 0;
  font-weight: var(--fw-bold);
  cursor: pointer;
}

/* ---------------------------------------------------------------------------
 * THE GRID: `auto-fill`, WITH A BOUNDED TRACK COUNT (design.md §10)
 * ---------------------------------------------------------------------------
 * The stepped 1fr / 2-column rules are GONE — replaced, not supplemented, so
 * the column count follows available width instead of a breakpoint table.
 *
 * `--card-min` IS THE INVARIANT, and 220px is derived rather than picked. The
 * band's inner measure is 1060px; the filter aside (this slice) claims 236px
 * plus a 24px gutter, leaving 800px for the grid. With a 16px gap:
 *
 *   3 tracks need 3x220 + 2x16 = 692px   fits in 800  -> the handoff's layout
 *   4 tracks need 4x220 + 3x16 = 928px   does not fit -> never 4 beside the aside
 *   5 tracks need 5x220 + 4x16 = 1164px  does not fit even at FULL width
 *
 * so every reachable count is 1, 2, 3 or 4 — each a divisor of `PAGE_SIZE`
 * (12), which is what keeps the last row of a full page from being ragged.
 * The `min()` guard is not decoration: without it a track wider than the
 * container overflows the page at the narrowest supported viewport.
 *
 * Card-internal heights are FIXED (the 132px image zone, the three-line name
 * box), so a reflow changes the number of columns and never the height a card
 * reserves — the CLS surface `.product__image` already had to close once.
 * ------------------------------------------------------------------------ */

.product-grid {
  --card-min: 220px;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(var(--card-min), 100%), 1fr));
  gap: var(--sp-4);
  margin: 0;
  padding: 0;
  list-style: none;
}

.product-grid__item {
  display: block;
}

/* The link is the WHOLE card and carries no visual box of its own: the cut
   frame inside it draws the border and the surface. Keeping the link
   unclipped is what leaves the focus ring intact (see `.cut-frame`). */
.product-card {
  display: block;
  block-size: 100%;
  color: var(--text-heading);
  text-decoration: none;
}

/* ---------------------------------------------------------------------------
 * FOCUS RING ON THE TWO CARD SURFACES (kept from the round-3 correction)
 *
 * The bare `:focus-visible` rule near the top of this file declares BOTH an
 * outline and the `--focus-ring` halo, and `.category-card`/`.product-card`
 * — equal specificity, later in source — used to win the cascade with
 * `--shadow-card` and silently swallow the halo. `.product-card` no longer
 * declares a shadow at all (the cut frame is its edge now), so on that half
 * the conflict is structurally gone; the rule stays because `.category-card`
 * still declares one, and because it is the pair a cascade-resolving test
 * asserts rather than a single rule body.
 * ------------------------------------------------------------------------ */

.category-card:focus-visible,
.product-card:focus-visible {
  box-shadow: var(--focus-ring);
}

/* Hover moves the FRAME's colour, exactly as the handoff specifies
   (`#0A3266` is `--navy-700`, this file's `--border-strong`). The card no
   longer carries a shadow to raise, and it no longer needs one: the cut and
   the border are the card's edge now. */
.product-card:hover .cut-frame {
  background-color: var(--border-strong);
}

/* AMENDMENT ROUND, Fork C (design.md §12): the media box is SQUARE at every
 * width, not a fixed 132px. Bare `aspect-ratio` — no `auto` — because the
 * reserving element here is `<span class="product-card__media">`, a
 * non-replaced flex container with no UA ratio to override, so issue #120's
 * defect class (a `width`/`height`-bearing replaced element letting the
 * decoded bitmap's natural ratio win) cannot be constructed on it.
 * `flex: none`: the media is a column flex item of `.cut-frame__surface`
 * (below), and a flex item carrying a ratio must never be shrunk below it.
 * `beta-catalog-discovery`'s MODIFIED requirement supersedes the retired
 * "reserved height stays constant across column counts" clause this fixed
 * height existed for — a square box makes height follow width by
 * construction, which is the point. */
.product-card__media {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  aspect-ratio: 1 / 1;
  flex: none;
  overflow: hidden;
  background-color: var(--surface-subtle);
  border-block-end: 1px solid var(--border-default);
}

/* The kit board's light-panel gray corner, pinned to the zone's top-left.
   Decorative, `alt=""`, and one shipped file referenced once per card — one
   fetch for the whole page. */
.product-card__corner {
  position: absolute;
  inset-block-start: 0;
  inset-inline-start: 0;
  inline-size: 54px;
  block-size: auto;
  display: block;
}

/* The canonical placeholder artwork in the design system's 1:1 "cards and
   grids" format variant. The derived-44% sizing (0.44 x 220 = 96.8px, the
   shipped 96px at the 220px desktop track) is SUPERSEDED (Slice 6, third
   review round, 2026-08-24: the media-fill requirement) by the SAME
   full-fill rule its sibling `.product-card__image` already carries, just
   below — the owner's stated intent is that the placeholder behaves exactly
   as a real photograph will, so both now obey one longest-side-touches-edge
   rule instead of the placeholder staying pinned below what a real image at
   `object-fit: contain` can grow past. `aspect-ratio`/`object-fit` are
   UNCHANGED by this — this phase governs only what fills the box, never the
   box's own ratio. */
.product-card__placeholder {
  display: block;
  inline-size: 100%;
  block-size: 100%;
  aspect-ratio: 1 / 1;
  object-fit: contain;
}

/* Approved media occupies the SAME reserved box, letterboxed rather than
   distorted, so the two states are the same size on screen. AMENDMENT ROUND
   (design.md §12): fills the now-square `.product-card__media` container
   instead of a fixed 132px — no `width`/`height` attributes on this
   element (verified against results.ts), so it never carried a UA ratio to
   override either. */
.product-card__image {
  display: block;
  inline-size: 100%;
  block-size: 100%;
  object-fit: contain;
}

/* 148px = 14 + 18 of block padding, the 25.2px two-line eyebrow box, two 8px
   gaps, the 57.6px three-line name box and a 16.8px code line, which sums to
   147.6. Every one of those parts is BOTH reserved and clamped, so this floor
   is also the ceiling — which is what makes a card the same height at one
   column and at four, and the same height whether or not its category
   resolved to a name. It was 152px while the eyebrow was 12px; the correction
   round returned the eyebrow to the mock's 10.5px and this arithmetic moved
   with it, because a floor that no longer matches the sum it was derived from
   is a magic number. */
.product-card__body {
  display: flex;
  flex: 1;
  flex-direction: column;
  gap: var(--sp-2);
  min-block-size: 148px;
  padding: 14px var(--sp-4) 18px;
}

/* Category only.

   SIZE AND TRACKING ARE THE MOCK'S AGAIN (correction round). This shipped at
   `--fs-caption` (12px) with `--ls-caps` (.14em), which put the eyebrow at
   nearly the weight of the product name and broke the card's hierarchy: the
   category read as the headline and the product read as a subtitle. The mock
   draws 10.5px at .12em, and both are literals here for the same reason the
   26/132/236px literals in this file are — the token set has no step at
   either value and design.md §8 freezes `:root`.

   THE COLOUR IS NOT THE MOCK'S, AND THAT IS DELIBERATE. The handoff paints
   this element `#7E8899`, which is `--text-muted` and measures 3.58:1 on the
   card's white surface; 10.5px/700 is nowhere near the 18.66px large-text
   exemption, so it fails AA outright and fails it harder at this smaller size
   than it did at 12px. `--text-body` (#3F4A5C, 8.95:1) is the lightest
   declared value that clears 4.5:1 here, so it is the closest this stylesheet
   can come to the handoff's intent without either failing the criterion or
   adding a token to a frozen block. The hierarchy the mock is after still
   holds, because it is carried by size and weight against a name painted
   `--text-heading` (#001430) at 16px — not by the grey alone.

   TWO RESERVED, CLAMPED LINES — measured, not chosen. The approved category
   names run to 45 characters ("Calefacción, ventilación y aire acondicionado"),
   which is two lines at every card width this grid can produce (221px of text
   at four columns, 256px at one) and never three; at 10.5px with .12em
   tracking those 45 characters still measure well past 256px, so the smaller
   size does not turn any of them into one line. Reserving them is what makes
   the card's height the same at every column count: a floor alone let a
   one-line eyebrow render a 271px card beside a two-line eyebrow's 285px one,
   observed against the real 40-row catalog at 320/360/640/1024px before this
   rule existed. */
.product-card__eyebrow {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
  min-block-size: calc(2 * var(--lh-heading) * 10.5px);
  color: var(--text-body);
  font-size: 10.5px;
  font-weight: var(--fw-bold);
  letter-spacing: .12em;
  line-height: var(--lh-heading);
  text-transform: uppercase;
}

/* THREE LINES, RESERVED AND CLAMPED. The reserved box is what keeps every
   card the same height through a reflow; the clamp is what keeps the reserved
   box from being a floor a long name walks straight through. The catalog's
   longest approved name is 66 characters and reaches three lines at the
   narrowest track, so three is the line count that truncates the fewest rows
   while still bounding the box. The full name stays in the DOM — the clip is
   visual, the accessible name is not shortened, and the detail page is one
   activation away. */
.product-card__name {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 3;
  line-clamp: 3;
  overflow: hidden;
  min-block-size: calc(3 * var(--lh-heading) * var(--fs-body));
  font-family: var(--font-display);
  font-size: var(--fs-body);
  font-weight: var(--fw-bold);
  line-height: var(--lh-heading);
}

/* The code is data, shown in a monospaced face so a visitor comparing it
   against the part in their hand can do it character by character. It carries
   NO type, issuer, OE/OEM or equivalence label — the handoff prints `OEM
   {code}` and the approved spec forbids exactly that.

   COLOUR IS --border-strong (--navy-700 #0A3266), WHICH IS THE MOCK'S OWN
   VALUE (correction round). Round 3 moved this rule to --text-body and
   justified it by citing the handoff's #7E8899 at 3.58:1 — but the handoff
   paints #7E8899 on the EYEBROW and paints this code navy. The rationale
   belonged to a different element, and the value it displaced was already
   compliant and higher-contrast than its replacement: navy measures 12.64:1
   on this white card surface against --text-body's 8.95:1. Restoring it is
   therefore a correction in both directions at once — closer to the reference
   AND higher contrast — not a fidelity concession.

   The DEFECT that round 3 was really fixing is untouched and still fixed:
   --text-muted must not paint body-size text, here or on
   .product__field-label, and `tokens-css.test.ts` still pins both. The TOKEN
   is a faithful Decision 7 transcription and was never the problem. This is
   also the code a visitor is meant to read character by character, which is
   the last thing that should be low contrast — and it is now the highest-
   contrast string on the card. */
.product-card__code {
  color: var(--border-strong);
  font-family: ui-monospace, monospace;
  font-size: var(--fs-small);
  letter-spacing: var(--ls-wide);
  line-height: var(--lh-heading);
}

/* The compatibility statement (design.md §5 "Render", `DEC-174`; PR 2d
   tasks 21.7/21.8/23.1) — `product-card__body`'s LAST child, present only
   when the product carries an attested application. WRAPS, never
   TRUNCATES: no `white-space: nowrap`, no line-clamp — CSS truncation could
   cut a multi-variant statement mid-token into a garbled claim (this
   file's own doc comment on the source function). One reserved line of
   height, so a wrapping statement grows the card and the server-rendered
   grid absorbs it with no client-side shift. */
.product-card__compatibility {
  min-block-size: calc(var(--lh-heading) * var(--fs-small));
  color: var(--text-body);
  font-size: var(--fs-small);
  line-height: var(--lh-heading);
}

/* The category route's page-1 empty state, now on the results body's gray
   band: `--surface-card` rather than `--surface-subtle`, which would be the
   band's own colour and leave the block with no edge at all. */
.empty-state {
  margin: 0;
  padding: var(--sp-6) var(--sp-4);
  background-color: var(--surface-card);
  color: var(--text-body);
  border: 1px solid var(--border-default);
  text-align: center;
}

/* --- Pagination --------------------------------------------------------- */

.pagination {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-3);
  margin-top: var(--sp-6);
}

/* `--surface-card`, not `--surface-subtle`: the links sit on the results
   body's gray band now, and `--surface-subtle` IS that band's colour, so a
   subtle-on-subtle control would have no visible box left. */
.pagination__link {
  display: inline-block;
  min-height: 44px;
  padding: var(--sp-3) var(--sp-5);
  background-color: var(--surface-card);
  color: var(--text-heading);
  font-weight: var(--fw-semibold);
  text-decoration: none;
  border: 1px solid var(--border-default);
  border-radius: var(--radius-md);
  transition:
    background-color var(--speed-fast) var(--ease-brand),
    transform var(--speed-fast) var(--ease-brand);
}

.pagination__link:hover {
  background-color: var(--navy-100);
}

/* Direction is carried by the token-driven order marker, not by the link
   text alone, so the pair reads correctly when they wrap onto two lines. */
.pagination__link--prev {
  order: 0;
}

.pagination__link--next {
  order: 1;
  margin-left: auto;
}

/* The numbered pagination's current page (adopt-results-view design.md
   §4/§10, PR 2c) — the mock's own literal treatment (navy fill, white
   text), same box as `.pagination__link` so the strip does not jump when a
   link becomes the current page across a navigation. Not a link: a visitor
   is already on this page, so nothing here is `href`-clickable. */
.pagination__current {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 44px;
  min-height: 44px;
  padding: var(--sp-3) var(--sp-4);
  background-color: var(--navy-950);
  color: var(--text-inverse);
  font-weight: var(--fw-semibold);
  border-radius: var(--radius-md);
}

/* The elided-pages marker (spec "Ellipsis and last page"). No box, no
   target — it names no interaction, so it carries none of `.pagination__
   link`'s target-size floor. */
.pagination__ellipsis {
  display: inline-flex;
  align-items: center;
  padding: 0 var(--sp-2);
  color: var(--text-body);
}

/* --- Search states ------------------------------------------------------ */

/* `.search-page__field` REMOVED (seam correction, `DEC-179`, 2026-08-21):
   it wrapped `/buscar`'s own light field skeleton, rendered above every
   message state before the navy header extended to cover them too — see
   http/templates/search.ts's removed `renderSearchField` for the full
   record. The field now renders inside the SAME shared navy
   `.results-header` band every message state's own `renderSearchHeader`
   call renders, so this wrapper's one rule (a bottom margin between the
   field and the state box that followed it as a sibling) has no caller left
   to serve. */

/* CORRECTED (task 24.3's independent validation, owner ruling 1): the
   frame REPLACES the shipped panel, keeping only its gray band. This used
   to also carry `border-radius: var(--radius-lg)` and a 4px
   `border-left`, stacking a SECOND shape language (a rounded panel with
   its own coloured border) on top of PR 2d's `.cut-frame` nested inside
   it — the mock draws no rounded corner and no left border here, only the
   gray band BEHIND the frame's own single 1px border, which is what makes
   the frame's angular cut and hairline border visible at all rather than
   dissolving into the page's white background. Both removed below; the
   background colour (the band itself) stays. */
.search-state {
  padding: var(--sp-5) var(--sp-4);
  background-color: var(--surface-subtle);
}

/* CORRECTED (same ruling): the left border removed above was every
   state's only PER-STATE colour signal. Correction 4 replaces it — all
   four states now render a red `.state-block__label` (task 23.1,
   extended by ruling 4), uniform across every state rather than
   differentiated per kind, which is what the mocks themselves draw (9d
   and 9e's own labels share the identical red). These four kind
   modifiers stay declared — the closed class set the templates still
   render `search-state--${kind}` from — with no properties of their own;
   `.search-state--results` stays RETIRED (adopt-results-view): the
   `results` kind never renders through this frame, so no rule exists for
   it. */
.search-state--malformed,
.search-state--ambiguous,
.search-state--none,
.search-state--toolong {
}

.search-state__title {
  font-size: var(--fs-h2);
}

.search-state__message {
  margin: 0;
  color: var(--text-body);
  font-size: var(--fs-body);
}

/* The no-useful-result state's two onward routes (design.md Data Flow;
   beta-catalog-discovery's "Routes onward" scenario): a static category-
   discovery link and the site's existing generic contact action. Spaced the
   same way `.search-state__message` sits below the heading. */
.search-state__actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-4);
  margin: var(--sp-4) 0 0;
}

/* Deliberately NOT `--action-primary`: `tokens-css.test.ts`'s red allowlist
   ("paints red on exactly the declared usages, and nowhere else") enumerates
   every selector permitted to use it, and this pair is not on it — the
   design's single red accent stays reserved for the page's ONE primary CTA
   (Decision 7). Styled like `.pagination__link`'s existing bordered
   secondary-link pattern instead: same neutral palette, same 44px touch
   target floor. */
/* CORRECTED (second correction round, owner ruling 6): this action pair
   used to draw `--radius-md` (8px) rounded corners inside a hairline frame
   that also carries a 30px diagonal corner cut — the exact shape-stacking
   the frame's own rounded-panel removal (ruling 1, above) was meant to end,
   surviving one level in on the actions themselves. Neither mock draws any
   rounding here — `border-radius` stays absent below, unchanged. */
/* THE CUT RETURNS (owner review, seam correction round, 2026-08-21). A
   PRIOR round applied `clip-path` to `.search-state__action` itself — the
   focusable `<a>` — which erases that element's own outline/box-shadow, so
   the fix at the time dropped the cut entirely rather than accept a clipped
   focusable. That tradeoff was never necessary: `.product-card` (above; see
   its own doc comment, "the link is the WHOLE card and carries no visual
   box of its own: the cut frame inside it draws the border and the
   surface. Keeping the link unclipped is what leaves the focus ring
   intact") already solves exactly this with the SAME `.cut-frame`/
   `.cut-frame__surface` two-layer mechanism reused (below) — the link stays
   a bare box with no clip of its own, and the cut lives on a nested,
   non-focusable span instead (`renderStateActionLabel`,
   `templates/search.ts`).

   `.search-state__action-frame`/`.search-state__action-surface` are
   ADDITIVE classes, rendered alongside `.cut-frame`/`.cut-frame__surface`
   on the SAME two spans — never a replacement, and never an ancestor-scoped
   `.search-state__action .cut-frame`/`.cut-frame__surface` selector — the
   SAME "additive class, not a descendant selector" discipline
   `.state-block__surface` already establishes (above; see its own doc
   comment): this file's `focus-ring cascade` tests model plain compound
   selectors, not descendant ones, so a rule with `.cut-frame__surface`
   itself as the selector's SUBJECT would be an unmodelled cascade winner
   that guard is built to catch loudly. */
.search-state__action {
  display: inline-block;
  min-height: 44px;
  color: var(--text-heading);
  font-weight: var(--fw-semibold);
  text-decoration: none;
}

/* A button-sized cut, not the card's 26px or the state frame's own 30px/
   20px (same `--cut` custom-property override mechanism as
   `.search-state .cut-frame` below, expressed as an additive class instead
   of a descendant selector for the reason above). */
.search-state__action-frame {
  --cut: 12px;
}

.search-state__action-surface {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 44px;
  padding: var(--sp-3) var(--sp-5);
  background-color: var(--surface-subtle);
}

/* BOTH doors' SOLID action pair (proposal.md "The one visual note worth
   carrying forward"; design.md §8; PR 2d task 23.1, corrected — task
   24.3's independent validation), overriding the bordered base above
   rather than replacing it. CORRECTED: batch 1/2 applied this pair to 9d
   only, reasoning that 9e's own mock (`codigo-no-reconocido.dc.html`)
   drew a plain bordered row — re-reading that mock's inline styles shows
   its primary action filled `var(--red-600)` and its WhatsApp action
   filled `#25D366`, the SAME solid pair 9d draws. The three door-less
   states (`malformed`/`ambiguous`/`toolong`) keep the bordered treatment —
   this pair belongs to the `none` composition alone, both doors.
   `--action-primary` is `--red-600` (Decision 7), the declared red
   allowlist's own entry; `#25D366` is WhatsApp's own official green,
   already declared for the chrome's own affordance
   (`.footer__social-link--whatsapp`) on the identical terms. Both layers
   (the frame's "border" ring and the surface's fill) paint the SAME
   colour, exactly as the original single-element rule's
   `background-color` and `border-color` already were — nothing about the
   solid look changes, only which element draws it. Scoped through the
   ADDITIVE `.search-state__action-frame`/`-surface` classes, never through
   `.cut-frame`/`.cut-frame__surface` directly (see this rule's own header
   comment above). */
.search-state__action--primary .search-state__action-frame,
.search-state__action--primary .search-state__action-surface {
  background-color: var(--action-primary);
}

.search-state__action--primary .search-state__action-surface {
  color: var(--text-inverse);
}

.search-state__action--primary:hover .search-state__action-frame,
.search-state__action--primary:focus-visible .search-state__action-frame,
.search-state__action--primary:hover .search-state__action-surface,
.search-state__action--primary:focus-visible .search-state__action-surface {
  background-color: var(--action-primary-hover);
}

/* AMENDED — OWNER RULING, 2026-08-21, SUPERSEDING THE 2026-08-18 (task
   24.3) CORRECTION DIRECTLY ABOVE IN HISTORY. That correction repointed
   this label from the mocks' own `color:#fff` to `var(--text-heading)`
   because white-on-`#25D366` measures 1.98:1, under SC 1.4.3's 4.5:1 floor
   for normal text, and a real axe scan blocked on it. The owner has now
   reviewed that exact number and restored WhatsApp's brand pairing anyway:
   white text on `#25D366`, matching both mocks' own literal styling and
   the floating bubble's/footer link's identical fill. The failure is
   real and accepted in writing, not reasoned away.

   THIS IS NOT THE SAME CASE AS `.whatsapp-bubble` OR
   `.footer__social-link--whatsapp`, and must not be read as though it
   were. Those two controls paint NO text node at all — their accessible
   name lives in `aria-label`, so axe's `color-contrast` rule (which only
   scans text nodes) has nothing to flag there; the criterion that
   actually governs their glyph is SC 1.4.11 Non-Text Contrast (3:1), and
   axe-core 4.12.1 ships no rule for it at all, which is why that pairing
   is recorded as a documented TOOLING GAP rather than a filtered finding
   (see `.whatsapp-bubble`'s own comment above). THIS button renders a
   real, visible text label ("Consultar por WhatsApp"): SC 1.4.3 applies,
   axe implements it, and 1.98:1 is a genuine failure axe correctly
   detects. Filtering it is a deliberate acceptance of a real AA failure
   on text, not the documentation of a tooling gap — recorded as a
   load-bearing, narrowly-scoped `color-contrast` exemption in
   `test/e2e/accessibility.spec.ts` (selector
   `search-state__action--whatsapp`), never by relaxing the axe
   configuration itself. See `openspec/changes/2026-08-17-adopt-results-view/
   proposal.md`'s `DEC-177` for the full ruling. Both layers paint the SAME
   colour, on the identical two-layer, additive-class terms
   `.search-state__action--primary` above already establishes. */
.search-state__action--whatsapp .search-state__action-frame {
  background-color: #25D366;
}

/* The surface declares BOTH halves of the pair in one rule, deliberately.
   `tokens-css.test.ts`'s contrast table reads a rule body by selector and
   takes the first match, so splitting the colour away from its own
   background put the accepted `DEC-177` ratio out of that guard's reach —
   and that guard is the only machine check left on it, since `clip-path`
   stops axe computing this node's background at all. Keeping them together
   is what keeps the number pinned. */
.search-state__action--whatsapp .search-state__action-surface {
  background-color: #25D366;
  color: var(--white);
}

.search-state__action--whatsapp:hover .search-state__action-frame,
.search-state__action--whatsapp:focus-visible .search-state__action-frame,
.search-state__action--whatsapp:hover .search-state__action-surface,
.search-state__action--whatsapp:focus-visible .search-state__action-surface {
  background-color: #1EB855;
}

/* The message-state frame's own `--cut` context (design.md §7: "30px on
   state blocks, 20px below 900px" — distinct from the 26px cards use, which
   stays constant). Mobile-first: this IS the base rule; the >= 900px block
   below overrides it. PR 2d task 23.1 — task 21.1 added the `.cut-frame`
   markup itself but left this context override unset, so every state block
   rendered at the CARD size (26px) until now. */
.search-state .cut-frame {
  --cut: 20px;
}

/* CORRECTED (task 24.3's independent validation, defect found by BOTH
   validators, every viewport, both filter states): `.cut-frame__surface`
   itself carries NO padding, so state-block content sat flush against the
   frame's own 1px border and the primary action visually merged into it.
   The mock insets `40px 44px 44px` (`sin-resultados.dc.html`/
   `codigo-no-reconocido.dc.html`'s own inline styles, identical in both).
   `.state-block__surface` is an ADDITIVE class `results.ts`'s
   `renderStateBlock` renders alongside `.cut-frame__surface`, never a
   replacement — the base class stays bare because `.product-card` shares
   it and sets its OWN padding via `.product-card__body`; adding padding
   there would double it. A single compound class rather than an
   ancestor-scoped `.search-state .cut-frame__surface` selector, so this
   file's own cascade-modelling tests (`focus-ring cascade`, below) can
   resolve it as a normal compound selector instead of an unmodelled
   descendant one. Literal px values, not `--sp-*` tokens: the token set
   has no step at 40 or 44 (the SAME "the token set has no step at either
   value" judgment this file's own card-eyebrow and card-height comments
   already use for the mocks' own literals).

   CORRECTED (second correction round, defect 2): this 44px horizontal inset
   shipped UNSCALED to narrow viewports — at 320px the frame is 254px, so
   88px of it (34.6%) was padding alone, leaving a 166px content column. Two
   independent symptoms shared that one root cause: the actions row wrapped
   to three lines, and the echoed code broke MID-TOKEN ("ZZZNOEXIST" /
   "E123") on 9e, the one composition whose entire job is showing the
   visitor their own code back. The EXISTING `@media (max-width: 639.98px)`
   block (this file's own header/brand-mark narrow breakpoint, near the top
   of this file) now ALSO narrows this rule's horizontal padding to
   `--sp-4` (16px each side) — the smallest step that keeps the 9e code
   echo (`ZZZNOEXISTE123`, `--fs-h2`/900 weight, measured 218px unwrapped)
   on one line at 320px: 254 - 32 = 222px of column, verified directly
   (Playwright, live seeded catalog) at both 320 and 360. Vertical padding
   is untouched — the defect and its fix are both horizontal. Merged into
   that EXISTING block rather than a second one of its own: a second
   `@media (max-width: 639.98px) {}` would be valid CSS but would also
   defeat this file's own `ruleBody(stylesheet, ...)`/`baseLayer()` test
   helpers, which assume each at-rule prelude appears once. */
.state-block__surface {
  position: relative;
  padding: 40px 44px 44px;
  /* Query container for the isotype's own promotion (second correction
     round, defect 4, below) — this element's OWN available width, not the
     viewport, is what decides whether there is room for it. */
  container-type: inline-size;
}

/* Second correction round, defect 2 — see the base rule's own comment above
   for the full measurement. A SEPARATE `@media (max-width: 639.98px)` block
   from the existing header/nav one earlier in this file, and DELIBERATELY
   positioned AFTER the base rule above: same selector, same specificity, so
   the cascade's own last-rule-wins tiebreak is what actually makes this
   override win at a narrow viewport — placing it before the base rule (as
   this file's single-block-per-breakpoint convention would otherwise
   suggest) would have the base rule's LATER position in the file silently
   win instead, regardless of which media query matched. Verified directly
   (Playwright, live seeded catalog): the base padding survived unchanged
   at 320px when this block was tried earlier in the file, and only visibly
   narrowed once moved here. */
/* The frame's own corner ornament (second correction round, defect 8): both
   mocks (`sin-resultados.dc.html`/`codigo-no-reconocido.dc.html`) place a
   96px gray corner at the frame's top-left; the shipped composition carried
   none. THE SAME FILE `.product-card__corner` already ships
   (`kit/esquina-superior-izquierda-l2.svg` — "the LIGHT panel's ... gray"
   crop, `landing-assets.test.ts`'s own `KIT_CROPS` comment), not a new
   promotion: its `viewBox="935 170 170 206"` is byte-identical to the design
   handoff's own `kit2/esq-sup-izq-gris.svg` crop window (verified directly),
   so this is a second referrer to an asset already inventoried and digest-
   pinned, rendered bigger via `inline-size` alone — no new file, no new
   digest pin. `position: relative` above is this ornament's containing
   block; `inset: 0` needs no offset for the same reason
   `.product-card__corner`'s own containing block (`.product-card__media`)
   never needed one — the containing block for an absolutely positioned
   child is the PADDING box of the nearest positioned ancestor, which is the
   frame's own visible corner regardless of how much padding this element
   carries (`.results-filters__corner`'s own doc comment has the fuller
   reasoning, corrected there for the identical class of bug). */
.state-block__corner {
  position: absolute;
  inset-block-start: 0;
  inset-inline-start: 0;
  inline-size: 96px;
  block-size: auto;
  display: block;
  pointer-events: none;
}

/* Second correction round, defects 2 and 8 together — both narrow-viewport
   overrides live in ONE block, positioned AFTER both base rules above (same
   cascade-order reasoning as this file's other post-base override blocks):
   equal-specificity CSS needs the later rule to win regardless of which
   media query matched.

   `.state-block__corner` specifically: a 96px ornament and a 16px inset
   (the padding step immediately above) collide — verified directly
   (Playwright, live seeded catalog, viewports 320-639px): the corner's own
   ink overlaps the red state label and the `<h1>` at EVERY width in this
   narrow range, never merely a bounding-box false positive (confirmed by
   cropped screenshot, not just rect math). Above 640px the SAME 96px
   corner clears real content with the full 44px inset and is left alone.
   No mock authorizes a narrow-viewport reading of a fixed-width desktop
   composition, so gating this decorative element on the SAME available-
   space principle defect 4 already establishes for the isotype — rather
   than shipping a collision, or shrinking the ornament away from its own
   96px spec — is the consistent fix.

   THIRD CORRECTION ROUND, DEFECT 6 — FIX THE CAUSE, NOT THE SAMPLE. The
   padding step above was sized to ONE literal's own unwrapped pixel width
   (`ZZZNOEXISTE123`, 218px at the un-narrowed `--fs-h2`/900) fitting inside
   the resulting 222px column at 320px — it never accounted for a
   DIFFERENT code of the SAME length rendering WIDER: `A0009937302XYZ` is
   the identical 14 characters and still breaks mid-token
   ("A0009937302XY"/"Z") at 320px, because different glyphs occupy
   different pixel widths at the same size. The column itself cannot grow
   much further without repeating defect 2's own regression (a narrower
   column feeding the actions row and the corner ornament), so the lever is
   the ECHOED CODE'S OWN SIZE, scoped to `.search-state__title` — 9e's
   message-state heading — alone: the navy results-header usage of this
   SAME class (above) keeps its unmodified `--fs-h2`/`--fw-black` size and
   its own `large: true` 3:1 contrast clearance untouched, because THAT
   context's `overflow-wrap: anywhere` at the SAME `--surface-dark`
   pairing was never part of this defect and this fix does not need to
   touch it. `--action-primary` on white measures ~4.88:1, clearing the
   4.5:1 body-text floor at ANY size, so shrinking this context needs no
   large-text exemption to justify it.

   Measured directly (Playwright, live seeded catalog): at 18px the widest
   of three codes of genuinely different glyph widths — `ZZZNOEXISTE123`,
   14 chars (150px); `A0009937302XYZ`, 14 chars (158px); and
   `WDB9066331S1234567`, 19 chars (199px) — still clears the narrowed
   column at BOTH supported narrow widths (222px at 320px, 262px at
   360px), with 23-63px of margin to spare — a real margin against a
   different real sample, not a fit tuned to one literal again. */
@media (max-width: 639.98px) {
  .state-block__surface {
    padding: 40px var(--sp-4) 44px;
  }

  .state-block__corner {
    display: none;
  }

  .search-state__title .results-header__query {
    font-size: 18px;
  }
}

/* The isotype's own two-column ROW (correction round, task 24.3, ruling
   8): `.state-block__row` renders ONLY when 9d's isotype is present
   (`results.ts`'s `renderStateBlock`); every other state's content sits
   directly inside `.cut-frame__surface`'s own column flow, unchanged.
   Mobile-first: below 900px the isotype itself stays `display:none`
   (unchanged below), so this row's one visible child is
   `.state-block__content` regardless of `flex-direction` — the >= 900px
   step further down is what turns it into the mock's own side-by-side
   row (`sin-resultados.dc.html`: `display:flex;gap:40px;
   align-items:flex-start`). */
.state-block__row {
  display: flex;
  flex-direction: column;
}

.state-block__content {
  min-width: 0;
}

/* The `.state-block__*` family (design.md §8/§12; PR 2d task 23.1): the
   door-conditional 9d/9e composition batch 1 shipped classless, retrofitted
   here. Lives INSIDE the pre-existing `.search-state`/`.cut-frame` frame —
   see `results.ts`'s `StateBlockProps` doc comment. */
.state-block__label {
  display: inline-block;
  color: var(--text-accent);
  font-size: var(--fs-caption);
  font-weight: var(--fw-bold);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
}

.state-block__suggestions {
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
  margin: var(--sp-4) 0 0;
}

.state-block__suggestion {
  color: var(--text-body);
  font-size: var(--fs-body);
}

.state-block__category-chips {
  margin: var(--sp-5) 0 0;
}

/* EYEBROW TREATMENT (third correction round, defect 9). Rendered 16px/600
   navy with no transform or tracking — nothing but the size distinguished
   it from the surrounding body text, so it competed with the title block
   instead of reading as a quiet eyebrow above the chips, against the
   mock's own 11.5px/700 uppercase `.12em` gray. The RULED type-scale
   exemption (owner ruling 9, second correction round — the larger,
   accessibility-preferred scale stays) covers SIZE alone: this rule keeps
   `--fs-body` (16px), explicit rather than inherited, and adds the THREE
   properties the exemption never touched — `text-transform: uppercase`,
   `letter-spacing: var(--ls-caps)`, and GRAY rather than navy — the exact
   treatment `.state-block__label` (this file, above) already proves out at
   its own smaller size. `--text-body`, not `--text-muted`: the SAME
   contrast judgment `.results-filters__group-label`'s own doc comment
   already records for an identical uppercase-caption shape on this
   surface (`--text-muted` measures 3.578:1 here, under the 4.5:1 floor;
   `--text-body` measures 8.954:1) — at 16px bold this margin is even less
   contested than that 12px case. */
.state-block__category-chips-heading {
  margin: 0 0 var(--sp-3);
  color: var(--text-body);
  font-size: var(--fs-body);
  font-weight: var(--fw-bold);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
}

.state-block__chip-list {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-3);
}

/* CORRECTED (second correction round, owner ruling 9): both mocks draw
   these chips SQUARE, with the SAME 1px gray border this rule already
   carries (`--border-default`) — `--radius-pill` (999px) was the one
   element in the shipped product carrying a radius at or above 100px. The
   larger type sizes and the 44px `min-height` floor stay exactly as they
   are: both validators named those a better accessibility outcome than the
   mock's own smaller scale, and the owner ruled to keep them — this
   correction touches only the corner radius, no font size. Applies to BOTH
   chip families this one class serves — the category chips and the
   source-of-code chips (`renderCategoryChips`/`renderSourceCodeChips`,
   `templates/results.ts`/`templates/search.ts`) — since neither renders
   through a class of its own. */
.state-block__chip {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
  min-height: 44px;
  padding: var(--sp-2) var(--sp-4);
  background-color: var(--surface-page);
  color: var(--text-heading);
  font-weight: var(--fw-semibold);
  text-decoration: none;
  border: 1px solid var(--border-default);
}

a.state-block__chip:hover,
a.state-block__chip:focus-visible {
  border-color: var(--border-strong);
}

.state-block__chip-icon {
  display: block;
}

.state-block__source-chips {
  margin: var(--sp-4) 0 0;
}

/* Mirrors `.state-block__category-chips-heading` exactly (second correction
   round, defect 7; eyebrow treatment added third correction round, defect
   9 — see that rule's own comment for the full reasoning) — the same
   heading treatment, applied symmetrically to 9e's own chip group. */
.state-block__source-chips-heading {
  margin: 0 0 var(--sp-3);
  color: var(--text-body);
  font-size: var(--fs-body);
  font-weight: var(--fw-bold);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
}

/* `.state-block__retry`/`.state-block__retry-heading` REMOVED (third
   correction round, `DEC-178`) with the retry field they styled —
   `templates/search.ts`'s own removed `renderRetryField` doc comment
   carries the full record. */

/* 9d's promoted isotype (design.md §11 asset table, §12; PR 2d task 23.2):
   HIDDEN below the query container's own threshold per the mock — this is
   the mobile-first base rule; the `@container` rule below (second
   correction round, defect 4) un-hides it at 16% opacity, purely
   decorative, ONLY where the state block's own available width can actually
   hold it. */
.state-block__isotype {
  display: none;
}

/* CORRECTED (second correction round, defect 4): the isotype's promotion
   used to be gated on VIEWPORT width alone (`@media (min-width: 900px)`),
   but the state block's own available width depends on whether the filter
   aside (236px + gap) is present beside it, which the viewport cannot see.
   `.state-block__surface` is the query container (`container-type:
   inline-size`, its own rule above), so promotion is gated on the space
   actually available instead.

   CORRECTED AGAIN (third correction round, defect 7): the number above was
   WRONG on its own terms, independent of anything below. A `container-type:
   inline-size` query resolves against the container's own CONTENT box —
   `.state-block__surface`'s border-box width MINUS its `40px 44px 44px`
   padding (88px horizontal) — never the border box. The previous comment
   reasoned from border-box measurements (the FORMER unfiltered surface's
   own ~834px at vw=900) to justify a `800px` threshold, which is 46px
   ABOVE that surface's real 746px content box — so the isotype did not
   actually promote until vw≈954, a real ~54px band where the comment
   claimed it rendered and it did not. The declared value must be measured
   against the content box directly; nothing else in this rule needed to
   change to fix THAT part of the bug.

   THE PREMISE ITSELF IS ALSO GONE, in the SAME round (owner ruling 2,
   "Unify the measure" — `templates/search.ts`'s `renderMessageStateShell`).
   The asymmetry this rule was built to detect — an UNFILTERED composition
   with a wide, aside-free row (746-1046px content box) against a FILTERED
   one capped at ~686px — no longer exists: ruling 2 renders EVERY message
   state, filtered or not, inside the SAME `236px 1fr` grid, so
   `.state-block__surface`'s content box is now IDENTICAL whether or not a
   filter is applied (measured directly, Playwright, live seeded catalog:
   446px at vw=900, 686px at vw=1440 — BOTH doors, BOTH filter states).
   `.state-block__row`'s own >= 900px layout step (above) still uses the
   viewport, which is harmless — see that rule's own comment.

   WHAT THIS LEAVES UNRESOLVED, REPORTED RATHER THAN FORCED. Defect 7's own
   instruction asks for "unfiltered promotion resumes at vw 900" — a target
   this comment cannot honour without reopening the EXACT problem defect 4
   fixed: at the now-UNIFIED vw=900 content box (446px), isotype+gap
   (190px) still eats 42.6% of the row and still compresses content to
   256px, the SAME number defect 4 measured and rejected as unacceptable —
   now for BOTH doors, not only the filtered one, because there is no
   longer a wider "unfiltered" row to distinguish it from. The threshold is
   `640px`, chosen from the measured content box across the supported range
   rather than from a viewport number: 446px at vw=900, 506 at 960, 570 at
   1024, 646 at 1100, and a 686px ceiling from vw=1200 up. At 446px the
   isotype plus its 40px gap would take 42.6% of the row — the compression
   defect the second correction round fixed. At the 686px ceiling it takes
   27.7%. `640px` therefore promotes only from roughly vw=1094, where the
   row is wide enough to carry it, and stays hidden below that.

   Owner ruling, 2026-08-21, after unifying the measure left the isotype
   unable to render at any width under the previous `746px` value. A
   `min-width` low enough to fire at 446px would indeed fire everywhere and
   always compress, which is why the value sits between the two measured
   extremes rather than below both. */
@container (min-width: 640px) {
  .state-block__isotype {
    display: block;
    flex: none;
    padding-top: 6px;
    opacity: .16;
    pointer-events: none;
  }
}

/* --- Product detail ------------------------------------------------------
 * adopt-product-detail Slice 1 (design.md §1/§4): the whole page rewritten
 * in place. One mobile-first source order — breadcrumb band, viewer, panel —
 * that reads correctly at every viewport (design §4). The two-column split
 * (viewer | panel) engages at >= 900px, in the `@media (min-width: 900px)`
 * block below, per Fork 2's ruling: 900px is where every OTHER shipped
 * surface already transitions, replacing the archived 1024px step.
 * ------------------------------------------------------------------------ */
/* AMENDMENT ROUND, Fork A (design.md §10): `.product` adopts the band
 * mechanism itself so every top-level section on this page shares ONE
 * 1060px content measure — CSS only, no markup change, scoped to this
 * route. `margin-inline` escapes to the full viewport (byte-identical to
 * `.band`'s own two lines, tokens.css:957) and `padding-inline` (below, in
 * the existing `@media (min-width: 900px)` block) reproduces `.band`'s
 * 16/40 gutter step and folds the 1060px cap into it via `max()`. Nothing
 * else in this route was measured to need this: `.pd-context` and
 * `.vehicles-band` keep their OWN `margin-inline: calc(50% - 50vw)`
 * untouched — that escape resolves against the viewport via `vw`
 * regardless of nesting depth, so it bled to the full width before this
 * change too. What was missing was `.product`'s OWN shared measure for its
 * direct children (the viewer, the panel, the availability card). */
.product {
  display: grid;
  /* `minmax(0, 1fr)`, not bare `1fr` — a real bug this amendment round
     found and closes here, not a style preference. A bare `fr` track
     carries an IMPLICIT `auto` minimum, which does not shrink below its
     content's min-content contribution. At 320px some descendant's
     min-content (transitively, deep inside the viewer or the breadcrumb
     band) exceeded the intended 288px content box, so the track — and
     therefore every grid item's own containing block, including
     `.pd-context`'s — silently widened past it: measured in Chromium,
     `.pd-context`'s own `calc(50% - 50vw)` escape resolved against a
     ~324.94px containing block instead of 288px, landing its content box
     at x=34.47 instead of x=16 and breaking Fork A's whole point — one
     shared content measure — at exactly the narrowest supported width.
     `minmax(0, 1fr)` is the standard fix: it keeps the track's PREFERRED
     size at `1fr` (fills available space) while explicitly floors its
     MINIMUM at 0, so it can shrink to the intended 288px regardless of
     what a descendant would otherwise prefer — the same pattern
     `.product-grid`'s own `minmax(min(var(--card-min), 100%), 1fr)`
     already uses for the identical reason. */
  grid-template-columns: minmax(0, 1fr);
  gap: var(--sp-5);
  margin-inline: calc(50% - 50vw);
  padding-inline: var(--sp-4);
}

/* ---------------------------------------------------------------------------
 * THE BREADCRUMB BAND (design.md §5, AMENDED — owner-review round,
 * 2026-08-22)
 * Three levels — Inicio · category · product — plus the shipped
 * `renderSearchForm(null, true)`, the ONLY `role="search"` landmark on this
 * document (the chrome carries none). Spans both `.product` tracks at
 * >= 900px (below), since it is not part of the viewer/panel split.
 *
 * NAVY, REVERSING R2 (`proposal.md:220`, "Light gray, as the mock draws
 * it"). R2's own text scoped the gray ruling to this route specifically
 * ("`DEC-179` governs the six `/buscar` routes, not this one: the ficha is
 * a destination, not a search result"); the owner reversed that call
 * knowingly this round, choosing navy over the destination-page exception.
 * This band therefore now reuses `--surface-dark`/`--text-inverse`/
 * `--border-on-dark` — the same COLOUR TOKENS `.results-header`
 * (`DEC-179`'s own navy band) and `.vehicles-band` already carry — instead
 * of inventing a second navy. `renderSearchForm` is called with
 * `onDark=true` for the same reason: the label, status and red submit reuse
 * `DEC-179`'s existing on-dark MODIFIERS (`.search-form__label--on-dark`,
 * `.search-form__submit--on-dark`, `tokens.css:874-886`), not a second
 * red-submit mechanism invented here.
 *
 * CORRECTED (amendment round, task 23.1): the paragraph above once claimed
 * visual identity with `DEC-179`'s own navy control — an overstatement the
 * spec now forbids. Sharing colour tokens and on-dark
 * form modifiers is NOT visual identity. Measured against `/buscar`'s own
 * navy band (`.results-header__search`): `/buscar` renders blueprint
 * texture, a bordered card wrapper, a 334x44 label, and a 207.2px FLUID
 * input; this band renders FLAT navy, NO wrapper, a label laid out on its
 * OWN full-width row rather than `/buscar`'s inline placement, and a 220px
 * FIXED input (§10's own gutter arithmetic, not `/buscar`'s). The two
 * controls share only their palette and their on-dark form modifiers; any
 * future documentation of this relationship must state that narrower
 * truth, not a broader parity claim (spec: "documentation states the
 * accurate scope of similarity to `/buscar`").
 *
 * The design.md §5 doc comment this replaces described a `onDark=false` +
 * scoped-red-override mechanism that existed only because the band was
 * gray; it no longer applies.
 * ------------------------------------------------------------------------ */
.pd-context {
  position: relative;
  /* FULL-BLEED BAND, not a card (owner review, 2026-08-23 round). Drops the
     card's `padding`/`border`/`border-radius` entirely and reuses `.band`'s
     shipped full-bleed mechanism (`margin-inline: calc(50% - 50vw)` plus the
     shared gutter) and `.results-header`'s own additive rules verbatim:
     `position: relative` (puts this band's content above nothing extra here
     — there is no texture/veil layer on this band, unlike `.results-header`
     and `.vehicles-band` — but it is declared for parity with the shared
     precedent and because a future decorative layer would need it), and
     `padding-block: var(--sp-5)`.

     `margin-block-start: calc(-1 * var(--sp-5))` IS carried over, MEASURED
     rather than assumed: `.pd-context` sits at the exact same adjacency
     `.results-header` does. `.product` (this band's parent) is a bare
     `display: grid` box with no padding/border of its own, and
     `.results-view` (`/buscar`'s equivalent parent) is the same kind of
     no-op wrapper (`display: block; padding: 0; border: 0`) — both bands
     are the FIRST rendered content inside `.site-main`, immediately after
     the chrome's beta banner, and `.site-main`'s own `padding-block-start:
     var(--sp-5)` is the exact gap this negative margin cancels either way.
     Measured in a real browser before and after: without the negative
     margin, a 24px gray gap sat between the beta banner and the navy band
     (`.site-main`'s own top padding, unswallowed); with it, the navy starts
     flush against the chrome, byte-for-byte the same seam `.results-header`
     already draws on `/buscar`. */
  margin-block-start: calc(-1 * var(--sp-5));
  padding-block: var(--sp-5);
  background-color: var(--surface-dark);
  color: var(--text-inverse);
}

/* Mobile-first: stacked (breadcrumb row, then the search row). From 640px
   — the same threshold `.search-form`'s own base rule (below, the shared
   `@media (min-width: 640px)` block) already uses — the package's inline
   distribution engages: breadcrumbs left, the search control right, on one
   row (README.md:45 "migas... a la derecha input + Button"). That step
   lives in the shared `@media (min-width: 640px)` block rather than a
   second one here — this file keeps exactly one block per breakpoint
   (`tokens-css.test.ts`'s own "declares X exactly once" discipline), and
   `flex-wrap: wrap` there is the OUTER row (breadcrumb block vs. search
   block) wrapping as TWO ATOMIC BLOCKS if the band gets too narrow for
   both — a normal, bounded reflow between two neighbours, not the
   INTERNAL per-child reflow `.search-form`'s own children used to risk
   (see `.pd-context__search .search-form__label` below). */
.pd-context__inner {
  display: flex;
  flex-direction: column;
  gap: var(--sp-3);
}

/* `gap` raised from `--sp-1` to `--sp-2` (correction round): the touch-
   target `::after` above needs clearance from the NEXT item's own box, not
   only visual breathing room. */
.pd-breadcrumb__list {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--sp-2);
  margin: 0;
  padding: 0;
  list-style: none;
  color: var(--navy-100);
  font-size: var(--fs-small);
}

.pd-breadcrumb__item {
  display: flex;
  align-items: center;
  gap: var(--sp-1);
}

/* The "·" separator between levels, drawn as a generated element on every
   item but the first — the same "one accessible name, no extra node"
   discipline `.media-placeholder__title::after` already uses above, applied
   to punctuation instead of a rule. */
.pd-breadcrumb__item:not(:first-child)::before {
  content: "\00b7";
  color: var(--navy-300);
}

/* Touch target floor (design.md §5.5 "Minimum touch target 44px
   throughout"). Vertical axis: TECHNIQUE A — `min-height: 44px` grown in
   flow, same as `.footer__link`; the item's own `align-items: center`
   keeps the taller box vertically centred against the "·" separator
   instead of pushing it off-baseline.

   Horizontal axis: TECHNIQUE B, not A (correction round). `padding-inline`
   was tried first and rejected — it shifts the crumb's own TEXT away from
   the box's left edge, and the box's left edge is what aligns with the
   band's content edge and the search label above/below it. Measured: the
   content edge and the search label both sit at x=33.00, but with
   `padding-inline` the "Inicio" crumb's TEXT sat at x=45.00 — a 12px
   optical misalignment, most visible at 360px where the third (non-link)
   crumb wraps directly under it. The `::after` below widens the hit area
   without moving the box or its text — the same technique
   `.footer__social-link::after` already uses for the identical reason
   (padding-based Technique A "enlarges a square whose ... background is
   VISIBLE artwork"; here it would move visible text instead). */
.pd-breadcrumb__link {
  position: relative;
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  color: var(--navy-100);
  text-decoration: underline;
}

/* Vertical axis needs no extension — `min-height: 44px` above already
   clears the guard's own centred-square floor on its own. Horizontal:
   widened against the narrowest crumb's own measured natural width
   ("Inicio", ~33px; half-width 16.5px, short of the ~21.5px a 44px square
   needs from centre). `.pd-breadcrumb__list`'s `gap` grew alongside this
   (below) to keep the extension from reaching into the NEXT crumb's own
   box, whose later paint order would otherwise win the overlapping pixels
   and shrink the measured hit area rather than grow it — verified directly
   against `document.elementFromPoint` in a real browser before landing on
   this pair of numbers. */
.pd-breadcrumb__link::after {
  content: "";
  position: absolute;
  inset: 0 -6px;
}

.pd-breadcrumb__link:hover,
.pd-breadcrumb__link:focus-visible {
  color: var(--text-inverse);
}

/* The current page's own level: plain text, no link — matches
   `aria-current="page"`'s own semantics (nothing to navigate TO). */
.pd-breadcrumb__item[aria-current="page"] {
  color: var(--text-inverse);
  font-weight: var(--fw-semibold);
}

/* ---------------------------------------------------------------------------
 * THE SEARCH CONTROL'S LABEL: VISIBLE, ON ITS OWN ROW (amendment round,
 * task 22.1/22.2 — REVERSES the "visually hidden, one inline row" ruling the
 * comment this replaces once recorded)
 * ---------------------------------------------------------------------------
 * A clipped label with no placeholder leaves a sighted visitor an unlabelled
 * box beside a submit button — a WCAG 2.2 SC 3.3.2 (Level A) gap an automated
 * scan cannot see, because the accessible name was always intact (`for`/`id`
 * survived the clip). The owner has now ruled the label visible. This is NOT
 * a return to the package's drawn control (`ficha-de-producto.dc.html:45`,
 * still a placeholder-only `<input>` with no visible label) — the package is
 * simply overruled here, same as the navy background and the two other
 * owner-ruled deviations this route carries (§ below).
 *
 * THE TECHNIQUE MATCHES `/buscar`'S OWN NAVY BAND
 * (`.results-header__search .search-form__label { flex: 1 0 100%; }`,
 * above): the label takes its own full-width row instead of sharing a row
 * with the input/submit pair. This is deliberately NOT the same composition
 * — `/buscar`'s equivalent renders inside a bordered card wrapper on
 * blueprint-textured navy with a fluid input; this band renders flat navy,
 * no wrapper, a 220px FIXED input (tasks.md 23.1 corrects the comment that
 * once overstated the parity). What is shared is only the layout mechanism
 * for making the label visible without perturbing the row it sits above.
 *
 * WHY THIS DOES NOT REINTRODUCE THE FONT-SWAP CLS DEFECT the visually-hidden
 * version existed to close (680px/0.3738 measured, traced to the label's
 * RENDERED WIDTH moving the wrap decision under `font-display: swap` while
 * the label shared a row with input+submit). `flex: 1 0 100%` (below, inside
 * the shared `@media (min-width: 640px)` block) gives the label its OWN row
 * UNCONDITIONALLY — its rendered width can no longer influence whether
 * input+submit share a line, because it no longer competes with them for
 * space on the same line at all. This is the same mechanism `/buscar`'s
 * label already uses safely; below 640px the base `display: grid` layout
 * already stacks every child (including a now-visible label) on its own row
 * with no override needed. `--on-dark` (base rule, above) already supplies
 * the label's colour on this surface — no rule is needed here for that. */

/* The fixed-width input/auto submit above 640px live in the shared
   `@media (min-width: 640px)` block below (with `.pd-context__inner`'s own
   row step), not a second block here — this file keeps exactly one block
   per breakpoint. */

/* ---------------------------------------------------------------------------
 * THE VIEWER: the shared `.cut-frame` mechanism, additively re-cut
 * (design.md §3). `.pd-viewer__frame`/`.pd-viewer__surface` ride alongside
 * `.cut-frame`/`.cut-frame__surface` on the SAME two spans — never a
 * replacement, and never an ancestor-scoped selector — the
 * `.search-state__action-frame` precedent (below, Slice 2's own reuse of
 * this same discipline). NOTHING FOCUSABLE CARRIES THIS CLASS: the media
 * slot inside carries no link and no button, so the doctrine `#280`
 * violates (tokens.css's own "NOTHING FOCUSABLE CARRIES THIS CLASS" note,
 * further below) is not at risk here regardless. */
.pd-viewer {
  display: flex;
  flex-direction: column;
  gap: var(--sp-3);
}

.pd-viewer__frame,
.pd-viewer__surface {
  clip-path: polygon(0 0, 100% 0, 100% 88%, 90% 100%, 0 100%);
}

.pd-viewer__surface {
  position: relative;
}

/* The corner accent (design.md §3): a second reference to the already-shipped
   `.product-card__corner` file — zero new bytes. Decorative, `alt=""`. */
.pd-viewer__corner {
  position: absolute;
  inset-block-start: 0;
  inset-inline-start: 0;
  inline-size: 54px;
  block-size: auto;
  display: block;
}

.product__media {
  margin: 0;
}

/* REQUIRED PAIRING (task 4.7's correction round): the photo and the media
   placeholder reserve an IDENTICAL 4:3 slot, so the layout holds the same box
   either way and nothing shifts when the image arrives.
   `width: 100%; height: auto` lets that box scale with the column.

   `aspect-ratio` IS DECLARED HERE AND THE BARE KEYWORD IS THE POINT (issue
   #120). The `width`/`height` attributes on the <img> alone are NOT enough:
   the UA stylesheet turns them into `aspect-ratio: auto 400 / 300`, and the
   `auto` keyword makes the attribute ratio a PRE-LOAD reservation only — once
   the bitmap decodes, the photo's natural ratio wins and the container changes
   shape. Measured in Chromium before this declaration existed: used ratios
   3.928, 3.950, 0.376 and 1.000 — never 4:3 — with CLS reaching 0.1904 on a
   portrait photo at 360x800, over the <= 0.1 budget. A bare `aspect-ratio`
   overrides the UA's `auto` ratio, so the box is 4:3 before AND after decode.
   The value mirrors `.media-placeholder`'s below, which has always been bare
   and has always measured CLS 0; `test/e2e/product-media-box.spec.ts` measures
   the two boxes against each other in a real browser.

   `object-fit: contain` is what makes that box safe for a photo that is not
   4:3: it letterboxes the picture inside the reserved box INSTEAD OF
   DISTORTING IT. It only has anything to do BECAUSE the ratio above is bare —
   while the used box equalled the natural ratio there was never anything to
   letterbox. Without `object-fit`, a non-4:3 photo would now be stretched to
   the reserved ratio and the part in the picture would be the wrong shape. */
/* CORRECTION ROUND: `border`/`border-radius` REMOVED, and `display: block`
   ADDED. The approved handoff package (design_handoff_ficha_lutricon/
   README.md §4) draws the viewer's border on the two-layer `.cut-frame`
   mechanism itself — outer layer `--border-default` background + 1px
   padding, inner layer white, BOTH cut by the same polygon — and specifies
   no `border-radius` anywhere in it. This element sits INSIDE that frame
   now (Slice 1), so its own border doubled the frame's (a 1px-apart double
   line down the left edge) and its 14px radius clipped the corner accent's
   tip. `display: block` closes a SEPARATE defect the frame exposed: an
   `<img>` is inline by default, so `.product__media` reserved baseline
   descender space below it that `.media-placeholder` (a `display: grid`
   container) never did — measured 433.30px vs 426.50px frame height for
   the identical inner box (424.50px both), 3/3 runs. Neither change touches
   `width`/`height`/`aspect-ratio`/`object-fit`, issue #120's CLS pin. */
.product__image {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 400 / 300;
  object-fit: contain;
  background-color: var(--surface-subtle);
}

/* ---------------------------------------------------------------------------
 * MEDIA PLACEHOLDER (DEC-132)
 *
 * The design system defines three FORMAT VARIANTS for its placeholders — 1:1
 * for cards and grids, 4:3 for detail pages, 16:9 for banners — and they are
 * LAYOUTS, not croppings of the artwork. This is the 4:3 detail-page variant:
 * the square icon sits on the left, spanning both text rows, with the heading
 * and the body text beside it.
 *
 * So the 4:3 proportion lives HERE, on the container, and never on the
 * drawing. `width: 100%` + `height: auto` are the same two rules
 * `.product__image` above uses, and `aspect-ratio` supplies the ratio the
 * photo DECLARES through its intrinsic `width`/`height` attributes.
 * `test/unit/http/media-placeholder.test.ts` reads BOTH artifacts and fails if
 * this ratio and the photo's declared box ever disagree.
 *
 * THE TWO ELEMENTS NOW DO END UP THE SAME SIZE, and this rule is why the other
 * one had to change. A BARE `aspect-ratio`, with no `auto` keyword, is what
 * holds a box across the moment its image decodes: this container has always
 * carried one and has always measured CLS 0. `.product__image` did not, and its
 * UA-computed `aspect-ratio: auto 400 / 300` let the photo's natural ratio take
 * over after decode — measured used ratios 3.928, 3.950, 0.376 and 1.000, never
 * 4:3, with CLS 0.1904 on a portrait photo at 360x800, above the <= 0.1 budget
 * (issue #120). It now declares the same bare ratio this rule does, so both
 * states reserve one identical box: measured 328 x 246 at 360x800 and
 * 473.33 x 354.98 at 1280x800, for the photo and the placeholder alike, with
 * CLS 0. `test/e2e/product-media-box.spec.ts` measures the two against each
 * other in a real browser, over a 600x1600 fixture chosen so a photo that
 * ignored the declared box could not hide behind a 4:3 one.
 *
 * The rows are content-sized and vertically centred rather than stretched, so
 * the text stays optically beside the icon at every column width. If the copy
 * ever needs more room than the ratio reserves, the container grows instead of
 * clipping: silently truncating approved copy would be the worse failure.
 * ------------------------------------------------------------------------ */
/* CORRECTION ROUND: `border`/`border-radius` REMOVED — same reason as
   `.product__image` above: the approved package draws the viewer's border
   on the `.cut-frame` mechanism, with no radius anywhere in it, and this
   element sits inside that frame now. `aspect-ratio`/`width`/`height`
   untouched, issue #120's CLS pin.

   AMENDMENT ROUND (design.md §11): the owner ruled the two visible
   paragraphs off screen and the mark is named instead by `role="img"` +
   `aria-label` (placeholder.ts). `.media-placeholder__title`,
   `.media-placeholder__title::after` and `.media-placeholder__body` are
   REMOVED, not hidden — a half-done removal fails the closed-class-set
   guard rather than shipping a dangling rule. A two-column grid meant for
   an icon beside text is not a composition once the text is gone, so the
   icon becomes a single centred item.

   CORRECTION ROUND (C6, now SUPERSEDED, see below) padding-vs-percentage
   history is unchanged: the 16px `padding` was removed so the icon's
   percentage sizing resolves against `.media-placeholder`'s whole box, not
   its content box.

   TRAP `.media-placeholder__icon`'s full-fill rule below deliberately sets
   BOTH `inline-size` and `block-size` to `100%`, never `block-size: auto`:
   the box is 4:3, not square, so `auto` combined with the icon's own 1:1
   `aspect-ratio` would derive a height from the full width and exceed the
   box, growing it — exactly the failure this explicit `100%`/`100%` pairing
   (not `.product__image`'s own `width: 100%; height: auto`, which is safe
   only because ITS box already matches its content's declared ratio)
   avoids. */
.media-placeholder {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: auto;
  aspect-ratio: 400 / 300;
  background-color: var(--surface-subtle);
}

/* SUPERSEDED (Slice 6, third review round, 2026-08-24: the media-fill
   requirement). The derived `inline-size: 44%` match with
   `.product-card__placeholder` (CORRECTION ROUND C6) no longer holds — both
   marks now obey the SAME full-fill rule their real-image siblings already
   carry (`.product-card__image` here, `.product__image` above) instead of a
   fixed proportion of their box. `aspect-ratio: 1 / 1` stays: with both
   `inline-size`/`block-size` now explicit, it has no sizing effect on this
   box (aspect-ratio only resolves a dimension left `auto`), but it keeps the
   artwork's own square intrinsic shape stated rather than merely assumed —
   see the TRAP comment above for why `block-size: 100%`, not `auto`, is what
   keeps this box from growing past its non-square 4:3 container. */
.media-placeholder__icon {
  display: block;
  inline-size: 100%;
  block-size: 100%;
  aspect-ratio: 1 / 1;
  object-fit: contain;
}

/* ---------------------------------------------------------------------------
 * THE THUMBNAIL RAIL (design.md §3, ruling R1)
 * Renders nothing below two images — structurally unreachable on all 40
 * seeded products today, since the schema carries a single `media_key`
 * (0 or 1 entries). Ships anyway, against the schema's possible state.
 * ------------------------------------------------------------------------ */
.pd-rail {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2);
  margin: 0;
  padding: 0;
  list-style: none;
}

.pd-rail__thumb {
  display: block;
  inline-size: 64px;
  block-size: 64px;
  object-fit: cover;
  background-color: var(--surface-subtle);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-sm);
}

.product__body {
  display: block;
}

/* The category-only eyebrow (spec "Eyebrow names the category only"): no
   manufacturer text, because the catalog carries no manufacturer column.
   `--text-body`, matching `.product-card__eyebrow`'s own colour — the
   page's one red accent stays reserved for the WhatsApp CTA (Decision 7). */
.product__eyebrow {
  display: block;
  margin: 0 0 var(--sp-2);
  color: var(--text-body);
  font-size: var(--fs-caption);
  font-weight: var(--fw-bold);
  text-transform: uppercase;
  letter-spacing: var(--ls-caps);
}

/* task 4.1: the panel's own 32px/900 heading — a literal size, not
   `--fs-h1` (34px), which the archived scale reserves for a different
   composition. */
.product__name {
  margin: 0 0 var(--sp-5);
  color: var(--text-heading);
  font-family: var(--font-display);
  font-size: 32px;
  font-weight: var(--fw-black);
  line-height: var(--lh-heading);
}

/* The description slot (Slice 5, design.md §17): block margin ONLY, no
   font-size/line-height/max-width literal. The mock's own `15px / 1.6 /
   460px` (`ficha-de-producto.dc.html:76`) is REJECTED — those literals sit
   on no declared token, and this surface has no rendered data on any of
   the 40 seeded products for a visual validation to judge them against.
   Typography inherits the panel's own defaults; the owner can tune this
   rule once the first real description exists. */
.product__description {
  margin: var(--sp-4) 0 0;
}

.product__fields {
  margin: var(--sp-6) 0 0;
  padding: 0;
}

/* COLOUR IS --text-body, NOT --text-muted — same WCAG 2.1 AA correction as
   .product-card__code above. At 12px/600 on white, --gray-500 measured
   3.58:1 and 12px is nowhere near the 18.66px/bold large-text threshold. One
   of the two labels this rule paints is the barcode caveat "Código de barras
   de inventario (tipo y emisor no verificados)" — a legal-ish qualifier that
   must not be the least readable text on the page. */
/* CORRECTION ROUND (C4): the mock rules EVERY specification row — `padding:
   9px 0; border-bottom: 1px solid var(--border-default)` (mock line 99) —
   and ours carried neither, leaving the rows floating on `row-gap` alone.

   THE MOCK'S ROW IS ONE ELEMENT; OURS IS A `<dt>`/`<dd>` PAIR, so the row's
   padding and its closing rule are split across the pair that makes it up:
   the label opens the row, the value closes it and carries the rule. In this
   base (below 640px) layout the pair is stacked, so a rule under the label
   too would divide a row from its own value rather than from the next row.
   The >= 640px block, where the pair shares one grid row side by side, rules
   both cells instead — see its own comment.

   The label's `margin-top` retires into that padding: 9px IS the row rhythm
   now, and keeping the 16px as well would restore exactly the loose,
   unruled spacing this correction removes. */
.product__field-label {
  margin-top: 0;
  padding-top: 9px;
  color: var(--text-body);
  font-size: var(--fs-caption);
  font-weight: var(--fw-semibold);
  text-transform: uppercase;
  letter-spacing: var(--ls-caps);
}

.product__field-value {
  margin: var(--sp-1) 0 0;
  padding-bottom: 9px;
  border-bottom: 1px solid var(--border-default);
  color: var(--text-heading);
  font-size: var(--fs-body);
}

.product__code {
  font-family: ui-monospace, monospace;
  font-size: var(--fs-h3);
  font-weight: var(--fw-bold);
  letter-spacing: var(--ls-wide);
}

.product__barcode {
  font-family: ui-monospace, monospace;
  color: var(--text-body);
}

/* --- Availability card (amendment round §21) -----------------------------
 * The mock's bordered "Disponibilidad" card, adopted whole: ONE bordered
 * box encloses the action frame and the specifications grid, with a header
 * strip on a distinct light-neutral ground (`--surface-subtle`, i.e.
 * `--gray-100`) separated from the card's white body. No independent
 * `margin-inline`/`max-width` here — see the render function's own doc
 * comment for why that is deliberate, not an oversight. */
/* CORRECTION ROUND (C1): NO `border-radius`. The package's prose keeps the
   cut corner as the one softened edge and rules that "el resto es esquina
   viva", and the mock draws this card with a bare `border` and no radius
   (mock line 77). The owner scoped that ruling to THIS card alone — the
   ~12 other `--radius-md`/`--radius-lg` surfaces across the product page,
   `.handoff` included, were explicitly left as they ship. `overflow:
   hidden` stays: it is what keeps the header's `--surface-subtle` ground
   inside the border rather than painting over it. */
.product-availability {
  margin: var(--sp-6) 0 0;
  border: 1px solid var(--border-default);
  overflow: hidden;
  background-color: var(--surface-page);
}

/* CORRECTION ROUND (C3, C5): the header's own bottom rule (mock line 78) —
   without it the grey strip bled into the white body with nothing dividing
   them. Vertical padding is 14px, the mock's literal, not `--sp-3` (12px);
   the horizontal 18px was already literal for the same reason. */
.product-availability__header {
  padding: 14px 18px;
  background-color: var(--surface-subtle);
  border-bottom: 1px solid var(--border-default);
}

/* CORRECTION ROUND (C5): 900 and .12em, transcribed from mock line 79 —
   NOT `--fw-bold`/`--ls-caps` (700/.14em), which are the page-wide caps
   defaults this one label deviates from. Exo 2 is declared `font-weight:
   400 900`, so the 900 is a real weight the variable font renders, not a
   synthesised-bold fallback. `.product-availability__label` RETIRED
   (adopt-product-detail Slice 5, design.md §13's closing note): its sole
   consumer, the header strip's own `<span>`, is removed — the card's SOLE
   heading was `.handoff__title`, then still inside `.product-availability__body`
   (Slice 5). Slice 6 (third review round, 2026-08-24, task 33.3) moves it
   INSIDE `.product-availability__header` instead — see the override just
   below, which resets its shared bottom margin so the strip does not gain
   an extra band beneath it. */
.product-availability__header .handoff__title {
  margin: 0;
}

.product-availability__body {
  padding: 18px;
  display: flex;
  flex-direction: column;
  gap: var(--sp-5);
}

.product-availability__body .product__fields {
  margin-top: 0;
}

/* The caution line (spec "advises verification without implying an ordering
   capability"; design.md §8): below the specifications grid, adjacent to the
   printed code it tells the visitor to check. */
.product__caution {
  margin: var(--sp-3) 0 0;
  color: var(--text-body);
  font-size: var(--fs-small);
}

/* --- Handoff ------------------------------------------------------------ */

/* CORRECTION ROUND (C2): NO SURFACE OF ITS OWN. `.handoff` shipped its
   `--surface-subtle` fill, border and radius back when it was a standalone
   section on the page; the availability card (amendment round §21) moved it
   inside `.product-availability__body`, where that fill repeated the card
   header's own grey and the border repeated the card's — a grey rounded
   panel floating in a white bordered box. The mock draws no inner surface at
   all: its card body is plain white with `padding: 18px` and nothing else
   (mock line 81).

   NOT SCOPED TO THE NESTING, because there is no other case to scope
   against: `renderHandoffSection` has exactly ONE call site
   (`renderAvailabilityCard`, product.ts), and BOTH its states — including
   the `--unavailable` fail-closed branch — render from it, so `.handoff`
   cannot paint outside the card. A `.product-availability__body .handoff`
   override would be a rule guarding a state the composition cannot reach.

   The padding goes with the surface: with no box to inset from, it only
   pushed the action frame off the 18px the card body already reserves, and
   out of line with the specification rows below it. */
.handoff {
  margin: 0;
  padding: 0;
}

/* The fail-closed state carries no action, so it carries no accent either —
   but it must still READ as degraded rather than as an ordinary block of
   text, which is the whole point of a fail-closed state being visible.
   CORRECTION ROUND (C2): the dashed edge is declared WHOLE here. It used to
   be a `border-style: dashed` override of the base border above; with that
   border gone, a lone `border-style` would compute against `border-width:
   0` and paint nothing — the signal would have disappeared silently, with
   every test still green. Sharp corners, per C1's ruling on this card. */
.handoff--unavailable {
  padding: var(--sp-4);
  border: 1px dashed var(--border-default);
}

.handoff__unavailable {
  margin: 0;
  color: var(--text-body);
}

/* ---------------------------------------------------------------------------
 * THE ACTION FRAME: THE MIRRORED PAIR (design.md §2, adopt-product-detail
 * Slice 2; INVERTED and split by Slice 5, design.md §13/§14, Fork D/E). Base
 * styles here target below 1200px (single stacked column, label-first — the
 * non-interactive label is first in source order, per §13's "no
 * row-reverse/column-reverse at any breakpoint" ruling); the >= 1200px
 * override (further below) switches the row to horizontal and applies the
 * mirrored bevel.
 *
 * THE BEVEL IS A POSITION MECHANISM; THE MEASURE, TYPOGRAPHY, WIDTH RATIO,
 * FILL, ICON ROW AND HOVER ARE THE ROLE (design.md §13's split table — see
 * that section for the full reasoning against renaming or swapping instead
 * of splitting):
 *   - `.pd-action__frame--start/--end` / `.pd-action__surface--start/--end`
 *     (further below, inside the >= 1200px block) carry ONLY `clip-path`,
 *     byte-identical to what shipped before this slice — POSITION-keyed,
 *     because the markup keeps DOM order equal to visual order everywhere,
 *     so `--start` always names the inline-start element AND edge at once.
 *   - `.pd-action__frame` / `.pd-action__surface` below (additive, no
 *     position key) carry the shared measure and typography both halves
 *     wear regardless of which element occupies which slot.
 *   - `.handoff__cta …` (green, the WhatsApp `<a>`) and `.handoff__acquisition
 *     …` (red, the non-interactive label `<span>`) carry the width ratio,
 *     fill, icon-row composition and hover — ROLE-keyed, so the icon-bearing
 *     half's `flex-direction: row; gap: 9px` composition travels WITH the
 *     element that owns the icon, never transplanted onto the other half
 *     (the already-measured-and-fixed 75.92px stacking defect this exact
 *     trap reproduces if the modifiers are swapped whole instead of split).
 *
 * NEITHER `.handoff__cta` (the `<a>`) NOR `.handoff__acquisition` (the
 * `<span>`) DECLARES A `clip-path`, A `background-color`, OR TEXT STYLING OF
 * ITS OWN — see `handoff.ts`'s own doc comment for the full #280 reasoning.
 * `.handoff__cta` stays a bare focusable box; the global
 * `:focus-visible { outline: ... }` rule (`tokens.css:304`) is what paints
 * the ring on it, unclipped. All visual chrome (fill, cut, label typography)
 * lives on the nested `.pd-action__frame`/`.pd-action__surface` spans below,
 * additive alongside `.cut-frame`/`.cut-frame__surface` on the SAME two
 * spans — the `.pd-viewer`/`.search-state__action-frame` discipline reused,
 * never a replacement and never an ancestor-scoped
 * `.cut-frame`/`.cut-frame__surface` selector.
 * ======================================================================== */

.handoff__actions {
  display: flex;
  flex-direction: column;
  gap: var(--sp-3);
}

.handoff__cta {
  display: block;
  min-height: 48px;
  text-decoration: none;
  border: none;
  background: none;
  padding: 0;
  cursor: pointer;
  transition: transform var(--speed-fast) var(--ease-brand);
}

/* `.handoff__acquisition` is a `<span>`, not a control (design.md §14): only
   `display: block; min-height: 48px` transfer from `.handoff__cta`'s reset
   above. `cursor: pointer`, the `transform` transition and every
   `:hover`/`:focus-visible` rule do NOT — a pointer cursor or a hover state
   on a non-control is the affordance lie this omission exists to prevent,
   named here because copy-pasting the neighbouring interactive rule is the
   obvious way to get it wrong. */
.handoff__acquisition {
  display: block;
  min-height: 48px;
}

/* GEOMETRY — TRANSCRIBED FROM THE APPROVED HANDOFF PACKAGE
   (`design_handoff_ficha_lutricon/ficha-de-producto.dc.html:92-93`, 8a's
   action pair; README.md "Par de acciones (rojo + WhatsApp)"), not derived.
   The mock's own Cotizar/WhatsApp pair carries a bottom-corner cut on EACH
   button's own OUTER edge, mirrored one toward each side — POSITION-based,
   not identity-based, so whichever element sits on a given side carries
   that side's cut. Slice 5 puts the non-interactive label FIRST (LEFT,
   "start") and the WhatsApp CTA SECOND (RIGHT, "end") — the owner-ruled
   inversion (design.md §13) — so `start` now carries the label's LEFT-corner
   shape and `end` the CTA's RIGHT-corner shape, regardless of which
   element occupied that slot under Slice 2.

   THIS MIRRORED SHAPE IS THE >= 1200px STEP ONLY (owner ruling, round 4).
   Below 1200px the pair is stacked, full-width, and the mirrored two-corner
   shape has no side to mirror against — so the base (mobile-first) rule
   below instead sets the STANDARD `.cut-frame`/`.cut-frame__surface`
   bottom-right corner at the package's own `20px`, the SAME mobile-first
   `--cut` context override `.search-state .cut-frame` already establishes
   (this file, above), and the mirrored percent polygons move into the
   `>= 1200px` block (further below) where the row layout actually exists. */
.handoff__actions .cut-frame {
  --cut: 20px;
}

/* THE SHARED MEASURE AND TYPOGRAPHY (design.md §13's split table) — worn by
   BOTH halves regardless of role or position. `font-size: 13.5px` — a
   LITERAL, not `var(--fs-h3)` — CORRECTED (owner review round): the
   approved package fixes BOTH halves at `13.5px/700` in the shared 48px row
   (`ficha-de-producto.dc.html:92-93`), and no declared `--fs-*` step lands
   on 13.5px.
   `min-height: 46px`, NOT 48px — CORRECTED (owner review round 5, measured
   not assumed): `.cut-frame`'s own `padding: 1px` wraps this surface on
   both block-start and block-end (`tokens.css:2142`), and the role rules
   below override the frame's `background-color` to the SAME fill as the
   surface rather than leaving it the `--border-default` hairline the
   mechanism was built for — so that 1px ring paints as more fill, not a
   border. 46px here plus the frame's 1px+1px lands the painted band back on
   the package's exact 48px, and 48px still clears the package's own 44px
   touch-target floor. */
.pd-action__surface {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 46px;
  font-family: var(--font-display);
  font-size: 13.5px;
  font-weight: var(--fw-bold);
  letter-spacing: var(--ls-wide);
  text-align: center;
  text-transform: uppercase;
}

/* The WhatsApp glyph (owner ruling round 5, README.md:89;
   `ficha-de-producto.dc.html:93`) — `aria-hidden`, `fill="currentColor"`
   so it inherits the surface's white text colour; the accessible name
   stays the anchor's own text alone (`HANDOFF_PRIMARY_CTA_ES`), never the
   icon. `flex: none` keeps it from being squeezed by the label at the
   pair's narrowest fitting width. */
.handoff__cta-icon {
  flex: none;
  display: block;
}

/* THE GREEN HALF — the shipped product-contextual WhatsApp CTA, reusing
   `#25D366`, WhatsApp's own official green, already declared for the
   chrome's own affordance (`.whatsapp-bubble`) on the identical terms.
   ROLE-scoped, not position-scoped (design.md §13): this composition stays
   with `.handoff__cta` wherever it sits — currently `--end`, the RIGHT
   slot, after Slice 5's inversion.

   `padding: var(--sp-3) var(--sp-6)`, wider than the label's — the icon
   pushes this half's inner content wider than a text-only surface needs.
   `gap: 9px` matches the icon spacing (README.md:89, "gap 9px; icono SVG
   20px currentColor"). `flex-direction: row` IS LOAD-BEARING, not a default
   left in place: the base `.cut-frame__surface` rule this class rides
   alongside declares `flex-direction: column` (`tokens.css:2154`), which a
   text-only surface never overrides — the mismatch is invisible until the
   icon becomes a SECOND flex child. Without this override the icon stacks
   ABOVE the label instead of beside it, and the surface measures 75.92px
   tall instead of 46px — the exact defect this override exists to prevent,
   found by measurement, not assumed correct from the markup. */
.handoff__cta .pd-action__frame {
  background-color: #25D366;
}

.handoff__cta .pd-action__surface {
  background-color: #25D366;
  color: var(--white);
  flex-direction: row;
  gap: 9px;
  padding: var(--sp-3) var(--sp-6);
}

.handoff__cta:hover .pd-action__frame,
.handoff__cta:focus-visible .pd-action__frame {
  background-color: #1EB855;
}

.handoff__cta:hover .pd-action__surface,
.handoff__cta:focus-visible .pd-action__surface {
  background-color: #1EB855;
}

/* THE RED HALF — the non-interactive `A cotizar` label, carrying this
   cluster's one `var(--action-primary)` (Decision 7's "one red accent per
   element cluster": the red lives here, unmoved by Slice 5's inversion,
   only the element wearing it changed shape from a `<button>` to a
   `<span>`). ROLE-scoped: this fill stays with `.handoff__acquisition`
   wherever it sits — currently `--start`, the LEFT slot. NO `:hover`/
   `:focus-visible` rule exists for this half — see `.handoff__acquisition`'s
   own doc comment above for why that omission is deliberate. */
.handoff__acquisition .pd-action__frame {
  background-color: var(--action-primary);
}

.handoff__acquisition .pd-action__surface {
  background-color: var(--action-primary);
  color: var(--text-inverse);
  padding: var(--sp-3) var(--sp-5);
}

.handoff__hours {
  margin: var(--sp-3) 0 0;
  color: var(--text-body);
  font-size: var(--fs-small);
}

/* `.handoff__fallback`/`.handoff__fallback-summary`/`.handoff__fallback-body`/
   `.handoff__destination`/`.handoff__message` RETIRED WHOLE (Slice 6, third
   review round, 2026-08-24, task 34.3): the `<details>` disclosure they
   styled is removed outright, not merely hidden — the beta-whatsapp-handoff
   spec's amended requirement supersedes Slice 2's "sole non-WhatsApp path"
   ruling in place. `.handoff__tel` below is the one remaining non-WhatsApp
   path this round adds instead: narrower in scope (mobile viewports only, a
   dialing action, not a text-selection fallback). */
.handoff__tel {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  margin-top: var(--sp-3);
  color: var(--text-body);
  font-size: var(--fs-small);
  text-decoration: underline;
}

.handoff__tel:hover,
.handoff__tel:focus-visible {
  color: var(--text-heading);
}

/* `.handoff__feedback` RETIRED (adopt-product-detail Slice 5, design.md
   §15): the `aria-live` region it styled had no writer left once the copy
   button and `bindCopyButton` retired. */

/* ---------------------------------------------------------------------------
 * THE VEHICLES BAND (adopt-product-detail Slice 3, design.md §6, Fork 3
 * resolved: OWN class names — `__texture`, `__veil`, `__inner`, `__title`,
 * `__table`, `__note`, `__statement` — NOT a reuse of `.results-header__*`.
 * `.results-header` is a composition tuned for its own band (a negative
 * `margin-block-start` swallowing the chrome's top padding; veil stops
 * tuned for a heading band with a search field), not a shared mechanism.
 * Cost accepted: this duplicates the texture/veil layer SHAPE
 * `.results-header__texture` and `.category-section__texture` already
 * repeat between themselves — the file's own existing convention for these
 * layers, not a new one. `.band`/`on-dark` ARE reused (the shared bleed
 * mechanism and the shared dark-surface focus-ring rule), exactly as
 * `.category-section` reuses them.
 *
 * The reserved table renders exactly one header — `Vehículo` — and an
 * ALWAYS-EMPTY `<tbody>`: `Motor`/`Años`/`Estado` never render, in any
 * form (spec, single-header ruling).
 * ------------------------------------------------------------------------ */
.vehicles-band {
  position: relative;
  padding-block: var(--sp-6);
  background-color: var(--surface-dark);
  color: var(--text-inverse);
}

.vehicles-band__texture {
  position: absolute;
  inset: 0;
  background-image: url("/assets/hero/hero-blueprint.avif");
  background-position: center;
  background-size: cover;
  opacity: .45;
}

.vehicles-band__veil {
  position: absolute;
  inset: 0;
  background-image: linear-gradient(90deg, rgba(0, 20, 48, .96) 42%, rgba(0, 20, 48, .6));
}

/* `position: relative` puts the content above the two decorative layers
   without a z-index, same reasoning as `.results-header__inner`. */
.vehicles-band__inner {
  position: relative;
}

/* 20px/900 uppercase, transcribed from the approved package
   (`ficha-de-producto.dc.html:113`; README §8) — `--fs-h3` is the one
   declared step that lands on 20px. Fork 4 (design.md §7) drops the
   `motif-separador-inv.svg` rule this heading carried in the mock: no
   canonical inverse-variant asset exists, and the shipped light-group
   variants' navy ink would die on this navy band. */
.vehicles-band__title {
  margin: 0 0 var(--sp-4);
  color: var(--text-inverse);
  font-family: var(--font-display);
  font-size: var(--fs-h3);
  font-weight: var(--fw-black);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
}

.vehicles-band__table {
  width: 100%;
  border-collapse: collapse;
  background-color: rgba(0, 20, 48, .55);
  border: 1px solid rgba(255, 255, 255, .16);
}

.vehicles-band__table th {
  padding: var(--sp-3) var(--sp-4);
  background-color: rgba(255, 255, 255, .07);
  color: var(--navy-300);
  font-size: var(--fs-caption);
  font-weight: var(--fw-bold);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
  text-align: left;
}

/* Notes-style RULED body rows (owner ruling, 2026-08-22 owner-review round:
   "a minimum of four empty rows beneath its header ... visibly reserves
   space"). `min-height` alone would not reserve anything on a `<td>` — a
   table cell's block size is governed by its ROW, so the height is set on
   `min-height` here for when real vehicle text one day wraps to two lines,
   AND on each `<tr>`'s own fixed row size below, which is what actually
   holds four rows' worth of navy space open before any real row data
   exists. The bottom border is the "ruled" line separating one reserved
   row from the next; the last row's own border double-draws against the
   table's own `border`, which is the same acceptable doubling
   `.results-header__search`'s bordered fields already accept elsewhere in
   this file. */
.vehicles-band__table td {
  height: 40px;
  padding: 0 var(--sp-4);
  border-top: 1px solid rgba(255, 255, 255, .1);
}

/* OWNER-RULED, NOT A DEFECT (amendment round, task 23.2; spec "The first
   row's missing top rule is not reported as a defect"). The package rules
   EVERY row including the first (`ficha-de-producto.dc.html:120`); the
   shipped behaviour — no top rule on the first reserved row, so the
   table's own `border` (above) is the only line above it — wins by owner
   ruling. Do not "fix" this by removing the `:first-child` exclusion. */
.vehicles-band__table tbody tr:first-child td {
  border-top: none;
}

/* `.vehicles-band__note` RETIRED WHOLE (adopt-product-detail Slice 6, third
   review round, 2026-08-24, task 36.3): the honest line and its in-page
   anchor to `HANDOFF_TITLE_ID` are removed outright — the band no longer
   links to the action frame. The band's empty table renders alone now,
   justified by the band's transitional (not permanent) empty state. */

/* The attested statement (spec "A recorded attestation renders verbatim") —
   reached by zero of the 40 seeded products today, styled against the
   schema's possible state. */
.vehicles-band__statement {
  margin: var(--sp-3) 0 0;
  color: var(--text-inverse);
  font-size: var(--fs-body);
  font-weight: var(--fw-bold);
}

/* ---------------------------------------------------------------------------
 * THE RELATED-PRODUCTS SECTION (adopt-product-detail Slice 3, design.md
 * §8; spec "names the real relation and renders nothing when it would show
 * zero cards"). Light surface — no texture/veil, unlike the vehicles band
 * above. Reuses `.product-grid`'s own `auto-fill` reflow UNCHANGED
 * (design §8: "designed for 0-4 tracks"), so this section declares no
 * responsive column-count rule of its own.
 * ------------------------------------------------------------------------ */
.pd-related {
  padding-block: var(--sp-6) 0;
}

/* --- Error pages -------------------------------------------------------- */

.error-page {
  padding: var(--sp-6) var(--sp-4);
  background-color: var(--surface-subtle);
  border-radius: var(--radius-lg);
  border-left: 4px solid var(--border-default);
}

.error-page--not-found {
  border-left-color: var(--navy-600);
}

/* A provider failure is the one genuinely degraded state, so it is the one
   place --danger is used; it is a border, not a fill, so the copy keeps its
   AA contrast against the subtle surface. */
.error-page--provider-failure {
  border-left-color: var(--danger);
}

.error-page__title {
  font-size: var(--fs-h2);
}

/* Technique A (see "Touch targets" above), measured 107.3x19 — the SEVENTH
   under-floor control, and the one nobody had measured: it renders on the 404
   and 503 pages only, so every viewport sweep taken against `/` missed it. It
   is the only way back out of an error page, which makes it the worst place on
   the site to ship a 19px-tall target. Found by the guard in
   `test/e2e/accessibility.spec.ts`, not by inspection, which is the argument
   for the guard. `min-height` is safe here: the link is the last element of
   `.error-page`, so the reserved height pushes nothing. */
.error-page__home-link {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  color: var(--navy-700);
  font-weight: var(--fw-semibold);
}

/* ===========================================================================
 * HOVER DARKEN PAIRS (Decision 7: "hover darkens (--red-600 -> --red-700,
 * navy fills -> --navy-700)")
 *
 * Written against the SEMANTIC pair tokens (--action-primary ->
 * --action-primary-hover, --action-secondary -> --action-secondary-hover),
 * which resolve to exactly those ramp steps. Using the semantic pair means a
 * future ramp change moves both halves together instead of leaving a hover
 * state pointing at a colour the base no longer relates to.
 *
 * `:focus-visible` is included alongside `:hover` so a keyboard user gets the
 * same state change a pointer user gets, rather than only the focus ring.
 * ======================================================================== */

/* `.handoff__cta`'s hover/focus darkening lives on the role-scoped
   `.handoff__cta .pd-action__frame`/`.pd-action__surface` rules above
   (design.md §13): the anchor itself declares no `background-color` of its
   own to darken, since the fill lives on the nested, non-focusable spans.
   `.handoff__acquisition` carries NO hover/focus darkening at all — it is
   not a control (design.md §14). */
.search-form__submit:hover,
.search-form__submit:focus-visible {
  background-color: var(--action-secondary-hover);
}

/* The refine submit darkens too, and this rule must come AFTER the pair
   above: the two selectors have identical specificity, so source order is the
   only thing that keeps the modifier's own hover from being overwritten by
   the navy one. Now that the control is red (correction round) its hover is
   the SEMANTIC PAIR `--action-primary-hover`, exactly as `.handoff__cta`'s
   is — the token set declares red's darkened partner once, and a control that
   invented its own step would be a second answer to a question already
   answered. */
.search-form__submit--on-dark:hover,
.search-form__submit--on-dark:focus-visible {
  background-color: var(--action-primary-hover);
}

/* ===========================================================================
 * PRESS SCALE (Decision 7: "press scales to --press-scale")
 * ======================================================================== */

.handoff__cta:active,
.search-form__submit:active,
.vehicle-search__submit:active,
.whatsapp-bubble:active,
.category-card:active,
.product-card:active,
.pagination__link:active {
  transform: scale(var(--press-scale));
}

/* ===========================================================================
 * ONE RED ACCENT PER ELEMENT CLUSTER (Decision 7)
 *
 * Encoded, not merely observed: `--action-primary` (red) is assigned to
 * exactly one selector-family in this whole stylesheet within the handoff
 * cluster — `.handoff__acquisition .pd-action__frame`/`.pd-action__surface`,
 * the red half of the mirrored pair (design.md §13/§14, adopt-product-detail
 * Slice 5 — unmoved by the inversion, only the element wearing it changed
 * from a `<button>` to a non-interactive `<span>`). `.handoff__cta` (the
 * green CTA) and every other action in this cluster (`.handoff__tel`, Slice
 * 6) is navy, green or neutral by construction.
 *
 * THE PRIOR DEFENSIVE RULE HERE — `.handoff .handoff__cta ~ .handoff__cta`,
 * demoting a hypothetical SECOND red CTA to navy — stays REMOVED: the pair
 * this section renders is structurally fixed at exactly one `<a>` and one
 * non-interactive `<span>` (design.md §13's own "no extension seam" ruling)
 * — there is no second CTA shape for the sibling-selector guard to defend
 * against.
 * ======================================================================== */

/* ===========================================================================
 * MOTION (Decision 7: "slide/fade from the left only, never bounces, always
 * within the two durations and --ease-brand")
 *
 * `translateX` from a NEGATIVE offset only — the motion enters from the left
 * and stops. --ease-brand's control points are all within [0,1] on the y
 * axis, so it cannot overshoot: there is no bounce available even by
 * accident.
 * ======================================================================== */

@keyframes slide-fade-in-from-left {
  from {
    opacity: 0;
    transform: translateX(calc(-1 * var(--sp-4)));
  }
  to {
    opacity: 1;
    transform: translateX(0);
  }
}

/* `.handoff__feedback` LEFT THIS LIST in adopt-product-detail Slice 2. It
   used to sit inside the `<details>` fallback body and animate in only when
   that disclosure opened; the relocated region (design.md §2) is
   unconditionally present under the action pair from first paint, so an
   entrance animation here would replay on every page load for a region
   that has nothing to say yet — not the "newly appeared content" case this
   keyframe exists for. `.handoff__fallback-body` LEFT TOO (Slice 6, third
   review round, 2026-08-24): the `<details>` disclosure it animated is
   retired outright, leaving `.search-form__status` as this list's sole
   selector. */
.search-form__status {
  animation: slide-fade-in-from-left var(--speed-base) var(--ease-brand) both;
}

/* ===========================================================================
 * RESPONSIVE CONTRACT (design.md, mobile-first)
 *
 * Base styles above target viewports below 640 px: single column, full-width
 * controls and CTA. Everything below is additive.
 * ======================================================================== */

/* >= 640 px: inline search form, CTA no longer full width, category product
   list at two columns.
   `.category-grid` LEFT THIS BLOCK in U3. It used to break to two columns
   here, on the archived v1 home-grid contract (1/2/3 at 640/1024). It is now
   the LANDING's category band, whose own contract is design.md 5.5's
   1/2/3 at <900 / 900-1199 / >=1200 — the two rules would otherwise both
   apply and the narrower breakpoint would win between 640 and 900, where the
   design asks for one column. */
@media (min-width: 640px) {
  /* `.product-grid` LEFT THIS BLOCK in adopt-results-view. Its column count
     is `auto-fill` against `--card-min` now (design.md §10), so a breakpoint
     step here would fight the track sizing rather than complement it — the
     same reason `.category-grid` left this block in U3. */
  .search-form {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--sp-3);
  }

  .search-form__input {
    flex: 1 1 16rem;
    width: auto;
  }

  .search-form__submit {
    width: auto;
  }

  .search-form__status {
    flex: 1 0 100%;
  }

  /* Same rule, same reason, as `.search-form__submit` above: the submit
     stops being a full-width block once there is room beside it. */
  .vehicle-search__submit {
    width: auto;
  }

  /* The breadcrumb band's own inline distribution (`.pd-context`, above):
     breadcrumbs left, the search control right, on one row (owner-review
     round, README.md:45). `flex-wrap: wrap` lets the two blocks drop to
     separate lines if the band gets too narrow for both — a bounded
     reflow between two neighbours, never the internal per-child reflow
     the rules below remove from the search form itself. */
  .pd-context__inner {
    flex-direction: row;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
  }

  /* AMENDMENT ROUND (task 22.1/22.2): GRID, not the shared flex-wrap base
     rule — a scoped override, and the reason is a real bug this round found
     and closes in the same edit, not a style preference.

     `/buscar`'s own visible-label technique
     (`.results-header__search .search-form__label { flex: 1 0 100%; }`)
     works only because `.results-header__search` carries `inline-size: 100%`
     (`tokens.css:2285`), giving that `flex-basis: 100%` a DETERMINATE
     container to resolve against. `.pd-context__search` has no such width —
     it is a row flex item of `.pd-context__inner`, sized to CONTENT
     (shrink-to-fit) so it can share a line with the breadcrumb trail. Under
     that indeterminate sizing, a flex item's `flex-basis: 100%` degrades to
     its own max-content size when the flex CONTAINER computes ITS OWN
     intrinsic width — so copying `/buscar`'s exact rule here let the
     label's long single-line Spanish text (`SEARCH_FIELD_LABEL_ES`, ~34
     characters) WIDEN `.search-form` to ~589px (measured in Chromium),
     which widened `.pd-context__search` past the point where it could still
     share a row with the breadcrumb trail even at 1200px — silently
     reintroducing a WIDER version of the two-row-too-often shape task 23.3
     exists to bound to 640-1019px only.

     CSS Grid does not have this failure mode for THIS shape: a
     `max-content`-sized column takes its size from the NON-SPANNING items
     assigned to it (input, submit — each in one column), and a SPANNING
     item (the label, `grid-column: 1 / -1`) is laid out and WRAPPED inside
     whatever width those columns already resolved to, rather than
     inflating them — verified directly in Chromium: the label wraps to two
     lines at exactly the input+submit row's own width, and `.search-form`'s
     own measured width no longer moves when the label's text does. */
  .pd-context__search .search-form {
    display: grid;
    grid-template-columns: max-content max-content;
    column-gap: var(--sp-3);
    row-gap: var(--sp-2);
    align-items: center;
  }

  .pd-context__search .search-form__label {
    grid-column: 1 / -1;
  }

  .pd-context__search .search-form__input {
    grid-column: 1;
    width: 220px;
    min-inline-size: 0;
  }

  .pd-context__search .search-form__submit {
    grid-column: 2;
    inline-size: auto;
  }

  .pd-context__search .search-form__status {
    grid-column: 1 / -1;
  }

  /* The specifications grid: one column below 640px, two from here up
     (design.md §4). Two rows only (spec "Exactly two specification
     rows"), so `<dt>`/`<dd>` alternate into a 2x2 grid rather than a
     label-above-value stack: `dt1` and `dd1` land in the same grid ROW,
     side by side, not stacked as the base (below-640px) layout has them.

     CORRECTION ROUND: `.product__field-label`'s `margin-top: var(--sp-4)`
     (16px) and `.product__field-value`'s `margin-top: var(--sp-1)` (4px)
     are BOTH the base layer's stacked-column spacing — correct there, where
     each margin only has to clear the ELEMENT ABOVE IT in one column. In
     this 2-column grid the label and its own value share a row instead, so
     the two different top margins put them 12px out of vertical alignment
     with each other (measured exactly, every row, every width >= 640px).
     Zeroing both here and moving the between-row rhythm onto the grid's own
     `row-gap` (the same 16px `--sp-4` the label's margin used to carry)
     fixes the row-internal alignment without touching the base layer's
     stacked spacing at all.

     CORRECTION ROUND (C4): `row-gap` is now ZERO, and the rhythm it carried
     moves onto the mock's own row treatment (`padding: 9px 0; border-bottom:
     1px solid var(--border-default)`, mock line 99). Here — and only here —
     the label and its value share ONE grid row, so BOTH cells take that
     padding and both draw the rule, which is what makes the rule run the
     full width of the row rather than stopping under one column. A gap
     between rows would open a stripe of unruled white between two rules and
     stop the rows reading as a table, which is the shape the mock draws. */
  .product__fields {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    /* ZERO, not a gutter: the row is a `dt`/`dd` PAIR in two tracks, so each
       cell draws its own half of the rule. Any column gap is therefore a
       visible hole in the middle of every separator — measured at 24px on
       `var(--sp-5)`, at 640, 900 and 1440 alike — which reads as a rendering
       fault rather than a design. The mock draws the row as ONE element with
       `space-between` (`ficha-de-producto.dc.html:99`) and so never had this
       problem to solve; keeping `dt`/`dd` keeps the description-list
       semantics, and closing the gutter is what makes the two halves meet.
       The label and the value cannot collide despite the zero gutter: each
       owns a full `1fr` track, and the value is pushed to the far edge of its
       own track by the `text-align` below. */
    column-gap: 0;
    row-gap: 0;
  }

  .product__field-label,
  .product__field-value {
    margin-top: 0;
    padding: 9px 0;
    border-bottom: 1px solid var(--border-default);
  }

  /* The mock right-aligns the value (`ficha-de-producto.dc.html:99`,
     `text-align:right`), which is what turns two adjacent tracks into the
     label-left/value-right row it draws. Scoped to this block on purpose:
     below 640px the pair STACKS, and a right-aligned value under a
     left-aligned label would read as a stray indent rather than a column. */
  .product__field-value {
    text-align: right;
  }
}

/* ===========================================================================
 * >= 900 px: the hero's own breakpoint (design.md §5.5)
 *
 * The chrome's breakpoints are 640/1024 (the archived v1 Responsive
 * contract); §5.5 sets the landing's at 900/1200. This block is the <900px
 * boundary of that table — "hero apilado" gives way to the mock's side-by-
 * side composition, and the search card's three selects stop being stacked.
 * §5.5's 900-1199 row is "fluid" and its >= 1200 row is "as the mock", which
 * this one block satisfies for both: every width here is a percentage or a
 * bounded measure, so the layout IS the mock at the mock's width and stays
 * proportional below it. No second breakpoint is added for a row that asks
 * for no second layout.
 * ======================================================================== */

@media (min-width: 900px) {
  /* THE EYEBROW IS SIZED AGAINST THE TITLE, AND ONLY ABOVE THIS BREAKPOINT.
     At `--fs-caption` (12px) against the 40px `h1` this block sets, the ratio
     is 3.3:1 and the eyebrow reads as a footnote rather than as the
     antetitle it is — the brand line that the van's own livery carries. 20px
     puts it at 2.0:1, which is the widest step that still leaves the `h1`
     unambiguously dominant; 24px measures 1.7:1 and the two start competing,
     because this element is already black-weight, uppercase and tracked.
     THE MARKER DOES NOT SCALE WITH THE TEXT, by owner ruling. Growing it to
     the text's 1.17 ratio (23px) was tried and rejected as too heavy beside
     the word; it keeps the 14px it renders at every other width, so the
     larger text sits against an unchanged mark.

     BOTH ARE LITERALS BECAUSE THE SCALE HAS NO STEP AT EITHER VALUE and
     design.md §8 freezes `:root`, the same reason the 26/132/236px literals
     elsewhere in this file are literals.

     DELIBERATELY NOT APPLIED BELOW 900px. The eyebrow's rendered height is a
     term in the small-screen title calculation — see the `.hero__title`
     clamp comment above, "minus the 16px inset and the eyebrow's own
     30.59px". Growing it there would spend the clearance that budget
     reserves over the photograph's safe region, which is the same margin
     that ruled out an uppercase title. */
  .hero__eyebrow-text {
    font-size: 20px;
  }

  /* `overflow: hidden` clips the out-of-flow photograph to the band. It does
     not clip `.whatsapp-bubble`, which is why that control is rendered as a
     SIBLING of the band rather than a descendant. */
  .hero {
    position: relative;
    overflow: hidden;
  }

  /* Out of flow, pinned right at the mock's 58%. `aspect-ratio: auto`
     cancels the stacked layout's 16/9 box: here the height is the band's,
     set by the text column beside it. */
  .hero__image {
    position: absolute;
    inset-block: 0;
    inset-inline-end: 0;
    width: 58%;
    height: 100%;
    aspect-ratio: auto;
  }

  .hero__blend {
    display: block;
    position: absolute;
    inset-block: 0;
    inset-inline-end: 36%;
    width: 22%;
    background-image: linear-gradient(90deg, var(--surface-page) 55%, rgba(255, 255, 255, 0));
  }

  /* `position: relative` puts the text column above the photograph and the
     gradient without a z-index: all three are positioned, so source order
     decides, and this one is last. The 1060 px measure stays on this
     element (it is the band's `.band__inner`); the 52 % text column is the
     child inside it, exactly as the design nests them, so the column is
     52 % of the MEASURE and not of the viewport. */
  .hero__content {
    position: relative;
    padding: 52px 40px 56px;
  }

  .hero__column {
    width: 52%;
  }

  .hero__title {
    font-size: 40px;
  }

  .vehicle-search__fields {
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }

  /* Responsive contract 5.5, middle step: pillars 3 up, categories 2 up,
     featured parts 2 up, and the CTA band becomes a space-between row. */
  .pillar-band__list {
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }

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

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

  .cta-band__row {
    flex-direction: row;
    align-items: center;
    justify-content: space-between;
    gap: var(--sp-6);
  }

  /* The design's 40px band gutter, above the small-viewport 16px one. */
  .band {
    padding-inline: 40px;
  }

  /* THE CHROME BANDS BLEED (README §"Layout general"). They used to be
     centred boxes of at most 1200px, so on a 1280px viewport the navy
     utility bar and the navy footer stopped 40px short of each edge and the
     page read as a centred column against the mock's edge-to-edge bands.
     Now each band is full width and its CONTENT is centred by padding
     instead: `max()` keeps the 40px minimum gutter until the viewport is
     wide enough for the measure, then grows the gutter to centre it.

     TARGETED BY NAME, not `.site-header > *`. That selector also matched
     `.skip-link`, and a `padding-inline` on it would have overridden the
     skip link's own padding — visible the moment a keyboard user focuses it.

     THE MEASURE HERE IS 1200px, NOT THE DESIGN PACKAGE'S 1060px, and that
     is a deliberate, reported difference from the mock: the chrome is shared
     with `/buscar`, the category pages and the product pages, whose `<main>`
     is 1200px wide (the archived Responsive contract). Matching the mock
     here would align the header with the landing's bands and MISALIGN it
     with every other route's content by 70px. The landing's own bands keep
     the design's 1060px measure, so on the entry page the header's content
     column is 70px wider than the bands below it. Recorded for the owner
     rather than resolved by changing three other routes' layout. */
  .utility-bar,
  .site-header__row,
  .site-footer {
    padding-inline: max(40px, calc((100% - 1200px) / 2));
  }

  /* The bubble moves off the 16px edge the small-viewport row specifies. */
  .whatsapp-bubble {
    right: var(--sp-5);
    bottom: var(--sp-5);
  }

  /* ---------------------------------------------------------------------
   * THE RESULTS HEADER TAKES THE HANDOFF'S OWN COMPOSITION AT 900px
   * (adopt-results-view design.md §9; the handoff's "Responsive behavior"
   * table, whose only internal step for these views is <900 / 900-1199 /
   * >=1200)
   *
   * Below this width the band is a stack: heading, then a full-width refine
   * field. At and above it the two sit on one row with the field pinned
   * right and the heading takes the handoff's 30px. That is the WHOLE of
   * this band's >=900px step now: the third thing this block used to
   * introduce, a speed accent appearing at 1200px, was removed by owner
   * ruling in the correction round, and the >=1200px block below no longer
   * touches this band at all.
   * ------------------------------------------------------------------- */
  /* `center`, not the reference's `flex-end`. The reference bottom-aligns a
     30px heading against a 34px-tall field; this field is taller, because
     this project's controls carry a 44px touch target and a VISIBLE label,
     and bottom-aligning against it strands the heading at the band's floor
     with an empty navy block above it. Recorded as a deliberate difference
     from the reference rather than absorbed. */
  .results-header__inner {
    flex-direction: row;
    align-items: center;
    justify-content: space-between;
    gap: var(--sp-5);
  }

  .results-header__title {
    font-size: 30px;
  }

  /* A bounded measure, so the field cannot grow into the heading's half of
     the row and so the label stays on one line in both faces. The handoff
     draws a 220px input beside a ~100px button. */
  .results-header__search {
    flex: none;
    inline-size: 360px;
  }

  /* The message-state frame's own >=900px `--cut` step (design.md §7): 30px,
     up from the mobile-first 20px base rule — see `.search-state .cut-frame`'s
     own base-rule comment. */
  .search-state .cut-frame {
    --cut: 30px;
  }

  /* The isotype row's own layout step, >= 900px viewport (correction round,
     task 24.3): turns the mobile-first column stack into the mock's own
     side-by-side row WHENEVER the isotype itself is visible. This step is
     harmless when the isotype stays hidden (second correction round, defect
     4, below): a `.state-block__row` with one visible child renders
     identically whether its `flex-direction` is `row` or `column`, so
     leaving this at the viewport breakpoint — rather than also moving it to
     the container query — changes nothing observable. Below 900px the
     isotype stays `display:none` (its own base rule) regardless. */
  .state-block__row {
    flex-direction: row;
    gap: 40px;
    align-items: flex-start;
  }

  .state-block__content {
    flex: 1;
  }

  /* THE LAYOUT SHELL's >=900px step (adopt-results-view PR 2b task 14.3):
     the `236px 1fr` grid `.results-body__inner`'s own doc comment (near its
     base rule) describes. */
  .results-body__inner {
    display: grid;
    grid-template-columns: 236px 1fr;
    gap: var(--sp-4);
    align-items: start;
  }

  /* PINNED TO THE SECOND TRACK, EXPLICITLY (third correction round, owner
     ruling 2, "Unify the measure"). `.results-body__inner`'s grid ALWAYS
     declares two tracks (`236px 1fr`) regardless of how many children it
     has: with no aside present (an unfiltered message state,
     `templates/search.ts`'s `renderMessageStateShell` called with
     `aside: null`), `.results-content` would otherwise be the grid's ONLY
     item and auto-place into the FIRST (236px) track — squeezing an entire
     page's content into an aside-sized column — rather than the intended
     `1fr` one. Independent validation measured the resulting discrepancy
     directly: the SAME 9e composition rendered its content column at
     1136px unfiltered (no aside, no grid at all — the old shell) against
     776px filtered (aside present, `.results-content` correctly in track
     2), an inconsistency ruling 2 requires closed. This rule makes the
     placement explicit rather than positional, so the measure stays
     IDENTICAL whether or not `.results-filters` (the aside) is the
     preceding sibling. */
  .results-content {
    grid-column: 2;
  }

  /* THE NO-JS ASIDE's >=900px step (design.md §9): the summary row is
     redundant once the panel is always visible, and `::details-content`
     forces the panel visible regardless of the native `open` attribute —
     measured supported across the full §4.6 browser matrix at
     tasks.md 14.1 (Chrome 151/150, Firefox 154/153, Safari 26.6/18.x, all at
     or above the feature's chrome 131 / firefox 143 / safari 18.4 floor). */
  .results-filters__summary {
    display: none;
  }

  /* BOTH properties are load-bearing. The UA stylesheet's closed-state rule
     gates visibility with `content-visibility: hidden` (skips rendering
     entirely, independent of size) AND collapses `block-size: 0` — a
     `block-size: auto` override alone leaves the content un-rendered.
     Measured directly: dropping `content-visibility` here reproduced a real
     closed-looking panel at >=900px in Chromium 151 even with `open`
     removed from the markup and `block-size: auto` present. */
  .results-filters::details-content {
    content-visibility: visible;
    block-size: auto;
  }

  .results-filters:not([open]) .results-filters__panel {
    margin-block-start: 0;
  }

  /* The product detail page's two-column split (design.md §4, Fork 2's
     ruling), TWO changes from the archived rule, both stated here rather
     than one (correction round — a prior version of this comment named
     only the first and left the second silent):
       1. BREAKPOINT moved from the archived 1024px step to 900px — the
          width every OTHER shipped surface already transitions at.
       2. RATIO changed from `minmax(0, 5fr) minmax(0, 7fr)` to an even
          `1fr 1fr` — the approved handoff package's own figure
          (design_handoff_ficha_lutricon/README.md §4 and
          `ficha-de-producto.dc.html`), not a value carried over from the
          archived rule.
     The breadcrumb band spans both tracks; it is not part of the split. */
  .product {
    grid-template-columns: 1fr 1fr;
    align-items: start;
    gap: var(--sp-6);
    /* Fork A's gutter (design.md §10): reproduces `.band`'s 16/40 step and
       folds the 1060px cap into it. `100vw`, not `.site-main`'s `100%` —
       `.product` has escaped its containing block (base rule, above), so a
       percentage here would resolve against `.site-main`'s box, not the
       viewport, and land the content 24px off target. Above 1140px the
       `max()` grows the gutter until the content box is exactly 1060px;
       below it the flat 40px governs. `.product-grid` needs NO rule of its
       own here — converging this measure resolves its dead fifth track
       (5 x 220 + 4 x 16 = 1164 > 1060) as a side effect. Adding one is a
       defect: `tokens-css.test.ts`'s "columns declared exactly once"
       guard asserts this. */
    padding-inline: max(40px, calc((100vw - 1060px) / 2));
  }

  .pd-context {
    grid-column: 1 / -1;
  }

  /* The vehicles band and the related-products section (Slice 3) are, like
     the breadcrumb band above, NOT part of the viewer/panel split — both
     span the full width of the two-column grid from 900px up. Below 900px
     `.product`'s base rule is already a single column, so both are already
     full width there with no extra rule. */
  .vehicles-band,
  .pd-related {
    grid-column: 1 / -1;
  }
}

/* ===========================================================================
 * >= 1200 px: "as the mock" (design.md 5.5's top row)
 *
 * The only two bands whose top step differs from their middle one: the five
 * pillars in one row, and the category list in three columns. Everything
 * else is already a percentage or a bounded measure at 900 px and reaches
 * the mock's proportions on its own.
 * ======================================================================== */

@media (min-width: 1200px) {
  .pillar-band__list {
    grid-template-columns: repeat(5, minmax(0, 1fr));
  }

  /* THE MIRRORED PAIR'S ROW STEP (design.md §2, adopt-product-detail
     Slice 2 — owner ruling, round 4; Slice 5 inverts WHICH element sits in
     which slot, design.md §13, but not this breakpoint or step). Below
     1200px `.handoff__actions` stays the unconditional (mobile-first)
     `flex-direction: column` its base rule declares — the SAME full-width
     stacked, label-first composition, simply extended across the band the
     package left open, per the owner's own ruling. */
  .handoff__actions {
    flex-direction: row;
  }

  /* THE MOBILE-ONLY `tel:` AFFORDANCE (Slice 6, third review round,
     2026-08-24, task 35.4) — hidden from this breakpoint up, mirroring the
     mirrored pair's own `<1200px` mobile-first shape above: below 1200px
     the WhatsApp CTA is comfortably reachable, so the secondary dialing
     affordance is unconditionally visible in the base (mobile-first) rule
     and withdrawn here rather than the other way around. */
  .handoff__tel {
    display: none;
  }

  /* `flex: 1.35` (green/WhatsApp) / `flex: 1` (red/label) — CORRECTED
     against the approved package (`ficha-de-producto.dc.html:92-93`):
     the mock's literal inline styles put `flex:1.35` on the WhatsApp `<a>`
     and `flex:1` on Cotizar, matching its own stated "WhatsApp algo más
     ancho" ("WhatsApp somewhat wider"). NEITHER VALUE CHANGES under Slice
     5's inversion (design.md §13: "only which element sits where, and
     which bevel it wears") — the red half plays Cotizar's ROLE (secondary,
     narrower), never its identity, and now that role is worn by the
     non-interactive label rather than the retired copy button. */
  .handoff__cta {
    flex: 1.35;
  }

  .handoff__acquisition {
    flex: 1;
  }

  /* THE MIRRORED CUT — the row-only step (see the base-rule doc comment,
     above, at `.handoff__actions .cut-frame`, for why this shape does not
     exist below the row threshold). Percent vertices transcribed
     byte-identical from `ficha-de-producto.dc.html:92-93`; unchanged since
     the earlier correction round — only the breakpoint moved. */
  .pd-action__frame--start,
  .pd-action__surface--start {
    clip-path: polygon(0 0, 100% 0, 100% 100%, 7% 100%, 0 62%);
  }

  .pd-action__frame--end,
  .pd-action__surface--end {
    clip-path: polygon(0 0, 100% 0, 100% 62%, 93% 100%, 0 100%);
  }

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

/* >= 1024 px: the 1200 px centred content measure. The category PRODUCT list
   deliberately stays at two columns — the archived Responsive contract
   specifies 1/2/2 for it. `.category-grid` moved to the landing's own
   900/1200 breakpoints (see the two blocks above); this block keeps only the
   rules that belong to the CHROME and to the non-landing routes.
   The 1200 px measure still applies to `<main>`, and the landing's bands
   still escape it — `.band`'s `calc(50% - 50vw)` is computed against
   whatever this rule leaves, so the two compose rather than fight.

   `.product`'s two-column split MOVED to the >= 900px block below
   (adopt-product-detail Slice 1, design.md §4, Fork 2): 900px is where every
   OTHER shipped surface already transitions, and the archived 1024px step
   was the mock's own choice, not a cross-surface convention this change had
   a reason to keep. */
@media (min-width: 1024px) {
  .site-main {
    max-width: 1200px;
    margin-inline: auto;
    width: 100%;
  }
}

/* ===========================================================================
 * REDUCED MOTION (Decision 7 and the Route/state contract accessibility
 * paragraph: "prefers-reduced-motion disables all non-essential motion in
 * CSS only")
 *
 * ALL of it, not a selected subset: the entry animation, every transition,
 * and the press scale. The press scale is motion too — a control that jumps
 * under the finger is exactly the vestibular trigger this query exists for —
 * so it is cancelled explicitly rather than left standing because it happens
 * to be a transform rather than an animation.
 *
 * `!important` is used deliberately: these declarations must beat every rule
 * above regardless of selector specificity, and a user preference is the one
 * place where that is the correct tool rather than a shortcut.
 * ======================================================================== */

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation: none !important;
    transition: none !important;
    scroll-behavior: auto !important;
  }

  .handoff__cta:active,
  .search-form__submit:active,
  .vehicle-search__submit:active,
  .whatsapp-bubble:active,
  .category-card:active,
  .featured-card:active,
  .cta-band__action:active,
  .product-card:active,
  .pagination__link:active {
    transform: none !important;
  }

  /* The category row's hover shifts its content 12px to the right. The
     universal `transition: none` above stops it ANIMATING, but the jump
     itself is still motion under the pointer — the same reason the press
     scale is cancelled rather than merely un-animated — so the shift is
     removed and the red left rule alone carries the affordance. */
  .category-card:hover,
  .category-card:focus-visible {
    padding-left: 6px !important;
  }
}
