/* ============================================================================
   Initial T — site stylesheet
   Plain CSS, no build step, no preprocessor. Mobile-first: every base rule is
   the small-screen rule, and min-width media queries add the larger layouts.

   Palette is locked (see inspiration/website/initial-t/palette.md). The five
   custom properties below are the ONLY colours in this file. Racing red is a
   single accent — CTA, dividers, one accent element per section at most. If a
   shade needs changing, change it here, never inline.
   ========================================================================= */

:root {
  --ink: #1A1A1A;       /* wordmark, linework, primary text */
  --paper: #F7F5F0;     /* background, off-white not pure white */
  --red: #B0362E;       /* THE accent. Do not flood. */
  --steel: #6B6B6B;     /* secondary text (on paper only — fails AA on dark) */
  --charcoal: #2E2E2E;  /* deep shadow, secondary dark panels */

  /* System font stack — no webfont request at all (Apple-reference note:
     "system font stack, no webfont fetch"). */
  --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
          "Helvetica Neue", Arial, "Noto Sans", sans-serif;

  --gutter: 20px;
  --max: 1240px;
  --header-h: 60px;
  --tap: 44px;                 /* minimum touch target, hard requirement */
  --ease: cubic-bezier(.22, .61, .36, 1);
  /* Spring ease (polish round 2, item 9 "snazz it up"): a touch of overshoot,
     used ONLY on discrete, JS/hover-triggered state changes (card lift,
     button hover, nav-button hover, the wordmark's on-complete settle) —
     never on a scroll-scrubbed continuous value (loader fill, wordmark
     draw progress), where any overshoot would read as jitter, not spring.
     prefers-reduced-motion zeroes animation/transition-duration globally
     below, so this never fights that rule; it just changes the CURVE, not
     whether motion happens. */
  --ease-spring: cubic-bezier(.34, 1.56, .64, 1);

  /* Wordmark resting-state floor (roughly a quarter drawn, not a blank
     frame) is expressed as FLOOR_FRAC inside initHeroWordmark() in
     js/main.js. Nothing in this stylesheet reads --wm-ghost-opacity any
     more; kept out entirely rather than left as a dead knob. */
}

/* --- reset / base ------------------------------------------------------- */

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

html {
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
  scroll-padding-top: calc(var(--header-h) + 12px);
  /* iOS Safari's root scroller is <html>, not <body> — the CSS overflow
     spec says overflow set on body should propagate up to the viewport when
     html's own overflow is 'visible' (which it is, by default), and that
     propagation is what the existing body rule below relies on. In practice
     iOS Safari's rubber-band horizontal pan can still reveal content past
     the edge despite that propagation, specifically on <html> as the actual
     scrolling element. Setting it here directly closes that gap instead of
     depending on propagation from body to do it. */
  overflow-x: clip;
}

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: .001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .001ms !important;
  }
}

body {
  margin: 0;
  background: var(--paper);
  color: var(--ink);
  font-family: var(--font);
  font-size: 17px;
  line-height: 1.55;
  overflow-x: clip;        /* belt-and-braces against 320px horizontal scroll */
}

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

a { color: inherit; }

h1, h2, h3 {
  margin: 0 0 .4em;
  font-weight: 800;
  line-height: 1.04;
  letter-spacing: -0.01em;   /* tight negative tracking on large headings */
}

h1 { font-size: clamp(2.1rem, 8vw, 4.2rem); }
h2 { font-size: clamp(1.7rem, 5.6vw, 3rem); }
h3 { font-size: clamp(1.05rem, 3.2vw, 1.3rem); letter-spacing: -0.005em; }

p { margin: 0 0 1em; }

/* Visible focus ring everywhere — keyboard users must always see where they
   are. Never remove this without replacing it. */
:focus-visible {
  outline: 3px solid var(--red);
  outline-offset: 3px;
  border-radius: 2px;
}

.wrap {
  width: 100%;
  max-width: var(--max);
  margin-inline: auto;
  padding-inline: var(--gutter);
}

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

.skip-link {
  position: absolute;
  left: 8px; top: -60px;
  z-index: 200;
  background: var(--ink);
  color: var(--paper);
  padding: 10px 16px;
  text-decoration: none;
  transition: top .15s var(--ease);
}
.skip-link:focus { top: 8px; }

/* ============================================================================
   1. Loader — T glyph fills blank -> racing red
   Tied to real load progress in js/main.js, with a minimum display time so it
   can't flash. THE FAILSAFE IS IN CSS, NOT JS: the fade-out keyframe below
   runs regardless of whether main.js ever executes, so a JS error can never
   trap the page behind the overlay.
   ========================================================================= */

#loader {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: grid;
  place-items: center;
  background: var(--paper);
  animation: loader-failsafe 4.5s steps(1, end) forwards;
}

@keyframes loader-failsafe {
  0%, 88% { opacity: 1; visibility: visible; }
  100%    { opacity: 0; visibility: hidden; pointer-events: none; }
}

/* JS-driven exit. Wins over the failsafe because it is applied later in the
   cascade with its own transition. Dissolve, not a pop: fades AND settles
   from a hair larger back to rest size, transform + opacity only. */
#loader.is-done {
  opacity: 0;
  transform: scale(1.04);
  visibility: hidden;
  pointer-events: none;
  animation: none;
  transition: opacity .5s var(--ease), transform .5s var(--ease),
              visibility 0s linear .5s;
}

/* Big enough to register as a deliberate brand moment, not a flicker — was
   min(140px, 34vw), which on a real phone viewport rendered under 110px. */
#loader .loader-inner { width: min(240px, 56vw); }
#loader .loader-inner svg {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 220 / 175;
}
#loader .t-empty { fill: none; stroke: var(--ink); stroke-width: 5; }
#loader .t-fill  { fill: var(--red); }

/* The fill rect is clipped to the T outline and grows bottom-up. No CSS
   transition here on purpose — main.js's rAF loop already eases the value
   toward its target every frame; a transition on top of that would just
   add a second, fighting layer of lag behind the real motion. */
#loader .fill-rect { transition: none; }

#loader .loader-label {
  margin-top: 18px;
  text-align: center;
}

/* The percentage readout — in the brand palette, sized to actually read at
   phone size, not a caption-scale afterthought. */
#loader .loader-pct {
  display: block;
  font-size: clamp(1.9rem, 9vw, 2.6rem);
  font-weight: 800;
  letter-spacing: -0.01em;
  color: var(--red);
  font-variant-numeric: tabular-nums;   /* digit width doesn't jitter as it climbs */
}
#loader .loader-caption {
  display: block;
  margin-top: 4px;
  font-size: .72rem;
  letter-spacing: .22em;
  text-transform: uppercase;
  color: var(--steel);
}

/* No JS at all: never show the loader in the first place. */
.no-js #loader { display: none; }

/* ============================================================================
   2. Header — bare icon row, deliberately NO hamburger (single-page site, an
   extra tap to reach one anchor is not justified).
   ========================================================================= */

.site-header {
  position: sticky;
  top: 0;
  z-index: 50;
  background: color-mix(in srgb, var(--paper) 92%, transparent);
  -webkit-backdrop-filter: blur(8px);
  backdrop-filter: blur(8px);
  border-bottom: 1px solid color-mix(in srgb, var(--ink) 12%, transparent);
}

@supports not (background: color-mix(in srgb, red 50%, transparent)) {
  .site-header { background: var(--paper); border-bottom-color: var(--steel); }
}

.icon-row {
  display: grid;
  grid-template-columns: var(--tap) 1fr var(--tap);
  align-items: center;
  height: var(--header-h);
}

.icon-btn {
  display: grid;
  place-items: center;
  width: var(--tap);
  height: var(--tap);
  color: var(--ink);
  text-decoration: none;
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
}
.icon-btn svg { width: 26px; height: 26px; }
.icon-btn:hover { color: var(--red); }

.brand-mark {
  justify-self: center;
  display: grid;
  place-items: center;
  min-height: var(--tap);
  padding-inline: 8px;
}
.brand-mark img { width: auto; height: 34px; }

/* ============================================================================
   3. Hero — two tiles (Adidas/Nike pattern). Stacked on mobile, side by side
   from 900px up.
   ========================================================================= */

.hero {
  display: grid;
  grid-template-columns: 1fr;
  gap: 0;
  border-bottom: 1px solid color-mix(in srgb, var(--ink) 12%, transparent);
}

.hero-tile {
  /* Grid items default to min-width:auto, i.e. min-content. Without this a
     wide child (the wordmark SVG) can force the column wider than the
     viewport and give the whole page horizontal overflow at 320px. */
  min-width: 0;
  min-height: min(78vh, 620px);
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding: clamp(28px, 7vw, 64px) var(--gutter);
}

.hero-tile--mark { background: var(--paper); }

/* Mobile-only order swap (2026-08-25 accessibility/design-critique fix, item 8):
   source order is mark-tile then shot-tile (the wordmark needs to be first in
   the DOM for a sane reading/tab order and for the sticky-wordmark trick
   above), but a garment storefront should show the product photo first on
   first glance. Desktop already reads photo-first at the 900px breakpoint via
   its natural grid placement, so it's left untouched below — this override
   only fires under 900px. Single tunable pair, revert by deleting this block. */
.hero-tile--shot { order: -1; }
.hero-tile--mark { order: 0; }

/* The scroll-drawn wordmark. Fixed aspect box so nothing shifts while the
   SVG paints. */
/* Sticky inside tile A: the mark pins just under the header while the reveal
   plays, instead of sliding out of view before it has finished drawing. It
   unpins again at the bottom of the tile and scrolls away normally. The paper
   background is load-bearing — it masks the hero copy passing underneath. */
.wordmark {
  position: sticky;
  top: var(--header-h);
  z-index: 1;
  width: 100%;
  max-width: 620px;
  padding-block-start: 22px;
  padding-block-end: 10px;
  background: var(--paper);
  margin-bottom: clamp(20px, 4vw, 34px);
  /* Row layout to seat the static "T" car-mark image beside the animated
     "INITIAL" SVG (see the markup comment on .wordmark-t in index.html).
     min-width:0 on the svg is load-bearing, same reasoning as .hero-tile's
     own min-width:0 below — without it a flex item won't shrink past its
     content's intrinsic size, which for an aspect-ratio'd SVG can force this
     row wider than its 100%-width container at narrow viewports. */
  display: flex;
  align-items: center;
  gap: clamp(6px, 2vw, 14px);
}

/* SVG wordmark — reverted 2026-08-25, see the long comment on the markup in
   index.html for why. Explicit aspect-ratio (matching the wordmark.svg
   viewBox, 898/185) stops the box resolving to a default intrinsic size and
   scaling sideways out of the layout on narrow screens. */
.wordmark-svg {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 898 / 185;
  overflow: visible;   /* stroke-width can nudge outside the tight glyph bbox */
  /* Flex-item cross-axis sizing: this is the specific min-width:0 fix
     described in the .wordmark comment above. flex:1 lets it give up width
     to .wordmark-t; min-width:0 lets it actually shrink below its
     aspect-ratio intrinsic size instead of forcing the row wider. */
  flex: 1 1 auto;
  min-width: 0;
}

/* The static "T" car-mark (2026-08-25 fix, see markup comment). Sized off
   the wordmark's own scale (clamp, not a fixed px) so it stays proportioned
   to the letters at every viewport instead of a flat size choice. flex:none
   so it never gets squeezed by the SVG above; min-width:0 is belt-and-braces
   consistent with every other flex/grid child on this page that sits between
   a wide-intrinsic-size image and a narrow mobile container. */
.wordmark-t {
  flex: none;
  min-width: 0;
  width: clamp(46px, 11vw, 86px);
}
.wordmark-t img { width: 100%; height: auto; }
.wordmark-ghost use {
  fill: var(--ink);
  opacity: .1;
}
.wordmark-stroke use {
  fill: none;
  stroke: var(--ink);
  stroke-width: 3;
  stroke-linecap: round;
  stroke-linejoin: round;
  /* dasharray/dashoffset set per-use by initHeroWordmark() in js/main.js
     once each path's real getTotalLength() is known — never guessed. No CSS
     transition here, same reasoning as the loader's fill-rect: a scroll-
     scrubbed value is eased in JS already; a CSS transition on top of that
     would just add a second, fighting layer of lag. */
}
.wordmark-fill {
  /* Default (no-JS, or before initHeroWordmark() runs): fully opaque, so a
     no-JS visitor — who also never gets the stroke-dashoffset hidden state,
     since that's JS-applied too — sees the plain, finished, solid wordmark
     immediately. JS then drives this to 0 and back up per-scroll as soon as
     it runs, same "hidden state is JS-applied only" guarantee used
     everywhere else on this page. */
  opacity: 1;
}
.wordmark-fill use {
  fill: var(--ink);
  /* opacity set per-frame by initHeroWordmark() as a pure function of scroll
     — same "no fighting transition on a continuous value" reasoning. */
}
.hero-claim {
  max-width: 30ch;
  font-size: clamp(1.05rem, 3.4vw, 1.25rem);
  color: var(--ink);
}
.hero-proof {
  max-width: 42ch;
  color: var(--steel);
  font-size: .95rem;
}

/* Real price anchor, text patched at build time by fetch_products.py from
   data/products.json — see the comment on the element in index.html. */
.hero-price {
  max-width: 42ch;
  color: var(--ink);
  font-weight: 700;
  font-size: .95rem;
}

.hero-tile--shot {
  padding: 0;
  background: var(--charcoal);
  min-height: min(78vh, 620px);
}
.hero-tile--shot picture,
.hero-tile--shot img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* .hero-tile--shot is display:flex (from the shared .hero-tile rule), so
     picture/img are flex items here too — same min-width:0 reasoning as
     .wordmark-svg above, applied at this level of the ancestry as well, not
     just at the .hero-tile tile boundary. */
  min-width: 0;
}

@media (min-width: 900px) {
  .hero { grid-template-columns: 1fr 1fr; }
  /* Reset the mobile-only order swap above — desktop keeps its natural
     source order (photo tile already reads first at this width). */
  .hero-tile--shot,
  .hero-tile--mark { order: initial; }
}

/* Scroll cue — fixed to the viewport (not the hero tile) so it always sits
   over the bottom of whatever is on screen at load, regardless of stacked
   vs. side-by-side hero layout. JS (initScrollCue in main.js) adds
   .is-hidden after the first real scroll and never removes it again. The
   bob keyframe below is the only motion here, so the global
   prefers-reduced-motion rule at the top of this file (which zeroes every
   animation-duration) is what satisfies "respect reduced motion" — no
   separate check needed in this rule. */
.scroll-cue {
  position: fixed;
  left: 50%;
  bottom: 18px;
  z-index: 40;
  transform: translateX(-50%);
  width: 26px;
  height: 26px;
  pointer-events: none;
  opacity: .6;
  transition: opacity .35s var(--ease);
}
.scroll-cue.is-hidden { opacity: 0; }

.scroll-cue-chevron {
  display: block;
  width: 12px;
  height: 12px;
  margin: 0 auto;
  border-right: 3px solid var(--ink);
  border-bottom: 3px solid var(--ink);
  transform: rotate(45deg);
  animation: scroll-cue-bob 1.6s ease-in-out infinite;
}
@keyframes scroll-cue-bob {
  0%, 100% { transform: rotate(45deg) translate(0, 0); }
  50%      { transform: rotate(45deg) translate(5px, 5px); }
}

/* --- buttons ------------------------------------------------------------ */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: .5em;
  min-height: var(--tap);
  padding: 12px 26px;
  background: var(--red);
  color: var(--paper);
  font-weight: 700;
  font-size: 1rem;
  letter-spacing: .01em;
  text-decoration: none;
  border: 2px solid var(--red);
  border-radius: 2px;
  transition: background .18s var(--ease), color .18s var(--ease),
              transform .28s var(--ease-spring), box-shadow .28s var(--ease);
}
.btn:hover,
.btn:focus-visible {
  background: transparent;
  color: var(--red);
  transform: translateY(-2px);
  box-shadow: 0 10px 20px -12px color-mix(in srgb, var(--ink) 45%, transparent);
}

.btn--ghost {
  background: transparent;
  color: var(--ink);
  border-color: var(--ink);
}
.btn--ghost:hover { background: var(--ink); color: var(--paper); }

.btn .ext { width: 15px; height: 15px; }

/* ============================================================================
   4. Sections-as-chapters — one claim, one visual, one line of proof each.
   Light/dark alternation marks the boundaries without a divider rule.
   ========================================================================= */

.chapter { padding: clamp(56px, 12vw, 110px) 0; }

/* Scroll-entrance reveal, driven by IntersectionObserver in main.js.
   transform + opacity only — never layout properties, so this stays off the
   main thread. Ends fully visible even without JS: the hidden state is only
   ever applied by main.js adding .is-visible, never a bare CSS default, so a
   JS failure just leaves the section shown, never stuck invisible. */
.reveal.is-visible {
  opacity: 1;
  transform: none;
}
.js .reveal:not(.is-visible) {
  opacity: 0;
  transform: translateY(28px);
}
.reveal {
  transition: opacity .7s var(--ease), transform .7s var(--ease);
}

.chapter--dark {
  background: var(--charcoal);
  color: var(--paper);
}
.chapter--dark .chapter-proof { color: var(--paper); opacity: .72; }
/* Racing red as TEXT fails contrast on charcoal — on dark sections red is only
   ever a block or a rule, never type. */
.chapter--dark .rule { background: var(--red); }

.chapter-eyebrow {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-bottom: 18px;
  font-size: .72rem;
  font-weight: 700;
  letter-spacing: .22em;
  text-transform: uppercase;
}
.chapter-eyebrow .rule {
  display: block;
  width: 34px;
  height: 3px;
  background: var(--red);
  flex: none;
}

.chapter-claim { max-width: 18ch; }
.chapter-proof {
  max-width: 56ch;
  color: var(--steel);
}

.chapter-grid {
  display: grid;
  gap: clamp(28px, 6vw, 56px);
  align-items: center;
}
@media (min-width: 860px) {
  .chapter-grid { grid-template-columns: 1fr 1fr; }
  .chapter-grid--flip .chapter-visual { order: -1; }
}

.chapter-visual img { width: 100%; border-radius: 2px; }

/* Dark chapter image (item 2, polish round 2): fades in on its OWN
   IntersectionObserver when scrolled into view — armVisualReveal() in
   js/main.js, same pattern as the carousel stagger. Gated behind .js so a
   no-JS page (or a JS error before this runs) shows it fully visible from
   the start, never permanently hidden. transform + opacity only. */
.js .chapter-visual--fade:not(.is-visible) {
  opacity: 0;
  transform: translateY(22px) scale(.985);
}
.chapter-visual--fade.is-visible {
  opacity: 1;
  transform: none;
}
.chapter-visual--fade {
  transition: opacity .7s var(--ease), transform .7s var(--ease);
}

/* ============================================================================
   General image fade-in (item 3, polish round 2). Any <img> the page arms
   via armImageFade() in js/main.js starts at low opacity and eases to full
   once it has actually finished loading (or is already .complete — cached
   images skip straight to shown, no fake fade on top of an instant paint).
   Only ever applied by JS adding .img-fade before wiring the load listener —
   default state for every <img> in the document stays opacity:1, so no-JS or
   a JS error before this runs never leaves an image invisible. Layers on top
   of, and does not replace, the section .reveal / carousel-stagger work:
   this fades the PIXELS in once decoded, those fade/rise the CONTAINER in on
   scroll — independent triggers, same easing curve so they read as one
   system. ========================================================================= */
.img-fade { opacity: 0; }
.img-fade-in {
  opacity: 1;
  transition: opacity .45s var(--ease);
}

.spec-list {
  margin: 0;
  padding: 0;
  list-style: none;
  border-top: 1px solid color-mix(in srgb, currentColor 20%, transparent);
}
.spec-list li {
  display: flex;
  justify-content: space-between;
  gap: 16px;
  padding: 12px 0;
  border-bottom: 1px solid color-mix(in srgb, currentColor 20%, transparent);
  font-size: .95rem;
}
.spec-list dt, .spec-list .k { font-weight: 700; }
.spec-list .v { text-align: right; opacity: .8; }

/* ============================================================================
   5. Product carousel — real Etsy listings, every click opens the real listing
   in a new tab. Touch swipe is native scroll-snap (momentum, no library);
   arrow buttons and keyboard/tab focus drive the same scroller.
   ========================================================================= */

.shop-head {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  justify-content: space-between;
  gap: 16px;
  margin-bottom: 26px;
}

.carousel { position: relative; }

.carousel-track {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: minmax(230px, 76vw);
  gap: 16px;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scroll-snap-type: x mandatory;
  scroll-padding-inline: var(--gutter);
  padding: 4px var(--gutter) 20px;
  margin-inline: calc(var(--gutter) * -1);
  -webkit-overflow-scrolling: touch;
  /* Firefox: thin + brand colours (thumb, track), not the bare OS default. */
  scrollbar-width: thin;
  scrollbar-color: var(--red) color-mix(in srgb, var(--ink) 8%, transparent);
}
.carousel-track::-webkit-scrollbar { height: 6px; }
.carousel-track::-webkit-scrollbar-track {
  background: color-mix(in srgb, var(--ink) 8%, transparent);
  border-radius: 3px;
}
.carousel-track::-webkit-scrollbar-thumb {
  background: var(--red);
  border-radius: 3px;
}

@media (min-width: 620px) { .carousel-track { grid-auto-columns: 280px; } }
@media (min-width: 900px) { .carousel-track { grid-auto-columns: 300px; } }

.card {
  scroll-snap-align: start;
  display: flex;
  flex-direction: column;
  text-decoration: none;
  color: inherit;
  background: var(--paper);
  /* Lift + grow on hover AND keyboard focus — :focus-visible only, so a
     mouse click that leaves the link focused doesn't also show the
     ring/lift combo. Spring ease (item 9): a discrete hover trigger, not a
     scroll-scrubbed value, so a touch of overshoot is safe here — it's the
     one "springy" interaction the page repeats consistently (cards, button,
     nav arrows), not a one-off gimmick. */
  transition: transform .32s var(--ease-spring), box-shadow .32s var(--ease);
}
.card:hover,
.card:focus-visible {
  transform: translateY(-4px) scale(1.025);
  /* Two-layer shadow (item 9): a tight, dark contact shadow close to the
     card plus a softer, wider ambient one — reads as real lift instead of a
     single flat blur. Both layers are var(--ink) at different opacities;
     no colour outside the five locked hexes. */
  box-shadow:
    0 3px 8px -4px color-mix(in srgb, var(--ink) 45%, transparent),
    0 18px 32px -14px color-mix(in srgb, var(--ink) 38%, transparent);
}
/* The whole card is already the link's own focus target — :focus-visible
   above supplies the lift, and the global :focus-visible outline rule near
   the top of this file still draws the ring. No separate focus style to add
   here beyond matching the hover state. */

/* Staggered one-by-one reveal, armed only by main.js once the carousel
   scrolls into view. .is-armed is never present without JS, so a no-JS page
   (or a JS error before this runs) shows every card already fully visible —
   the hidden state can only ever be JS-applied, matching the .reveal pattern
   above. transform + opacity only. */
.carousel-track.is-armed .card {
  opacity: 0;
  transform: translateY(20px);
  transition: opacity .55s var(--ease), transform .55s var(--ease);
}
.carousel-track.is-armed .card.is-in {
  opacity: 1;
  transform: none;
}
.card-media {
  position: relative;
  aspect-ratio: 1 / 1;
  overflow: hidden;
  background: color-mix(in srgb, var(--ink) 6%, transparent);
}
.card-media img {
  width: 100%; height: 100%;
  object-fit: cover;
  transition: transform .5s var(--ease), opacity .5s var(--ease);
}
.card:hover .card-media img,
.card:focus-visible .card-media img { transform: scale(1.04); }

/* Second listing photo (build/fetch_products.py's optional p.image2),
   layered on top of the primary image and crossfaded in on hover/focus.
   Only created by main.js when a listing actually has a second photo — a
   one-image listing has no .card-media-alt at all, so there is nothing to
   silently fail to swap. */
.card-media-alt {
  position: absolute;
  inset: 0;
  display: block;
  opacity: 0;
}
.card:hover .card-media-alt,
.card:focus-visible .card-media-alt { opacity: 1; }

/* Unconditional end state (polish item 2): if an image genuinely fails to
   load — not just "hasn't loaded yet" — main.js adds this class instead of
   ever leaving the browser's broken-image icon on screen. Title and price
   stay fully visible either way; only the picture area degrades. */
.card-media.media-error img,
.card-media.media-error picture { display: none; }
.card-media.media-error::after {
  content: '';
  position: absolute;
  inset: 0;
  background: color-mix(in srgb, var(--ink) 6%, transparent);
}

.card-title {
  margin: 12px 0 6px;
  font-size: .95rem;
  font-weight: 700;
  line-height: 1.3;
  letter-spacing: -0.005em;
  /* Etsy titles are long keyword strings — clamp to keep the cards even. */
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.card-price { font-size: .95rem; color: var(--red); font-weight: 700; }
.card-meta {
  margin-top: 2px;
  font-size: .78rem;
  color: var(--steel);
  display: flex;
  align-items: center;
  gap: 6px;
}
.card-meta .ext { width: 12px; height: 12px; }

.carousel-nav {
  display: flex;
  gap: 10px;
}
.carousel-nav button {
  width: var(--tap);
  height: var(--tap);
  display: grid;
  place-items: center;
  background: transparent;
  /* Item 8: brand red, matching the CTA button and bonnet-stripe accent —
     was ink/black. */
  color: var(--red);
  border: 2px solid var(--red);
  border-radius: 50%;
  cursor: pointer;
  transition: background .18s var(--ease), color .18s var(--ease),
              transform .28s var(--ease-spring);
}
.carousel-nav button:hover:not(:disabled),
.carousel-nav button:focus-visible:not(:disabled) {
  background: var(--red);
  color: var(--paper);
  transform: scale(1.08);
}
.carousel-nav button:disabled { opacity: .3; cursor: default; }
.carousel-nav svg { width: 18px; height: 18px; }

.carousel-empty {
  padding: 28px 0;
  color: var(--steel);
}
/* No-JS-first pattern (2026-08-25 fix, item 1), same shape as .js .reveal
   above: the element carries NO static hidden attribute in the HTML any
   more, so a no-JS visitor always sees this fallback message instead of a
   blank shop section. Once .js is live, initCarousel() in main.js is fully
   responsible from then on — it flips the real "hidden" attribute true on a
   successful product load and false on a genuine empty/failed fetch. The
   two rules below just set the .js default (hidden, since cards are about
   to be built) without fighting that attribute toggle: the :not([hidden])
   rule re-shows it the moment JS explicitly clears the attribute. */
.no-js .carousel-empty { display: block; }
.js .carousel-empty { display: none; }
.js .carousel-empty:not([hidden]) { display: block; }

/* ============================================================================
   6. Footer — minimal on purpose. Socials row + one privacy line. Nothing else.
   ========================================================================= */

.site-footer {
  padding: 44px 0 40px;
  background: var(--ink);
  color: var(--paper);
}

.socials {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  margin: 0 0 22px;
  padding: 0;
  list-style: none;
}

.social {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-height: var(--tap);
  padding: 0 16px;
  border: 1px solid color-mix(in srgb, var(--paper) 35%, transparent);
  border-radius: 2px;
  font-size: .82rem;
  letter-spacing: .1em;
  text-transform: uppercase;
  text-decoration: none;
  color: var(--paper);
}
a.social:hover { border-color: var(--paper); }

/* Instagram and TikTok (2026-08-25) hold the real official marks, dropped in
   unmodified per the draw-it-in-house exception documented on the markup in
   index.html. Etsy's mark is still TODO, so its slot keeps the dashed
   placeholder box below (.social-slot--placeholder) — a real, live link,
   just without the official logo drawn in yet. */
.social-slot {
  display: grid;
  place-items: center;
  width: 20px; height: 20px;
  border-radius: 2px;
  font-size: 9px;
  line-height: 1;
  flex: none;
}
/* Etsy's slot is still a bare letter (no official mark on disk yet) — its
   own placeholder modifier keeps the dashed box. Instagram/TikTok now hold
   the real official marks as an <img>, which draw their own shape — no
   border needed, and no :has() dependency to keep this working everywhere. */
.social-slot--placeholder { border: 1px dashed currentColor; }
.social-slot img { width: 100%; height: 100%; display: block; }

.footer-legal {
  margin: 0;
  font-size: .8rem;
  color: var(--paper);
  opacity: .72;
  max-width: 62ch;
}
.footer-legal a { text-decoration: underline; }

/* ============================================================================
   7. 404
   ========================================================================= */

.notfound {
  min-height: calc(100vh - var(--header-h));
  display: grid;
  place-items: center;
  text-align: center;
  padding: clamp(40px, 10vw, 90px) var(--gutter);
}
.notfound-code {
  font-size: .75rem;
  font-weight: 700;
  letter-spacing: .3em;
  text-transform: uppercase;
  color: var(--red);
  margin-bottom: 14px;
}
.notfound-art {
  display: block;
  width: min(560px, 100%);
  height: auto;
  aspect-ratio: 3 / 2;
  margin: 0 auto clamp(22px, 5vw, 40px);
}

/* Pure-CSS dissolve-in on load, same reasoning as the loader's CSS failsafe:
   no JS dependency, so it always runs, no observer to fail to fire. */
.art-dissolve-in {
  animation: art-dissolve-in .7s var(--ease) both;
}
@keyframes art-dissolve-in {
  from { opacity: 0; transform: translateY(10px); }
  to   { opacity: 1; transform: none; }
}
.notfound .chapter-proof { margin-inline: auto; }
.notfound-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  justify-content: center;
}
