/* ==========================================================================
   NXOS — design system

   Editorial, dark-first, monospace-accented. The reference points are
   auction-house catalogs and trading terminals rather than merch stores:
   this is a registry that happens to sell things, not a shop that happens
   to keep records.

   Every visual value is a custom property in :root. Change those and the
   whole app moves. Class names are the contract with the markup — they
   should survive a redesign; these values should not.

   Deliberate choices worth keeping regardless of direction:
     - Dark by default. Artifact photography sits better on black, and the
       verdict colors read louder against it.
     - Mobile-first. Desktop is the same layout with more room.
     - Tabular numerals on every figure. 07/25 and $1,240 must not wobble
       when they update in place.
     - 44px minimum touch targets.
     - Safe-area insets for notched devices.
     - The verdict is the largest thing on its page. The moment of
       verification is the product.
   ========================================================================== */

:root {
  /* --- Palette -------------------------------------------------------
     Near-black base with a warm off-white. The single accent is a pale
     gold used only for market and value signals — if it appears anywhere
     else it stops meaning anything. */
  --ink:           #f4f3ef;   /* primary text */
  --ink-soft:      #9d9b95;   /* secondary text */
  --ink-faint:     #66645f;   /* tertiary, labels */
  --paper:         #0b0b0c;   /* page background */
  --surface:       #141416;   /* cards */
  --surface-2:     #1c1c1f;   /* raised / hover */
  --line:          #26262a;   /* hairlines */
  --line-strong:   #3a3a40;   /* emphasised borders */

  --verified:      #4ade80;
  --verified-bg:   #10281a;
  --rejected:      #ff6b61;
  --rejected-bg:   #2e1614;
  --accent:        #c9a961;   /* market / value only */
  --accent-soft:   #2a2317;
  --shimmer:       #1f1f23;
  /* QF-TAG authentication badge. Its own token, never --accent:
     a property theme must not be able to repaint it. */
  --qf:            #e8600a;

  /* --- Elevated surfaces ---------------------------------------------
     Glass is used ONLY on things that float above the page: dialogs, the
     command palette, toasts. Applied to a page background it becomes
     noise behind text; applied to a floating panel it does the one job
     it is good at, which is signalling "this is on top of that". */
  --scrim:         rgba(6, 6, 8, 0.62);
  --glass:         rgba(22, 22, 26, 0.86);
  --glass-line:    rgba(255, 255, 255, 0.09);
  --shadow-lift:   0 24px 60px -12px rgba(0, 0, 0, 0.7);

  /* --- Type ----------------------------------------------------------
     One sans for everything, one mono for anything numeric or
     identifying. Serials, UIDs, editions and prices are all mono — they
     are read as data, not prose. */
  --font: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, Roboto, sans-serif;
  --font-num: ui-monospace, "SF Mono", Menlo, "Roboto Mono", monospace;

  --step--2: 0.6875rem;
  --step--1: 0.8125rem;
  --step-0:  0.9375rem;
  --step-1:  1.0625rem;
  --step-2:  1.375rem;
  --step-3:  1.875rem;
  --step-4:  2.5rem;
  --step-5:  3.5rem;

  /* --- Space --------------------------------------------------------- */
  --sp-1: 0.25rem;
  --sp-2: 0.5rem;
  --sp-3: 0.75rem;
  --sp-4: 1rem;
  --sp-5: 1.5rem;
  --sp-6: 2rem;
  --sp-7: 3rem;
  --sp-8: 4.5rem;

  --radius: 4px;       /* Tight. Soft corners read as consumer software. */
  --radius-sm: 3px;
  --radius-pill: 99px;

  --tap: 44px;
}

/* Light mode.

   Same tokens, inverted, with the accent darkened enough to hold contrast
   on white.

   Declared twice on purpose, driven by two different signals:

     :root[data-theme="light"]        an explicit choice the user made
     @media + :root:not([data-theme]) the OS preference, when they haven't

   The media query is scoped to :not([data-theme]) so that an explicit
   choice always wins. Without that scoping a user on a light-mode OS
   could select dark, and the media query would override them a moment
   later — the toggle would appear broken for exactly the people most
   likely to reach for it. Theme is a first-class setting here, not a
   passive reflection of the operating system. */
:root[data-theme="light"] {
  /* Page background #F1F1F1 — a neutral gray, not the warm off-white
     this used to be. The rest of the light ramp moved with it rather
     than only the one value: --surface-2 was #f4f2ed, which is LIGHTER
     than #F1F1F1, so every "recessed" surface would have appeared raised
     and the depth hierarchy would have read backwards. Hairlines
     neutralized too — warm grays go visibly yellow against a cool
     background. */
  --ink:           #16161a;
  --ink-soft:      #57575c;
  --ink-faint:     #86868b;
  --paper:         #f1f1f1;   /* page background */
  --surface:       #ffffff;   /* cards lift off the gray */
  --surface-2:     #e6e6e6;   /* recessed / hover, now genuinely darker */
  --line:          #dcdcdc;
  --line-strong:   #bfbfbf;

  --verified:      #0a7d32;
  --verified-bg:   #e4f2e8;
  --rejected:      #b3261e;
  --rejected-bg:   #fbeae9;
  /* Darkened from #8a6d2f. The lighter page background dropped that to
     4.31:1, just under AA for body text, and the accent IS used as text
     (links, the property attribution, the early-access label). Same hue
     and saturation, two steps down in lightness — 4.58:1. */
  --accent:        #85692d;
  --accent-soft:   #f0ead9;
  --shimmer:       #e4e4e4;
  --qf:            #d1540a;   /* a touch deeper, for white text on light */

  --scrim:         rgba(22, 22, 26, 0.34);
  --glass:         rgba(255, 255, 255, 0.88);
  --glass-line:    rgba(18, 18, 22, 0.1);
  --shadow-lift:   0 24px 60px -12px rgba(30, 30, 34, 0.22);
}

@media (prefers-color-scheme: light) {
  :root:not([data-theme]) {
    /* Mirrors :root[data-theme="light"] exactly. See the note there for
       why the whole ramp moved rather than just --paper. */
    --ink:           #16161a;
    --ink-soft:      #57575c;
    --ink-faint:     #86868b;
    --paper:         #f1f1f1;
    --surface:       #ffffff;
    --surface-2:     #e6e6e6;
    --line:          #dcdcdc;
    --line-strong:   #bfbfbf;

    --verified:      #0a7d32;
    --verified-bg:   #e4f2e8;
    --rejected:      #b3261e;
    --rejected-bg:   #fbeae9;
    /* Darkened from #8a6d2f. The lighter page background dropped that to
     4.31:1, just under AA for body text, and the accent IS used as text
     (links, the property attribution, the early-access label). Same hue
     and saturation, two steps down in lightness — 4.58:1. */
  --accent:        #85692d;
    --accent-soft:   #f0ead9;
    --shimmer:       #e4e4e4;
    --qf:            #d1540a;

    --scrim:         rgba(22, 22, 26, 0.34);
    --glass:         rgba(255, 255, 255, 0.88);
    --glass-line:    rgba(18, 18, 22, 0.1);
    --shadow-lift:   0 24px 60px -12px rgba(30, 30, 34, 0.22);
  }
}

/* -------------------------------------------------------------------- */

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

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

body {
  margin: 0;
  font-family: var(--font);
  font-size: var(--step-0);
  line-height: 1.55;
  color: var(--ink);
  background: var(--paper);
  -webkit-font-smoothing: antialiased;
  padding:
    env(safe-area-inset-top)
    env(safe-area-inset-right)
    env(safe-area-inset-bottom)
    env(safe-area-inset-left);
}

::selection { background: var(--accent); color: var(--paper); }

/* --- Layout -----------------------------------------------------------
   The SHELL is always full width and full height. Only the content
   measure is constrained, and it is constrained on .screen rather than on
   .app.

   This distinction is the whole layout system. Capping .app produces a
   narrow column stranded in the middle of the viewport with the page
   background showing on either side — which reads as a phone screenshot
   pasted onto a desktop, no matter how good the typography inside it is.
   Capping .screen instead means headers, borders and backgrounds run edge
   to edge while prose still stops at a readable width.

   --measure is the one knob:
     .app          44rem — focused flows: verdict, sign-in, claim.
     .app--wide   104rem — data surfaces: market, collection, studio.
   -------------------------------------------------------------------- */

.app {
  --measure: 44rem;
  width: 100%;
  min-height: 100vh;
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
}

.app--wide { --measure: 104rem; }

/* Fills the viewport and centers its content vertically. For landing and
   verdict screens, where a short page pinned to the top of a tall window
   leaves an obvious dead zone underneath. */
.app--fill { --measure: 100%; }
.app--fill .screen { display: flex; flex-direction: column; justify-content: center; }

/*
  Tighter under the header.

  The top padding matched the sides, which put the first line of every
  page a long way below a nav bar that is only 56px tall — enough dead
  space that a short page looked unfinished. Reduced at the top only;
  the sides and the generous bottom are unchanged, because those are
  doing different jobs.
*/
.screen {
  flex: 1;
  width: 100%;
  max-width: var(--measure);
  margin: 0 auto;
  padding: var(--sp-3) var(--sp-4) var(--sp-8);
}

/*
  TOP PADDING IS SET SEPARATELY FROM THE SIDES at every breakpoint.

  Reducing it on the base rule alone did nothing on a desktop, because
  these two queries reset the whole shorthand — and desktop is where the
  gap was most obviously wrong, since the nav is 56px tall regardless of
  how wide the window is.

  The sides still grow with the viewport (that is what keeps the measure
  readable). Only the distance from the nav to the first line shrinks.
*/
@media (min-width: 40rem) {
  .screen { padding: var(--sp-4) var(--sp-6) var(--sp-8); }
}
@media (min-width: 64rem) {
  .screen { padding: var(--sp-4) var(--sp-7) var(--sp-8); }
}
@media (min-width: 100rem) {
  .screen { padding-left: var(--sp-8); padding-right: var(--sp-8); }
}

/* --- Header ----------------------------------------------------------
   Sticky, translucent, hairline-separated. It should recede — the
   artifacts are the content. */

/* Spans the full viewport width — the rule underneath it is what makes a
   desktop layout read as a desktop layout rather than a centered card. Its
   contents are constrained separately by .topbar-inner. */
.topbar {
  border-bottom: 1px solid var(--line);
  background: color-mix(in srgb, var(--paper) 82%, transparent);
  backdrop-filter: saturate(140%) blur(12px);
  -webkit-backdrop-filter: saturate(140%) blur(12px);
  position: sticky;
  top: 0;
  z-index: 20;
}

.topbar-inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-4);
  width: 100%;
  max-width: 104rem;
  margin: 0 auto;
  padding: var(--sp-3) var(--sp-4);
  min-height: 56px;
}

@media (min-width: 40rem) {
  .topbar-inner { padding: var(--sp-3) var(--sp-6); min-height: 64px; }
}
@media (min-width: 64rem) {
  .topbar-inner { padding: var(--sp-3) var(--sp-7); }
}
@media (min-width: 100rem) {
  .topbar-inner { padding-left: var(--sp-8); padding-right: var(--sp-8); }
}

.topbar-nav { display: flex; align-items: center; gap: var(--sp-5); }
@media (max-width: 30rem) { .topbar-nav { gap: var(--sp-4); } }

.wordmark {
  display: inline-flex;
  align-items: center;
  font-size: var(--step-0);
  text-decoration: none;
  flex-shrink: 0;
}

.wordmark--hero { justify-content: center; }

/* --- Wordmark ---------------------------------------------------------
   ONE MARK EVERYWHERE: the stacked NXOS tile, 1:1, as SVG.

   It replaced a ~6:1 horizontal lockup that ran in the topbar while the
   landing page showed the stacked version — two logos for one product,
   which reads as two products. Being square also means it takes a plain
   box rather than the width-driven sizing the wide lockup required.

   The artwork is a KNOCKOUT: a filled square with the letterforms cut
   out of it, so the page shows through the letters. It is therefore a
   solid block of ink at whatever size it is set, not a light line-art
   mark — size it more conservatively than the pixel count suggests.

   Two files rather than one recolored by CSS: an <img> cannot inherit
   currentColor, and inlining the SVG on every page would duplicate the
   paths into markup that is otherwise generated. Both are always in the
   DOM; CSS shows one. No flash, no JS dependency.

   NAMED FOR THE MODE THEY ARE USED IN, not for their ink — the inverse
   of how the source files arrive, and getting it backwards renders the
   mark invisible rather than merely wrong-coloured:

     nxos-mark-light.svg   BLACK tile  → shown in LIGHT mode
     nxos-mark-dark.svg    WHITE tile  → shown in DARK mode
   -------------------------------------------------------------------- */

/*
   THE STACKED MARK in the topbar.

   Square, so it takes a box rather than the width-driven sizing the ~6:1
   horizontal lockup needed. 32px inside a 56px bar leaves the hairline
   border and the vertical padding room to breathe; going larger makes a
   solid knockout tile dominate a bar that is mostly quiet text links.
*/
.logo-mark { display: block; width: 32px; height: 32px; object-fit: contain; }
@media (min-width: 40rem) { .logo-mark { width: 34px; height: 34px; } }

/* Hero.

   Deliberately restrained. The mark is ~6:1, so width is the dimension
   that runs away — at 34rem it filled the upper third of the viewport and
   turned the headline into a caption. Half that reads as a mark rather
   than a banner, and lets "Every Artifact Verified." carry the page. */
.wordmark--hero .logo { width: min(17rem, 58vw); }

/* Dark is the default palette, so the white mark is the default state and
   light is the override — the inverse of the usual setup.

   Both signals are handled, matching the theme system: an explicit choice
   wins, and the OS preference applies only when no choice has been made.
   Keying this off the media query alone was a real bug once the theme
   toggle existed — choosing Light left the white mark on a white page,
   invisible. */
.logo-light { display: none; }
.logo-dark  { display: block; }

:root[data-theme="light"] .logo-light { display: block; }
:root[data-theme="light"] .logo-dark  { display: none; }

@media (prefers-color-scheme: light) {
  :root:not([data-theme]) .logo-light { display: block; }
  :root:not([data-theme]) .logo-dark  { display: none; }
}

.topbar-link {
  font-size: var(--step--1);
  font-weight: 500;
  letter-spacing: 0.01em;
  color: var(--ink-soft);
  text-decoration: none;
  min-height: var(--tap);
  display: inline-flex;
  align-items: center;
  transition: color 120ms ease;
}
@media (hover: hover) { .topbar-link:hover { color: var(--ink); } }

/* --- Type ------------------------------------------------------------ */

h1 {
  font-size: var(--step-3);
  line-height: 1.08;
  letter-spacing: -0.03em;
  font-weight: 600;
  margin: 0 0 var(--sp-3);
}
@media (min-width: 40rem) { h1 { font-size: var(--step-4); } }

h2 {
  font-size: var(--step-1);
  line-height: 1.25;
  letter-spacing: -0.015em;
  font-weight: 600;
  margin: 0 0 var(--sp-3);
}

.eyebrow {
  font-family: var(--font-num);
  font-size: var(--step--2);
  text-transform: uppercase;
  letter-spacing: 0.18em;
  color: var(--ink-faint);
  margin: 0 0 var(--sp-3);
}

.lede  { color: var(--ink-soft); font-size: var(--step-1); margin: 0 0 var(--sp-5); }
.muted { color: var(--ink-soft); }
.fine  { font-size: var(--step--1); color: var(--ink-faint); line-height: 1.5; }

code {
  font-family: var(--font-num);
  font-size: 0.9em;
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  padding: 0.1em 0.35em;
}

/* --- Verdict: the core moment ---------------------------------------- */

/* .verdict and .artifact are siblings of .screen rather than children of
   it, so they need the measure applied directly — otherwise they inherit
   the full viewport width and the verdict sprawls. */
.verdict {
  text-align: center;
  padding: var(--sp-8) var(--sp-4) var(--sp-6);
  position: relative;
  width: 100%;
  max-width: var(--measure);
  margin: 0 auto;
}

/* A single soft bloom behind the mark. The verdict screen is the one
   place in the product that is allowed a moment of drama. */
.verdict::before {
  content: '';
  position: absolute;
  inset: 0;
  background: radial-gradient(ellipse 60% 50% at 50% 30%, var(--glow, transparent), transparent 70%);
  pointer-events: none;
}
.is-verified { --glow: color-mix(in srgb, var(--verified) 14%, transparent); }
.is-rejected { --glow: color-mix(in srgb, var(--rejected) 14%, transparent); }

.verdict-mark {
  width: 68px;
  height: 68px;
  margin: 0 auto var(--sp-4);
  border-radius: 50%;
  display: grid;
  place-items: center;
  font-size: var(--step-3);
  line-height: 1;
  color: var(--paper);
  position: relative;
  animation: mark-in 340ms cubic-bezier(.2,.8,.2,1) both;
}

.is-verified .verdict-mark { background: var(--verified); }
.is-rejected .verdict-mark { background: var(--rejected); }
.is-neutral  .verdict-mark { background: var(--ink); }

@keyframes mark-in {
  from { transform: scale(.72); opacity: 0; }
  to   { transform: scale(1);   opacity: 1; }
}

.verdict-title {
  font-size: var(--step-4);
  font-weight: 700;
  letter-spacing: -0.04em;
  line-height: 1;
  margin: 0;
  position: relative;
}

.is-verified .verdict-title { color: var(--verified); }
.is-rejected .verdict-title { color: var(--rejected); }

.verdict-sub {
  color: var(--ink-soft);
  margin: var(--sp-3) 0 0;
  font-size: var(--step--1);
  position: relative;
}

/* --- Artifact hero ---------------------------------------------------- */

.artifact {
  text-align: center;
  padding: 0 var(--sp-4) var(--sp-6);
  width: 100%;
  max-width: var(--measure);
  margin: 0 auto;
}
.artifact-property {
  font-family: var(--font-num);
  font-size: var(--step--2);
  text-transform: uppercase;
  letter-spacing: 0.18em;
  color: var(--ink-faint);
}
.artifact-title {
  font-size: var(--step-2);
  font-weight: 600;
  letter-spacing: -0.02em;
  margin: var(--sp-2) 0 0;
}

.edition {
  font-family: var(--font-num);
  font-variant-numeric: tabular-nums;
  font-size: var(--step-5);
  font-weight: 700;
  letter-spacing: -0.05em;
  line-height: 1;
  margin: var(--sp-5) 0 var(--sp-1);
}
.edition small {
  font-size: var(--step-1);
  font-weight: 400;
  color: var(--ink-faint);
  letter-spacing: 0;
}

/* --- Cards & facts ---------------------------------------------------- */

.card {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: var(--sp-4);
  margin-bottom: var(--sp-4);
}

.facts { list-style: none; margin: 0; padding: 0; }
.facts li {
  display: flex; justify-content: space-between; align-items: baseline; gap: var(--sp-4);
  padding: var(--sp-3) 0;
  border-bottom: 1px solid var(--line);
  font-size: var(--step--1);
}
.facts li:last-child { border-bottom: 0; padding-bottom: 0; }
.facts li:first-child { padding-top: 0; }
.facts .k { color: var(--ink-soft); flex-shrink: 0; }
.facts .v {
  font-family: var(--font-num);
  font-variant-numeric: tabular-nums;
  text-align: right;
  overflow-wrap: anywhere;
}

/* --- Stats ------------------------------------------------------------ */

.stats {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-5) var(--sp-7);
  padding: var(--sp-5) 0;
  border-top: 1px solid var(--line);
  border-bottom: 1px solid var(--line);
}
/* Spread rather than cluster once there is room — four figures bunched at
   the left of a 1600px page look like a mobile component that was never
   revisited. */
@media (min-width: 56rem) {
  .stats { gap: var(--sp-5) var(--sp-8); }
  .stats > * { flex: 1; min-width: 8rem; }
}
.stat-n {
  font-family: var(--font-num);
  font-variant-numeric: tabular-nums;
  font-size: var(--step-3);
  font-weight: 600;
  letter-spacing: -0.04em;
  line-height: 1.1;
}
.stat-l {
  font-family: var(--font-num);
  font-size: var(--step--2);
  text-transform: uppercase;
  letter-spacing: 0.14em;
  color: var(--ink-faint);
  margin-top: var(--sp-1);
}

@media (max-width: 22rem) {
  .stats { gap: var(--sp-4); }
  .stat-n { font-size: var(--step-2); }
}

.group-label {
  font-family: var(--font-num);
  font-size: var(--step--2);
  text-transform: uppercase;
  letter-spacing: 0.18em;
  color: var(--ink-faint);
  margin: var(--sp-6) 0 var(--sp-3);
}

.section-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--sp-3);
  margin: var(--sp-6) 0 var(--sp-3);
  flex-wrap: wrap;
}
.section-head .group-label { margin: 0; }

/* --- Artifact cards ---------------------------------------------------
   The gallery surface: collection, market, profile, studio, encode.

   Moment and Archive render through the SAME card. The only difference is
   the badge. That is a positioning decision expressed in CSS: Moment is
   not a lesser product, it is a product without a tag, and a card that
   looked cheaper would contradict everything the tier language says.
   -------------------------------------------------------------------- */

/* Column count climbs with width. auto-fill with a minimum would be
   fewer rules, but it lets cards stretch to odd widths at the top of each
   range — explicit counts keep the card proportions predictable, which
   matters when every card holds a square photo. */
.art-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: var(--sp-3);
  margin-bottom: var(--sp-5);
}
@media (min-width: 34rem) { .art-grid { grid-template-columns: repeat(3, 1fr); gap: var(--sp-4); } }
@media (min-width: 56rem) { .art-grid { grid-template-columns: repeat(4, 1fr); } }
@media (min-width: 72rem) { .art-grid { grid-template-columns: repeat(5, 1fr); } }
@media (min-width: 90rem) { .art-grid { grid-template-columns: repeat(6, 1fr); gap: var(--sp-5); } }
@media (min-width: 110rem) { .art-grid { grid-template-columns: repeat(7, 1fr); } }

.art-card {
  position: relative;
  display: flex;
  flex-direction: column;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  overflow: hidden;
  text-decoration: none;
  color: inherit;
  transition: border-color 180ms ease, transform 180ms cubic-bezier(.2,.8,.2,1);
}
@media (hover: hover) {
  .art-card:hover { border-color: var(--line-strong); transform: translateY(-3px); }
  .art-card:hover .art-media img { transform: scale(1.04); }
}
.art-card:focus-within { outline: 1px solid var(--accent); outline-offset: 2px; }

.art-media {
  position: relative;
  aspect-ratio: 1 / 1;
  background: var(--shimmer);
  overflow: hidden;
}
.art-media img {
  width: 100%; height: 100%;
  object-fit: cover;
  display: block;
  transition: transform 400ms cubic-bezier(.2,.8,.2,1);
}

.art-media--empty {
  display: grid;
  place-items: center;
  background:
    repeating-linear-gradient(45deg, var(--surface-2) 0 10px, var(--shimmer) 10px 20px);
}
.art-media--empty span {
  font-family: var(--font-num);
  font-size: var(--step-2);
  font-weight: 700;
  letter-spacing: 0.06em;
  color: var(--ink-faint);
}

.art-badge {
  position: absolute;
  top: var(--sp-2);
  left: var(--sp-2);
  z-index: 2;
  font-family: var(--font-num);
  font-size: var(--step--2);
  text-transform: uppercase;
  letter-spacing: 0.1em;
  font-weight: 600;
  padding: 2px var(--sp-2);
  border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--paper) 78%, transparent);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  border: 1px solid var(--line);
  color: var(--ink-soft);
}
/*
  QF-TAG — orange, white text.

  It used to be the gold accent with near-black text, which read as a
  quality mark rather than as a claim about authentication. Orange makes
  it a status, not a flourish, and separates it from the gold used
  everywhere else for property theming — so a QF-TAG badge on a property
  that happens to theme itself gold no longer disappears into it.

  --qf holds the color rather than reusing --accent, precisely so a
  partner's theme can never repaint the authentication badge.
*/
.art-badge.is-signature {
  background: var(--qf);
  border-color: var(--qf);
  color: #ffffff;
}

/*
  ARCHIVE — an edition with nothing left to sell.

  A SECOND badge, never a replacement for the tier badge. The two answer
  different questions: "is this authenticated" and "can I still buy it".
  Collapsing them would mean a sold-out Signature edition stops advertising
  the thing that makes it worth owning, at the exact moment its scarcity
  is proven.

  Deliberately quiet — flat, no fill, low contrast. It marks a closed
  edition rather than selling one, and a loud badge here would compete
  with the QF-TAG mark beside it for no gain.
*/
/* Archive — a solid grey chip, white text.

   It was a transparent outline in --ink-faint, which is the same
   treatment the page uses for de-emphasised metadata. On a card that
   already carries a QF-TAG badge it read as a caption rather than a
   status, and at --ink-faint on --surface it sat near the contrast
   floor. A filled chip states the status plainly.

   Fixed greys rather than theme tokens: this must read the same in both
   modes, and every neutral token flips with the theme. */
.art-badge.is-archived,
.badge.is-archived {
  background: #6b6b73;
  border-color: #6b6b73;
  color: #ffffff;
  letter-spacing: 0.08em;
}

.art-body {
  padding: var(--sp-3);
  display: flex;
  flex-direction: column;
  gap: 2px;
  flex: 1;
  text-decoration: none;
  color: inherit;
}
.art-prop {
  font-family: var(--font-num);
  font-size: var(--step--2);
  text-transform: uppercase;
  letter-spacing: 0.12em;
  color: var(--ink-faint);
}
/*
   Two lines, always — clamped at the top AND reserved at the bottom.

   The clamp alone only caps a long title; it does nothing for a short
   one, so a grid mixing "Plate" with "Original Production Cel" ragged
   by a full line. min-height holds the second line open whether or not
   the text needs it, which is what actually keeps the price rows on one
   baseline across a row.
*/
.art-title {
  font-weight: 600;
  font-size: var(--step-0);
  line-height: 1.3;
  letter-spacing: -0.01em;
  overflow: hidden;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  min-height: 2.6em;   /* 2 lines × 1.3 */
}
.art-edition {
  font-family: var(--font-num);
  font-variant-numeric: tabular-nums;
  font-size: var(--step--1);
  color: var(--ink-soft);
}
/* Reserved rather than removed on Moment cards, so a mixed grid keeps its
   price rows on one baseline instead of ragging. */
.art-edition--placeholder { visibility: hidden; }

/* margin-top:auto pins the price to the bottom of the body, so a card
   whose title only ran one line does not float its price up to meet it. */
.art-price {
  margin-top: auto;
  padding-top: var(--sp-3);
  border-top: 1px solid var(--line);
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--sp-2);
}

/* An artifact with no price at all still reserves the row. Same reason
   as .art-edition--placeholder above: the alternative is a grid where
   some cards are a whole price row shorter than their neighbours. */
.art-price--placeholder { visibility: hidden; }
.art-price-n {
  font-family: var(--font-num);
  font-variant-numeric: tabular-nums;
  font-weight: 600;
  letter-spacing: -0.02em;
  color: var(--accent);
}
.art-price-delta { font-family: var(--font-num); font-size: var(--step--2); font-weight: 600; }
.art-price-delta.is-up { color: var(--verified); }
.art-price-delta.is-down { color: var(--rejected); }

.art-actions { display: flex; gap: var(--sp-2); padding: 0 var(--sp-3) var(--sp-3); }
.art-actions .btn-sm { flex: 1; }

/* Laid out, then hidden — it holds the row open on cards whose
   neighbours have a button. See actionsRow() in app.js. */
.art-actions--placeholder { visibility: hidden; }

/* --- Chips ------------------------------------------------------------ */

.chips {
  display: flex;
  gap: var(--sp-2);
  overflow-x: auto;
  padding-bottom: var(--sp-2);
  margin-bottom: var(--sp-4);
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
}
.chips::-webkit-scrollbar { display: none; }
.chip {
  flex-shrink: 0;
  font: inherit;
  font-size: var(--step--1);
  font-weight: 500;
  padding: var(--sp-2) var(--sp-3);
  border: 1px solid var(--line);
  border-radius: var(--radius-pill);
  background: var(--surface);
  color: var(--ink-soft);
  cursor: pointer;
  white-space: nowrap;
  min-height: 34px;
  transition: border-color 120ms ease, color 120ms ease;
}
@media (hover: hover) { .chip:hover { border-color: var(--line-strong); color: var(--ink); } }
.chip.is-active { background: var(--ink); border-color: var(--ink); color: var(--paper); }
.chip:focus-visible { outline: 1px solid var(--accent); outline-offset: 2px; }

/* --- Profile ---------------------------------------------------------- */

.profile-head { display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-5) 0; }
/* .avatar is declared ONCE, in the AVATAR component further down. There
   used to be a second copy here with hardcoded 68px dimensions, written
   before the component existed — two declarations of one selector nearly
   three thousand lines apart, resolved by source order rather than by
   anyone deciding. The later one won, so this was already dead; removing
   it means the next person editing sizing finds one place, not two. */
.profile-handle {
  font-size: var(--step-2);
  font-weight: 600;
  letter-spacing: -0.02em;
  margin: 0;
}
@media (min-width: 40rem) { .profile-handle { font-size: var(--step-3); } }

/* --- Forms ------------------------------------------------------------ */

.field { margin-bottom: var(--sp-4); }

label {
  display: block;
  font-family: var(--font-num);
  font-size: var(--step--2);
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  color: var(--ink-faint);
  margin-bottom: var(--sp-2);
}

/* Excludes checkbox/radio deliberately. This rule strips native
   appearance AND stretches to width:100% — applied to a checkbox that
   produces a full-width blank rectangle with no checkmark and no visible
   state. They get their own rule below. */
input:not([type="checkbox"]):not([type="radio"]), select, textarea {
  width: 100%;
  min-height: var(--tap);
  padding: var(--sp-3);
  font: inherit;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  appearance: none;
  transition: border-color 120ms ease;
}
/* textarea is declared once, in FORM CONTROLS further down. A one-line
   rule lived here and was overridden by it. */

input:not([type="checkbox"]):not([type="radio"]):focus, select:focus, textarea:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 1px var(--accent);
}

input::placeholder, textarea::placeholder { color: var(--ink-faint); }

input[type="checkbox"], input[type="radio"] {
  width: 18px;
  height: 18px;
  min-height: 0;
  flex-shrink: 0;
  accent-color: var(--accent);
  cursor: pointer;
}
input[type="checkbox"]:focus-visible, input[type="radio"]:focus-visible {
  outline: 1px solid var(--accent);
  outline-offset: 2px;
}

select {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%239d9b95' stroke-width='1.5' fill='none'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right var(--sp-3) center;
  padding-right: var(--sp-6);
}

/* --- Buttons ---------------------------------------------------------- */

/* FLEX, NOT BLOCK — this is what centers the label.

   As `display: block` the text sat immediately after the top padding, and
   any height beyond `padding + line-height + padding` fell to the bottom
   of the box. So the moment a button was stretched — by a flex row with
   `align-items: stretch`, by `min-height`, or by an explicit height — the
   label stayed pinned near the top with the slack underneath it.

   `text-align: center` only ever solved the horizontal half of that.
   `.btn-sm` was already inline-flex and centered correctly, which is why
   the small buttons looked right and the large ones did not.

   `display: flex` rather than `inline-flex` so the default full-width
   block behavior is unchanged; `.btn-sm` still overrides to inline-flex
   with `width: auto`. */
.btn {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  min-height: var(--tap);
  padding: var(--sp-3) var(--sp-5);
  font: inherit;
  font-size: var(--step-0);
  font-weight: 600;
  letter-spacing: -0.01em;
  /* Retained for labels long enough to wrap onto two lines — flex centers
     the block, this centers the lines within it. */
  text-align: center;
  text-decoration: none;
  color: var(--paper);
  background: var(--ink);
  border: 1px solid var(--ink);
  border-radius: var(--radius-sm);
  cursor: pointer;
  transition: opacity 120ms ease, background 120ms ease, border-color 120ms ease;
}
@media (hover: hover) { .btn:hover { opacity: 0.88; } }
.btn:active { opacity: 0.7; }
.btn:disabled { opacity: 0.32; cursor: default; }
.btn:focus-visible { outline: 1px solid var(--accent); outline-offset: 2px; }

.btn-secondary {
  color: var(--ink);
  background: transparent;
  border-color: var(--line-strong);
}

/*
   Copy confirmation on a .btn.

   The equivalent rule already existed for .copybox button, scoped to
   that component — so the copy buttons on artifact cards received the
   class and rendered no differently, leaving a text swap as the only
   confirmation. Green is the same signal the copybox uses.

   The colour is set on both states so a .btn-secondary that gets copied
   is filled rather than merely re-tinted.
*/
.btn.is-copied,
.btn-secondary.is-copied {
  background: var(--verified);
  border-color: var(--verified);
  color: var(--paper);
  opacity: 1;
}
@media (hover: hover) {
  .btn-secondary:hover { background: var(--surface-2); opacity: 1; border-color: var(--ink-faint); }
}

.btn-accent {
  color: #0b0b0c;
  background: var(--accent);
  border-color: var(--accent);
}

.btn + .btn { margin-top: var(--sp-3); }

/* Inline-sized button for card and toolbar actions. width:auto must be
   explicit — .btn is display:block/width:100% on mobile, so a font-size
   change alone would leave it full-bleed inside a flex row. */
.btn-sm {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--sp-2);
  width: auto;
  min-width: 0;
  min-height: 34px;
  padding: 0 var(--sp-3);
  font-size: var(--step--1);
  font-weight: 600;
  white-space: nowrap;
}
.btn-sm + .btn-sm { margin-top: 0; }

.btn-row { display: flex; flex-direction: column; gap: var(--sp-3); }
.btn-row .btn { width: 100%; }
.btn-row .btn + .btn { margin: 0; }

/* --- Verify CTA -------------------------------------------------------
   Fixed 290 × 60 on the item page.

   `max-width: 100%` because 290px overflows a 320px viewport once the
   screen padding is counted, and a button running off the edge of a phone
   is worse than one a few pixels narrower than specified. Everywhere with
   room, it is exactly 290.

   Height is set on both `height` and `min-height`: `.btn` carries
   `min-height: var(--tap)`, which would otherwise win over a plain
   `height` in some flex contexts. */
.btn-verify {
  width: 290px;
  max-width: 100%;
  height: 60px;
  min-height: 60px;
  flex: 0 0 auto;
}

/* --- Going-somewhere button -------------------------------------------
   For buttons that NAVIGATE rather than commit. A trailing arrow is the
   cheapest honest signal of the difference: "Buy" changes something and
   stays put, "Browse Marketplace" takes you elsewhere, and a reader
   should be able to tell which before they click.

   The arrow lives in a pseudo-element so the label stays a clean string —
   no stray glyph to strip out of markup, and nothing for a screen reader
   to read aloud as punctuation.

   Motion matches .door on the landing page deliberately. One arrow idiom
   in the product, not two that behave almost-but-not-quite alike. */
.btn-go::after {
  content: "→";
  display: inline-block;
  margin-left: var(--sp-1);
  transform: translateX(0);
  transition: transform 180ms ease;
}
@media (hover: hover) {
  .btn-go:hover::after { transform: translateX(3px); }
}
@media (prefers-reduced-motion: reduce) {
  .btn-go::after { transition: none; }
  .btn-go:hover::after { transform: none; }
}

/* --- Page header ------------------------------------------------------
   Title block on the left, one action on the right.

   Centered rather than top-aligned. `flex-start` measures from the top of
   the h1's LINE BOX, which includes its leading — so the button floated
   visually above the heading it was meant to sit beside, which is the
   kind of misalignment that reads as sloppiness without being obviously
   locatable. Centring against the whole title block reads level whether
   the title runs to one line or two. */
.page-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-4);
  flex-wrap: wrap;
  margin-bottom: var(--sp-5);
}
/*
  The avatar is the first item, so `.page-head-titles` has to be the one
  that grows — not the whole row. Without flex:1 on it the title block
  sits against the photo and the Browse button drifts to the middle.
*/
.page-head-titles { min-width: 0; flex: 1; }

/*
  `.avatar.page-head-avatar`, not `.page-head-avatar`.

  Both classes are on the same element, so at equal specificity the one
  declared later in the file wins — and .avatar is declared later. The
  size was silently falling back to the component default of 44px.

  Compounding the selector makes it (0,2,0) against (0,1,0), so it wins
  wherever either rule happens to sit. Position-independent, which is the
  only kind of override that survives a file this long.
*/
.avatar.page-head-avatar {
  --size: 65px;
  flex: 0 0 auto;
  text-decoration: none;
  transition: border-color 140ms ease;
}
.page-head-avatar:hover { border-color: var(--line-strong); }
.page-head-avatar:focus-visible {
  outline: 2px solid var(--accent, var(--ink));
  outline-offset: 2px;
}
.page-head-titles h1 { margin-bottom: var(--sp-2); }
.page-head-titles .lede { margin-bottom: 0; }

/* On a phone the action goes full width underneath rather than squeezing
   in beside a wrapped title. */
@media (max-width: 34rem) {
  .page-head { align-items: stretch; }
  /* Still 65px — that is the specified size, not a starting point. Only
     the alignment changes, so a wrapped heading sits against its top
     edge rather than being centered against a tall photo. */
  .page-head-avatar { align-self: flex-start; }
  .page-head .btn-sm { width: 100%; }
}

.link-btn {
  background: none; border: 0; padding: var(--sp-2) 0;
  font: inherit; font-size: var(--step--1);
  color: var(--ink-soft); text-decoration: underline;
  text-underline-offset: 3px;
  cursor: pointer; min-height: var(--tap);
}
@media (hover: hover) { .link-btn:hover { color: var(--ink); } }

/* --- Copy field -------------------------------------------------------
   A link the user must get onto their clipboard intact. Selecting a long
   URL by hand on a phone is genuinely difficult and easy to get subtly
   wrong, and a truncated tag URL fails in a way that looks like a
   cryptography bug rather than a copy-paste one.
   -------------------------------------------------------------------- */

.copybox {
  display: flex;
  align-items: stretch;
  gap: 0;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  overflow: hidden;
}
.copybox input,
.copybox .copybox-value {
  flex: 1;
  min-width: 0;
  border: 0;
  border-radius: 0;
  background: transparent;
  font-family: var(--font-num);
  font-size: var(--step--1);
  color: var(--ink);
  padding: var(--sp-3);
  white-space: nowrap;
  overflow-x: auto;
  scrollbar-width: none;
}
.copybox input:focus { outline: none; box-shadow: none; border: 0; }
.copybox .copybox-value::-webkit-scrollbar,
.copybox input::-webkit-scrollbar { display: none; }

.copybox button {
  flex-shrink: 0;
  border: 0;
  border-left: 1px solid var(--line);
  background: var(--surface);
  color: var(--ink);
  font: inherit;
  font-size: var(--step--1);
  font-weight: 600;
  padding: 0 var(--sp-4);
  min-height: var(--tap);
  cursor: pointer;
  transition: background 120ms ease, color 120ms ease;
}
@media (hover: hover) { .copybox button:hover { background: var(--surface-2); } }
.copybox button.is-copied { background: var(--verified); color: var(--paper); }
.copybox button:focus-visible { outline: 1px solid var(--accent); outline-offset: -2px; }

.copybox--block { flex-direction: column; align-items: stretch; }
.copybox--block .copybox-value { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 14rem; overflow-y: auto; }
.copybox--block button { border-left: 0; border-top: 1px solid var(--line); }

/* --- Notes & alerts --------------------------------------------------- */

.note {
  font-size: var(--step--1);
  line-height: 1.55;
  color: var(--ink-soft);
  background: var(--surface);
  border: 1px solid var(--line);
  border-left: 2px solid var(--ink-faint);
  border-radius: var(--radius-sm);
  padding: var(--sp-3) var(--sp-4);
  margin: var(--sp-4) 0;
}
.note strong { color: var(--ink); font-weight: 600; }

/* Reserved for things that can actually hurt someone — key material,
   irreversible actions. If it appears on a routine screen it stops
   working, so it is used sparingly by design. */
.note--danger {
  border-left-color: var(--rejected);
  background: var(--rejected-bg);
  color: var(--ink);
}
.note--danger strong { color: var(--rejected); }

.error {
  font-size: var(--step--1);
  color: var(--rejected);
  padding: var(--sp-2) 0;
}

/* --- Utility ---------------------------------------------------------- */

.stack > * + * { margin-top: var(--sp-4); }
.center { text-align: center; }
.hidden { display: none !important; }
.spacer { flex: 1; }
.mono { font-family: var(--font-num); font-variant-numeric: tabular-nums; }

.skeleton {
  color: transparent;
  background: linear-gradient(90deg, var(--line) 25%, var(--shimmer) 50%, var(--line) 75%);
  background-size: 200% 100%;
  border-radius: var(--radius-sm);
  animation: shimmer 1.2s infinite;
}
@keyframes shimmer { to { background-position: -200% 0; } }

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}

/* --- Grids ------------------------------------------------------------ */

.grid { display: grid; gap: var(--sp-4); }
.grid--2 { grid-template-columns: repeat(2, 1fr); }

@media (min-width: 40rem) {
  .grid--3 { grid-template-columns: repeat(3, 1fr); }
}
@media (min-width: 56rem) {
  .grid--4 { grid-template-columns: repeat(4, 1fr); }
}

/* Metric tile */
.metric {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: var(--sp-4);
}
.metric-n {
  font-family: var(--font-num);
  font-variant-numeric: tabular-nums;
  font-size: var(--step-2);
  font-weight: 600;
  letter-spacing: -0.03em;
  line-height: 1.1;
}
@media (min-width: 40rem) { .metric-n { font-size: var(--step-3); } }
.metric-l {
  font-family: var(--font-num);
  font-size: var(--step--2);
  text-transform: uppercase;
  letter-spacing: 0.12em;
  color: var(--ink-faint);
  margin-top: var(--sp-2);
}
.metric-note { font-size: var(--step--2); color: var(--ink-faint); margin-top: var(--sp-2); line-height: 1.45; }

/* --- Meter ------------------------------------------------------------ */

.meter {
  height: 3px;
  background: var(--line);
  border-radius: var(--radius-pill);
  overflow: hidden;
  margin: var(--sp-3) 0 var(--sp-2);
}
.meter-fill {
  height: 100%;
  background: var(--ink);
  border-radius: var(--radius-pill);
  transition: width 500ms cubic-bezier(.2,.8,.2,1);
}
.meter-fill.is-over { background: var(--rejected); }

.pill {
  display: inline-block;
  font-family: var(--font-num);
  font-size: var(--step--2);
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  padding: 2px var(--sp-2);
  border-radius: var(--radius-sm);
}
.pill.is-ok   { background: var(--verified-bg); color: var(--verified); }
.pill.is-over { background: var(--rejected-bg); color: var(--rejected); }

/* --- Badge (inline, non-card) ----------------------------------------- */

.badge {
  display: inline-block;
  font-family: var(--font-num);
  font-size: var(--step--2);
  text-transform: uppercase;
  letter-spacing: 0.12em;
  font-weight: 600;
  padding: 3px var(--sp-2);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  color: var(--ink-faint);
  white-space: nowrap;
}
/* The outline variant, on detail pages. Filled rather than outlined now,
   so it matches the grid badge and reads as the same object in both
   places. */
.badge.is-signature {
  background: var(--qf);
  border-color: var(--qf);
  color: #ffffff;
}

/* --- Timeline --------------------------------------------------------- */

.timeline { list-style: none; margin: 0; padding: 0; }
.timeline li {
  position: relative;
  padding: 0 0 var(--sp-4) var(--sp-5);
  border-left: 1px solid var(--line);
}
.timeline li:last-child { border-left-color: transparent; padding-bottom: 0; }
.timeline li::before {
  content: '';
  position: absolute;
  left: -3px; top: 7px;
  width: 5px; height: 5px;
  border-radius: 50%;
  background: var(--verified);
}
.timeline li.is-rejected::before { background: var(--rejected); }
.timeline-title { font-size: var(--step--1); font-weight: 600; }
.timeline-time {
  font-family: var(--font-num);
  font-size: var(--step--2);
  color: var(--ink-faint);
  font-variant-numeric: tabular-nums;
}

/* --- Item detail ------------------------------------------------------ */

.detail-hero {
  text-align: center;
  padding: var(--sp-6) var(--sp-4);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  margin-bottom: var(--sp-5);
}

.back-link {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
  min-height: var(--tap);
  font-size: var(--step--1);
  color: var(--ink-soft);
  text-decoration: none;
}
@media (hover: hover) { .back-link:hover { color: var(--ink); } }

/* --- Rows (directory listings) ---------------------------------------- */

.item-row {
  display: flex; align-items: center; justify-content: space-between; gap: var(--sp-4);
  padding: var(--sp-4) 0;
  border-bottom: 1px solid var(--line);
  min-height: var(--tap);
  text-decoration: none;
  color: inherit;
  transition: opacity 120ms ease;
}
@media (hover: hover) { a.item-row:hover { opacity: 0.72; } }
a.item-row:focus-visible { outline: 1px solid var(--accent); outline-offset: 2px; }
.item-name { font-weight: 600; }
.item-prop { font-size: var(--step--1); color: var(--ink-faint); }
.item-meta { text-align: right; flex-shrink: 0; }
.item-edition {
  font-family: var(--font-num);
  font-variant-numeric: tabular-nums;
  font-size: var(--step--1);
  color: var(--ink-soft);
  margin-top: var(--sp-1);
}

@media (min-width: 40rem) {
  .item-list { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0 var(--sp-6); }
}

/* --- Dropzone ---------------------------------------------------------- */

.dropzone {
  border: 1px dashed var(--line-strong);
  border-radius: var(--radius);
  padding: var(--sp-5) var(--sp-4);
  text-align: center;
  cursor: pointer;
  background: var(--surface);
  transition: background 120ms ease, border-color 120ms ease;
}
@media (hover: hover) { .dropzone:hover { background: var(--surface-2); border-color: var(--accent); } }
.dropzone.is-over { background: var(--surface-2); border-color: var(--accent); }
.dropzone input[type="file"] { display: none; }
.dropzone-preview {
  max-width: 150px; max-height: 150px;
  border-radius: var(--radius-sm);
  margin: 0 auto var(--sp-3);
  display: block;
  object-fit: cover;
}

/* --- Studio media tray -------------------------------------------------
   What a partner has staged for this mint, in order. The first chip is
   flagged Primary because that is the one that ends up in every grid, and
   a studio should know which image is doing that work before they mint
   500 of something. */

.media-tray {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2);
  margin-top: var(--sp-3);
}

.media-chip {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
  padding: 4px var(--sp-2) 4px 4px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  font-size: var(--step--2);
  max-width: 15rem;
}
.media-chip.is-primary { border-color: var(--accent); }

.media-chip img {
  width: 28px; height: 28px;
  object-fit: cover;
  border-radius: 2px;
  flex-shrink: 0;
}
.media-chip-kind {
  width: 28px; height: 28px;
  display: flex; align-items: center; justify-content: center;
  background: var(--surface-2);
  border-radius: 2px;
  font-size: 10px;
  color: var(--ink-faint);
  flex-shrink: 0;
}
.media-chip-name {
  flex: 1; min-width: 0;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  color: var(--ink-soft);
}
.media-chip-tag {
  font-family: var(--font-num);
  font-size: var(--step--2);
  color: var(--accent);
  flex-shrink: 0;
}
.media-chip button {
  background: none; border: 0; padding: 0 2px;
  color: var(--ink-faint); cursor: pointer;
  font-size: var(--step-0); line-height: 1;
  flex-shrink: 0;
}

/* Reorder controls. Order decides which asset is primary, so these are
   ordinary controls — only the remove button is destructive, and the red
   hover belongs to it alone. It used to apply to every button in the
   chip, which was correct when × was the only one. */
.media-chip-move { display: inline-flex; flex-shrink: 0; }
.media-chip-move button { font-size: var(--step--1); padding: 0 1px; }
.media-chip button:disabled { opacity: 0.3; cursor: default; }

@media (hover: hover) {
  .media-chip button[data-remove]:hover { color: var(--rejected); }
  .media-chip-move button:not(:disabled):hover { color: var(--accent); }
}
.media-chip button:focus-visible { outline: 1px solid var(--accent); outline-offset: 1px; }

/* --- Empty state ------------------------------------------------------- */

.empty {
  text-align: center;
  padding: var(--sp-8) var(--sp-4);
  border: 1px dashed var(--line);
  border-radius: var(--radius);
  color: var(--ink-soft);
}

/* Auth form. Centered in a full-width page rather than living inside a
   narrow shell — the page is the desktop layout, the panel is just the
   readable measure for two input fields. */
.auth-panel { width: 100%; max-width: 26rem; margin: 0 auto; }

/* --- Landing ----------------------------------------------------------
   Fills the viewport: hero centered in the available space, entry points
   pinned below it. The type scales with viewport width rather than
   stepping at breakpoints, so the headline occupies the screen at any
   size instead of sitting at a fixed size in the middle of it.
   -------------------------------------------------------------------- */

/* .screen is already flex:1 inside a 100dvh column, so this fills the
   viewport without an explicit height — which also avoids the padding
   overflow an explicit calc() would cause. */
/* .screen is already flex:1 inside a 100dvh column, so this fills the
   viewport without an explicit height — which also avoids the padding
   overflow an explicit calc() would cause. */
.ledger {
  display: flex;
  flex-direction: column;
  justify-content: center;
  max-width: 62rem;
  margin-inline: auto;
  width: 100%;
}

/* --- Masthead ------------------------------------------------------- */

/*
   padding-top clears the fixed status light.

   On a wide screen the page is vertically centred and the two never meet,
   but the full ledger is taller than a phone viewport — so on mobile the
   content starts at the top and the mark would sit underneath the status.
   Reserving the strip keeps them apart at every size.
*/
.ledger-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-4);
  padding-top: var(--sp-5);
  margin-bottom: var(--sp-7);
}
@media (min-width: 44rem) { .ledger-head { padding-top: 0; } }

/*
   The mark is a KNOCKOUT TILE — a filled square with the letterforms cut
   out — not letterforms on transparent, so it carries real visual weight
   for its size and is allowed to be the only non-text object in the row.

   60px flat, at every width. The artwork is 1:1 so the box is square;
   object-fit guards the case where it is later swapped for one that is
   not, which would otherwise stretch rather than letterbox. */
.mark img { display: block; width: 60px; height: 60px; object-fit: contain; }

/* --- Network status ------------------------------------------------- */

/*
   Three real states, driven by /healthz. `checking` is the initial one
   and is deliberately not green — a light that assumes success and
   corrects itself later has told you a lie in the meantime.
*/
/*
   Pinned to the top-right of the PAGE, not the content column.

   The masthead is centred in a 62rem measure, so aligning the status
   inside that row put it wherever the column happened to end — floating
   in the middle of a wide window rather than reading as a property of
   the site. Fixed to the viewport is what actually makes it a status
   light: a fixed corner of the page that reports on the whole thing.

   env() with a max() floor so it clears a notch or a rounded display
   corner without drifting inward on a normal screen.
*/
.netstat {
  position: fixed;
  top: max(var(--sp-4), env(safe-area-inset-top));
  right: max(var(--sp-5), env(safe-area-inset-right));
  z-index: 5;
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
  font-family: var(--font-num);
  font-size: var(--step--2);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-faint);
  white-space: nowrap;
  text-align: right;
}
.netstat-dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--ink-faint);
  flex: 0 0 auto;
}
.netstat[data-state="up"] .netstat-dot   { background: var(--verified); }
.netstat[data-state="down"] .netstat-dot { background: var(--rejected); }
.netstat[data-state="up"]   { color: var(--ink-soft); }
.netstat[data-state="down"] { color: var(--rejected); }

/*
   The pulse rides on a ::after ring rather than the dot itself, so the
   dot stays a constant, readable size while the halo expands. Animating
   the dot's own scale makes a 7px target visibly wobble.

   Only the healthy state pulses. A red dot that throbs reads as an alarm
   demanding action, and there is nothing here for a visitor to do about
   it — the colour alone is the message.
*/
.netstat[data-state="up"] .netstat-dot { position: relative; }
.netstat[data-state="up"] .netstat-dot::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: var(--verified);
  animation: netpulse 2.4s ease-out infinite;
}
@keyframes netpulse {
  0%   { transform: scale(1);   opacity: 0.55; }
  70%  { transform: scale(3.2); opacity: 0; }
  100% { transform: scale(3.2); opacity: 0; }
}

/* A pulsing dot in the corner of the eye is exactly the kind of motion
   that triggers vestibular symptoms. Colour still carries the state. */
@media (prefers-reduced-motion: reduce) {
  .netstat[data-state="up"] .netstat-dot::after { animation: none; }
}

/* --- Headline ------------------------------------------------------- */

.ledger-title {
  font-size: clamp(2.25rem, 6.4vw, 4.5rem);
  font-weight: 600;
  letter-spacing: -0.042em;
  line-height: 1;
  margin: 0 0 var(--sp-4);
  max-width: 16ch;
  text-wrap: balance;
}

.ledger-lede {
  color: var(--ink-soft);
  font-size: clamp(var(--step-0), 1.25vw, var(--step-2));
  line-height: 1.55;
  max-width: 52ch;
  margin: 0 0 var(--sp-6);
}

.ledger-cta { display: flex; flex-direction: column; gap: var(--sp-3); max-width: 26rem; }
@media (min-width: 34rem) {
  .ledger-cta { flex-direction: row; max-width: none; }
  .ledger-cta .btn { flex: 0 0 auto; min-width: 12rem; margin: 0; }
}

.ledger-fine { margin: var(--sp-4) 0 var(--sp-8); }

/* --- Ledger rows ---------------------------------------------------- */

.ledger-label {
  font-family: var(--font-num);
  font-size: var(--step--2);
  text-transform: uppercase;
  letter-spacing: 0.14em;
  color: var(--ink-faint);
  padding-bottom: var(--sp-2);
  border-bottom: 1px solid var(--line);
}

.ledger-rows { display: flex; flex-direction: column; }

/*
   A row, not a card. The label and what it contains sit on one line at
   width, so the list reads as a table of contents rather than a grid of
   tiles — and a fifth destination is one more line instead of a layout
   that has to be re-balanced.
*/
.lrow {
  position: relative;
  display: grid;
  grid-template-columns: 1fr;
  gap: 2px;
  align-items: baseline;
  padding: var(--sp-4) var(--sp-6) var(--sp-4) 0;
  border-bottom: 1px solid var(--line);
  text-decoration: none;
  color: inherit;
  transition: padding-left 180ms cubic-bezier(.2,.8,.2,1);
}
@media (min-width: 40rem) {
  .lrow { grid-template-columns: 14rem minmax(0, 1fr); gap: var(--sp-5); }
}

.lrow-title {
  font-size: var(--step-1);
  font-weight: 600;
  letter-spacing: -0.015em;
  transition: color 180ms ease;
}
.lrow-detail { font-size: var(--step--1); color: var(--ink-faint); line-height: 1.45; }

.lrow-arrow {
  position: absolute;
  top: 50%;
  right: var(--sp-2);
  transform: translateY(-50%) translateX(-6px);
  color: var(--accent);
  opacity: 0;
  transition: opacity 180ms ease, transform 180ms ease;
}

@media (hover: hover) {
  .lrow:hover { padding-left: var(--sp-3); }
  .lrow:hover .lrow-title { color: var(--accent); }
  .lrow:hover .lrow-arrow { opacity: 1; transform: translateY(-50%) translateX(0); }
}
.lrow:focus-visible { outline: 1px solid var(--accent); outline-offset: -2px; }

/* --- Partner disclosure --------------------------------------------- */

/*
   Native <details>, so the disclosure is keyboard-operable and
   screen-reader-announced without a line of JavaScript to maintain.
*/
.partners { margin-top: var(--sp-6); }
.partners-summary {
  display: flex;
  align-items: baseline;
  gap: var(--sp-3);
  padding: var(--sp-4) 0;
  cursor: pointer;
  list-style: none;
  font-size: var(--step--1);
  color: var(--ink-soft);
  transition: color 180ms ease;
  min-height: var(--tap);
  box-sizing: border-box;
}
.partners-summary::-webkit-details-marker { display: none; }
.partners-summary::after {
  content: '+';
  margin-left: auto;
  font-family: var(--font-num);
  color: var(--ink-faint);
  transition: color 180ms ease;
}
.partners[open] .partners-summary::after { content: '–'; }
@media (hover: hover) {
  .partners-summary:hover { color: var(--ink); }
  .partners-summary:hover::after { color: var(--accent); }
}
.partners-summary:focus-visible { outline: 1px solid var(--accent); outline-offset: 2px; }
.partners-label { font-weight: 600; }
.partners-hint { font-size: var(--step--2); color: var(--ink-faint); }

/* Same component, lower rank — which is the whole point of separating
   them from the public rows. */
.partners-rows { border-top: 1px solid var(--line); }
.partners-rows .lrow { padding-top: var(--sp-3); padding-bottom: var(--sp-3); }
.partners-rows .lrow-title { font-size: var(--step-0); }

/* --------------------------------------------------------------------
   .door — STUDIO CARDS

   This was the landing page's entry-point component before that page
   became a ledger. Studios still uses it for its studio cards, inside a
   .grid rather than a bordered stack, so it survives here on its own
   terms instead of as a leftover the landing page happens to share.
   -------------------------------------------------------------------- */

.door {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: var(--sp-1);
  padding: var(--sp-5) var(--sp-5) var(--sp-5) 0;
  border-bottom: 1px solid var(--line);
  text-decoration: none;
  color: inherit;
  transition: padding-left 180ms cubic-bezier(.2,.8,.2,1), border-color 180ms ease;
}
.door-title {
  font-size: var(--step-1);
  font-weight: 600;
  letter-spacing: -0.015em;
  transition: color 180ms ease;
}
.door-detail { font-size: var(--step--1); color: var(--ink-faint); line-height: 1.45; max-width: 34ch; }
.door-arrow {
  position: absolute;
  top: var(--sp-5);
  right: var(--sp-2);
  color: var(--accent);
  opacity: 0;
  transform: translateX(-6px);
  transition: opacity 180ms ease, transform 180ms ease;
}
@media (hover: hover) {
  .door:hover { padding-left: var(--sp-3); }
  .door:hover .door-title { color: var(--accent); }
  .door:hover .door-arrow { opacity: 1; transform: translateX(0); }
}
.door:focus-visible { outline: 1px solid var(--accent); outline-offset: -2px; }

/* ==========================================================================
   LEADERBOARDS — top collectors per property

   Ranked by Signature units held. The medal colours are deliberately
   restrained: --accent is reserved for market and value signals, so
   first place borrows it and second and third stay neutral rather than
   inventing a silver and a bronze the palette does not have.
   ========================================================================== */

.lb-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--sp-4);
  margin-top: var(--sp-4);
}
@media (min-width: 52rem) { .lb-grid { grid-template-columns: repeat(2, 1fr); } }

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

.lb-head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--sp-3);
  margin-bottom: var(--sp-3);
}
.lb-prop { font-size: var(--step-1); font-weight: 600; letter-spacing: -0.015em; }

.lb-list { list-style: none; margin: 0; padding: 0; }

.lb-row {
  display: grid;
  grid-template-columns: 1.5rem minmax(0, 1fr) auto;
  align-items: center;
  gap: var(--sp-3);
  padding: var(--sp-2) 0;
  border-bottom: 1px solid var(--line);
}
.lb-row:last-child { border-bottom: 0; }

.lb-rank {
  font-family: var(--font-num);
  font-size: var(--step--1);
  color: var(--ink-faint);
  text-align: right;
  font-variant-numeric: tabular-nums;
}
.lb-row.is-first .lb-rank { color: var(--accent); font-weight: 600; }

.lb-who {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  min-width: 0;
  text-decoration: none;
  color: inherit;
}
.lb-handle {
  font-size: var(--step-0);
  font-weight: 500;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
@media (hover: hover) { .lb-who:hover .lb-handle { color: var(--accent); } }
.lb-who:focus-visible { outline: 1px solid var(--accent); outline-offset: 2px; }

.lb-stats {
  display: flex;
  align-items: baseline;
  gap: var(--sp-2);
  white-space: nowrap;
}
/* Tabular figures so the column does not shuffle between 9 and 10. */
.lb-sig {
  font-family: var(--font-num);
  font-variant-numeric: tabular-nums;
  font-size: var(--step-1);
  font-weight: 600;
}
.lb-row.is-first .lb-sig { color: var(--accent); }
.lb-unit { font-size: var(--step--2); color: var(--ink-faint); }
.lb-taps {
  font-family: var(--font-num);
  font-size: var(--step--2);
  color: var(--ink-faint);
  border-left: 1px solid var(--line);
  padding-left: var(--sp-2);
}

.lb-note { margin: var(--sp-3) 0 0; padding-top: var(--sp-3); border-top: 1px solid var(--line); }

/* ==========================================================================
   FIRST CONTACT — the off-platform buyer's first screen

   Someone who bought from a brand's own shop, tapped the chip, and has
   never seen NXOS before. Everything here is sized for reading while
   standing up holding a parcel.
   ========================================================================== */

.onboard { margin-top: var(--sp-5); }

/* The property they recognise, ahead of the name they don't. */
.onboard-from {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  padding-bottom: var(--sp-4);
  margin-bottom: var(--sp-4);
  border-bottom: 1px solid var(--line);
  font-size: var(--step--1);
  color: var(--ink-soft);
}
.onboard-logo { height: 28px; width: auto; max-width: 7rem; object-fit: contain; display: block; }

.onboard-title {
  font-size: var(--step-2);
  font-weight: 600;
  letter-spacing: -0.02em;
  line-height: 1.2;
  margin: 0 0 var(--sp-3);
  text-wrap: balance;
}
.onboard-lede { color: var(--ink-soft); margin: 0 0 var(--sp-5); max-width: 46ch; }

/*
   Three beats, numbered. Numbers rather than bullets because they
   describe a sequence that already happened — the chip signed, a copy
   would have failed, anyone can check — and a reader who only takes the
   bold openers still leaves with the idea.
*/
.onboard-steps {
  list-style: none;
  counter-reset: ob;
  margin: 0 0 var(--sp-6);
  padding: 0;
}
.onboard-steps li {
  counter-increment: ob;
  position: relative;
  padding: 0 0 var(--sp-4) var(--sp-6);
  font-size: var(--step--1);
  color: var(--ink-soft);
  line-height: 1.5;
}
.onboard-steps li::before {
  content: counter(ob);
  position: absolute;
  left: 0;
  top: 0;
  font-family: var(--font-num);
  font-size: var(--step--2);
  color: var(--accent);
  font-weight: 600;
}
.onboard-steps strong { color: var(--ink); display: block; font-weight: 600; }

.onboard-form {
  padding: var(--sp-5);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius);
}
.onboard-form-title {
  font-size: var(--step-1);
  font-weight: 600;
  margin: 0 0 var(--sp-4);
}

/* NXOS named last, and quietly — it is the layer underneath, not the
   thing they came for. */
.onboard-foot {
  margin: var(--sp-5) 0 0;
  padding-top: var(--sp-4);
  border-top: 1px solid var(--line);
  font-size: var(--step--2);
  color: var(--ink-faint);
  line-height: 1.5;
}

/* --------------------------------------------------------------------
   TAG EXPORT — the full run of tap links for one edition

   Scrolls rather than truncating: a partner checking an export against
   a packing list needs every row, and an ellipsis is what sends them to
   ask a question instead of finishing the job.
   -------------------------------------------------------------------- */

.tag-export {
  max-height: 18rem;
  overflow-y: auto;
  border: 1px solid var(--line);
  border-radius: var(--radius);
}
.tag-export-row {
  display: grid;
  grid-template-columns: 2.5rem minmax(0, 1fr) auto;
  align-items: center;
  gap: var(--sp-3);
  padding: var(--sp-2) var(--sp-3);
  border-bottom: 1px solid var(--line);
}
.tag-export-row:last-child { border-bottom: 0; }
.tag-export-ed {
  font-family: var(--font-num);
  font-variant-numeric: tabular-nums;
  font-size: var(--step--2);
  color: var(--ink-faint);
}
/* The URL is the long field, so it truncates rather than wrapping every
   row to two lines — the copy button carries the whole value regardless
   of what is visible. */
.tag-export-url {
  font-family: var(--font-num);
  font-size: var(--step--2);
  color: var(--ink-soft);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* --------------------------------------------------------------------
   IMAGE CROPPER
   -------------------------------------------------------------------- */

.crop-stage {
  display: flex;
  justify-content: center;
  padding: var(--sp-4) 0 var(--sp-3);
}
.crop-canvas {
  display: block;
  width: 260px;
  height: 260px;
  background: var(--surface-2);
  border: 1px solid var(--line);
  /* grab, not pointer — the canvas is draggable, not clickable, and the
     cursor is the only thing that says so before someone tries. */
  cursor: grab;
  touch-action: none;   /* the drag is ours; do not also scroll the page */
}
.crop-canvas:active { cursor: grabbing; }
/* Cosmetic only. The export is square regardless — the circle is a CSS
   decision that avatars make and other surfaces may not. */
.crop-canvas.is-round { border-radius: 50%; }

.crop-zoom {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  padding: 0 var(--sp-2);
}
.crop-zoom input[type="range"] { flex: 1; }

/* ==========================================================================
   MEMBERSHIP CARD

   Credit-card proportions (85.60 × 53.98mm ≈ 1.586), because it is one.
   Fixed dark palette rather than theme tokens: the physical card is
   black in both modes, and a card that turned white in light mode would
   be previewing something that will never be manufactured.
   ========================================================================== */

.card-stage {
  display: flex;
  justify-content: center;
  padding: var(--sp-6) 0 var(--sp-5);
  /* The scene. Perspective on the STAGE rather than the card, so the
     vanishing point sits where the viewer is rather than travelling with
     the element. */
  perspective: 1100px;
}

.nxcard-wrap {
  width: min(30rem, 92vw);
  touch-action: none;   /* the drag tilts; it must not also scroll */
}

.nxcard {
  position: relative;
  aspect-ratio: 1.586;
  border-radius: 14px;
  overflow: hidden;
  transform-style: preserve-3d;
  transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
  /* Slow easing back to rest, quick to follow. The transition is removed
     while actively tilting so the card tracks the pointer 1:1 instead of
     lagging behind it. */
  transition: transform 600ms cubic-bezier(.2, .8, .2, 1);

  /* The body: a near-black with a soft off-axis lift, which is what
     makes it read as a surface catching light rather than a flat fill. */
  background:
    radial-gradient(120% 90% at 78% 12%, #3a3a40 0%, #202024 42%, #141416 68%, #0d0d0f 100%);
  box-shadow:
    0 26px 50px -18px rgba(0, 0, 0, 0.75),
    0 2px 0 0 rgba(255, 255, 255, 0.07) inset,
    0 0 0 1px rgba(255, 255, 255, 0.06) inset;
}
.nxcard.is-tilting { transition: none; }

/* The gloss. Follows the pointer via --mx/--my so it behaves like a
   fixed light source the card moves under. */
.nxcard-sheen {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background:
    radial-gradient(46% 60% at var(--mx, 50%) var(--my, 0%),
      rgba(255, 255, 255, 0.20) 0%,
      rgba(255, 255, 255, 0.07) 38%,
      rgba(255, 255, 255, 0) 68%);
  mix-blend-mode: screen;
  transition: background 120ms linear;
}

/* Content sits above the gloss and slightly forward in Z, so the sheen
   passes under the type rather than washing it out. */
.nxcard-face {
  position: relative;
  z-index: 1;
  height: 100%;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  padding: clamp(var(--sp-4), 4.4%, var(--sp-6));
  transform: translateZ(1px);
  color: #ffffff;
}

.nxcard-top { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-4); }
.nxcard-logo { width: clamp(38px, 13%, 64px); height: auto; display: block; }

/* The script line. A cursive stack — the card artwork uses a specific
   signature face, and until that font is licensed and self-hosted this
   is the closest a browser can get on its own. */
.nxcard-script {
  font-family: "Snell Roundhand", "Apple Chancery", "Segoe Script", "Brush Script MT", cursive;
  font-size: clamp(0.95rem, 4.4vw, 1.7rem);
  line-height: 1;
  white-space: nowrap;
  opacity: 0.96;
}

.nxcard-id { margin-top: auto; margin-bottom: auto; }
.nxcard-id-label {
  display: block;
  font-family: var(--font-num);
  font-size: clamp(0.5rem, 1.6vw, 0.6rem);
  letter-spacing: 0.22em;
  text-transform: uppercase;
  opacity: 0.55;
  margin-bottom: 0.35em;
}
/* Tabular figures: an ID is read character by character and must not
   re-space itself between two cards. */
.nxcard-id-value {
  display: block;
  font-family: var(--font-num);
  font-variant-numeric: tabular-nums;
  font-size: clamp(1.1rem, 5.6vw, 2.1rem);
  font-weight: 600;
  letter-spacing: 0.02em;
}

.nxcard-bottom { display: flex; align-items: flex-end; justify-content: space-between; gap: var(--sp-4); }
.nxcard-name { display: flex; gap: clamp(var(--sp-3), 4vw, var(--sp-6)); min-width: 0; }
.nxcard-field { display: flex; flex-direction: column; gap: 0.2em; min-width: 0; }
.nxcard-field--right { text-align: right; align-items: flex-end; flex-shrink: 0; }
.nxcard-field-label {
  font-family: var(--font-num);
  font-size: clamp(0.45rem, 1.5vw, 0.56rem);
  letter-spacing: 0.18em;
  text-transform: uppercase;
  opacity: 0.5;
}
.nxcard-field-value {
  font-size: clamp(0.7rem, 2.6vw, 1rem);
  font-weight: 600;
  letter-spacing: 0.01em;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* An unminted card says so. Without this a preview and the real thing
   are indistinguishable in a screenshot. */
.nxcard.is-preview::after {
  content: 'Preview';
  position: absolute;
  top: 0; right: 0;
  z-index: 2;
  padding: 0.35em 0.8em;
  font-family: var(--font-num);
  font-size: 0.56rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: #0d0d0f;
  background: rgba(255, 255, 255, 0.82);
  border-bottom-left-radius: 8px;
}

/* Motion here is decoration. The card still renders and still shows
   every field; it simply stops chasing the pointer. */
@media (prefers-reduced-motion: reduce) {
  .nxcard { transition: none; transform: none; }
  .nxcard-sheen { transition: none; }
}

/* --- The page around it ---------------------------------------------- */

.member-grid { display: grid; grid-template-columns: 1fr; gap: var(--sp-4); }
@media (min-width: 52rem) { .member-grid { grid-template-columns: 1fr 1fr; } }

.member-meter {
  height: 6px;
  border-radius: var(--radius-pill);
  background: var(--surface-2);
  overflow: hidden;
}
.member-meter-fill { height: 100%; background: var(--accent); }

.member-perks {
  display: flex;
  flex-direction: column;
  gap: var(--sp-3);
  margin-top: var(--sp-5);
  padding-top: var(--sp-4);
  border-top: 1px solid var(--line);
}
.member-perks div { display: flex; flex-direction: column; gap: 2px; }
.member-perks strong { font-size: var(--step--1); }

/* Thin rule used to separate sections */
.rule { height: 1px; background: var(--line); border: 0; margin: var(--sp-6) 0; }

/* ==========================================================================
   RESPONSIVE — page-level refinements

   One set of markup that reflows. No separate desktop build, no layout
   that exists only at one size.
   ========================================================================== */

/* Buttons go inline-width once there is room, but primary actions inside
   narrow flows stay full-width for thumb reach.

   NOTE THE DISPLAY VALUES. These were `inline-block` and `block`, which
   silently undid the flex centring from the base rule at exactly the
   widths most people view the app on — so the label went back to sitting
   near the top of any button taller than its own text. They must stay
   flex variants; only the WIDTH behavior changes at this breakpoint. */
@media (min-width: 40rem) {
  .btn { display: inline-flex; width: auto; min-width: 12rem; }
  .btn + .btn { margin-top: 0; margin-left: var(--sp-3); }
  .btn-block { display: flex; width: 100%; }
  .btn-sm { min-width: 0; }
  .form-narrow { max-width: 24rem; }
  .btn-row { flex-direction: row; }
  .btn-row .btn { width: auto; flex: 1; min-width: 0; margin: 0; }
}

@media (min-width: 40rem) {
  .verdict { padding-top: var(--sp-8); }
  .verdict-mark { width: 80px; height: 80px; font-size: var(--step-4); }
  .verdict-title { font-size: var(--step-5); }
  .edition { font-size: 4.5rem; }
}

/* Item detail: two columns once there is room */
@media (min-width: 52rem) {
  .detail-grid {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: var(--sp-6);
    align-items: start;
  }
  .detail-hero { grid-column: 1 / -1; }
}

@media (min-width: 44rem) {
  .consent-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--sp-4); }
  .consent-grid .card { margin-bottom: 0; }
}

/* Cap the reading measure on prose even inside wide containers */
.lede, .note { max-width: 68ch; }

/* Tap targets stay comfortable on coarse pointers at any width */
@media (pointer: coarse) {
  .topbar-link, .back-link, .link-btn { min-height: var(--tap); }
}

/* ==========================================================================
   OVERLAYS — dialogs, command palette, toasts

   These replace the browser's alert/confirm/prompt. See app.js for why
   that swap is a correctness matter and not a cosmetic one.

   Motion here is deliberately restrained. The 2026 convention that
   actually earns its place is motion that communicates state and
   direction rather than performing: a dialog rises a few pixels and
   settles, a toast enters from the edge it lives on. Nothing bounces,
   nothing overshoots, and everything is under 200ms — long enough to
   read as movement, short enough that a user clicking through ten
   listings never waits on it.
   ========================================================================== */

.nx-overlay {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: flex;
  align-items: flex-end;
  justify-content: center;
  padding: var(--sp-4);
  padding-bottom: calc(var(--sp-4) + env(safe-area-inset-bottom));
  background: var(--scrim);
  opacity: 0;
  transition: opacity 140ms ease;
  /* Blur belongs on the scrim, not the panel: it separates the dialog
     from the page without putting a translucent layer between the user
     and the text they are being asked to read. */
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
}
.nx-overlay.is-open { opacity: 1; }

/* Bottom sheet on phones, centered dialog from tablet up. A centered modal
   on a tall phone puts the confirm button in the dead zone above the
   thumb; a sheet puts it exactly where the hand already is. */
@media (min-width: 34rem) {
  .nx-overlay { align-items: center; padding: var(--sp-6); }
}

.nx-dialog {
  width: 100%;
  max-width: 30rem;
  max-height: 88vh;
  overflow-y: auto;
  background: var(--glass);
  border: 1px solid var(--glass-line);
  border-radius: var(--radius);
  box-shadow: var(--shadow-lift);
  transform: translateY(12px);
  transition: transform 160ms cubic-bezier(0.2, 0, 0.1, 1);
}
.nx-overlay.is-open .nx-dialog { transform: translateY(0); }

.nx-dialog-main { padding: var(--sp-5); }

.nx-dialog-title {
  font-size: var(--step-2);
  font-weight: 600;
  letter-spacing: -0.02em;
  margin: 0 0 var(--sp-3);
  text-wrap: balance;
}

.nx-dialog-text {
  color: var(--ink-soft);
  font-size: var(--step--1);
  margin: 0 0 var(--sp-4);
  max-width: 52ch;
}
.nx-dialog-text:last-child { margin-bottom: 0; }
.nx-dialog-text strong { color: var(--ink); font-weight: 600; }

.nx-dialog-fields { margin-top: var(--sp-4); }
.nx-dialog-fields .field:last-child { margin-bottom: 0; }

.nx-dialog-actions {
  display: flex;
  flex-direction: column-reverse;
  gap: var(--sp-3);
  padding: var(--sp-4) var(--sp-5) var(--sp-5);
  border-top: 1px solid var(--line);
}
.nx-dialog-actions .btn { margin: 0; width: 100%; }

/* Side by side once there is room, with confirm on the right. Stacked on
   phones with confirm on top (column-reverse), because the reachable
   position should hold the action the user came here to take. */
@media (min-width: 34rem) {
  .nx-dialog-actions { flex-direction: row; justify-content: flex-end; }
  .nx-dialog-actions .btn { width: auto; min-width: 7.5rem; }
}

/* Destructive confirmation. The color is the signal that this button is
   not like the others — deleting an account and saving a handle should
   never look identical a half-second before the click. */
.btn-danger {
  color: #fff;
  background: var(--rejected);
  border-color: var(--rejected);
}

/* Prefixed input — the unit sits inside the field frame. */
.nx-affix {
  display: flex;
  align-items: stretch;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--surface);
  overflow: hidden;
  transition: border-color 120ms ease;
}
.nx-affix:focus-within { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
.nx-affix-lead {
  display: flex;
  align-items: center;
  padding: 0 var(--sp-3);
  font-family: var(--font-num);
  color: var(--ink-faint);
  background: var(--surface-2);
  border-right: 1px solid var(--line);
}
/* Same specificity trap as the palette field — see the note there. Without
   the attribute selector the inner input keeps its own border and fill and
   renders as a box inside a box, with the "$" stranded beside it. */
.nx-affix input[type] {
  border: 0;
  border-radius: 0;
  background: transparent;
  font-family: var(--font-num);
}
.nx-affix input[type]:focus { outline: none; box-shadow: none; border: 0; }

/* --- Toasts ------------------------------------------------------------ */

.nx-toast-stack {
  position: fixed;
  z-index: 200;
  left: var(--sp-4);
  right: var(--sp-4);
  bottom: calc(var(--sp-4) + env(safe-area-inset-bottom));
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
  pointer-events: none;
}
@media (min-width: 34rem) {
  .nx-toast-stack { left: auto; right: var(--sp-5); bottom: var(--sp-5); width: 24rem; }
}

.nx-toast {
  pointer-events: auto;
  display: flex;
  align-items: flex-start;
  gap: var(--sp-3);
  padding: var(--sp-3) var(--sp-4);
  background: var(--glass);
  border: 1px solid var(--glass-line);
  border-left: 2px solid var(--ink-faint);
  border-radius: var(--radius-sm);
  box-shadow: var(--shadow-lift);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  font-size: var(--step--1);
  opacity: 0;
  transform: translateY(8px);
  transition: opacity 160ms ease, transform 160ms cubic-bezier(0.2, 0, 0.1, 1);
}
.nx-toast.is-open { opacity: 1; transform: translateY(0); }

.nx-toast--success { border-left-color: var(--verified); }
.nx-toast--danger  { border-left-color: var(--rejected); }

/* A color-coded left edge is not enough on its own — it is invisible to
   a user with a red/green deficiency, which is roughly one man in twelve.
   The glyph carries the same meaning independently. */
.nx-toast-mark {
  flex-shrink: 0;
  font-family: var(--font-num);
  line-height: 1.5;
  color: var(--ink-faint);
}
.nx-toast--success .nx-toast-mark { color: var(--verified); }
.nx-toast--danger  .nx-toast-mark { color: var(--rejected); }
.nx-toast-mark::before { content: "•"; }
.nx-toast--success .nx-toast-mark::before { content: "✓"; }
.nx-toast--danger  .nx-toast-mark::before { content: "!"; }

.nx-toast-text { flex: 1; min-width: 0; color: var(--ink); }

.nx-toast-x {
  flex-shrink: 0;
  background: none;
  border: 0;
  padding: 0 var(--sp-1);
  font-size: var(--step-1);
  line-height: 1;
  color: var(--ink-faint);
  cursor: pointer;
}
@media (hover: hover) { .nx-toast-x:hover { color: var(--ink); } }

/* --- Command palette ---------------------------------------------------
   Keyboard-first navigation, which is the pattern this product should
   have had first. The reference points for NXOS are auction catalogs
   and trading terminals, and nobody operating a terminal navigates it by
   clicking through pages — they type the identifier they already know.

   A collector who owns forty artifacts knows the serial of the one they
   want. Making them load a grid and scan it is asking them to search for
   something they were never lost.
   -------------------------------------------------------------------- */

.nx-overlay--palette { align-items: flex-start; padding-top: 12vh; }
@media (min-width: 34rem) { .nx-overlay--palette { padding-top: 14vh; } }

.nx-palette {
  width: 100%;
  max-width: 34rem;
  max-height: 70vh;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  background: var(--glass);
  border: 1px solid var(--glass-line);
  border-radius: var(--radius);
  box-shadow: var(--shadow-lift);
  transform: translateY(-10px);
  transition: transform 160ms cubic-bezier(0.2, 0, 0.1, 1);
}
.nx-overlay.is-open .nx-palette { transform: translateY(0); }

.nx-palette-search {
  display: flex;
  align-items: center;
  gap: var(--sp-4);
  padding: 0 var(--sp-5);
  border-bottom: 1px solid var(--line);
}

/* SPECIFICITY NOTE — do not "simplify" this selector.
   The global form rule is `input:not([type="checkbox"]):not([type="radio"])`,
   which scores (0,2,1). A plain `.nx-palette-search input` scores (0,1,1)
   and LOSES, so the field keeps the standard border, fill and padding and
   renders as a boxed input floating inside the panel instead of a seamless
   search line. The attribute selector brings this to (0,2,1); source order
   then decides, and this block is later in the file. */
.nx-palette-search input[type="text"] {
  border: 0;
  border-radius: 0;
  background: transparent;
  padding: var(--sp-5) 0;
  font-size: var(--step-1);
  min-height: 4rem;
}
.nx-palette-search input[type="text"]:focus {
  outline: none;
  box-shadow: none;
  border: 0;
}

.nx-palette-glyph {
  flex-shrink: 0;
  font-size: var(--step-1);
  color: var(--ink-faint);
  font-family: var(--font-num);
}

.nx-palette-results { overflow-y: auto; padding: var(--sp-3) var(--sp-2) var(--sp-2); }

.nx-palette-group {
  font-family: var(--font-num);
  font-size: var(--step--2);
  text-transform: uppercase;
  letter-spacing: 0.12em;
  color: var(--ink-faint);
  padding: var(--sp-4) var(--sp-4) var(--sp-2);
}
/* No stacked gap above the first group — the search field's border already
   separates it, and doubling that reads as a mistake. */
.nx-palette-results > .nx-palette-group:first-child { padding-top: var(--sp-2); }

.nx-palette-item {
  display: flex;
  align-items: center;
  gap: var(--sp-4);
  width: 100%;
  padding: var(--sp-3) var(--sp-4);
  text-align: left;
  background: none;
  border: 0;
  border-radius: var(--radius-sm);
  color: inherit;
  font: inherit;
  cursor: pointer;
  min-height: var(--tap);
}
/* Selection is driven by the keyboard, so it is a class rather than
   :hover. A mouse moving across the list must not silently change what
   Enter is about to do. */
.nx-palette-item.is-active { background: var(--surface-2); }

/* The title and its detail are <span>s, which are inline by default — so
   without this they render on one line ("Market  Everything for sale")
   and the row loses its hierarchy entirely. */
.nx-palette-item-main {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.nx-palette-item-title,
.nx-palette-item-sub {
  display: block;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.nx-palette-item-title { font-size: var(--step-0); line-height: 1.3; }
.nx-palette-item-sub {
  font-family: var(--font-num);
  font-size: var(--step--2);
  line-height: 1.3;
  color: var(--ink-faint);
}
/* An empty sub would still occupy a line and make rows uneven. */
.nx-palette-item-sub:empty { display: none; }

.nx-palette-thumb {
  width: 36px; height: 36px;
  flex-shrink: 0;
  border-radius: var(--radius-sm);
  object-fit: cover;
  background: var(--surface-2);
}

.nx-palette-foot {
  display: flex;
  gap: var(--sp-4);
  padding: var(--sp-3) var(--sp-5);
  border-top: 1px solid var(--line);
  font-size: var(--step--2);
  color: var(--ink-faint);
}
/* The footer advertises arrow keys, return and escape. Now that the
   palette is reachable on a phone, that legend is instructions for
   hardware the reader does not have — so it goes where there is no
   pointer to hover, alongside the key hint in the topbar button. */
@media (hover: none) { .nx-palette-foot { display: none; } }
.nx-palette-empty { padding: var(--sp-6) var(--sp-4); text-align: center; color: var(--ink-soft); font-size: var(--step--1); }

kbd {
  font-family: var(--font-num);
  font-size: var(--step--2);
  padding: 1px 5px;
  border: 1px solid var(--line-strong);
  border-radius: 3px;
  background: var(--surface-2);
  color: var(--ink-soft);
}

/* Topbar affordance for the palette. Hidden on touch, where there is no
   keyboard to advertise and the shortcut is unreachable anyway. */
/*
   SEARCH — an icon on small screens, a labelled pill on large ones.

   This was `display: none` until a breakpoint, which hid the control
   rather than shrinking it. Since the palette's other entry point is a
   keyboard shortcut, a phone had neither: search existed and could not
   be opened. The button is now always present and only the label and
   the key hint are conditional.

   Square and --tap tall in icon form, because at that size it is a
   touch target before it is a graphic.
*/
.topbar-search {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--sp-3);
  min-height: var(--tap);
  min-width: var(--tap);
  padding: 0;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  color: var(--ink-faint);
  font: inherit;
  font-size: var(--step--1);
  cursor: pointer;
}
.topbar-search-icon { font-size: var(--step-1); line-height: 1; }

/* Hidden rather than removed from the DOM — the accessible name comes
   from aria-label on the button, so nothing is lost to assistive tech
   when the visible label goes away. */
.topbar-search-label,
.topbar-search-kbd { display: none; }

@media (hover: hover) and (min-width: 48rem) {
  .topbar-search {
    min-height: 32px;
    min-width: 0;
    padding: 0 var(--sp-3);
  }
  .topbar-search-icon { font-size: inherit; }
  .topbar-search-label { display: inline; }
  .topbar-search-kbd { display: inline; }
}

@media (hover: hover) { .topbar-search:hover { border-color: var(--line-strong); color: var(--ink-soft); } }
.topbar-search:focus-visible { outline: 1px solid var(--accent); outline-offset: 2px; }

/* --- Theme toggle ------------------------------------------------------ */

.theme-toggle {
  display: inline-flex;
  padding: 2px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius-pill);
}
.theme-toggle button {
  min-height: 32px;
  padding: 0 var(--sp-3);
  background: none;
  border: 0;
  border-radius: var(--radius-pill);
  color: var(--ink-faint);
  font: inherit;
  font-size: var(--step--1);
  cursor: pointer;
}
.theme-toggle button.is-active { background: var(--surface-2); color: var(--ink); }

/* ==========================================================================
   BENTO — the collection overview

   Asymmetric tiles rather than a uniform stat row. The proportions carry
   meaning: portfolio value is the number a collector opens this page to
   see, so it occupies the largest cell, and the rest arrange around it.
   A row of four equal boxes says every figure matters the same amount,
   which is never true and quietly costs the reader the ranking they
   would otherwise get for free.
   ========================================================================== */

.bento {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: var(--sp-3);
  margin-bottom: var(--sp-6);
}
@media (min-width: 48rem) {
  .bento { grid-template-columns: repeat(4, 1fr); gap: var(--sp-4); }
}

.bento-tile {
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  gap: var(--sp-4);
  padding: var(--sp-4);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  min-height: 7rem;
  transition: border-color 140ms ease, transform 140ms ease;
}
@media (min-width: 48rem) { .bento-tile { padding: var(--sp-5); } }

/* The lead cell. Spans both columns on a phone and two of four above it,
   so it stays dominant at every width instead of collapsing into
   equality on small screens where the ranking matters most. */
.bento-tile--lead { grid-column: span 2; }
.bento-tile--wide { grid-column: span 2; }

.bento-label {
  font-family: var(--font-num);
  font-size: var(--step--2);
  text-transform: uppercase;
  letter-spacing: 0.12em;
  color: var(--ink-faint);
}
.bento-value {
  font-family: var(--font-num);
  font-size: var(--step-3);
  font-weight: 600;
  letter-spacing: -0.03em;
  line-height: 1;
  font-variant-numeric: tabular-nums;
}
.bento-tile--lead .bento-value { font-size: clamp(var(--step-3), 6vw, var(--step-5)); }
.bento-sub { font-size: var(--step--2); color: var(--ink-soft); }
.bento-sub .is-up { color: var(--verified); }
.bento-sub .is-down { color: var(--rejected); }

/* Interactive tiles. The 2026 "active grid" idea is worth taking only
   where the tile leads somewhere real — a cell that lifts on hover and
   then does nothing when clicked is a promise the interface breaks. */
a.bento-tile { text-decoration: none; color: inherit; }
@media (hover: hover) {
  a.bento-tile:hover { border-color: var(--line-strong); transform: translateY(-2px); }
}

/* Sparkline. Deliberately unlabeled and unaxised — it carries shape,
   not values. The precise figures live in the price history on the item
   page; this only answers "which way has this been going". */
.bento-spark { width: 100%; height: 34px; display: block; overflow: visible; }
.bento-spark path { fill: none; stroke: var(--accent); stroke-width: 1.5; vector-effect: non-scaling-stroke; }
.bento-spark .spark-fill { fill: var(--accent-soft); stroke: none; }

/* ==========================================================================
   SKELETONS

   Replacing the bare word "Loading…". A skeleton reserves the shape of
   what is coming, so the page does not jump when it arrives, and the
   user can see the structure of the answer before its content — which is
   what makes a slow load feel like progress rather than a stall.
   ========================================================================== */

.skel {
  background: linear-gradient(90deg, var(--shimmer) 25%, var(--surface-2) 50%, var(--shimmer) 75%);
  background-size: 200% 100%;
  animation: skel-slide 1.4s ease-in-out infinite;
  border-radius: var(--radius-sm);
}
@keyframes skel-slide {
  from { background-position: 200% 0; }
  to   { background-position: -200% 0; }
}

/* Visually hidden but announced.

   Skeletons are meaningless to a screen reader — they are shapes with no
   text. Replacing the word "Loading…" with them would have silently
   removed the only signal a non-sighted user had that anything was
   happening, so the word stays, just not on screen. */
.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

.skel-line { height: 0.75rem; margin-bottom: var(--sp-2); }
.skel-line--title { height: 1.5rem; width: 45%; margin-bottom: var(--sp-4); }
.skel-line--short { width: 60%; }
.skel-card { height: 100%; min-height: 18rem; border-radius: var(--radius); }
.skel-tile { min-height: 7rem; border-radius: var(--radius); }

/* ==========================================================================
   REDUCED MOTION

   Not a courtesy toggle. For a user with a vestibular disorder, movement
   they did not ask for can cause actual nausea — so when the system says
   to stop, everything stops, including the skeleton shimmer, which is
   the largest continuously animating surface in the app.
   ========================================================================== */

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
  .nx-dialog, .nx-palette, .nx-toast { transform: none !important; }
  .skel { background: var(--shimmer); }
  a.bento-tile:hover { transform: none; }
  /* Video is hidden entirely rather than paused — a paused frame sitting
     under a poster is two images fighting. The poster IS the media here. */
  .has-video video { display: none; }
  .art-media-badge { display: none; }
}

/* ==========================================================================
   ARTIFACT MEDIA — stills and video

   The still is the contract; video is layered on top and never required.
   See artifactMedia() in app.js for the behavioral rules.
   ========================================================================== */

/* SCOPED TO .has-video, NOT .art-media.has-video.

   These rules used to be written against `.art-media` specifically, which
   was correct while that was the only media box. artifactMedia() now
   takes a className, and five other containers use it —
   .product-media-box, .detail-media, .listing-media, .feature-media,
   .verdict-media. None of them matched, so on every surface except the
   collection grid the <video> received NO sizing at all: it rendered at
   its intrinsic 1280px inside an ~850px box, overflowed, and got cropped
   off-center by the container's overflow:hidden.

   .has-video is the marker artifactMedia always adds, so binding here
   covers every present and future container by construction. */
.has-video { position: relative; }

/* The video sits ON TOP of the poster rather than replacing it, and is
   revealed on play. Swapping the source instead would show a flash of
   empty element on the first hover, before the first frame decodes. */
.has-video video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* Explicit even though it is the default — this is the property that
     decides what gets cropped, and it should be visible at the point
     where the cropping is chosen. */
  object-position: center;
  opacity: 0;
  transition: opacity 200ms ease;
}
.has-video.is-playing video { opacity: 1; }
.has-video[data-media-play="auto"] video { opacity: 1; }

/* WHERE THE WHOLE FRAME MATTERS, CONTAIN RATHER THAN COVER.

   A grid tile wants `cover`: uniform shapes are what make a grid read as
   one catalog, and a little cropping is an acceptable price.

   A detail page does not. Someone looking at a single object came to see
   the object, and cropping a 16:9 product loop into a square frame cuts
   the ends off the thing they are examining. `contain` letterboxes it
   against the media well's own background and keeps it centered. */
.product-media-box.has-video video,
.detail-media.has-video video,
.verdict-media.has-video video {
  object-fit: contain;
  background: #000;
}

/* Signals there is motion available without demanding a hover to find out.
   Hidden once playing — at that point the video says it itself. */
.art-media-badge {
  position: absolute;
  left: var(--sp-3);
  bottom: var(--sp-3);
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 26px; height: 26px;
  font-size: 10px;
  color: var(--ink);
  background: var(--glass);
  border: 1px solid var(--glass-line);
  border-radius: var(--radius-pill);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  transition: opacity 160ms ease;
}
.is-playing .art-media-badge { opacity: 0; }

/* ==========================================================================
   PROPERTY KEY ART

   The IP's visual identity. Entertainment buyers recognize key art long
   before they read a studio name, and a registry that renders a film as
   plain text is asking them to identify their own property from a label.
   ========================================================================== */

.prop-banner {
  position: relative;
  display: flex;
  align-items: flex-end;
  min-height: 15rem;
  margin-bottom: var(--sp-6);
  overflow: hidden;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--surface);
}
@media (min-width: 48rem) { .prop-banner { min-height: 19rem; } }
.prop-banner--compact { min-height: 9rem; }

.prop-banner-art {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* The scrim is what makes the type legible over arbitrary artwork. Key
   art is delivered by a studio and could be any brightness at the bottom
   edge, so this cannot be left to chance — without it the title is
   unreadable on roughly half the posters anyone will supply. */
.prop-banner::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(to top,
    rgba(6, 6, 8, 0.92) 0%,
    rgba(6, 6, 8, 0.55) 42%,
    rgba(6, 6, 8, 0.15) 100%);
}
.prop-banner--bare::after { display: none; }

.prop-banner-body {
  position: relative;
  z-index: 1;
  padding: var(--sp-5);
  width: 100%;
}
@media (min-width: 48rem) { .prop-banner-body { padding: var(--sp-6); } }

/* Fixed colors, not tokens: this type always sits on a dark scrim over
   artwork, in both themes. Using --ink would make it invert to near-black
   on a light theme and vanish. */
.prop-banner-name {
  margin: 0;
  font-size: clamp(var(--step-2), 4vw, var(--step-4));
  font-weight: 600;
  letter-spacing: -0.03em;
  line-height: 1.05;
  color: #f4f3ef;
  text-wrap: balance;
}
.prop-banner-sub {
  margin: var(--sp-2) 0 0;
  font-family: var(--font-num);
  font-size: var(--step--2);
  text-transform: uppercase;
  letter-spacing: 0.12em;
  color: rgba(244, 243, 239, 0.72);
}
.prop-banner-logo { display: block; max-width: min(22rem, 70%); height: auto; }

/* No artwork: fall back to the plain typographic header this replaced. */
.prop-banner--bare {
  min-height: 0;
  padding: 0;
  border: 0;
  background: none;
  margin-bottom: var(--sp-5);
  display: block;
}
.prop-banner--bare .prop-banner-name { color: var(--ink); }
.prop-banner--bare .prop-banner-sub { color: var(--ink-faint); }

/* --- Key-art filter chips --------------------------------------------- */

.prop-chip {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
  padding: 4px var(--sp-3) 4px 4px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius-pill);
  color: var(--ink-soft);
  font: inherit;
  font-size: var(--step--1);
  cursor: pointer;
  min-height: 36px;
  transition: border-color 120ms ease, color 120ms ease;
}
.prop-chip img {
  width: 26px; height: 26px;
  border-radius: var(--radius-pill);
  object-fit: cover;
  flex-shrink: 0;
}
/* Keeps a chip without artwork the same height as one with it, so the
   filter row does not jitter as properties gain key art. */
.prop-chip:not(:has(img)) { padding-left: var(--sp-3); }
.prop-chip.is-active { border-color: var(--ink); color: var(--ink); }
@media (hover: hover) { .prop-chip:hover { border-color: var(--line-strong); color: var(--ink); } }
.prop-chip-count { font-family: var(--font-num); font-size: var(--step--2); color: var(--ink-faint); }



/* ==========================================================================
   ARTIFACT GALLERY

   ONE FRAME: 780 × 680, with every asset centered inside it.

   The stage used to take its shape from whichever asset was on it, so a
   2:3 poster got a 2:3 frame and a 16:9 trailer got a 16:9 one. That
   avoided letterboxing, and it cost more than it saved: the page resized
   on every thumbnail click, the buy panel beside it moved, and a gallery
   of mixed shapes made the whole layout twitch.

   A fixed frame with `contain` costs some empty space around a tall
   poster and buys a page that holds still. Nothing is cropped.
   ========================================================================== */

/*
  780px CEILING.

  The media column is wider than that on a large screen, and a product
  shot stretched to fill 870px is being enlarged past the size it was
  photographed for — which softens it and makes a small object look like
  a poster. 780 is the stated frame width.

  On the column rather than on the image, so the thumbnail rail and the
  caption stay aligned with the picture instead of running wider than it.
*/
.gallery {
  width: 100%;
  max-width: 780px;
}

/*
  NO FRAME. The product sits on the page.

  The stage carried a hairline border and a filled background, which drew
  a visible rectangle around every artifact — and product photography is
  almost always shot on white or a light sweep, so the border read as the
  edge of a box the object was sitting inside rather than as the edge of
  the photograph. On the new #F1F1F1 page it was worse: the fill was a
  different gray to the page, so the rectangle showed even where the image
  did not reach.

  Removing both lets the object float. The photograph's own background
  blends into the page, and what remains visible is the product and its
  shadow, which is the whole point of shooting it that way.

  overflow:hidden stays — it is what clips the slides during a transition,
  not decoration.
*/
/*
  A FIXED 780 × 680 FRAME, with the asset centered inside it.

  The stage used to take each asset's own shape, so the page resized
  every time you switched between a 2:3 poster and a 16:9 trailer — the
  buy panel beside it moved, and a gallery of mixed shapes made the
  layout twitch.

  One frame instead. `aspect-ratio: 780 / 680` rather than a hard height,
  so it IS 780 × 680 at full width and scales proportionally below that
  — a fixed 680px would leave a phone with a tall box holding a small
  picture. The max-height keeps the exact number as the ceiling.

  Nothing is cropped: images and video are `contain`, centered by the
  slide's flex, so a tall poster and a wide still both sit whole inside
  the same rectangle.
*/
.gallery-stage {
  position: relative;
  width: 100%;
  aspect-ratio: 780 / 680;
  max-height: 680px;
  overflow: hidden;
}

/*
  THE FRAME IS A TWO-COLUMN DECISION, so it stops applying in one column.

  780 × 680 is ~1.147:1 — nearly square — and it was chosen to match the
  height of the text column beside it on a desktop. That shape is fixed at
  every width, and the arithmetic on a phone is bad in both axes: a 16:9
  video leaves ~35% of the frame empty below it, and a 2:3 poster leaves
  ~42% of the width empty beside it. On a 375px screen that is a ~330px
  box holding a 211px video.

  The original reason for a fixed frame was that the page twitched when
  switching between a poster and a trailer — the buy panel BESIDE it moved.
  Below 56rem nothing is beside it: the buy panel is underneath, so a
  height change pushes content down instead of jerking it sideways. The
  cost the frame was paying for is much lower here than the space it wastes.

  56rem matches the .product grid breakpoint deliberately. If that number
  ever moves, these two must move together — the condition is literally
  "is there a second column", and two different answers to it would put
  the desktop frame in a single-column layout.
*/
@media (max-width: 55.999rem) {
  .gallery-stage {
    /*
      THE RATIO STAYS. Solving for the frame that minimises worst-case
      empty space across what actually goes in it — 16:9 video, 2:3
      poster, 3:2 still — gives 1.089, and 780/680 is 1.147. It is already
      within a few percent of optimal, and no single frame does better
      than ~39% worst case while the content mixes portrait and landscape.

      A 4/5 portrait frame was tried here first, on the theory that taller
      suits a phone. It does suit posters — they go from using 58% of the
      width to 83% — but it pushes a 16:9 video from 35% empty to 55%.
      Better for one input, worse for the other, worse on average.

      So the fix below is NOT the shape of the frame. It is the two things
      that are genuinely wrong in one column: where the spare space lands
      (see the video rule), and the frame having no relationship to the
      height of the screen.
    */
    /*
      Never taller than most of the viewport. The desktop ceiling is a
      flat 680px, which on a landscape phone is taller than the screen —
      the frame fills it and the title, price and buy button all start
      below the fold on the page whose job is to sell the thing.
    */
    max-height: 70vh;
  }
}


.gallery-slide {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 0;
  pointer-events: none;
  transition: opacity 200ms ease;
}
.gallery-slide.is-active { opacity: 1; pointer-events: auto; }

/*
  `contain`, always. The stage is already the asset's own shape, so there
  is nothing to crop — and on the frame where the stage is still settling
  into a new ratio, showing the whole asset beats cropping it.

  RESTORED. Last change removed a rule for `.gallery-slide video` that was
  the second half of a GROUPED selector — `.gallery-slide img,
  .gallery-slide video { … }` — and deleting the block left `img,`
  dangling with no declarations. It then absorbed the next rule in the
  file, so images lost all their sizing AND inherited the placeholder's
  border and fill.

  That is the broken photo display: an <img> with no max-width renders at
  its intrinsic pixel size, overflowing a stage that clips it, so a 2000px
  product shot appeared enormous and cut off. Editing one member of a
  comma-separated selector is not a local change.
*/
/*
  Centered in both axes and never larger than the frame.

  The slide is already a centering flex container, but width/height auto
  plus max-* is what keeps the asset's own proportions — without it a
  portrait poster would be stretched to fill a landscape frame.
*/
.gallery-slide img,
.gallery-slide video {
  max-width: 100%;
  max-height: 100%;
  width: auto;
  height: auto;
  margin: auto;
  object-fit: contain;
  display: block;
}

/*
  VIDEO SITS AT THE TOP OF THE FRAME, images stay centered.

  The stage is 780 × 680, roughly 1.15:1. A 16:9 video fitted to that
  width is only ~439px tall, so centering left about 120px of dead frame
  above it — and since the right-hand column starts at the same y, the
  video appeared to begin well below the product title it should have been
  level with. The columns were already aligned; the picture inside the box
  was not.

  `margin-top: 0` with `margin-bottom: auto` is what does it. Auto margins
  on a flex item beat `align-items`, so overriding align-self alone would
  have done nothing while looking correct — the `margin: auto` shorthand
  above would still have won.

  Images keep centering deliberately. A tall poster in a 1.15:1 frame is
  narrower than the box rather than shorter, so it has no vertical slack
  to give back, and pinning it to the top would only shift the imbalance
  to the bottom edge on the ones that do.
*/
.gallery-slide video {
  margin-top: 0;
  margin-bottom: auto;
}

/*
  RE-CENTRE THE VIDEO IN ONE COLUMN.

  The rule directly above pins the video to the top of the frame so its
  top edge lines up with the product title in the NEXT COLUMN. Below 56rem
  there is no next column — the title sits under the gallery — so the pin
  has nothing left to align to, and all it does is collect the frame's
  spare height into one empty band beneath the video. That reads as a
  broken player rather than a margin.

  Centring splits the space above and below, which reads as framing.

  PLACED AFTER THE PIN ON PURPOSE. Both selectors are `.gallery-slide
  video`, so they have identical specificity and the later one wins. When
  this block sat earlier in the file it parsed fine, matched fine, and did
  nothing at all — the pin overwrote it on every screen. Moving either
  rule past the other silently disables this one.
*/
@media (max-width: 55.999rem) {
  .gallery-slide video {
    margin-top: auto;
    margin-bottom: auto;
  }
}

/* The exception: with nothing to show, the placeholder needs an edge or
   it is an invisible hole rather than a stated absence. */
/* Same frame as a real asset, so an artifact with no media does not
   change the shape of the page. It keeps its edge because a placeholder
   with no border is an invisible hole rather than a stated absence. */
.gallery-stage--empty {
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-num);
  font-size: var(--step-4);
  color: var(--ink-faint);
}

.gallery-caption { margin: var(--sp-3) 0 0; }

/* --- Thumbnail rail ---------------------------------------------------
   Scrolls rather than wraps. A second row of thumbnails pushes the
   artifact's title below the fold on a laptop, which trades the thing
   someone came to read for navigation they may not need. */

.gallery-rail {
  display: flex;
  gap: var(--sp-2);
  margin-top: var(--sp-3);
  overflow-x: auto;
  padding-bottom: var(--sp-2);
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
  /*
    Keeps a horizontal swipe inside the rail. Without it, swiping past the
    last thumbnail hands the gesture to the browser, which on iOS is the
    back-navigation swipe — so flicking through photos can leave the page
    entirely. Costs nothing on a mouse.
  */
  overscroll-behavior-x: contain;
  scroll-snap-type: x proximity;
}
.gallery-thumb { scroll-snap-align: start; }
.gallery-rail::-webkit-scrollbar { display: none; }

.gallery-thumb {
  position: relative;
  flex: 0 0 auto;
  width: 64px;
  height: 64px;
  padding: 0;
  overflow: hidden;
  background: var(--surface-2);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  cursor: pointer;
  transition: border-color 140ms ease, opacity 140ms ease;
  opacity: 0.65;
}
.gallery-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.gallery-thumb-fill { display: block; width: 100%; height: 100%; background: var(--surface-2); }
.gallery-thumb.is-active { opacity: 1; border-color: var(--ink); }
@media (hover: hover) { .gallery-thumb:hover { opacity: 1; } }
.gallery-thumb:focus-visible { outline: 1px solid var(--accent); outline-offset: 2px; }

.gallery-thumb-play {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 11px;
  color: #fff;
  background: rgba(0, 0, 0, 0.35);
}

@media (prefers-reduced-motion: reduce) {
  .gallery-stage, .gallery-slide { transition: none; }
}

/* ==========================================================================
   ARTIFACT HERO — gallery left, identity right

   Replaces a centered block whose image was pinned to its own left edge:
   .detail-hero set text-align:center while .detail-media carried a
   max-width and no auto margin, so the poster sat hard left and the title
   floated in the middle of several hundred pixels of empty card.

   Two columns fixes the alignment and suits the content: the artwork is
   why someone opened the page, and the edition, badge and facts are what
   they read while looking at it.
   ========================================================================== */

/*
  minmax(0, 1fr), NOT 1fr — this is the mobile overflow.

  A `1fr` grid track carries an implicit `min-width: auto`, which means it
  refuses to shrink below the INTRINSIC width of its contents. A gallery
  photo is a few thousand pixels wide intrinsically, so the track grew to
  fit it and pushed the column wider than the screen: the image kept its
  left gutter and ran straight off the right edge, and the page scrolled
  sideways. Nothing was being cropped — `object-fit: contain` was working
  correctly against a box that was simply too wide.

  `minmax(0, 1fr)` sets that floor to zero, so the track takes the width
  available and the image's `max-width: 100%` finally has a sane 100% to
  measure against.

  The two-column rule below already did this. Only the single-column
  default — the mobile case — was missed, which is exactly why this
  looked like a phone-only bug.
*/
.artifact-hero {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--sp-5);
  margin-bottom: var(--sp-6);
}

/* Media takes the larger share. On a page about an object, the object
   should be the biggest thing on it. */
@media (min-width: 56rem) {
  .artifact-hero {
    grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
    gap: var(--sp-7);
    align-items: start;
  }
}

.artifact-hero-media { min-width: 0; }

.artifact-hero-info {
  min-width: 0;
  /* Left-aligned, explicitly. The old block inherited text-align:center
     from its container, which is what put the title in the middle of
     nowhere once the image stopped being centered with it. */
  text-align: left;
}

.artifact-hero-info .artifact-property {
  font-family: var(--font-num);
  font-size: var(--step--2);
  text-transform: uppercase;
  letter-spacing: 0.14em;
  color: var(--ink-faint);
  margin-bottom: var(--sp-2);
}

.artifact-hero-info .artifact-title {
  font-size: clamp(var(--step-2), 3.6vw, var(--step-4));
  font-weight: 600;
  letter-spacing: -0.035em;
  line-height: 1.05;
  margin: 0 0 var(--sp-4);
  text-wrap: balance;
}

.artifact-hero-info .edition {
  font-family: var(--font-num);
  font-size: var(--step-4);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  letter-spacing: -0.04em;
  line-height: 1;
  margin-bottom: var(--sp-3);
}
.artifact-hero-info .edition small {
  font-size: var(--step-1);
  font-weight: 400;
  color: var(--ink-faint);
}

.artifact-hero-info .btn-sm { width: auto; }

/* --- Detail page media (legacy single-asset well) ---------------------- */

.detail-media {
  position: relative;
  width: 100%;
  max-width: 320px;
  aspect-ratio: 1 / 1;
  margin-bottom: var(--sp-4);
  overflow: hidden;
  border-radius: var(--radius);
  background: var(--surface-2);
}
.detail-media img { width: 100%; height: 100%; object-fit: contain; object-position: center; display: block; }
@media (min-width: 48rem) { .detail-media { max-width: 380px; } }

/* --- Verdict media -----------------------------------------------------
   Large, and deliberately still. The second half of "is this real?" is
   "is this the object in my hand?", which is a visual comparison — so the
   image is sized to make that easy rather than to decorate the page. */

.verdict-media {
  width: min(260px, 62vw);
  aspect-ratio: 1 / 1;
  margin: 0 auto var(--sp-5);
  overflow: hidden;
  border-radius: var(--radius);
  background: var(--surface-2);
}
.verdict-media img { width: 100%; height: 100%; object-fit: contain; object-position: center; display: block; }
@media (min-width: 40rem) { .verdict-media { width: 300px; } }

/* --- Unlockable player -------------------------------------------------
   Self-hosted video plays in place. A reward that opens a new tab on
   someone else's domain stops being a reward and becomes a redirect. */

.unlock-card { border-color: var(--accent); }

.unlock-player {
  margin: 0 0 var(--sp-4);
  border-radius: var(--radius-sm);
  overflow: hidden;
  background: #000;
  aspect-ratio: 16 / 9;
}
.unlock-player video { width: 100%; height: 100%; display: block; }

/* ==========================================================================
   THE MARKET

   Restructured to lead with objects rather than metrics. The old page
   opened on four stat boxes — accurate and completely inert. Someone
   arriving at a marketplace came to look at things; the numbers are
   context for a decision they have not started making yet, so they moved
   below the grid.
   ========================================================================== */

/* Display type. Larger and tighter than a section header, used only for
   the three structural moments on this page: Featured, the grid, and
   Rising/falling. */
.display-title {
  margin: 0;
  font-size: clamp(var(--step-2), 3.4vw, var(--step-4));
  font-weight: 600;
  letter-spacing: -0.035em;
  line-height: 1.05;
}

/* --- Featured rail ------------------------------------------------------
   Horizontal scroll with snap. Deliberately runs past the right edge of
   the measure — a rail that ends flush with the content looks like a row
   that happens to have stopped, whereas one bleeding off-screen reads as
   "there is more this way" without needing an affordance to say so. */

.market-feature { margin-bottom: var(--sp-5); }

.feature-rail {
  display: flex;
  gap: var(--sp-4);
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  padding: var(--sp-4) var(--sp-4) var(--sp-5);
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
}
.feature-rail::-webkit-scrollbar { display: none; }

@media (min-width: 40rem) { .feature-rail { padding-inline: var(--sp-6); } }
@media (min-width: 64rem) { .feature-rail { padding-inline: var(--sp-7); } }
@media (min-width: 100rem) { .feature-rail { padding-inline: var(--sp-8); } }

.feature-card {
  flex: 0 0 auto;
  width: min(21rem, 78vw);
  scroll-snap-align: start;
  display: flex;
  flex-direction: column;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  overflow: hidden;
  transition: border-color 140ms ease;
}
@media (hover: hover) { .feature-card:hover { border-color: var(--line-strong); } }

.feature-media-link { position: relative; display: block; }
.feature-media {
  position: relative;
  aspect-ratio: 4 / 5;
  overflow: hidden;
  background: var(--surface-2);
}
.feature-media img { width: 100%; height: 100%; object-fit: cover; display: block; }

.feature-body { padding: var(--sp-4); display: flex; flex-direction: column; gap: var(--sp-2); }

/* The spec line is mono and quiet: it is reference data being scanned,
   not prose being read. */
.feature-spec {
  font-family: var(--font-num);
  font-size: var(--step--2);
  color: var(--ink-faint);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.feature-title {
  font-size: var(--step-1);
  font-weight: 600;
  letter-spacing: -0.015em;
  color: inherit;
  text-decoration: none;
}
@media (hover: hover) { .feature-title:hover { text-decoration: underline; text-underline-offset: 3px; } }

.feature-foot {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: var(--sp-3);
  margin-top: var(--sp-2);
}
.feature-price {
  font-family: var(--font-num);
  font-size: var(--step-2);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  letter-spacing: -0.02em;
}
.feature-foot .is-up { color: var(--verified); }
.feature-foot .is-down { color: var(--rejected); }

/* --- Sticky filter bar --------------------------------------------------
   Sticks because it is the control you want AFTER scrolling past the
   fold, and having to scroll back up to filter is the friction that makes
   people abandon a grid. */

.market-filter {
  position: sticky;
  top: 0;
  z-index: 20;
  background: var(--paper);
  border-block: 1px solid var(--line);
  /* Opaque, not translucent: artifact media scrolling underneath a blurred
     bar turns the chips into soup exactly when they are being read. */
}

/* --- Rising / falling ---------------------------------------------------
   Asymmetric: label column on the left, data on the right. The heading
   stays put while the rows carry the information, which is how a market
   report reads rather than how a feed does. */

.movers {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--sp-4);
  padding-block: var(--sp-5);
  border-bottom: 1px solid var(--line);
  margin-bottom: var(--sp-5);
}
@media (min-width: 56rem) {
  .movers { grid-template-columns: 16rem 1fr; gap: var(--sp-6); }
  .movers-side { position: sticky; top: 6rem; align-self: start; }
}

.movers-body { display: flex; flex-direction: column; }

/*
  FOUR COLUMNS — thumb, main, sub, delta.

  The studio's edition row grew a fifth child (the Details button) and
  there was no column for it, so it landed in an implicit track and the
  row broke. `.mover--action` below adds the column rather than making
  every mover carry an empty one.
*/
.mover {
  display: grid;
  grid-template-columns: 40px 1fr auto auto;
  align-items: center;
  gap: var(--sp-3);
  padding: var(--sp-3) 0;
  border-top: 1px solid var(--line);
  text-decoration: none;
  color: inherit;
  min-height: var(--tap);
}
.mover:first-child { border-top: 0; }

/* A row that ends in a control. The button column is auto-width and the
   text column absorbs the difference. */
.mover--action { grid-template-columns: 40px 1fr auto auto auto; }

/* Below this the four text columns cannot coexist with a button without
   crushing the title, so the row stacks and the button goes full width —
   a 34px-tall control squeezed into 60px is not a tap target. */
@media (max-width: 42rem) {
  .mover--action {
    grid-template-columns: 40px 1fr auto;
    row-gap: var(--sp-3);
  }
  .mover--action > .btn {
    grid-column: 1 / -1;
    justify-self: stretch;
  }
}
@media (hover: hover) { .mover:hover .mover-title { text-decoration: underline; text-underline-offset: 3px; } }

.mover-thumb {
  width: 40px; height: 40px;
  border-radius: var(--radius-sm);
  object-fit: cover;
  background: var(--surface-2);
}
.mover-main { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.mover-title { font-size: var(--step-0); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mover-sub {
  font-family: var(--font-num);
  font-size: var(--step--2);
  color: var(--ink-faint);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.mover .bento-spark { width: 64px; height: 22px; }
.mover-delta {
  font-family: var(--font-num);
  font-size: var(--step--1);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.mover-delta.is-up { color: var(--verified); }
.mover-delta.is-down { color: var(--rejected); }
/* The sparkline follows the direction of the row rather than sitting on
   the accent, so color and shape agree. */
.mover:has(.is-down) .bento-spark path { stroke: var(--rejected); }
.mover:has(.is-down) .bento-spark .spark-fill { fill: var(--rejected-bg); }
.mover:has(.is-up) .bento-spark path { stroke: var(--verified); }
.mover:has(.is-up) .bento-spark .spark-fill { fill: var(--verified-bg); }

@media (max-width: 34rem) {
  .mover { grid-template-columns: 36px 1fr auto; }
  .mover .bento-spark { display: none; }
}

/* --- Listing grid -------------------------------------------------------
   A continuous hairline grid rather than detached cards. Auction
   catalogs are set as tables of lots, and the shared rules read as one
   inventory instead of a scattering of tiles. */

.listing-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
  gap: 0;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  overflow: hidden;
}
@media (min-width: 48rem) {
  .listing-grid { grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr)); }
}

.listing {
  display: flex;
  flex-direction: column;
  border-right: 1px solid var(--line);
  border-bottom: 1px solid var(--line);
  background: var(--surface);
  transition: background 140ms ease;
}
@media (hover: hover) { .listing:hover { background: var(--surface-2); } }

.listing-media-wrap { position: relative; }
.listing-media {
  position: relative;
  aspect-ratio: 1 / 1;
  overflow: hidden;
  background: var(--surface-2);
}
.listing-media img { width: 100%; height: 100%; object-fit: cover; display: block; }

/* Watch control. Sits over the media, top-right — the position every
   marketplace uses for it, so it needs no explanation. */
.listing-watch {
  position: absolute;
  top: var(--sp-3);
  right: var(--sp-3);
  z-index: 2;
  display: inline-flex;
  align-items: center;
  gap: 4px;
  min-height: 28px;
  padding: 0 var(--sp-2);
  font-family: var(--font-num);
  font-size: var(--step--2);
  color: var(--ink-soft);
  background: var(--glass);
  border: 1px solid var(--glass-line);
  border-radius: var(--radius-pill);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  cursor: pointer;
  transition: color 120ms ease, border-color 120ms ease;
}
@media (hover: hover) { .listing-watch:hover { color: var(--ink); border-color: var(--ink-faint); } }
.listing-watch.is-on { color: var(--accent); border-color: var(--accent); }

.listing-body {
  padding: var(--sp-4);
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
  flex: 1;
}

.listing-spec {
  font-family: var(--font-num);
  font-size: var(--step--2);
  color: var(--ink-faint);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/*
   Clamped to two lines and held at two lines.

   This had neither, so a long title wrapped to three lines and pushed
   the whole footer down while a short one sat on one — the single
   biggest source of ragged card heights in the market grid. The clamp
   caps the tall case; min-height fills the short one.
*/
.listing-title {
  font-size: var(--step-0);
  font-weight: 600;
  line-height: 1.3;
  letter-spacing: -0.01em;
  color: inherit;
  text-decoration: none;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  min-height: 2.6em;   /* 2 lines × 1.3 */
}
@media (hover: hover) { .listing-title:hover { text-decoration: underline; text-underline-offset: 3px; } }

/* Price and action split onto opposite ends of the row. The number and
   the commitment should never be mistaken for one another — a price that
   looks like a button is a misclick waiting to happen. */
/*
   The footer holds a price, an optional subline and optional buttons.
   All three vary: a sold-out card has no buttons, an unreleased one has
   a countdown where another has nothing. Reserving a floor here means
   the price sits on the same baseline whether or not the rest showed up.

   min-height, not a fixed height — a wrapped two-button row is still
   allowed to grow rather than being clipped.
*/
.listing-foot {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: var(--sp-3);
  margin-top: auto;
  padding-top: var(--sp-3);
  flex-wrap: wrap;
  min-height: 3.5rem;
}
/* Same idea one level down: price plus a subline that is often absent. */
.listing-price-block {
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  min-height: 2.75rem;
}
.listing-price {
  font-family: var(--font-num);
  font-size: var(--step-1);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  letter-spacing: -0.02em;
}
.listing-actions { display: flex; gap: var(--sp-2); }

/* ==========================================================================
   PRODUCT PAGE — public, no account required

   Exists because the funnel was broken: clicking a listing hit an
   ownership-gated endpoint and refused the shopper. Media on the left,
   decision on the right, evidence below.
   ========================================================================== */

/*
  minmax(0, 1fr), NOT 1fr — this is the mobile overflow.

  A `1fr` grid track carries an implicit `min-width: auto`, which means it
  refuses to shrink below the INTRINSIC width of its contents. A gallery
  photo is a few thousand pixels wide intrinsically, so the track grew to
  fit it and pushed the column wider than the screen: the image kept its
  left gutter and ran straight off the right edge, and the page scrolled
  sideways. Nothing was being cropped — `object-fit: contain` was working
  correctly against a box that was simply too wide.

  `minmax(0, 1fr)` sets that floor to zero, so the track takes the width
  available and the image's `max-width: 100%` finally has a sane 100% to
  measure against.

  The two-column rule below already did this. Only the single-column
  default — the mobile case — was missed, which is exactly why this
  looked like a phone-only bug.
*/
.product {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--sp-5);
  margin-top: var(--sp-4);
}
@media (min-width: 56rem) {
  .product { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--sp-7); align-items: start; }
}

.product-media-box {
  position: relative;
  aspect-ratio: 1 / 1;
  overflow: hidden;
  border-radius: var(--radius);
  background: var(--surface-2);
  border: 1px solid var(--line);
}
.product-media-box img { width: 100%; height: 100%; object-fit: contain; object-position: center; display: block; background: #000; }

.product-prop-logo { display: block; max-width: 12rem; height: auto; margin-bottom: var(--sp-3); }

.product-title {
  font-size: clamp(var(--step-2), 4vw, var(--step-4));
  font-weight: 600;
  letter-spacing: -0.035em;
  line-height: 1.05;
  margin: 0 0 var(--sp-3);
  text-wrap: balance;
}

.product-badges { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; margin-bottom: var(--sp-4); }
.product-edition {
  font-family: var(--font-num);
  font-size: var(--step-1);
  font-variant-numeric: tabular-nums;
  color: var(--ink-soft);
}
.product-lede { max-width: 52ch; margin-bottom: var(--sp-5); }
/* The description and the lede are alternatives in the same slot, so
   they carry the same trailing space — otherwise the gap above the buy
   panel changes depending on whether a studio wrote a description. */

/*
  The buy panel. Bordered and set apart, because it is the only part of
  this page that commits anyone to anything — and that separation needs
  space above it as well as a border around it. The description ran
  straight into the panel, so the price appeared to be the last line of
  the copy rather than the start of a decision.
*/
.buy-panel {
  margin-top: var(--sp-6);
  padding: var(--sp-5);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius);
  background: var(--surface);
}
.buy-row {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: var(--sp-4);
  flex-wrap: wrap;
}
.buy-price {
  font-family: var(--font-num);
  font-size: var(--step-3);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  letter-spacing: -0.03em;
  line-height: 1;
}
.buy-row .btn { width: auto; min-width: 9rem; margin: 0; }

/* ==========================================================================
   ACTIVITY FEED
   ========================================================================== */

.topbar-bell {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 32px; height: 32px;
  background: none;
  border: 1px solid var(--line);
  border-radius: var(--radius-pill);
  color: var(--ink-faint);
  font-size: var(--step-0);
  cursor: pointer;
}
@media (hover: hover) { .topbar-bell:hover { color: var(--ink); border-color: var(--line-strong); } }
.topbar-bell.has-unread { color: var(--accent); border-color: var(--accent); }
.topbar-bell-count {
  position: absolute;
  top: -5px; right: -5px;
  min-width: 16px; height: 16px;
  padding: 0 3px;
  font-family: var(--font-num);
  font-size: 10px;
  line-height: 16px;
  color: var(--paper);
  background: var(--accent);
  border-radius: var(--radius-pill);
}
.topbar-bell-count:empty { display: none; }

.feed { border-top: 1px solid var(--line); }

.feed-row {
  display: grid;
  grid-template-columns: auto 36px 1fr;
  align-items: center;
  gap: var(--sp-3);
  padding: var(--sp-3) var(--sp-2);
  border-bottom: 1px solid var(--line);
  text-decoration: none;
  color: inherit;
  min-height: var(--tap);
}
@media (hover: hover) { a.feed-row:hover { background: var(--surface-2); } }

/* Unread is a rule, not a background wash — a highlighted row that stays
   highlighted for a second after you arrive is noise, but a marker on the
   edge reads instantly and disappears cleanly. */
.feed-row.is-unread { box-shadow: inset 2px 0 0 var(--accent); }

.feed-mark {
  font-family: var(--font-num);
  font-size: var(--step--1);
  color: var(--ink-faint);
  width: 1rem;
  text-align: center;
}
.feed-row.is-unread .feed-mark { color: var(--accent); }

.feed-thumb { width: 36px; height: 36px; border-radius: var(--radius-sm); object-fit: cover; background: var(--surface-2); }
.feed-thumb--none { display: block; background: var(--surface-2); border-radius: var(--radius-sm); }

.feed-main { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.feed-body { font-size: var(--step--1); }
.feed-when { font-family: var(--font-num); font-size: var(--step--2); color: var(--ink-faint); }

/* --- Contested claim ---------------------------------------------------
   The only item in the feed that expires against the reader, so it is
   given a card rather than a row and a color that says so. */

.contest-card { border-color: var(--rejected); }
.contest-head { display: flex; gap: var(--sp-4); align-items: flex-start; }
.contest-thumb {
  width: 64px; height: 64px;
  flex-shrink: 0;
  border-radius: var(--radius-sm);
  object-fit: cover;
  background: var(--surface-2);
}

/* --- Shop ------------------------------------------------------------- */

/* Early-access flag on a tile the viewer can buy before anyone else. */
.listing-flag {
  position: absolute;
  left: var(--sp-3);
  top: var(--sp-3);
  z-index: 2;
  padding: 2px var(--sp-2);
  font-family: var(--font-num);
  font-size: var(--step--2);
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: #0b0b0c;
  background: var(--accent);
  border-radius: var(--radius-sm);
}

/* Segmented control — the seller filter.

   Same visual language as the theme toggle, because they do the same
   job: pick one of a small fixed set, with the current choice obvious
   without reading it. */
.seg {
  display: inline-flex;
  padding: 2px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius-pill);
}
.seg button {
  min-height: 30px;
  padding: 0 var(--sp-3);
  background: none;
  border: 0;
  border-radius: var(--radius-pill);
  color: var(--ink-faint);
  font: inherit;
  font-size: var(--step--1);
  cursor: pointer;
}
.seg button.is-active { background: var(--surface-2); color: var(--ink); }
@media (hover: hover) { .seg button:hover { color: var(--ink); } }

/* --- Key-art chip row --------------------------------------------------
   Scrolls horizontally on a phone rather than wrapping into four rows of
   artwork, which would push the listings themselves below the fold. */

.chips--art {
  display: flex;
  flex-wrap: nowrap;
  gap: var(--sp-2);
  overflow-x: auto;
  padding-bottom: var(--sp-2);
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
}
.chips--art::-webkit-scrollbar { display: none; }
.chips--art > * { flex-shrink: 0; }
@media (min-width: 64rem) { .chips--art { flex-wrap: wrap; overflow-x: visible; } }

/* ==================================================================== *
 * ATTRIBUTION — "Studio / Property"
 *
 * The eyebrow above an artifact title. The two names are weighted
 * differently on purpose: the studio is who you are buying from and
 * carries across a whole catalog, the property is this one title. Same
 * size, different color, so the pair reads as a hierarchy rather than as
 * one long string with a slash in the middle.
 * ==================================================================== */

.attrib {
  font-family: var(--font-num);
  font-size: var(--step--2);
  text-transform: uppercase;
  letter-spacing: 0.16em;
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.5em;
  margin-bottom: var(--sp-2);
}
.attrib-studio { color: var(--ink-soft); }
.attrib-prop   { color: var(--ink-faint); }
.attrib-sep    { color: var(--ink-faint); opacity: 0.5; }

/* ==================================================================== *
 * DESCRIPTION AND SPECIFICATIONS
 * ==================================================================== */

.artifact-desc {
  margin: var(--sp-4) 0 var(--sp-5);
  max-width: 62ch;
}
.artifact-desc p {
  margin: 0 0 var(--sp-3);
  color: var(--ink-soft);
  line-height: 1.65;
}
.artifact-desc p:last-child { margin-bottom: 0; }

.spec-block { margin-top: var(--sp-5); }

.spec-head {
  font-size: var(--step--1);
  font-weight: 600;
  letter-spacing: 0.02em;
  margin: 0 0 var(--sp-3);
}

.spec-list {
  margin: 0;
  padding: 0;
  border-top: 1px solid var(--line);
}

/*
  A definition list, not a table.

  Semantically right — these are term/description pairs — and it avoids
  the thing tables do badly on a 360px screen, which is refusing to wrap
  and forcing a horizontal scrollbar onto the whole page. The row is a
  flex container that wraps, so a long value ("Cotton rag with archival
  pigment ink") drops under its own label instead of squeezing it.
*/
.spec-row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2) var(--sp-4);
  justify-content: space-between;
  padding: var(--sp-3) 0;
  border-bottom: 1px solid var(--line);
  font-size: var(--step--1);
}
.spec-row dt {
  color: var(--ink-soft);
  flex: 0 0 auto;
}
.spec-row dd {
  margin: 0;
  color: var(--ink);
  text-align: right;
  flex: 1 1 12rem;
  min-width: 0;
  overflow-wrap: anywhere;
}

@media (max-width: 26rem) {
  /* Below this the two columns stop being two columns honestly. */
  .spec-row dd { text-align: left; flex-basis: 100%; }
}

/* ==================================================================== *
 * EDITION CARDS — collapsed primary stock
 *
 * One card standing for several identical units. The distinction from a
 * resale card has to be visible at a glance, because they are different
 * purchases: this one is "a unit from this edition", the other is "this
 * exact unit".
 * ==================================================================== */

.listing-avail {
  font-family: var(--font-num);
  font-variant-numeric: tabular-nums;
  font-size: var(--step--2);
  color: var(--ink-soft);
}

.listing-next {
  display: inline-flex;
  align-items: baseline;
  gap: 0.4em;
  font-family: var(--font-num);
  font-variant-numeric: tabular-nums;
  font-size: var(--step--2);
  color: var(--ink-faint);
}
.listing-next strong { color: var(--ink-soft); font-weight: 600; }

/* Scarcity, shown as a bar rather than only as a sentence. */
.avail-bar {
  height: 3px;
  border-radius: 2px;
  background: var(--line);
  overflow: hidden;
  margin-top: var(--sp-2);
}
.avail-bar span {
  display: block;
  height: 100%;
  background: var(--accent, var(--ink-soft));
  transition: width 240ms ease;
}

.edition-facts {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2) var(--sp-5);
  margin: var(--sp-4) 0 0;
  padding: var(--sp-4) 0 0;
  border-top: 1px solid var(--line);
}
.edition-fact { display: flex; flex-direction: column; gap: 2px; }
.edition-fact-n {
  font-family: var(--font-num);
  font-variant-numeric: tabular-nums;
  font-size: var(--step-0);
  font-weight: 600;
}
.edition-fact-l {
  font-size: var(--step--2);
  color: var(--ink-faint);
  text-transform: uppercase;
  letter-spacing: 0.1em;
}

.reserved-note {
  margin-top: var(--sp-3);
  font-size: var(--step--2);
  color: var(--ink-faint);
}

/* Spec inputs in the studio, and in the details dialog. */
.spec-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--sp-3);
}
@media (min-width: 34rem) {
  .spec-grid { grid-template-columns: 1fr 1fr; }
}
.spec-field { margin-bottom: 0; }

.spec-extra {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr) auto;
  gap: var(--sp-2);
  align-items: center;
  margin-top: var(--sp-2);
}
.spec-extra-x {
  border: 1px solid var(--line);
  background: none;
  color: var(--ink-faint);
  border-radius: var(--radius);
  width: var(--tap);
  height: var(--tap);
  font-size: var(--step-0);
  cursor: pointer;
}
.spec-extra-x:hover { color: var(--rejected); border-color: var(--rejected); }

/* The mint form's description box. The global input rule doesn't reach
   textareas, so they need the treatment applied explicitly. */
textarea {
  width: 100%;
  /* Carried over from the earlier duplicate declaration this replaced. */
  line-height: 1.5;
  font: inherit;
  font-size: var(--step--1);
  color: var(--ink);
  background: var(--paper);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: var(--sp-3);
  resize: vertical;
  min-height: 5.5rem;
}
textarea:focus-visible {
  outline: 2px solid var(--accent, var(--ink));
  outline-offset: 1px;
  border-color: transparent;
}

/* ==================================================================== *
 * GALLERY VIDEO — fills its frame
 *
 * It used to be capped at the source's own pixel width so it could never
 * be upscaled. Correct in the abstract and wrong in practice: a 16:9 loop
 * sat marooned in the middle of a 16:9 stage with bars around it, which
 * reads as a bug rather than as restraint.
 *
 * It fills the frame. Upscaling a small file does soften it, so the
 * honest response is to make that visible rather than to hide it by
 * shrinking the player: the caption states the real resolution, and the
 * studio is warned at upload when an export is below 720p. The fix for a
 * soft video is a better export, not a smaller window.
 * ==================================================================== */

/* Only what differs from the img/video rule above: no black backing. The
   stage is frameless now, and a black slab behind a letterboxed video
   reinstates exactly the rectangle that was just removed. Sizing is NOT
   repeated here — that was how three competing declarations of this
   selector accumulated in the first place. */
.gallery-slide video { background: transparent; }

/* Media chip detail — resolution, duration, size. */
.media-chip-main { display: flex; flex-direction: column; min-width: 0; gap: 1px; }
.media-chip-meta {
  font-family: var(--font-num);
  font-variant-numeric: tabular-nums;
  font-size: var(--step--2);
  color: var(--ink-faint);
}
.media-chip-warn {
  flex: 0 0 auto;
  font-size: var(--step--2);
  padding: 2px var(--sp-2);
  border-radius: 999px;
  color: var(--pending, var(--ink-soft));
  border: 1px solid var(--line);
  cursor: help;
}

/* ==================================================================== *
 * SHELVES — named rows on the marketplace landing view
 *
 * Horizontal rails rather than a wall of grid. Each row is a claim, and
 * a rail makes it obvious the row is a selection rather than everything
 * that exists — a grid implies completeness it does not have.
 * ==================================================================== */

.shelf { margin-bottom: var(--sp-7); }

.shelf-head { align-items: flex-end; }
.shelf-title-block { display: flex; flex-direction: column; gap: 2px; min-width: 0; }

.shelf-eyebrow {
  font-family: var(--font-num);
  font-size: var(--step--2);
  text-transform: uppercase;
  letter-spacing: 0.16em;
  color: var(--accent, var(--ink-faint));
}

.shelf-all {
  flex: 0 0 auto;
  font-size: var(--step--1);
  color: var(--ink-soft);
  text-decoration: none;
  white-space: nowrap;
}
.shelf-all:hover { color: var(--ink); }

/*
  The rail bleeds to the viewport edge on small screens.

  A horizontally scrolling row that stops at the page gutter looks like a
  row that has ended. Running it to the edge is the thing that says
  "there is more this way" without needing an arrow to say it.
*/
.shelf-rail {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: minmax(15rem, 16rem);
  gap: var(--sp-4);
  overflow-x: auto;
  scroll-snap-type: x proximity;
  padding-bottom: var(--sp-2);
  margin-inline: calc(var(--sp-5) * -1);
  padding-inline: var(--sp-5);
  scrollbar-width: thin;
}
/*
   The rail's grid items stretch to the tallest card — but .listing sits
   INSIDE .shelf-item rather than being the grid item itself, so it kept
   its own content height and the cards along the rail ended at different
   points even though their containers matched. Making the wrapper a
   flex column and letting the card fill it passes the stretch through.
*/
.shelf-item {
  scroll-snap-align: start;
  display: flex;
  flex-direction: column;
}
.shelf-item > .listing { flex: 1; width: 100%; }

@media (min-width: 60rem) {
  .shelf-rail { grid-auto-columns: minmax(16rem, 18rem); }
}

/* A property shelf gets that property's accent, which is the
   white-label groundwork doing something useful in the meantime. */
.shelf--property .display-title { color: var(--ink); }

/* ==================================================================== *
 * CATEGORY NAV
 * ==================================================================== */

.cat-nav {
  position: sticky;
  top: var(--topbar-h, 56px);
  z-index: 20;
  background: var(--paper);
  border-bottom: 1px solid var(--line);
  backdrop-filter: blur(12px);
}
.cat-nav-inner {
  display: flex;
  gap: var(--sp-1);
  overflow-x: auto;
  padding-top: var(--sp-3);
  padding-bottom: var(--sp-3);
  scrollbar-width: none;
}
.cat-nav-inner::-webkit-scrollbar { display: none; }

.cat-link {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
  min-height: 34px;
  padding: 0 var(--sp-3);
  border-radius: 999px;
  font-size: var(--step--1);
  color: var(--ink-soft);
  text-decoration: none;
  white-space: nowrap;
  transition: background 140ms ease, color 140ms ease;
}
.cat-link:hover { background: var(--surface-2); color: var(--ink); }
/*
  --paper, not --bg. --bg was never defined anywhere in this file.

  An undefined custom property makes the whole declaration invalid at
  computed-value time, so `color` fell back to inherited — white text on
  the white active pill. The "All" chip rendered as an empty capsule and
  looked like a loading state.

  Undefined variables fail silently and locally, which is what makes them
  worth grepping for rather than trusting.
*/
.cat-link.is-active { background: var(--ink); color: var(--paper); }

.cat-count {
  font-family: var(--font-num);
  font-variant-numeric: tabular-nums;
  font-size: var(--step--2);
  opacity: 0.6;
}

/* ==================================================================== *
 * CARD — state label, price slot, hover swap
 * ==================================================================== */

/*
  Reserved height, not conditional rendering.

  Cards sit side by side in a rail and a grid. If the state line only
  existed on cards that had a state, every title in the row would sit at
  a different height and the row would read as broken rather than as
  varied.
*/
.listing-state {
  min-height: 1.1em;
  margin-bottom: var(--sp-1);
  font-family: var(--font-num);
  font-size: var(--step--2);
  text-transform: lowercase;
  letter-spacing: 0.1em;
  color: var(--ink-faint);
}
.listing-state.is-early  { color: var(--accent, var(--ink)); }
.listing-state.is-last,
.listing-state.is-few    { color: var(--pending, var(--ink-soft)); }
.listing-state.is-gone   { color: var(--ink-faint); }
.listing-state.is-mine   { color: var(--ink-soft); }

.listing-price.is-gone {
  color: var(--ink-faint);
  font-variant-numeric: normal;
}

/* A sold-out card stays legible rather than being dimmed into
   illegibility — it is still evidence that the edition existed and sold,
   which is exactly what a collector browsing scarcity wants to see. */
.listing.is-gone .listing-media { opacity: 0.55; }
.listing.is-gone:hover .listing-media { opacity: 0.75; }

/* ---- Hover swap ---- */

.listing-media-link { position: relative; display: block; }

.listing-media-alt {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0;
  transition: opacity 220ms ease;
  pointer-events: none;
}
.listing-media-link:hover .listing-media-alt,
.listing-media-link:focus-visible .listing-media-alt { opacity: 1; }

/* Touch has no hover, so the second frame would be permanently invisible
   and the download permanently wasted. Not rendered as a swap there —
   the gallery on the product page is where the rest of the assets live. */
@media (hover: none) {
  .listing-media-alt { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  .listing-media-alt { transition: none; }
}

/* ==================================================================== *
 * THE POSTER LAYER
 *
 * The still beneath the video. Without this the tile was empty until
 * playback started, because the only poster was the video's own `poster`
 * attribute and the video is transparent until it plays.
 *
 * It stays visible underneath rather than being swapped out — a video
 * that fails to load, is still buffering, or is blocked by a data-saver
 * setting leaves the photograph in place instead of a hole.
 * ==================================================================== */

.has-video .art-media-poster {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
  display: block;
}

/* Detail surfaces show the whole frame rather than cropping it, and the
   poster has to agree with the video above it or the image visibly
   jumps at the moment playback starts. */
.product-media-box.has-video .art-media-poster,
.detail-media.has-video .art-media-poster,
.verdict-media.has-video .art-media-poster {
  object-fit: contain;
  background: transparent;
}

/* An inline control that reads as prose. Used where an action belongs
   inside a sentence rather than as a button beside it. */
/* .link-btn is declared once, above. A second copy lived here and won on
   source order — dropping padding and min-height, which took the tap
   target with them. An inline control still has to be tappable. */

/* ==================================================================== *
 * COUNTDOWN
 * ==================================================================== */

.countdown {
  display: flex;
  align-items: baseline;
  gap: var(--sp-2);
  flex-wrap: wrap;
  font-family: var(--font-num);
  font-variant-numeric: tabular-nums;
  font-size: var(--step--2);
}

.countdown-label,
.countdown-expired { color: var(--ink-faint); }

.countdown-units { display: inline-flex; gap: var(--sp-2); }

.cd-part { display: inline-flex; align-items: baseline; gap: 1px; }

/*
  Tabular figures AND a fixed minimum width.

  The seconds digit changes every second. Without both of these the row
  reflows on every tick — "9s" is narrower than "10s" — and everything
  beside it jitters. This is the difference between a clock and a twitch.
*/
.cd-part b {
  font-weight: 600;
  color: var(--ink);
  min-width: 1.4em;
  text-align: right;
}
.cd-part i {
  font-style: normal;
  color: var(--ink-faint);
  font-size: 0.85em;
}

/* On a buy panel the clock is the main event, not a footnote. */
.buy-panel .countdown { font-size: var(--step--1); }
.buy-panel .cd-part b { font-size: var(--step-1); min-width: 1.6em; }

@media (prefers-reduced-motion: reduce) {
  /* The digits still update — a clock that does not tick is not a clock.
     There is simply nothing animated about them to disable. */
  .countdown { transition: none; }
}

/* A pre-order says so before the button, not under it. */
.preorder-note {
  margin-top: var(--sp-4);
  padding: var(--sp-3) var(--sp-4);
  border: 1px solid var(--line);
  border-left: 2px solid var(--accent, var(--ink));
  border-radius: var(--radius);
  font-size: var(--step--1);
  color: var(--ink-soft);
  line-height: 1.55;
}
.preorder-note strong { color: var(--ink); }

.listing-state.is-pre { color: var(--accent, var(--ink-soft)); }


/* ==================================================================== *
 * AVATAR
 *
 * One component for the account page and the public profile, so a photo
 * cannot be round in one place and square in the other.
 * ==================================================================== */

.avatar {
  --size: 44px;
  width: var(--size);
  height: var(--size);
  flex: 0 0 auto;
  border-radius: 50%;
  overflow: hidden;
  display: grid;
  place-items: center;
  background: var(--surface-2);
  border: 1px solid var(--line);
  font-family: var(--font-num);
  font-weight: 600;
  font-size: calc(var(--size) * 0.34);
  letter-spacing: 0.04em;
  color: var(--ink-faint);
  text-transform: uppercase;
  user-select: none;
}
.avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
.avatar--lg { --size: 96px; }

.avatar-row {
  display: flex;
  align-items: center;
  gap: var(--sp-4);
  flex-wrap: wrap;
}
.avatar-actions {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  flex-wrap: wrap;
  min-width: 0;
}
/* The hint owns the full width beneath the buttons rather than competing
   with them for horizontal space. */
.avatar-actions .fine { flex-basis: 100%; }

/* ==================================================================== *
 * DESCRIPTION PREVIEW — studio
 *
 * Rendered through the same descriptionBlock() the artifact pages use, so
 * it inherits .artifact-desc and cannot drift from the real thing.
 * ==================================================================== */

.desc-preview {
  margin-top: var(--sp-3);
  padding: var(--sp-3) var(--sp-4);
  border: 1px dashed var(--line);
  border-radius: var(--radius);
  background: var(--surface-2);
}
.desc-preview-label {
  display: block;
  font-family: var(--font-num);
  font-size: var(--step--2);
  text-transform: uppercase;
  letter-spacing: 0.14em;
  color: var(--ink-faint);
  margin-bottom: var(--sp-2);
}
/* The preview is a sample, not the page — it should not inherit the
   generous margins the real block uses to separate itself from the
   buy panel. */
.desc-preview .artifact-desc { margin: 0; }

/* Emphasis inside a description. Bold lifts to full ink so it actually
   reads as emphasis against the softer body color; italic stays at the
   body color, since the slant is doing the work. */
.artifact-desc strong { color: var(--ink); font-weight: 600; }
.artifact-desc em { font-style: italic; }

/* A visual break between the condition pills and the category pills.
   They answer different questions and should not read as one list. */
.cat-sep {
  flex: 0 0 auto;
  align-self: center;
  width: 1px;
  height: 18px;
  margin: 0 var(--sp-2);
  background: var(--line);
}


/* ==================================================================== *
 * ACCOUNT PAGE
 *
 * Was nine peer-level headings in one scroll, all the same weight. Now
 * four groups behind a summary and a jump list.
 * ==================================================================== */

/* --- Identity summary -------------------------------------------------
   Read-only on purpose. Everything in it is edited below, and a summary
   that is also a form is neither. */
.acct-summary {
  display: flex;
  align-items: center;
  gap: var(--sp-4);
  flex-wrap: wrap;
  padding: var(--sp-4);
  margin-bottom: var(--sp-6);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--surface);
}
.acct-summary-main { flex: 1; min-width: 0; }

.acct-name {
  font-size: var(--step-1);
  font-weight: 600;
  letter-spacing: -0.01em;
  overflow-wrap: anywhere;
}
.acct-meta {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-1) var(--sp-3);
  margin-top: var(--sp-1);
  font-size: var(--step--1);
  color: var(--ink-soft);
}
.acct-qfid {
  font-family: var(--font-num);
  font-variant-numeric: tabular-nums;
  color: var(--ink-faint);
}

/* --- Two columns, above 60rem ---------------------------------------- */
.acct-layout { display: block; }

.acct-nav { display: none; }

@media (min-width: 60rem) {
  .acct-layout {
    display: grid;
    grid-template-columns: 12rem minmax(0, 1fr);
    gap: var(--sp-7);
    align-items: start;
  }
  /*
     Sticky, so Security stays reachable from anywhere on the page —
     which is the entire reason the nav exists. Hidden below the
     breakpoint rather than stacked: on a phone it would be one more
     thing to scroll past on the way to the thing you wanted.
  */
  .acct-nav {
    display: flex;
    flex-direction: column;
    gap: 2px;
    position: sticky;
    top: calc(var(--topbar-h, 56px) + var(--sp-4));
  }
  .acct-nav a {
    padding: var(--sp-2) var(--sp-3);
    border-radius: var(--radius);
    font-size: var(--step--1);
    color: var(--ink-soft);
    text-decoration: none;
    transition: background 140ms ease, color 140ms ease;
  }
  .acct-nav a:hover { background: var(--surface-2); color: var(--ink); }
}

/* --- Groups and cards ------------------------------------------------- */

.acct-group { scroll-margin-top: calc(var(--topbar-h, 56px) + var(--sp-5)); }
.acct-group + .acct-group { margin-top: var(--sp-7); }

.acct-group-title {
  font-size: var(--step-1);
  font-weight: 600;
  letter-spacing: -0.01em;
  margin: 0 0 var(--sp-2);
  padding-bottom: var(--sp-2);
  border-bottom: 1px solid var(--line);
}
.acct-group-note { margin: 0 0 var(--sp-4); max-width: 62ch; }

.acct-card {
  padding: var(--sp-4);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--surface);
}
.acct-card + .acct-card,
.consent-grid + .acct-card { margin-top: var(--sp-4); }

.acct-card h3 {
  font-size: var(--step-0);
  font-weight: 600;
  margin: 0 0 var(--sp-2);
}
.acct-card > .fine { margin: 0 0 var(--sp-4); max-width: 62ch; }

/* Destructive actions do not get the same weight as a theme picker. */
.acct-card--danger { border-color: color-mix(in srgb, var(--rejected) 40%, var(--line)); }
.acct-card--danger h3 { color: var(--rejected); }

/* --- Shared bits ------------------------------------------------------ */

/* First and last name side by side above 30rem — they are one answer. */
.field-row { display: grid; gap: var(--sp-3); }
@media (min-width: 30rem) { .field-row { grid-template-columns: 1fr 1fr; } }
.field-row .field { margin-bottom: 0; }

/*
  Actions sit inline with their confirmation, not on their own line.

  Every save button on this page used to be btn-block — full width, which
  on a settings card reads as the primary action of the whole page rather
  than of the field above it.
*/
.acct-actions {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  flex-wrap: wrap;
  margin-top: var(--sp-4);
}
.acct-actions .fine { margin: 0; }

.acct-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-4);
  flex-wrap: wrap;
}

.acct-toggle {
  display: flex;
  align-items: flex-start;
  gap: var(--sp-3);
  min-height: var(--tap);
  margin-top: var(--sp-4);
  padding-top: var(--sp-4);
  border-top: 1px solid var(--line);
  cursor: pointer;
}
.acct-toggle .fine { margin: 0; max-width: 58ch; }

/* ==================================================================== *
 * PROPERTY HEADER — studio
 *
 * The controls that act on a property sit on the property, not in a
 * settings screen elsewhere.
 * ==================================================================== */

.prop-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-4);
  flex-wrap: wrap;
  padding-bottom: var(--sp-4);
  margin-bottom: var(--sp-5);
  border-bottom: 1px solid var(--line);
}
.prop-head-main { min-width: 0; }
.prop-head-name {
  font-size: var(--step-1);
  font-weight: 600;
  letter-spacing: -0.01em;
  overflow-wrap: anywhere;
}
.prop-head-sub {
  margin-top: 2px;
  font-size: var(--step--1);
  color: var(--ink-faint);
}
/* The id is shown deliberately: it is what a rename does NOT change, and
   seeing it makes that behavior predictable rather than surprising. */
.prop-head-sub code {
  font-family: var(--font-num);
  font-size: 0.92em;
}
.prop-head-actions { display: flex; gap: var(--sp-2); flex-shrink: 0; }

/* ==================================================================== *
 * TOPBAR AVATAR
 *
 * Replaces the "Account" text link. A face is faster to find than a word
 * — it is the one item in the nav that is about you rather than about
 * the catalog — and it doubles as the answer to "am I signed in".
 * ==================================================================== */

.topbar-avatar {
  --size: 30px;
  flex: 0 0 auto;
  margin-left: var(--sp-1);
  font-size: 11px;
  letter-spacing: 0.02em;
  text-decoration: none;
  border-color: var(--line);
  transition: border-color 140ms ease, transform 140ms ease;
}
.topbar-avatar:hover { border-color: var(--line-strong); }
.topbar-avatar:focus-visible {
  outline: 2px solid var(--accent, var(--ink));
  outline-offset: 2px;
}
/* On the account page itself, the ring says you are already here — the
   same job the other nav links do by going full-ink. */
.topbar-avatar[aria-current="page"] {
  border-color: var(--ink);
  box-shadow: 0 0 0 1px var(--ink);
}

/* A hair smaller on phones, where the nav is tight. Still above the 24px
   that makes a target findable, and it sits inside the 44px tap row. */
@media (max-width: 30rem) {
  .topbar-avatar { --size: 26px; font-size: 10px; }
}

/* ==================================================================== *
 * PROVENANCE TIMELINE
 *
 * Three columns: what happened, when — and who, on the right. The person
 * gets its own column so the ownership chain can be read straight down
 * without parsing every line, which is the question this section exists
 * to answer.
 * ==================================================================== */

.prov-list { list-style: none; margin: 0; padding: 0; }

.prov-row {
  display: grid;
  grid-template-columns: 1.25rem minmax(0, 1fr) auto;
  align-items: baseline;
  gap: var(--sp-3);
  padding: var(--sp-3) 0;
  border-bottom: 1px solid var(--line);
}
.prov-row:last-child { border-bottom: 0; }

.prov-mark {
  font-family: var(--font-num);
  color: var(--ink-faint);
  text-align: center;
}
.prov-row.is-verified .prov-mark { color: var(--verified); }
.prov-row.is-rejected .prov-mark { color: var(--rejected); }
.prov-row.is-minted .prov-mark   { color: var(--accent, var(--ink-soft)); }
/* Ownership by tap. Same weight as an NXOS purchase, because it is the
   same event in the chain of custody — the object reached a collector. */
.prov-row.is-registered .prov-mark { color: var(--accent, var(--ink-soft)); }

.prov-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.prov-main .timeline-title { font-weight: 600; font-size: var(--step--1); }
.prov-main .fine { margin: 0; }

.prov-actor {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 2px;
  text-align: right;
  min-width: 0;
}
.prov-who {
  font-weight: 600;
  font-size: var(--step--1);
  overflow-wrap: anywhere;
}
.prov-from { font-size: var(--step--2); color: var(--ink-faint); }

/* Below this the third column has nowhere to go, so the person moves
   under the event rather than being squeezed into a two-character sliver. */
@media (max-width: 34rem) {
  .prov-row { grid-template-columns: 1.25rem minmax(0, 1fr); }
  .prov-actor {
    grid-column: 2;
    align-items: flex-start;
    text-align: left;
    margin-top: 2px;
  }
}

/* ==================================================================== *
 * COUNTERFEIT WATCH — partner dashboard only
 * ==================================================================== */

.cf-tag {
  padding: var(--sp-4);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--surface);
  margin-bottom: var(--sp-3);
}
.cf-tag-head {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: var(--sp-3);
  flex-wrap: wrap;
  margin-bottom: var(--sp-3);
}
.cf-uid {
  font-family: var(--font-num);
  font-weight: 600;
  overflow-wrap: anywhere;
}
.cf-counts { display: flex; gap: var(--sp-2); flex-wrap: wrap; }
.cf-pill {
  font-family: var(--font-num);
  font-size: var(--step--2);
  padding: 2px var(--sp-2);
  border: 1px solid var(--line);
  border-radius: 999px;
  color: var(--ink-soft);
  white-space: nowrap;
}
/* Forgery is the signal; replay is mostly noise from real owners. The
   colour difference is the whole point of separating them. */
.cf-pill.is-forged { color: var(--rejected); border-color: var(--rejected); }

.cf-scans { list-style: none; margin: 0; padding: 0; }
.cf-scans li {
  display: grid;
  grid-template-columns: minmax(7rem, auto) 5.5rem minmax(0, 1fr) minmax(0, auto);
  gap: var(--sp-3);
  align-items: baseline;
  padding: var(--sp-2) 0;
  border-top: 1px solid var(--line);
  font-size: var(--step--1);
}
.cf-when { font-family: var(--font-num); color: var(--ink-soft); }
.cf-kind { font-size: var(--step--2); text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-faint); }
.cf-kind.is-forged { color: var(--rejected); }
.cf-who { overflow-wrap: anywhere; }
.cf-src { font-family: var(--font-num); font-size: var(--step--2); color: var(--ink-faint); text-align: right; overflow-wrap: anywhere; }

@media (max-width: 44rem) {
  .cf-scans li { grid-template-columns: minmax(0, 1fr) auto; row-gap: 2px; }
  .cf-src { grid-column: 1 / -1; text-align: left; }
}

/* --- Studio profiles -------------------------------------------------- */

/*
  The studio header: logo beside the name, stacking on narrow screens.
  align-items:center rather than start because a wordmark logo is
  optically centered against the name, not hung from its cap height.
*/
.studio-head {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--sp-4);
  margin-top: var(--sp-4);
}
/*
  A fixed 180 × 180 box, with the logo fitted inside it.

  Fixed dimensions rather than max-* so every studio header is the same
  height whatever shape its logo is — a wordmark is wide and a monogram
  is square, and sizing to the asset made the name and bio beside it jump
  from one studio page to the next.

  `object-fit: contain` is what keeps that from being a distortion: the
  box is square, most logos are not, so the image fits within and centers
  rather than stretching to fill. `flex-shrink: 0` because the header is
  a flex row and the text beside it would otherwise squeeze the box below
  180 on narrow screens — the one place a "fixed" size quietly stops
  being fixed.
*/
.studio-logo {
  /*
    Raised 180 -> 200 to match the index card.

    The hero logo being SMALLER than the thumbnail on the list page is
    backwards — a visitor arrives at the profile expecting more of the
    studio, not less. Aligning them also removes a second magic number.
  */
  width: 200px;
  height: 200px;
  flex: 0 0 200px;
  object-fit: contain;
  object-position: center;
  display: block;
}

/*
  THE GRID IS SIZED TO A POSTER, which is what makes the rest work.

  A 400px-tall poster at 2:3 is 267px wide. The properties were laid out
  in the shared three-column grid, whose cells run ~380px on a desktop —
  so every card carried about 110px of dead gutter, and the only ways out
  were cropping the artwork or letting the cards get tall again.

  Columns capped near the poster's own width remove the choice: the
  artwork very nearly fills its card, nothing is cropped, and the height
  stays fixed. auto-fill rather than a fixed count so the row reflows
  by available space instead of by a number that is right at one
  breakpoint, and justify-content:start so two properties sit left rather
  than drifting apart across an empty row.
*/
.prop-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 280px));
  justify-content: start;
  gap: var(--sp-4);
}

/*
  Property cards. Portrait media well, because the thing that goes in it
  is a movie poster.
*/
.prop-card {
  display: block;
  text-decoration: none;
  color: inherit;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  overflow: hidden;
  background: var(--surface);
  transition: border-color 150ms ease, transform 150ms ease;
}
@media (hover: hover) {
  .prop-card:hover { border-color: var(--ink-faint); transform: translateY(-2px); }
}
/*
  A fixed 400px well, not a 2:3 ratio.

  The ratio was sized off the cell WIDTH, so on a wide screen a
  three-column grid gave each card roughly 380px across and therefore
  ~570px of poster — one property filling most of the viewport. Height is
  the dimension that was actually wrong, so height is what is now pinned:
  400px regardless of how wide the column gets.

  `contain` rather than `cover`, which is a change from what the ratio
  version did. At 2:3 the cell matched the artwork and cover cropped
  nothing; at a fixed 400 the cell is close to square, and cover would
  scale a poster to fill it and cut roughly 15% off the top and bottom —
  which on a movie poster is the title and the billing block. Letterboxing
  into the surface costs some empty space and keeps the artwork whole.
*/
.prop-card-media {
  height: 400px;
  background: var(--surface-2);
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
}
.prop-card-media img {
  max-width: 100%;
  max-height: 100%;
  width: auto;
  height: auto;
  object-fit: contain;
  display: block;
}
.prop-card-blank {
  font-size: var(--step-4);
  font-weight: 600;
  color: var(--ink-faint);
}
.prop-card-body { padding: var(--sp-3); }
.prop-card-title { font-weight: 600; line-height: 1.2; }
.prop-card-sub { font-size: var(--step--1); color: var(--ink-faint); margin-top: var(--sp-1); }

/* --- Admin: partner keys ---------------------------------------------- */

.key-rows { display: flex; flex-direction: column; gap: var(--sp-2); }

.key-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-3);
  padding: var(--sp-3);
  border: 1px solid var(--line);
  border-radius: var(--radius);
}
/* Revoked and expired keys stay listed — see keyRow — but recede, so the
   active credentials are what the eye lands on. */
.key-row.is-dead { opacity: 0.55; }
.key-row-main { min-width: 0; }
.key-row-name { font-weight: 600; display: flex; align-items: baseline; gap: var(--sp-2); flex-wrap: wrap; }
.key-row-side { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; }

/*
  The one-time key reveal. Monospace and selectable, wrapping rather than
  scrolling: a key that overflows its box invites a partial copy, and a
  half-copied credential fails with "not recognized" and no clue why.
*/
.note--strong { border-color: var(--qf); }
.key-reveal {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  margin-top: var(--sp-2);
  flex-wrap: wrap;
}
.key-reveal code {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: var(--step--1);
  background: var(--surface-2);
  padding: var(--sp-2) var(--sp-3);
  border-radius: var(--radius-sm, 6px);
  overflow-wrap: anywhere;
  user-select: all;
}

/* --- Apparel sizes ---------------------------------------------------- */

.size-picker { margin-top: var(--sp-4); }
.size-options { display: flex; flex-wrap: wrap; gap: var(--sp-2); }

.size-opt {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  min-width: 3.5rem;
  padding: var(--sp-2) var(--sp-3);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--surface);
  color: inherit;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
@media (hover: hover) {
  .size-opt:not(:disabled):hover { border-color: var(--ink-faint); }
}
.size-opt.is-on { border-color: var(--ink); box-shadow: inset 0 0 0 1px var(--ink); }

/*
  Sold out: struck through and unclickable, but still legible. The whole
  point of keeping it on screen is that a buyer can read which size is
  gone — dimming it to the edge of visibility would be hiding it slowly.
*/
.size-opt.is-gone {
  text-decoration: line-through;
  color: var(--ink-faint);
  cursor: not-allowed;
  background: var(--surface-2);
}
.size-left {
  font-size: var(--step--2, 0.72rem);
  font-weight: 400;
  color: var(--ink-faint);
  text-decoration: none;
}
/* Only after a buy is attempted with nothing chosen — an input that
   starts red is scolding someone for not having acted yet. */
.size-picker.needs-size .size-options { outline: 2px solid var(--danger); outline-offset: 4px; border-radius: var(--radius); }

/* --- Brand: size demand ----------------------------------------------- */
.size-table { display: flex; flex-direction: column; gap: 1px; background: var(--line); border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
.size-th, .size-tr { display: grid; grid-template-columns: 4rem 1fr 1fr 1.6fr; gap: var(--sp-3); padding: var(--sp-3); background: var(--surface); align-items: center; }
.size-th { font-size: var(--step--1); color: var(--ink-faint); background: var(--surface-2); }
.size-td-name { font-weight: 600; }
@media (max-width: 40rem) {
  .size-th { display: none; }
  .size-tr { grid-template-columns: 4rem 1fr; row-gap: var(--sp-1); }
}

/*
  A mint that SUCCEEDED but will not do what the studio expects.

  Warning, not error: nothing failed and nothing needs undoing — the
  artifacts exist and are valid. Only the outcome is not the obvious one.
  Styling it as an error would send someone hunting for a broken mint.
*/
.note--warn {
  border-color: var(--warn, #b8860b);
  background: var(--surface-2);
}

/*
  THE SECOND HALF OF THE SAME BUG.

  Fixing the TRACK with minmax(0, 1fr) is not sufficient on its own: grid
  and flex ITEMS also default to `min-width: auto`, so a cell holding a
  3000px-wide photo can still refuse to shrink and overflow a track that
  was perfectly willing to. Both floors have to be lowered or the wide
  asset finds whichever one was left.

  Applied to the children of the two media layouts rather than globally —
  `min-width: 0` everywhere would let genuinely un-shrinkable things
  (a long unbroken serial, a wide table) silently squash instead of
  scrolling, which trades one layout bug for a quieter one.
*/
.product > *,
.artifact-hero > * {
  min-width: 0;
}

/*
  And the stage itself can never exceed what contains it, whatever a
  parent does later. Cheap, and it makes the guarantee local to the
  element instead of depending on every ancestor staying correct.
*/
.gallery-stage {
  max-width: 100%;
  min-width: 0;
}

/*
  Studio logo on the INDEX card — 200 × 200, matching the profile header.

  It was inline-styled at max-width 7rem / max-height 3rem (112 × 48), a
  wordmark-shaped slot. A square or stacked logo fitted into a 48px-tall
  box renders tiny, which is what made A24 look like a favicon on a card
  with 500px of room beside it.

  Square rather than wordmark-shaped for the same reason as the profile
  header: logos are square, wide, or stacked, and a box that assumes one
  shape shrinks the other two. `contain` means none of them distort.

  200 rather than 190 for one reason worth stating — the profile header
  uses the same number. Two near-identical sizes across two pages is a
  value someone later has to check rather than know, and there is no
  visual argument for the 10px.
*/
.studio-card-logo {
  width: 200px;
  height: 200px;
  max-width: 100%;
  object-fit: contain;
  object-position: left center;
  display: block;
  margin-bottom: var(--sp-3);
}
