/* ============================================================
   Card Catalog — design system

   Rebuilt to a spec rather than assembled component by component.
   The rules below are not descriptive, they are binding: if a
   value is not in one of these scales it does not go in the file.

   TYPE     11 · 13 · 15 · 18 · 24 · 32
            three weights: 450 body, 600 medium, 700 display
   SPACE    4 · 8 · 12 · 16 · 24 · 32 · 48 · 64 · 96
   RADIUS   4 media · 10 controls · 18 panels · full
   DEPTH    three levels, each with one job:
              z1 a control sitting on the page
              z2 something floating above it
              z3 something covering it
            plus one card recipe, and the tactile pair.

   MATERIAL
   The page is a calm flat sheet. White things sit ON it; they
   are crisp, hairlined, lightly shadowed. The soft tactile
   treatment is NOT the page language — it is a signature, used
   in exactly three places where you are meant to feel a physical
   object:

              the QR plinth      (the thing you point a phone at)
              the shutter        (on the phone)
              the filter track   (the one control you toggle a lot)

   Everywhere else it is banned. Applied to every surface it made
   depth meaningless — eighteen of eighty-six elements carried a
   shadow — and the whole page read as mush. That is the failure
   this section exists to prevent.

   Dark mode swaps the palette only. No geometry changes.
   ============================================================ */

:root {
  color-scheme: light;

  /* --- surfaces --- */
  --sheet: #eef0f4; /* the page */
  --raised: #ffffff; /* things sitting on it */
  --sunken: #e6e9ef; /* things set into it */
  --hairline: #e0e3ea;
  --hairline-strong: #cfd4dd;

  /* --- ink --- */
  --ink: #14171c;
  --muted: #5c6472;
  --faint: #8b93a1;

  /* --- accent --- */
  --accent: #2f5fd0;
  --accent-press: #24499f;
  --accent-wash: rgba(47, 95, 208, 0.1);
  --on-accent: #ffffff;
  --danger: #c33025;
  --danger-wash: rgba(195, 48, 37, 0.09);
  --good: #1f8a55;

  /* --- type --- */
  --sans:
    -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif;
  --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
  --t-micro: 11px;
  --t-small: 13px;
  --t-body: 15px;
  --t-lead: 18px;
  --t-head: 24px;
  --t-display: 32px;
  --w-body: 450;
  --w-med: 600;
  --w-display: 700;

  /* --- space --- */
  --s1: 4px;
  --s2: 8px;
  --s3: 12px;
  --s4: 16px;
  --s5: 24px;
  --s6: 32px;
  --s7: 48px;
  --s8: 64px;
  --s9: 96px;
  --gutter: var(--s6);
  /* One measure everything aligns to. Without it the page was full-bleed
     while the rack centred itself, so on a wide display the headings pinned
     to the left edge and the cards floated in the middle of the window --
     two different layouts on one screen. */
  --spine: 1180px;

  /* --- radius --- */
  --r-media: 4px;
  --r-ctl: 10px;
  --r-panel: 18px;
  --r-full: 999px;

  /* --- depth --- */
  --z1: 0 1px 2px rgba(20, 23, 28, 0.06);
  --z2:
    0 2px 4px rgba(20, 23, 28, 0.05), 0 8px 20px -6px rgba(20, 23, 28, 0.12);
  --z3:
    0 4px 10px rgba(20, 23, 28, 0.08), 0 32px 64px -16px rgba(20, 23, 28, 0.28);

  /* A card is a real object: tight contact shadow first, then spread.
     One even blur reads as foam, which is what three ranges avoid. */
  --card-rest:
    0 1px 1.5px rgba(20, 23, 28, 0.22), 0 4px 8px -2px rgba(20, 23, 28, 0.14),
    0 12px 22px -10px rgba(20, 23, 28, 0.16);
  --card-lift:
    0 2px 3px rgba(20, 23, 28, 0.2), 0 10px 18px -4px rgba(20, 23, 28, 0.2),
    0 28px 44px -16px rgba(20, 23, 28, 0.26);

  /* the signature pair — three uses, listed above */
  --tac-hi: rgba(255, 255, 255, 0.95);
  --tac-lo: rgba(163, 175, 194, 0.55);
  --tac-out: -6px -6px 14px var(--tac-hi), 6px 6px 14px var(--tac-lo);
  --tac-in: inset -4px -4px 9px var(--tac-hi), inset 4px 4px 9px var(--tac-lo);

  --skeleton-tint: rgba(20, 23, 28, 0.035);
  --dot: rgba(20, 23, 28, 0.16);
  --dot-lit: rgba(47, 95, 208, 0.85);
}

[data-theme="dark"] {
  color-scheme: dark;

  --sheet: #15181d;
  --raised: #1d2127;
  --sunken: #101317;
  --hairline: #292e37;
  --hairline-strong: #39404b;

  --ink: #eef1f6;
  --muted: #9aa3b2;
  --faint: #6d7686;

  --accent: #6d9bff;
  --accent-press: #5185f0;
  --accent-wash: rgba(109, 155, 255, 0.14);
  --on-accent: #0d1015;
  --danger: #ef6a5e;
  --danger-wash: rgba(239, 106, 94, 0.13);
  --good: #4fbb84;

  --z1: 0 1px 2px rgba(0, 0, 0, 0.5);
  --z2: 0 2px 4px rgba(0, 0, 0, 0.4), 0 8px 20px -6px rgba(0, 0, 0, 0.55);
  --z3: 0 4px 10px rgba(0, 0, 0, 0.5), 0 32px 64px -16px rgba(0, 0, 0, 0.7);

  --card-rest:
    0 1px 2px rgba(0, 0, 0, 0.6), 0 4px 10px -2px rgba(0, 0, 0, 0.5),
    0 14px 26px -10px rgba(0, 0, 0, 0.5);
  --card-lift:
    0 2px 4px rgba(0, 0, 0, 0.6), 0 12px 20px -4px rgba(0, 0, 0, 0.55),
    0 32px 48px -16px rgba(0, 0, 0, 0.6);

  --tac-hi: rgba(255, 255, 255, 0.05);
  --tac-lo: rgba(0, 0, 0, 0.55);

  --skeleton-tint: rgba(255, 255, 255, 0.03);
  --dot: rgba(255, 255, 255, 0.16);
  --dot-lit: rgba(109, 155, 255, 0.9);
}

* {
  box-sizing: border-box;
}

/* Any `display` beats the UA rule for [hidden], so a component class like
   .sheet{display:grid} silently defeats el.hidden = true. Settle it once. */
[hidden] {
  display: none !important;
}

html,
body {
  margin: 0;
  min-height: 100%;
  background: var(--sheet);
  color: var(--ink);
  font-family: var(--sans);
  font-size: var(--t-body);
  font-weight: var(--w-body);
  line-height: 1.5;
  font-synthesis: none;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

/* Optical sizing by hand. One letter-spacing across a 11-to-32px range is
   wrong at both ends: display type needs tightening, small type needs
   opening up or it sets solid. */
h1,
h2,
h3 {
  margin: 0;
  font-weight: var(--w-display);
  line-height: 1.15;
  letter-spacing: -0.022em;
  text-wrap: balance;
}

h2,
h3 {
  line-height: 1.25;
  letter-spacing: -0.014em;
}

/* Small text was inheriting the body's 1.5 and no tracking, which is what
   made every caption and count look cramped against its neighbour. */
.mono,
small {
  letter-spacing: 0.002em;
}

img {
  display: block;
}

::selection {
  background: var(--accent-wash);
}

:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: var(--r-media);
}

/* ---------- type utilities ---------- */

.mono {
  font-size: var(--t-small);
  line-height: 1.5;
}

.code,
code {
  font-family: var(--mono);
  font-size: var(--t-small);
}

.dim {
  color: var(--muted);
}

.spacer,
.grow {
  flex: 1;
}

/* ============================================================
   Header
   ============================================================ */

/* The bar is full width; its contents are not. The wordmark has to line up
   with the page heading below it or the whole screen looks untucked. */
.masthead {
  background: var(--sheet);
  border-bottom: 1px solid var(--hairline);
  position: sticky;
  top: 0;
  z-index: 60;
}

.masthead-inner {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s4);
  height: 60px;
  max-width: var(--spine);
  margin-inline: auto;
  padding-inline: var(--gutter);
}

.masthead-left,
.masthead-right {
  display: flex;
  align-items: center;
  gap: var(--s3);
  min-width: 0;
}

/* Set as a wordmark rather than as a link that happens to be first.
   Tighter tracking and a hair more weight than body text, so it reads as a
   name and not as navigation. */
.wordmark {
  display: inline-flex;
  align-items: center;
  gap: 9px;
  font-size: var(--t-body);
  font-weight: var(--w-display);
  letter-spacing: -0.028em;
  color: var(--ink);
  text-decoration: none;
  white-space: nowrap;
  flex-shrink: 0;
}

.wordmark-glyph {
  color: var(--accent);
  flex: none;
  /* The top card sits a touch high in the viewBox; nudge the whole mark so
     its optical centre lines up with the cap height beside it. */
  margin-top: 1px;
}

.wordmark:hover .wordmark-glyph {
  color: var(--accent-press);
}

/* Truly centred, whatever the two side groups weigh. Centring with
   margin-inline:auto in a flex row put it 26px off, because the left group
   is 113px and the right 166px. */
.topsearch {
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  align-items: center;
  gap: var(--s2);
  width: min(420px, calc(100% - 500px));
  height: 38px;
  padding-inline: var(--s3);
  background: var(--raised);
  border: 1px solid var(--hairline);
  border-radius: var(--r-ctl);
  color: var(--faint);
  transition:
    border-color 0.15s,
    box-shadow 0.15s;
}

.topsearch:hover {
  border-color: var(--hairline-strong);
}

.topsearch:focus-within {
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-wash);
  color: var(--accent);
}

.topsearch svg {
  flex-shrink: 0;
}

.topsearch input {
  flex: 1;
  min-width: 0;
  border: 0;
  background: transparent;
  font: inherit;
  font-size: var(--t-small);
  color: var(--ink);
  padding: 0;
}

.topsearch input::placeholder {
  color: var(--faint);
}

.topsearch input:focus {
  outline: none;
}

input[type="search"]::-webkit-search-cancel-button {
  -webkit-appearance: none;
}

@media (max-width: 900px) {
  .topsearch {
    position: static;
    transform: none;
    width: auto;
    flex: 1;
  }
}

/* ============================================================
   Buttons — flat. The tactile press lives in the signature list.
   ============================================================ */

button,
.btn {
  font: inherit;
  font-size: var(--t-small);
  font-weight: var(--w-med);
  color: var(--ink);
  background: var(--raised);
  border: 1px solid var(--hairline);
  border-radius: var(--r-ctl);
  padding: var(--s2) var(--s3);
  cursor: pointer;
  text-decoration: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--s2);
  white-space: nowrap;
  box-shadow: var(--z1);
  transition:
    background 0.14s,
    border-color 0.14s,
    color 0.14s,
    box-shadow 0.14s;
}

button:hover:not(:disabled),
.btn:hover {
  border-color: var(--hairline-strong);
}

button:active:not(:disabled),
.btn:active {
  background: var(--sunken);
  box-shadow: none;
}

button:disabled {
  color: var(--faint);
  background: transparent;
  box-shadow: none;
  cursor: not-allowed;
}

button.primary,
.btn.primary {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--on-accent);
}

button.primary:hover:not(:disabled),
.btn.primary:hover {
  background: var(--accent-press);
  border-color: var(--accent-press);
}

button.primary:active:not(:disabled),
.btn.primary:active {
  background: var(--accent-press);
}

button.primary:disabled {
  background: var(--hairline);
  border-color: var(--hairline);
  color: var(--faint);
}

button.quiet {
  background: transparent;
  border-color: transparent;
  box-shadow: none;
  color: var(--muted);
}

button.quiet:hover:not(:disabled) {
  color: var(--ink);
  background: var(--raised);
  border-color: var(--hairline);
}

button.destructive {
  color: var(--danger);
}

button.destructive:hover:not(:disabled) {
  border-color: var(--danger);
  background: var(--danger-wash);
}

.select {
  font: inherit;
  font-size: var(--t-small);
  color: var(--ink);
  background: var(--raised);
  border: 1px solid var(--hairline);
  border-radius: var(--r-ctl);
  padding: var(--s2) var(--s3);
  max-width: 260px;
}

.dot {
  width: 7px;
  height: 7px;
  border-radius: var(--r-full);
  background: var(--good);
  flex-shrink: 0;
}

/* ---------- SIGNATURE 1 of 3: the filter track ----------
   Carved into the sheet, with the current option raised out of it. The one
   control you toggle constantly, so it earns the physicality. */
.segmented {
  display: inline-flex;
  align-items: center;
  gap: var(--s1);
  padding: var(--s1);
  border-radius: var(--r-ctl);
  background: var(--sunken);
  box-shadow: var(--tac-in);
}

.segmented button {
  background: transparent;
  border-color: transparent;
  box-shadow: none;
  padding: 6px var(--s3);
  font-size: var(--t-small);
  color: var(--muted);
}

.segmented button:hover:not(:disabled) {
  color: var(--ink);
  border-color: transparent;
  background: transparent;
}

.segmented button[aria-pressed="true"] {
  color: var(--ink);
  background: var(--raised);
  box-shadow: var(--z1);
}

.divider-v {
  width: 1px;
  height: 20px;
  background: var(--hairline);
}

/* ============================================================
   Page shell
   ============================================================ */

.page {
  max-width: var(--spine);
  margin-inline: auto;
  padding: var(--s7) var(--gutter) var(--s9);
}

/* One quiet line above the cards, closed with a rule. The count used to be a
   32px display numeral, which made the least important thing on the page the
   loudest -- and it sat on nothing, so it read as a stray figure rather than
   part of the layout. */
.collection-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s4);
  flex-wrap: wrap;
  padding-bottom: var(--s4);
  margin-bottom: var(--s6);
  border-bottom: 1px solid var(--hairline);
}

/* The section label carries its own count as a quiet pill, so the two rows
   on the home page read as siblings -- STACKS and ALL CARDS -- rather than
   as a heading followed by a stray figure. */
.count-badge {
  display: inline-block;
  margin-left: var(--s1);
  padding: 1px 7px;
  border-radius: var(--r-full);
  background: var(--sunken);
  color: var(--muted);
  font-size: var(--t-micro);
  font-weight: var(--w-med);
  letter-spacing: 0;
  font-variant-numeric: tabular-nums;
}

.count-line {
  display: inline-flex;
  align-items: baseline;
  gap: var(--s1);
  font-size: var(--t-small);
}

.count {
  font-weight: var(--w-med);
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

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

/* the batch table still wants a real figure -- there it is the point */
.count-n {
  font-size: var(--t-head);
  font-weight: var(--w-display);
  letter-spacing: -0.022em;
  font-variant-numeric: tabular-nums;
}

.toolbar-right {
  display: flex;
  align-items: center;
  gap: var(--s4);
  flex-wrap: wrap;
}

/* ============================================================
   The rack

   Overlapping rows, and the row parts around whichever card you
   point at. Positions come from pile.js and are written by
   rack.js; nothing here may set a transform on .rack-card or it
   will fight the layout.
   ============================================================ */

.rack {
  position: relative;
}

/* Hidden until rack.js has positioned the cards at least once. visibility,
   not display, so the container still reports a width to measure. */
.rack:not([data-laid]) {
  visibility: hidden;
}

.rack[data-laid] .rack-card {
  animation: rack-in 0.32s ease-out both;
}

@keyframes rack-in {
  from {
    opacity: 0;
  }
}

.rack-card {
  position: absolute;
  left: 0;
  top: 0;
  transform-origin: 0 0;
  padding: 0;
  border: 0;
  background: none;
  border-radius: var(--r-media);
  overflow: hidden;
  cursor: pointer;
  box-shadow: var(--card-rest);
  transition: box-shadow 0.26s ease;
}

.rack-card:active {
  transform: none; /* the button press must not move it out of its slot */
}

.rack-card img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.rack-card.drawn {
  box-shadow: var(--card-lift);
}

/* ============================================================
   The opened card

   A clone flies from the card's place in the fan to the middle of
   the screen. The original stays put underneath, so nothing has
   to be re-laid-out to make room for it.
   ============================================================ */

.stage {
  position: fixed;
  inset: 0;
  z-index: 120;
}

.stage-scrim {
  position: absolute;
  inset: 0;
  background: rgba(20, 23, 28, 0.55);
}

[data-theme="dark"] .stage-scrim {
  background: rgba(8, 10, 13, 0.7);
}

.stage-inner {
  position: relative;
  height: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--s7);
  padding: var(--s7) var(--gutter);
  pointer-events: none;
}

.stage-card {
  flex-shrink: 0;
  border-radius: var(--r-media);
  overflow: hidden;
  box-shadow: var(--card-lift);
  pointer-events: auto;
}

.stage-card img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.stage-panel {
  width: min(360px, 40vw);
  pointer-events: auto;
  transform: translateX(-12px);
}

/* The panel's chrome belongs to the panel, not to whatever is dropped in
   it. It used to live on .stage-detail, a class the old form happened to
   carry -- so swapping in the shared card view left the overlay with no
   background at all and the collection showing through the text. */
.stage-panel > * {
  background: var(--raised);
  border: 1px solid var(--hairline);
  border-radius: var(--r-panel);
  box-shadow: var(--z3);
  padding: var(--s5);
  max-height: min(76vh, 720px);
  overflow-y: auto;
  overscroll-behavior: contain;
}

.stage-detail {
  background: var(--raised);
  border: 1px solid var(--hairline);
  border-radius: var(--r-panel);
  box-shadow: var(--z3);
  padding: var(--s5);
  display: flex;
  flex-direction: column;
  gap: var(--s4);
}

.stage-title {
  font-size: var(--t-head);
}

.stage-title.untitled {
  color: var(--faint);
  font-weight: var(--w-med);
}

.stage-detail .spec {
  margin: 0;
  padding: 0 var(--s3);
}

.stage-detail .detail-actions {
  margin-top: 0;
  align-items: center;
}

@media (max-width: 860px) {
  .stage-inner {
    flex-direction: column;
    gap: var(--s4);
    justify-content: flex-start;
    overflow-y: auto;
    /* 48px of top padding is a desktop luxury. On a phone it is a twelfth
       of the screen spent on nothing, above a card that already struggles
       to share the height with its own details. */
    padding: var(--s4) var(--gutter) var(--s6);
  }
  .stage-panel {
    width: min(420px, 92vw);
    /* Only a nudge for optical centring beside the card; stacked, it just
       pulls the panel off-centre. */
    transform: none;
  }
  /* One scroller, not two.
     
     The panel caps itself at 76vh and scrolls internally, which beside the
     card is right -- it is a column of its own. Stacked under the card it
     means a short scrollable box inside a scrollable stage: the page moves
     or the box does depending on which pixel you started the drag on, and
     neither reliably reaches the bottom of the record. Let it be its full
     height and let the stage do the scrolling. */
  .stage-panel > * {
    max-height: none;
    overflow-y: visible;
  }
}

/* ---------- empty ---------- */

.empty {
  max-width: 420px;
  margin: var(--s9) auto;
  text-align: center;
}

.empty h2 {
  font-size: var(--t-lead);
  margin-bottom: var(--s2);
}

.empty p {
  color: var(--muted);
  font-size: var(--t-small);
  margin: 0 0 var(--s5);
}

/* ============================================================
   Card detail
   ============================================================ */

.detail {
  display: grid;
  grid-template-columns: minmax(200px, 300px) 1fr;
  gap: var(--s7);
  align-items: start;
  max-width: 960px;
  margin: 0 auto;
}

.back {
  display: inline-flex;
  align-items: center;
  gap: var(--s2);
  font-size: var(--t-small);
  font-weight: var(--w-med);
  color: var(--muted);
  text-decoration: none;
  margin-bottom: var(--s5);
}

.back:hover {
  color: var(--ink);
}

/* The card on the detail page. Same object treatment as the fan: no plate,
   no frame -- the crop with its own shadow. */
.card-frame {
  width: 100%;
  aspect-ratio: 750 / 1212;
  border-radius: var(--r-media);
  overflow: hidden;
  background: var(--sunken);
  box-shadow: var(--card-lift);
}

.card-frame.standard {
  aspect-ratio: 750 / 1050;
}

.card-frame img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.detail-body {
  min-width: 0;
  display: flex;
  flex-direction: column;
}

.detail-title {
  font-size: var(--t-display);
  margin: var(--s1) 0 var(--s5);
}

.detail-title.untitled {
  color: var(--faint);
  font-weight: var(--w-med);
}

.spec {
  display: grid;
  grid-template-columns: 112px 1fr;
  margin: 0 0 var(--s5);
  padding: 0 var(--s4);
  background: var(--raised);
  border: 1px solid var(--hairline);
  border-radius: var(--r-panel);
}

.spec > dt,
.spec > dd {
  margin: 0;
  padding: var(--s3) 0;
  font-size: var(--t-small);
  border-top: 1px solid var(--hairline);
}

.spec > dt {
  color: var(--muted);
}

.spec > dt:first-child,
.spec > dt:first-child + dd {
  border-top: 0;
}

.field {
  display: flex;
  flex-direction: column;
  gap: var(--s2);
}

.field > label {
  font-size: var(--t-small);
  font-weight: var(--w-med);
  color: var(--muted);
}

.field input,
.focus-field input {
  width: 100%;
  height: 40px;
  padding-inline: var(--s3);
  background: var(--raised);
  border: 1px solid var(--hairline);
  border-radius: var(--r-ctl);
  font: inherit;
  font-size: var(--t-small);
  color: var(--ink);
}

.field input:focus,
.focus-field input:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-wash);
}

.detail-actions {
  display: flex;
  gap: var(--s2);
  margin-top: var(--s5);
  flex-wrap: wrap;
}

/* ============================================================
   Batch
   ============================================================ */

body.batch[data-stage="waiting"] .table,
body.batch[data-stage="revealing"] .hero,
body.batch[data-stage="table"] .hero {
  display: none;
}

.hero {
  min-height: calc(100vh - var(--s8));
  display: grid;
  place-items: center;
  padding: var(--s7) var(--gutter) var(--s8);
}

.hero-inner {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--s4);
  text-align: center;
  max-width: 480px;
}

/* ---------- SIGNATURE 2 of 3: the QR plinth ----------
   A block standing proud of the sheet with the code sunk into its face. The
   one object on the page you physically point something at. */
/* Rebuilt as a real card.
   
   It was a sheet-coloured block defined only by a soft outer glow, with the
   code pressed into it by an inset shadow of the same kind. Two opposing
   soft shadows in one small object cancel each other out: the plinth's
   edge disappeared, the white highlight bled into the code's quiet zone,
   and the whole thing read as a smudge rather than as something raised.
   
   So it is a plain raised card now -- white, hairlined, properly shadowed,
   like every other object on the page. The code sits on it with a real
   quiet zone instead of being sunk into it. */
/* The plinth, with its depth back.
 *
 * This is one of the three places the tactile treatment is allowed (see the
 * header of this file) -- the thing you physically point a phone at. It had
 * been flattened to an ordinary raised card while fixing an earlier bug
 * where two opposing soft shadows cancelled each other out and the edge
 * disappeared.
 *
 * The fix then was to remove the softness; the fix now is to use it once
 * rather than twice. The block is lifted out of the sheet with the tactile
 * pair, and the code sits ON it as a plain white tile -- no inset well. One
 * soft shadow, one hard surface, so the edge survives. */
.qr-large {
  position: relative;
  display: inline-flex;
  padding: var(--s5);
  border-radius: var(--r-panel);
  background: var(--sheet);
  box-shadow: var(--tac-out);
  margin-bottom: var(--s5);
}

.qr-large img {
  width: 208px;
  height: 208px;
  /* White in both themes: a scanner wants contrast and a quiet zone, not a
     tinted surface. Deliberately NOT inset into the plinth -- an inset well
     under a soft-lifted block is the pair of opposing shadows that made this
     read as a smudge before. */
  background: #fff;
  padding: var(--s4);
  border-radius: var(--r-ctl);
  box-shadow: 0 1px 2px rgba(20, 23, 28, 0.1);
}

.qr-small {
  padding: var(--s3);
  border-radius: var(--r-ctl);
  background: var(--raised);
  border: 1px solid var(--hairline);
  width: max-content;
}

.qr-small img {
  width: 124px;
  height: 124px;
  background: #fff;
  border-radius: var(--r-media);
}

.hero-title {
  font-size: var(--t-head);
  margin-bottom: var(--s2);
}

.hero-note {
  color: var(--muted);
  font-size: var(--t-body);
  line-height: 1.55;
  margin: 0 0 var(--s2);
  max-width: 400px;
}

.hero-link {
  display: flex;
  align-items: center;
  gap: var(--s2);
  width: 100%;
  max-width: 440px;
  padding: var(--s1) var(--s1) var(--s1) var(--s3);
  background: var(--raised);
  border: 1px solid var(--hairline);
  border-radius: var(--r-ctl);
}

.hero-link code {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  color: var(--muted);
  text-align: left;
}

.hero-alt {
  display: flex;
  align-items: center;
  gap: var(--s2);
  flex-wrap: wrap;
  justify-content: center;
}

.warn {
  margin-top: var(--s3);
  padding: var(--s3) var(--s4);
  border-radius: var(--r-ctl);
  background: var(--danger-wash);
  color: var(--ink);
  font-size: var(--t-small);
  text-align: left;
}

.warn code {
  background: var(--raised);
  padding: 1px var(--s1);
  border-radius: var(--r-media);
}

/* ---------- the table ---------- */

.table {
  padding: var(--s5) var(--gutter) var(--s6);
  min-height: calc(100vh - var(--s8));
  display: flex;
  flex-direction: column;
}

.table-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s4);
  flex-wrap: wrap;
  margin-bottom: var(--s4);
}

.table-count {
  display: flex;
  align-items: baseline;
  gap: var(--s2);
}

.table-tools {
  display: flex;
  gap: var(--s2);
  flex-wrap: wrap;
}

/* the surface the cards are dealt onto */
.pile {
  position: relative;
  flex: 1;
  min-height: 440px;
  background: var(--sunken);
  border: 1px solid var(--hairline);
  border-radius: var(--r-panel);
}

.pile-hint {
  text-align: center;
  color: var(--faint);
  margin: var(--s3) 0 0;
  font-size: var(--t-small);
}

/* Load-bearing: batch.js positions these with transform-origin 0 0 and writes
   the transform itself. No transforms here. */
.pile-card {
  position: absolute;
  left: 0;
  top: 0;
  cursor: pointer;
  transform-origin: 0 0;
  border-radius: var(--r-media);
  overflow: hidden;
  box-shadow: var(--card-rest);
}

.pile-canvas {
  width: 100%;
  height: 100%;
  display: block;
  background: var(--sunken);
}

.pile-card.drawn {
  box-shadow: var(--card-lift);
}

.pile-card.pending {
  opacity: 0;
}

.pile-card.gone {
  opacity: 0;
  transition: opacity 0.22s;
}

.shine {
  position: absolute;
  inset: 0;
  pointer-events: none;
  opacity: 0;
  background: linear-gradient(
    115deg,
    transparent 30%,
    rgba(255, 255, 255, 0.45) 48%,
    transparent 66%
  );
}

.shine.sweep {
  animation: sweep 0.9s ease-out;
}

@keyframes sweep {
  from {
    opacity: 0.9;
    transform: translateX(-70%);
  }
  to {
    opacity: 0;
    transform: translateX(70%);
  }
}

/* ---------- the reveal ---------- */

/* Opaque, and above the cards already on the table.
 *
 * Without a background the pile showed through around the photo; without the
 * z-index the resting cards painted *in front* of it, because the stage comes
 * earlier in the markup. Dimmed to 14% by .quiet, that read as the photo being
 * see-through. The incoming cards are lifted above this while they fly (see
 * FLIGHT_Z in batch.js) and dropped back to their own order once they land. */
.reveal-stage {
  position: absolute;
  inset: 0;
  z-index: 500;
  display: grid;
  place-items: center;
  padding: var(--s4);
  background: var(--sunken);
  border-radius: var(--r-panel);
  opacity: 0;
  transition: opacity 0.3s ease;
  /* Swallows clicks meant for the table beneath. The JS guard stops the fan
     reacting to the pointer; this stops the cursor suggesting it would. */
  cursor: default;
}

.reveal-stage.in {
  opacity: 1;
}

/* Sized in JS to the photo's aspect ratio, so the image fills it exactly and
   the overlay shares its box. Letterboxing inside a larger element is what
   makes outlines land somewhere other than the cards. */
/* Contained by CSS, not by measurement.
 *
 * There is no <img> laid out during the wait, so the frame has no intrinsic
 * size of its own -- height:100% against the centred grid area gives it one,
 * and aspect-ratio (set in JS from the photo's real dimensions) turns that
 * into the right width. max-width clamps a wide photo. */
.reveal-frame {
  position: relative;
  height: 100%;
  width: auto;
  max-width: 100%;
  aspect-ratio: 4 / 3;
  overflow: hidden;
  border-radius: var(--r-ctl);
  box-shadow: var(--z2);
}

.reveal-frame img {
  display: block;
  width: 100%;
  height: 100%;
  transition: opacity 0.45s ease-out;
}

/* Restored: these four rules were cut along with the status pill, which sat
   between them and the sheets. Losing them meant .dimmed did nothing, the
   overlay had no box, and every outline was drawn with no stroke -- so the
   reveal skipped silently from photograph to flight. */
.reveal-frame img.dimmed {
  opacity: 0;
}

.reveal-frame svg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  transition: opacity 0.4s ease;
}

.reveal-frame svg.faded {
  opacity: 0;
}

/* the outline drawn round each card before it lifts off the photo */
.reveal-quad {
  fill: rgba(255, 255, 255, 0.06);
  stroke: var(--accent);
  stroke-width: 5;
  /* screen pixels, not viewBox units: the viewBox is the photo's full
     resolution, so an unscaled stroke would be under half a pixel */
  vector-effect: non-scaling-stroke;
  stroke-dasharray: 4000;
  stroke-dashoffset: 4000;
  animation: draw-quad 0.55s ease-out forwards;
}

@keyframes draw-quad {
  to {
    stroke-dashoffset: 0;
  }
}

/* The frame before the photograph is in it.
 *
 * A dot matrix with a wave running through it, and translucent, so it reads
 * as the space the picture is going to occupy rather than as a panel in its
 * own right. The dots hold still; only the light moves across them. */
.photo-skeleton {
  position: absolute;
  inset: 0;
  background: var(--skeleton-tint);
  overflow: hidden;
  transition: opacity 0.45s ease;
}

/* the matrix itself, always there */
.photo-skeleton::before {
  content: "";
  position: absolute;
  inset: 0;
  background-image: radial-gradient(
    circle at center,
    var(--dot) 1.1px,
    transparent 1.7px
  );
  background-size: 14px 14px;
}

/* the light moving through it: a broad diagonal band, masked down to the same
   grid so it can only ever light the dots */
.photo-skeleton::after {
  content: "";
  position: absolute;
  /* inset 0, not negative: the mask repeats from this box's own origin, so
     any offset here puts the lit grid out of step with the base one and the
     wave lights the gaps between dots instead of the dots. */
  inset: 0;
  background: linear-gradient(
    108deg,
    transparent 22%,
    var(--dot-lit) 42%,
    var(--dot-lit) 58%,
    transparent 78%
  );
  background-size: 210% 100%;
  -webkit-mask-image: radial-gradient(
    circle at center,
    #000 1.1px,
    transparent 1.7px
  );
  -webkit-mask-size: 14px 14px;
  mask-image: radial-gradient(circle at center, #000 1.1px, transparent 1.7px);
  mask-size: 14px 14px;
  animation: dot-flow 2.9s linear infinite;
}

@keyframes dot-flow {
  from {
    background-position: 160% 0;
  }
  to {
    background-position: -160% 0;
  }
}

.photo-skeleton.gone {
  opacity: 0;
}

/* ============================================================
   Sheets
   ============================================================ */

.sheet {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: grid;
  place-items: center;
  padding: var(--s5);
}

.sheet-backdrop {
  position: absolute;
  inset: 0;
  background: rgba(20, 23, 28, 0.4);
}

.sheet-panel {
  position: relative;
  background: var(--raised);
  border: 1px solid var(--hairline);
  border-radius: var(--r-panel);
  box-shadow: var(--z3);
  overflow: hidden;
  max-width: 96vw;
}

.sheet-head {
  display: flex;
  align-items: center;
  gap: var(--s3);
  padding: var(--s4) var(--s5);
  border-bottom: 1px solid var(--hairline);
}

.sheet-head h3 {
  font-size: var(--t-body);
  font-weight: var(--w-med);
}

.sheet-foot {
  display: flex;
  align-items: center;
  gap: var(--s3);
  padding: var(--s4) var(--s5);
  border-top: 1px solid var(--hairline);
}

.more-panel {
  width: min(720px, 96vw);
}

.more-body {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--s5);
  padding: var(--s5);
}

.more-col {
  display: flex;
  flex-direction: column;
  gap: var(--s3);
}

.source-label {
  font-size: var(--t-small);
  font-weight: var(--w-med);
  color: var(--muted);
}

.source-note {
  font-size: var(--t-small);
  color: var(--muted);
  margin: 0;
}

.source-actions {
  display: flex;
  gap: var(--s2);
  flex-wrap: wrap;
  margin-top: auto;
}

.cam-panel {
  width: min(860px, 96vw);
}

.cam-body {
  position: relative;
  background: #0d1015;
}

#cam-video {
  width: 100%;
  max-height: 58vh;
  display: block;
  object-fit: contain;
}

#cam-error {
  padding: var(--s7) var(--s5);
  color: var(--danger);
  font-size: var(--t-small);
  text-align: center;
}

#dropzone {
  position: fixed;
  inset: 0;
  z-index: 250;
  display: grid;
  place-items: center;
  background: rgba(238, 240, 244, 0.86);
  pointer-events: none;
}

[data-theme="dark"] #dropzone {
  background: rgba(21, 24, 29, 0.88);
}

.dropzone-inner {
  padding: var(--s6) var(--s7);
  border-radius: var(--r-panel);
  background: var(--raised);
  border: 1px solid var(--hairline);
  box-shadow: var(--z3);
  font-size: var(--t-lead);
  font-weight: var(--w-med);
}

/* ============================================================
   The focus editor
   ============================================================ */

.pile-card.focused {
  cursor: default;
  overflow: visible;
  box-shadow: var(--card-lift);
}

/* Only the picture is clipped; handles straddle the edge by design. */
.focus-clip {
  position: absolute;
  inset: 0;
  overflow: hidden;
  border-radius: var(--r-media);
}

.focus-canvas {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  display: block;
  opacity: 0;
  transition: opacity 0.24s ease-out;
}

.focus-canvas.in {
  opacity: 1;
}

.focus-overlay {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity 0.25s ease-out;
}

.focus-overlay.in {
  opacity: 1;
}

.focus-svg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
}

.focus-outline {
  fill: none;
  stroke: #fff;
  stroke-width: 2;
  vector-effect: non-scaling-stroke;
  mix-blend-mode: difference;
}

.focus-shade {
  fill: rgba(16, 18, 22, 0.55);
  transition: opacity 0.22s;
}

.focus-handle {
  position: absolute;
  width: 26px;
  height: 26px;
  margin: -13px 0 0 -13px;
  cursor: grab;
  touch-action: none;
}

/* a ring, not a blob: the corner stays visible through the handle */
.focus-handle::after {
  content: "";
  position: absolute;
  inset: 8px;
  border-radius: var(--r-full);
  background: #fff;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
  transition: inset 0.12s;
}

.focus-handle:hover::after {
  inset: 5px;
}

.focus-handle.dragging {
  cursor: grabbing;
}

.focus-handle.dragging::after {
  inset: 4px;
  background: var(--accent);
}

.focus-panel {
  position: absolute;
  /* Wider than it was: it carries the whole record now, not one label. */
  width: 340px;
  max-height: min(560px, calc(100vh - 120px));
  z-index: 901;
  display: flex;
  flex-direction: column;
  gap: var(--s3);
  padding: var(--s5);
  border-radius: var(--r-panel);
  background: var(--raised);
  border: 1px solid var(--hairline);
  box-shadow: var(--z2);
  opacity: 0;
  transform: translateX(-8px);
  transition:
    opacity 0.3s ease-out,
    transform 0.3s cubic-bezier(0.22, 0.9, 0.24, 1);
}

.focus-panel.in {
  opacity: 1;
  transform: none;
}

/* The panel is pinned to the card's height, and a slab's record is taller
   than a slab. Let the fields scroll rather than letting the panel outgrow
   the card it belongs to. */
.focus-panel .cf-panels {
  overflow-y: auto;
  min-height: 0;
  flex: 1;
  margin-inline: calc(var(--s2) * -1);
  padding-inline: var(--s2);
}

/* ---------- crop controls ----------
   Type and rotation are why this panel exists, but they are two clicks
   against twenty-one fields. Given a captioned block each they made the
   panel read as a settings screen with one useful line in it, so they sit
   on one row above the actions instead. */

.focus-crop-row {
  display: flex;
  align-items: center;
  gap: var(--s2);
  padding-top: var(--s3);
  border-top: 1px solid var(--hairline);
}

.focus-kinds {
  display: flex;
  gap: 2px;
  padding: 3px;
  background: var(--sunken);
  border-radius: var(--r-ctl);
}

.focus-kinds button {
  height: 26px;
  padding: 0 var(--s3);
  border: 0;
  border-radius: 7px;
  background: transparent;
  font: inherit;
  font-size: var(--t-small);
  font-weight: var(--w-med);
  color: var(--muted);
  cursor: pointer;
}

.focus-kinds button[aria-pressed="true"] {
  background: var(--raised);
  color: var(--ink);
  box-shadow: var(--z1);
}

#focus-rotate {
  display: grid;
  place-items: center;
  width: 32px;
  height: 32px;
  padding: 0;
  flex: none;
}

.focus-cropstate {
  font-size: var(--t-micro);
  color: var(--faint);
}

.focus-actions {
  display: flex;
  align-items: center;
  gap: var(--s2);
}

/* The magnifier. Sizing is load-bearing: focus.js uses LOUPE_R = 84. */
#ed-loupe {
  position: fixed;
  width: 168px;
  height: 168px;
  margin-left: -84px;
  margin-top: -84px;
  border-radius: var(--r-full);
  overflow: hidden;
  background-color: var(--sunken);
  box-shadow:
    0 0 0 4px var(--raised),
    var(--z3);
  pointer-events: none;
  z-index: 1000;
}

/* The photo, on its own layer so it can be turned to match the card without
   rotating the crosshairs or the bezel with it. */
.loupe-img {
  position: absolute;
  inset: 0;
  background-repeat: no-repeat;
  transform-origin: 50% 50%;
}

/* Inverted, like the crop outline it belongs to -- white through
   `difference`, so the crosshair stays legible over a dark card and a light
   one without ever picking a colour that competes with the card itself.
   Accent blue vanished against a blue parallel.

   The z-index is load-bearing. `::before` paints before an element's real
   children, so the moment the photo moved onto its own layer the vertical
   line went under it -- while `::after`, which paints after, survived. That
   is why one crosshair disappeared and the other did not. */
#ed-loupe::before,
#ed-loupe::after {
  content: "";
  position: absolute;
  z-index: 1;
  background: #fff;
  mix-blend-mode: difference;
}

#ed-loupe::before {
  left: 50%;
  top: var(--s2);
  bottom: var(--s2);
  width: 1px;
}

#ed-loupe::after {
  top: 50%;
  left: var(--s2);
  right: var(--s2);
  height: 1px;
}

/* ============================================================
   Toasts
   ============================================================ */

#toasts {
  position: fixed;
  left: 50%;
  bottom: var(--s5);
  transform: translateX(-50%);
  z-index: 200;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--s2);
}

.toast {
  padding: var(--s3) var(--s4);
  border-radius: var(--r-ctl);
  background: var(--ink);
  color: var(--sheet);
  box-shadow: var(--z2);
  font-size: var(--t-small);
  font-weight: var(--w-med);
  animation: toast-in 0.2s cubic-bezier(0.3, 0.9, 0.3, 1);
}

.toast.bad {
  background: var(--danger);
  color: #fff;
}

@keyframes toast-in {
  from {
    opacity: 0;
    transform: translateY(8px);
  }
}

/* ============================================================
   Responsive
   ============================================================ */

@media (max-width: 860px) {
  :root {
    --gutter: var(--s4);
  }
  .detail {
    grid-template-columns: 1fr;
    gap: var(--s5);
  }
  .more-body {
    grid-template-columns: 1fr;
  }
  .qr-large img {
    width: 180px;
    height: 180px;
  }
}

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

/* ============================================================
   CARD FORM
   The record behind an opened card. Twenty-one fields, tiered
   across three tabs so a raw common shows seven and a graded
   numbered rookie shows all of them — see cardform.js.

   Reuses the existing control geometry (40px, --r-ctl,
   --hairline) rather than inventing a second form language.
   The only new idea here is the inferred marker.
   ============================================================ */

.cardform {
  gap: var(--s3);
}

.cf-head {
  display: flex;
  align-items: flex-start;
  gap: var(--s2);
  justify-content: space-between;
}

.cf-heading {
  min-width: 0;
}

.cf-head .stage-title {
  font-size: var(--t-lead);
  line-height: 1.25;
  margin: 0;
}

.cf-sub {
  margin: 3px 0 0;
  font-size: var(--t-small);
  line-height: 1.35;
  color: var(--muted);
}

/* ---------- PSA verdict ---------- */

.cf-psa {
  flex: none;
  margin-top: 2px;
  padding: 3px var(--s2);
  border-radius: var(--r-full);
  font-size: var(--t-micro);
  font-weight: var(--w-med);
  letter-spacing: 0.02em;
  white-space: nowrap;
  cursor: help;
  background: var(--sunken);
  color: var(--muted);
}

.cf-psa.is-hit {
  background: rgba(31, 138, 85, 0.12);
  color: var(--good);
}

/* A misread cert found a real but different card. Worth an alarm: this is
   the one state where the record could have been silently wrong. */
.cf-psa.is-mismatch {
  background: var(--danger-wash);
  color: var(--danger);
}

/* ---------- tabs ---------- */

.cf-tabs {
  display: flex;
  gap: 2px;
  padding: 3px;
  background: var(--sunken);
  border-radius: var(--r-ctl);
}

.cf-tab {
  flex: 1;
  height: 30px;
  padding: 0 var(--s2);
  border: 0;
  border-radius: 7px;
  background: transparent;
  font: inherit;
  font-size: var(--t-small);
  font-weight: var(--w-med);
  color: var(--muted);
  cursor: pointer;
  transition:
    background 0.14s ease,
    color 0.14s ease;
}

.cf-tab:hover {
  color: var(--ink);
}

.cf-tab.on {
  background: var(--raised);
  color: var(--ink);
  box-shadow: var(--z1);
}

.cf-panel {
  display: none;
}

.cf-panel.on {
  display: block;
}

/* ---------- fields ---------- */

.cf-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--s3) var(--s2);
}

.cf-field {
  display: flex;
  flex-direction: column;
  gap: 5px;
  grid-column: span 2;
  min-width: 0;
}

.cf-field.narrow {
  grid-column: span 1;
}

.cf-field > label {
  font-size: var(--t-micro);
  font-weight: var(--w-med);
  letter-spacing: 0.02em;
  color: var(--muted);
}

.cf-field input[type="text"],
.cf-field select {
  width: 100%;
  height: 36px;
  padding-inline: var(--s3);
  background: var(--raised);
  border: 1px solid var(--hairline);
  border-radius: var(--r-ctl);
  font: inherit;
  font-size: var(--t-small);
  color: var(--ink);
  transition:
    border-color 0.14s ease,
    box-shadow 0.14s ease;
}

.cf-field select {
  padding-inline: calc(var(--s3) - 2px);
  cursor: pointer;
}

.cf-field input::placeholder {
  color: var(--faint);
}

.cf-field input:focus,
.cf-field select:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-wash);
}

/* ---------- inferred ----------
   Deduced, not printed. A dashed underline rather than a badge: a badge on
   every reasoned field would out-shout the values themselves, and inferred
   is a caveat, not a warning. */

.cf-field.is-inferred input[type="text"],
.cf-field.is-inferred select {
  border-bottom-style: dashed;
  border-bottom-color: var(--hairline-strong);
}

.cf-field.is-inferred > label::after {
  content: "";
  display: inline-block;
  width: 3px;
  height: 3px;
  margin-left: 5px;
  vertical-align: 2px;
  border-radius: 50%;
  background: var(--faint);
}

.cf-legend {
  display: flex;
  align-items: center;
  gap: 7px;
  margin: 0;
  font-size: var(--t-micro);
  color: var(--faint);
}

.cf-swatch {
  width: 16px;
  border-bottom: 1px dashed var(--hairline-strong);
}

/* ---------- checkboxes ---------- */

/* Flags read as a list of statements about the card, so they run full width
   and stack. Two-up would pair them arbitrarily and imply a relationship. */
.cf-check {
  grid-column: span 2;
  gap: 0;
}

.check {
  display: flex;
  align-items: center;
  gap: var(--s2);
  font-size: var(--t-small);
  color: var(--ink);
  cursor: pointer;
  user-select: none;
}

.check input {
  width: 16px;
  height: 16px;
  accent-color: var(--accent);
  cursor: pointer;
}

/* ---------- footer ---------- */

.cf-foot {
  display: flex;
  align-items: center;
  gap: var(--s2);
  padding-top: var(--s1);
}

.cf-meta {
  margin: 0;
  font-size: var(--t-micro);
  color: var(--faint);
}

/* ---------- being read ----------
   The card is in the model's hands right now. Empty fields here mean "the
   answer has not arrived", which is a different statement from "this card
   has no answer" — so they get the same dot-matrix idiom the photo skeleton
   uses, rather than sitting there looking blank and finished. */

.cf-psa.is-reading {
  background: var(--accent-wash);
  color: var(--accent);
}

.cf-psa.is-reading i {
  display: inline-block;
  width: 3px;
  height: 3px;
  margin-left: 2px;
  vertical-align: 1px;
  border-radius: 50%;
  background: currentColor;
  animation: cf-blink 1.25s ease-in-out infinite;
}

.cf-psa.is-reading i:nth-child(2) {
  animation-delay: 0.18s;
}
.cf-psa.is-reading i:nth-child(3) {
  animation-delay: 0.36s;
}

@keyframes cf-blink {
  0%,
  70%,
  100% {
    opacity: 0.25;
  }
  35% {
    opacity: 1;
  }
}

/* Only the empty ones. A field that already has a value — because it was
   typed by hand, or arrived from an earlier read — is not waiting on
   anything and should not pretend to be. */
.cardform.is-reading .cf-field input[type="text"]:placeholder-shown {
  background-image: radial-gradient(
    circle at center,
    var(--dot) 1px,
    transparent 1.6px
  );
  background-size: 11px 11px;
  animation: cf-drift 2.4s linear infinite;
}

@keyframes cf-drift {
  to {
    background-position: 11px 0;
  }
}

.cardform.is-reading .cf-foot #cf-reread {
  opacity: 0.5;
  pointer-events: none;
}

/* ============================================================
   CARD PAGE
   A card's canonical address. Read first; Edit swaps in the
   same form the rack's overlay uses.

   Centred, and bounded on both sides. The record used to be
   capped at 460px inside a full-width page, so the whole
   thing hugged the left edge with a third of the window
   empty beside it. The pair is one object now, centred as a
   unit.
   ============================================================ */

.card-page {
  display: grid;
  grid-template-columns: auto minmax(320px, 420px);
  gap: var(--s8);
  align-items: center;
  justify-content: center;
  max-width: 980px;
  margin: 0 auto;
  min-height: calc(100vh - var(--s8) - var(--s6) * 2);
  padding-bottom: var(--s7);
}

/* Bounded three ways, and the height one matters most: a slab is 750x1212,
   so 480px wide makes it 776px tall and the record gets pushed off the
   screen. --ar is the card's own width/height, set per kind, so the figure
   shrinks to fit the viewport instead of the record sliding under it.

   Crops are 750px wide natively, so 480 is also about as far as this can go
   before the softness shows. Do not raise it without raising the source
   resolution first. */
.card-page-figure {
  width: min(480px, 42vw, calc((100vh - 240px) * var(--ar, 0.619)));
}

.card-page-figure .card-frame {
  width: 100%;
  border-radius: var(--r-media);
  overflow: hidden;
  box-shadow: var(--card-rest);
}

.card-page-figure img {
  display: block;
  width: 100%;
  height: auto;
}

.card-page-body {
  display: flex;
  flex-direction: column;
  gap: var(--s4);
}

.card-page-head {
  display: flex;
  flex-direction: column;
  gap: var(--s1);
}

.card-page-title {
  margin: 0;
  font-size: var(--t-display);
  line-height: 1.1;
  letter-spacing: -0.015em;
}

.card-page-title.untitled {
  color: var(--faint);
  font-weight: var(--w-med);
}

.card-page-sub {
  margin: 0;
  font-size: var(--t-lead);
  color: var(--muted);
  line-height: 1.35;
}

.card-page-tools {
  display: flex;
  align-items: center;
  gap: var(--s2);
}

.icon-btn.star.on {
  color: #d99e0b;
  border-color: rgba(217, 158, 11, 0.4);
  background: rgba(217, 158, 11, 0.12);
}

.card-stacks {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--s2);
}

.card-stacks-label {
  font-size: var(--t-small);
  color: var(--muted);
}

.chip.link {
  text-decoration: none;
  background: var(--sunken);
  color: var(--muted);
}

.chip.link:hover {
  background: var(--accent-wash);
  color: var(--accent);
}

/* ---------- the record, read-only ---------- */

.spec-grade {
  display: flex;
  align-items: baseline;
  gap: var(--s3);
  padding: var(--s3) var(--s4);
  background: var(--sunken);
  border-radius: var(--r-ctl);
}

.grade-mark {
  font-size: var(--t-lead);
  font-weight: var(--w-display);
}

.grade-words,
.grade-cert {
  font-size: var(--t-small);
  color: var(--muted);
}

.grade-cert {
  margin-left: auto;
  font-family: var(--mono);
}

.chips {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s2);
}

.chip {
  padding: 4px var(--s3);
  border-radius: var(--r-full);
  background: var(--accent-wash);
  color: var(--accent);
  font-size: var(--t-micro);
  font-weight: var(--w-med);
}

.spec-list {
  display: grid;
  grid-template-columns: 108px minmax(0, 1fr);
  gap: 0 var(--s4);
  margin: 0;
}

.spec-list dt,
.spec-list dd {
  margin: 0;
  padding: var(--s3) 0;
  font-size: var(--t-body);
  border-top: 1px solid var(--hairline);
}

.spec-list dt {
  color: var(--muted);
  font-size: var(--t-small);
}

.spec-list dt:first-child,
.spec-list dt:first-child + dd {
  border-top: 0;
}

.spec-list dd.is-inferred {
  border-bottom: 1px dashed var(--hairline-strong);
  cursor: help;
}

.spec-empty {
  margin: 0;
  color: var(--faint);
}

.card-page-actions {
  display: flex;
  align-items: center;
  gap: var(--s2);
  padding-top: var(--s3);
  border-top: 1px solid var(--hairline);
}

/* The form, when it is on this page rather than floating in the overlay:
   it is already inside a laid-out column, so it drops the panel chrome. */
.cardform.inline {
  padding: 0;
  background: none;
  border: 0;
  box-shadow: none;
}

.cardform.inline .cf-head {
  display: none;
}

.back {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  font-size: var(--t-small);
  color: var(--muted);
  text-decoration: none;
}

.back:hover {
  color: var(--ink);
}

@media (max-width: 900px) {
  .card-page {
    grid-template-columns: minmax(0, 1fr);
    gap: var(--s5);
    justify-items: center;
    min-height: 0;
    padding-top: var(--s3);
  }
  .card-page-figure {
    width: min(360px, 78vw);
  }
  .card-page-body {
    width: min(460px, 100%);
  }
}

/* ---------- stack picker ---------- */

.picker {
  display: flex;
  flex-direction: column;
  gap: 2px;
  max-height: 46vh;
  overflow-y: auto;
  margin-inline: calc(var(--s2) * -1);
}

.picker-row {
  display: flex;
  align-items: center;
  gap: var(--s3);
  width: 100%;
  padding: var(--s2) var(--s3);
  border: 0;
  border-radius: var(--r-ctl);
  background: none;
  font: inherit;
  font-size: var(--t-small);
  color: var(--ink);
  cursor: pointer;
  text-align: left;
}

.picker-row:hover {
  background: var(--sunken);
}

.picker-tick {
  display: grid;
  place-items: center;
  width: 20px;
  height: 20px;
  flex: none;
  border-radius: 6px;
  border: 1px solid var(--hairline-strong);
  color: transparent;
}

.picker-row.on .picker-tick {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--on-accent);
}

.picker-name {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.picker-meta {
  font-size: var(--t-micro);
  color: var(--faint);
}

/* ============================================================
   GRID
   How a stack is shown. Even, dense, nothing moving except
   what is under the pointer — see grid.js for why this is not
   the rack.
   ============================================================ */

.grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
  gap: var(--s7) var(--s5);
  margin-top: var(--s6);
}

.tile {
  display: flex;
  flex-direction: column;
  gap: var(--s2);
  cursor: pointer;
  border-radius: var(--r-ctl);
  outline: none;
}

.tile:focus-visible {
  box-shadow: 0 0 0 3px var(--accent-wash);
}

.tile-media {
  position: relative;
  aspect-ratio: 750 / 1212;
  border-radius: var(--r-media);
  overflow: hidden;
  background: var(--sunken);
  box-shadow: var(--card-rest);
  transition:
    transform 0.18s cubic-bezier(0.22, 0.9, 0.24, 1),
    box-shadow 0.18s ease-out;
}

.tile-media.standard {
  aspect-ratio: 750 / 1050;
}

.tile-media img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.tile:hover .tile-media,
.tile:focus-visible .tile-media {
  transform: translateY(-4px);
  box-shadow: var(--card-lift);
}

.tile-grade {
  position: absolute;
  left: 6px;
  bottom: 6px;
  padding: 2px 7px;
  border-radius: var(--r-full);
  background: rgba(20, 23, 28, 0.72);
  color: #fff;
  font-size: var(--t-micro);
  font-weight: var(--w-med);
  backdrop-filter: blur(6px);
}

/* Hidden entirely unless the grid is in edit mode. It used to appear on
   hover, which armed a delete control under the pointer on every pass
   across the grid -- the only thing between browsing and a mistake was
   where the cursor happened to stop. */
.tile-remove {
  pointer-events: none;
  position: absolute;
  top: 6px;
  right: 6px;
  display: grid;
  place-items: center;
  width: 24px;
  height: 24px;
  padding: 0;
  border: 0;
  border-radius: var(--r-full);
  background: rgba(20, 23, 28, 0.72);
  color: #fff;
  cursor: pointer;
  opacity: 0;
  transition: opacity 0.15s ease-out;
  backdrop-filter: blur(6px);
}

.grid.editing .tile-remove {
  opacity: 1;
  pointer-events: auto;
}

/* In edit mode the whole tile leans towards being removed rather than
   opened, so it says so. */
.grid.editing .tile-media {
  outline: 1px dashed var(--hairline-strong);
  outline-offset: 3px;
}

.grid.editing .tile:hover .tile-remove {
  background: var(--danger);
}

.tile-remove:hover {
  background: var(--danger);
}

/* Names, which the rack deliberately does without. In a rack the crop is the
   label; in a grid of forty you are scanning for one card, and reading beats
   recognising. */
.tile-name {
  font-size: var(--t-body);
  font-weight: var(--w-med);
  line-height: 1.35;
  letter-spacing: -0.006em;
  margin-top: var(--s1);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.tile-name.untitled {
  color: var(--faint);
  font-weight: var(--w-body);
}

.tile-line {
  font-size: var(--t-small);
  color: var(--muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  margin-top: -2px;
}

/* ============================================================
   STACK PAGE
   ============================================================ */

.stack-head {
  display: flex;
  align-items: flex-start;
  gap: var(--s5);
  flex-wrap: wrap;
  padding-bottom: var(--s5);
  margin-bottom: var(--s2);
  border-bottom: 1px solid var(--hairline);
}

.stack-head-main {
  flex: 1 1 260px;
  min-width: 0;
}

.stack-title {
  margin: 0;
  font-size: var(--t-display);
  line-height: 1.15;
}

.stack-count {
  margin: var(--s2) 0 0;
  font-size: var(--t-small);
  color: var(--muted);
}

.stack-head-actions {
  display: flex;
  gap: var(--s2);
  flex: none;
}

/* The rule, rendered. This is the payoff for storing a predicate rather than
   SQL: a definition you can read is one you can correct. */
.rule-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: var(--s3);
}

.rule-chip {
  padding: 3px var(--s3);
  border-radius: var(--r-full);
  background: var(--sunken);
  color: var(--muted);
  font-size: var(--t-micro);
  font-weight: var(--w-med);
}

.icon-btn {
  display: grid;
  place-items: center;
  width: 34px;
  height: 34px;
  border-radius: var(--r-full);
  border: 1px solid var(--hairline);
  background: var(--raised);
  color: var(--muted);
  text-decoration: none;
  cursor: pointer;
}

.icon-btn:hover {
  color: var(--ink);
  border-color: var(--hairline-strong);
}

.icon-btn.on {
  color: #d99e0b;
  border-color: rgba(217, 158, 11, 0.35);
  background: rgba(217, 158, 11, 0.1);
}

/* ============================================================
   SHELF
   Stacks drawn as stacks. Layered crops with a little offset
   and rotation; hover riffles them apart. A row of flat
   rectangles would carry the same information and none of the
   meaning — see shelf.js.
   ============================================================ */

.shelf-section {
  margin-bottom: var(--s8);
}

.shelf-head {
  display: flex;
  align-items: center;
  gap: var(--s3);
  margin-bottom: var(--s4);
}

/* A section label, not a headline. It names the row under it and then gets
   out of the way -- the cards are the content. */
.section-title {
  margin: 0;
  font-size: var(--t-small);
  font-weight: var(--w-med);
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--muted);
}

/* A wrapping grid, not a horizontal scroller.
   
   Two reasons, and the second is the one that was actually broken.
   
   A scroller hides half your stacks behind a gesture when there are six of
   them and room for all six -- you cannot see what you own, which is most of
   what this row is for.
   
   And `overflow-x: auto` establishes a clipping context. The tiles rotate
   and lift on hover, and a rotated 124px card puts its corners well outside
   its own box, so the scroller sliced them off. No amount of padding fixes
   that reliably; not clipping does. */
.shelf {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
  gap: var(--s7) var(--s5);
  padding-top: 10px;
}

.shelf-stack {
  width: 100%;
  max-width: 172px;
  display: flex;
  flex-direction: column;
  gap: var(--s3);
  text-decoration: none;
  color: inherit;
  background: none;
  border: 0;
  padding: 0;
  font: inherit;
  text-align: left;
  cursor: pointer;
}

.shelf-cards {
  position: relative;
  display: block;
  width: 100%;
  aspect-ratio: 750 / 1212;
}

.shelf-card {
  position: absolute;
  inset: 0;
  border-radius: var(--r-media);
  overflow: hidden;
  background: var(--sunken);
  box-shadow: var(--card-rest);
  transform-origin: 50% 100%;
  /* Each layer further back, rotated a touch the opposite way each time so
     the pile looks handled rather than machine-stacked. */
  transform: translateY(calc(var(--i) * -3px)) scale(calc(1 - var(--i) * 0.045))
    rotate(calc((var(--i) - 1.5) * 1.4deg));
  transition:
    transform 0.28s cubic-bezier(0.22, 0.9, 0.24, 1),
    box-shadow 0.28s ease-out;
}

.shelf-card img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.shelf-stack:hover .shelf-card,
.shelf-stack:focus-visible .shelf-card {
  transform: translateY(calc(var(--i) * -8px)) scale(calc(1 - var(--i) * 0.03))
    rotate(calc((var(--i) - 1.5) * 4deg));
}

.shelf-stack:hover .shelf-card[style*="--i:0"] {
  box-shadow: var(--card-lift);
}

/* The stacks section with nothing in it.
   Horizontal, because the collection sits directly below and this is not
   the important half of the page -- but it still carries its own button,
   which the paragraph it replaced did not. */
.shelf-none {
  display: flex;
  align-items: center;
  gap: var(--s5);
  padding: var(--s5);
  border-radius: var(--r-panel);
  background: var(--sunken);
}

.shelf-none-art {
  position: relative;
  flex: none;
  width: 46px;
  height: 60px;
}

.shelf-none-art span {
  position: absolute;
  inset: 0;
  border-radius: 3px;
  background: var(--raised);
  border: 1px solid var(--hairline);
  transform-origin: 50% 100%;
}

.shelf-none-art span:nth-child(1) {
  transform: rotate(-12deg);
  opacity: 0.5;
}

.shelf-none-art span:nth-child(2) {
  transform: rotate(5deg);
  opacity: 0.75;
}

.shelf-none-art span:nth-child(3) {
  transform: rotate(-1deg) translateY(-3px);
  box-shadow: var(--z1);
}

.shelf-none-text {
  display: flex;
  flex-direction: column;
  gap: 2px;
  flex: 1;
  min-width: 0;
}

.shelf-none-text strong {
  font-size: var(--t-body);
  font-weight: var(--w-med);
}

.shelf-none-text span {
  font-size: var(--t-small);
  color: var(--muted);
}

@media (max-width: 760px) {
  .shelf-none {
    flex-wrap: wrap;
    gap: var(--s4);
  }
  .shelf-none .btn {
    width: 100%;
  }
}

/* An empty stack still reads as a stack: same silhouette, same shadow, just
   nothing in it. A dashed outline made it look like a broken image or a drop
   target rather than a container waiting to be filled. */
.shelf-empty {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  border-radius: var(--r-media);
  background: var(--sunken);
  border: 1px solid var(--hairline);
  color: var(--faint);
  box-shadow: var(--z1);
}

.shelf-stack:hover .shelf-empty {
  color: var(--muted);
  border-color: var(--hairline-strong);
}

.shelf-label {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
  padding-top: var(--s1);
}

.shelf-name {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: var(--t-body);
  font-weight: var(--w-med);
  letter-spacing: -0.006em;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.shelf-count {
  font-size: var(--t-small);
  color: var(--muted);
}

.shelf-badge {
  display: inline-grid;
  place-items: center;
  flex: none;
}

.shelf-badge.fav {
  color: #d99e0b;
}

.shelf-badge.smart {
  color: var(--accent);
}

/* ============================================================
   SHEET
   A small centred dialog. Used where a prompt() would lose
   something worth showing — the new-stack flow has to display
   what the rule caught before the stack exists.
   ============================================================ */

.sheet {
  position: fixed;
  inset: 0;
  z-index: 1200;
  display: grid;
  place-items: center;
  padding: var(--s5);
}

.sheet-scrim {
  position: absolute;
  inset: 0;
  background: rgba(20, 23, 28, 0.4);
  backdrop-filter: blur(3px);
  animation: fade-in 0.18s ease-out;
}

.sheet-body {
  position: relative;
  width: min(460px, 100%);
  display: flex;
  flex-direction: column;
  gap: var(--s3);
  padding: var(--s5);
  border-radius: var(--r-panel);
  background: var(--raised);
  border: 1px solid var(--hairline);
  box-shadow: var(--z3);
  animation: sheet-in 0.22s cubic-bezier(0.22, 0.9, 0.24, 1);
}

@keyframes sheet-in {
  from {
    opacity: 0;
    transform: translateY(8px) scale(0.98);
  }
}

@keyframes fade-in {
  from {
    opacity: 0;
  }
}

.sheet-body h2 {
  margin: 0;
  font-size: var(--t-lead);
}

.sheet-note {
  margin: 0;
  font-size: var(--t-small);
  color: var(--muted);
}

.sheet-body input[type="text"] {
  width: 100%;
  height: 42px;
  padding-inline: var(--s4);
  background: var(--sunken);
  border: 1px solid var(--hairline);
  border-radius: var(--r-ctl);
  font: inherit;
  font-size: var(--t-body);
  color: var(--ink);
}

.sheet-body input[type="text"]:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-wash);
}

.sheet-result {
  display: flex;
  flex-direction: column;
  gap: var(--s2);
  padding: var(--s3) var(--s4);
  background: var(--sunken);
  border-radius: var(--r-ctl);
}

.sheet-count {
  margin: 0;
  font-size: var(--t-small);
  color: var(--muted);
}

.sheet-actions {
  display: flex;
  align-items: center;
  gap: var(--s2);
  margin-top: var(--s1);
}

/* ============================================================
   SEARCH
   One input, results as you type. Two destinations: a row
   answers "that one card", Enter answers "show me all of
   them" — see searchbox.js.
   ============================================================ */

/* No position rule here on purpose. .topsearch is already
   `position: absolute` further up -- that is what centres it in the
   masthead -- and an absolutely positioned element is a containing block for
   its own absolute children, so the panel below anchors to it either way.
   Re-declaring it `relative` here silently un-centred the whole field. */
.sb-panel {
  position: absolute;
  top: calc(100% + 8px);
  left: 0;
  right: 0;
  z-index: 80;
  max-height: 64vh;
  overflow-y: auto;
  padding: var(--s2);
  border-radius: var(--r-panel);
  background: var(--raised);
  border: 1px solid var(--hairline);
  box-shadow: var(--z3);
}

.sb-row {
  display: flex;
  align-items: center;
  gap: var(--s3);
  padding: var(--s2);
  border-radius: var(--r-ctl);
  text-decoration: none;
  color: inherit;
}

.sb-row:hover,
.sb-row.on {
  background: var(--sunken);
}

.sb-thumb {
  flex: none;
  width: 26px;
  height: 36px;
  border-radius: 3px;
  overflow: hidden;
  background: var(--sunken);
}

.sb-thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.sb-icon {
  display: grid;
  place-items: center;
  flex: none;
  width: 26px;
  height: 36px;
  color: var(--muted);
}

.sb-text {
  display: flex;
  flex-direction: column;
  gap: 1px;
  min-width: 0;
}

.sb-name {
  font-size: var(--t-small);
  font-weight: var(--w-med);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.sb-sub {
  font-size: var(--t-micro);
  color: var(--muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.sb-all {
  justify-content: center;
  font-size: var(--t-small);
  font-weight: var(--w-med);
  color: var(--accent);
  margin-top: 2px;
  border-top: 1px solid var(--hairline);
  border-radius: 0 0 var(--r-ctl) var(--r-ctl);
  padding-top: var(--s3);
}

.sb-none {
  padding: var(--s4);
  font-size: var(--t-small);
  color: var(--muted);
  text-align: center;
}

/* ---------- results page ---------- */

.search-head {
  display: flex;
  align-items: flex-start;
  gap: var(--s4);
  flex-wrap: wrap;
  padding-bottom: var(--s4);
  border-bottom: 1px solid var(--hairline);
}

.search-head-main {
  flex: 1 1 260px;
  min-width: 0;
}

.search-title {
  margin: 0;
  font-size: var(--t-head);
  line-height: 1.2;
}

.search-title em {
  font-style: normal;
  color: var(--muted);
}

.search-sub {
  margin: var(--s1) 0 0;
  font-size: var(--t-small);
  color: var(--muted);
}

/* The one affordance on this page. A search is a rule you are trying out;
   a smart stack is a search you decided to keep. */
.save-stack {
  flex: none;
}

.search-stacks {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s4);
  margin-top: var(--s5);
}

/* ============================================================
   NARROW SCREENS
   One app, not two. Every page works at every width; the only
   thing a phone does differently is the capture page, which
   is a different task rather than a smaller screen.
   ============================================================ */

.only-narrow {
  display: none;
}

.add-btn-short {
  display: none;
}

.wide-only {
  display: revert;
}

.wide-only[hidden] {
  display: none;
}

@media (max-width: 760px) {
  :root {
    --gutter: var(--s4);
  }

  .only-narrow {
    display: grid;
  }

  .wide-only {
    display: none !important;
  }

  /* ---- batch ---- */

  .hero-narrow {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--s3);
    text-align: center;
  }

  .btn.big {
    height: 52px;
    padding-inline: var(--s6);
    font-size: var(--t-body);
    margin-top: var(--s3);
  }

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

  /* The focus editor's panel cannot sit beside a card on a phone, so it goes
     under it and the whole thing scrolls. */
  .focus-panel {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    top: auto;
    width: 100%;
    max-height: 62vh;
    border-radius: var(--r-panel) var(--r-panel) 0 0;
    transform: translateY(8px);
    z-index: 950;
  }

  .focus-panel.in {
    transform: none;
  }

  /* The field cannot share a row with the wordmark and the buttons here, so
     it drops to a second row that exists only while it is in use. A
     permanently two-row header would spend a tenth of a phone screen on an
     empty input. */
  .masthead {
    flex-wrap: wrap;
    height: auto;
    min-height: 56px;
    padding-block: var(--s2);
    gap: var(--s2);
  }

  .topsearch {
    position: static;
    transform: none;
    width: 100%;
    order: 3;
    display: none;
  }

  .masthead.searching .topsearch {
    display: flex;
  }

  .masthead.searching #search-toggle {
    color: var(--accent);
    border-color: var(--accent);
  }

  .sb-panel {
    max-height: 60vh;
  }

  .add-btn-long {
    display: none;
  }

  .add-btn-short {
    display: block;
    font-size: var(--t-lead);
    line-height: 1;
  }

  .add-btn {
    width: 38px;
    height: 38px;
    padding: 0;
  }

  .wordmark {
    font-size: var(--t-small);
  }

  /* ---- content ---- */

  .page {
    padding: var(--s4) var(--gutter) var(--s8);
  }

  .grid {
    grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
    gap: var(--s4) var(--s3);
  }

  /* The grid's own minmax decides the column count, so shrinking the tile
     alone did nothing -- at 375px a 168px minimum leaves room for exactly
     one, and the phone showed a single stack beside an empty half-screen. */
  .shelf {
    grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
    gap: var(--s5) var(--s4);
  }

  .shelf-stack {
    max-width: none;
  }

  .shelf-name {
    font-size: var(--t-small);
  }

  .shelf-count {
    font-size: var(--t-micro);
  }

  .stack-title,
  .card-page-title {
    font-size: var(--t-head);
  }

  .stack-head,
  .search-head {
    gap: var(--s3);
  }

  .stack-head-actions {
    width: 100%;
  }

  .stack-head-actions button {
    flex: 1;
  }

  .card-page-sub {
    font-size: var(--t-body);
  }

  .spec-list {
    grid-template-columns: 96px minmax(0, 1fr);
  }

  .sheet {
    align-items: flex-end;
    padding: 0;
  }

  .sheet-body {
    width: 100%;
    border-radius: var(--r-panel) var(--r-panel) 0 0;
    padding-bottom: var(--s7);
  }
}

/* ============================================================
   POPOVER
   Panels attached to the control that opened them. Replaces
   the centred modals — see popover.js for why ticking a
   checkbox does not warrant dimming the page.
   ============================================================ */

.pop {
  position: fixed;
  z-index: 1300;
  overflow-y: auto;
  overscroll-behavior: contain;
  border-radius: 14px;
  background: var(--raised);
  border: 1px solid var(--hairline);
  box-shadow: var(--z3);
  opacity: 0;
  transform: translateY(-4px) scale(0.985);
  transform-origin: 50% 0;
  transition:
    opacity 0.13s ease-out,
    transform 0.13s cubic-bezier(0.22, 0.9, 0.24, 1);
}

.pop[data-flipped="true"] {
  transform-origin: 50% 100%;
  transform: translateY(4px) scale(0.985);
}

.pop.in {
  opacity: 1;
  transform: none;
}

.pop-body {
  display: flex;
  flex-direction: column;
  gap: var(--s3);
  padding: var(--s4);
}

.pop-list {
  gap: 0;
  padding: var(--s2);
}

.pop-head {
  font-size: var(--t-micro);
  font-weight: var(--w-med);
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--muted);
  padding: var(--s1) var(--s2) var(--s2);
}

.pop-label {
  font-size: var(--t-small);
  font-weight: var(--w-med);
  color: var(--muted);
}

.pop-note {
  font-size: var(--t-micro);
  color: var(--faint);
  line-height: 1.5;
}

.pop-note.pad {
  padding: var(--s3) var(--s2);
}

.pop-body input[type="text"] {
  width: 100%;
  height: 38px;
  padding-inline: var(--s3);
  background: var(--sunken);
  border: 1px solid var(--hairline);
  border-radius: var(--r-ctl);
  font: inherit;
  font-size: var(--t-body);
  color: var(--ink);
  box-sizing: border-box;
}

.pop-body input[type="text"]:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-wash);
}

.pop-row {
  display: flex;
  align-items: center;
  gap: var(--s2);
}

.pop-sep {
  height: 1px;
  background: var(--hairline);
  margin: var(--s2) calc(var(--s2) * -1);
}

.pop-scroll {
  display: flex;
  flex-direction: column;
  gap: 1px;
  max-height: 264px;
  overflow-y: auto;
}

.pop-item {
  display: flex;
  align-items: center;
  gap: var(--s3);
  width: 100%;
  padding: var(--s2) var(--s2);
  border: 0;
  border-radius: 9px;
  background: none;
  font: inherit;
  font-size: var(--t-small);
  color: var(--ink);
  cursor: pointer;
  text-align: left;
}

.pop-item:hover:not(:disabled) {
  background: var(--sunken);
}

.pop-item:disabled {
  cursor: default;
  opacity: 0.75;
}

.pop-tick {
  display: grid;
  place-items: center;
  width: 19px;
  height: 19px;
  flex: none;
  border-radius: 5px;
  border: 1px solid var(--hairline-strong);
  color: transparent;
}

.pop-item.on .pop-tick {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--on-accent);
}

.pop-tick.ghost {
  border: 0;
  color: var(--muted);
}

.pop-item-name {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.pop-item-meta {
  font-size: var(--t-micro);
  color: var(--faint);
  font-variant-numeric: tabular-nums;
}

.pop-result {
  display: flex;
  flex-direction: column;
  gap: var(--s2);
  padding: var(--s3);
  background: var(--sunken);
  border-radius: var(--r-ctl);
}

.pop-count {
  font-size: var(--t-micro);
  color: var(--muted);
}

/* A text button. Used for the secondary path out of a popover, where a
   bordered button would compete with the primary one. */
button.link {
  border: 0;
  background: none;
  box-shadow: none;
  padding: 0;
  color: var(--muted);
  font-size: var(--t-small);
  font-weight: var(--w-med);
  cursor: pointer;
}

button.link:hover {
  color: var(--accent);
}

.pop-confirm-title {
  font-size: var(--t-body);
  font-weight: var(--w-med);
  line-height: 1.35;
}

/* A destructive primary. The bordered .destructive is right for a button
   sitting among others; a confirmation's whole purpose is that one button
   is the answer, so it takes the weight. */
.destructive-solid {
  background: var(--danger);
  border-color: var(--danger);
  color: #fff;
}

.destructive-solid:hover {
  filter: brightness(0.94);
}

.pop-newstack {
  display: flex;
  flex-direction: column;
  gap: var(--s3);
  padding: 0 var(--s2) var(--s2);
}

/* ---------- selection mode ---------- */

.tile.selectable {
  cursor: pointer;
}

.tile-check {
  position: absolute;
  top: 8px;
  left: 8px;
  z-index: 2;
  display: grid;
  place-items: center;
  width: 22px;
  height: 22px;
  border-radius: var(--r-full);
  background: rgba(255, 255, 255, 0.9);
  border: 1px solid var(--hairline-strong);
  color: transparent;
  backdrop-filter: blur(6px);
  transition:
    background 0.12s,
    color 0.12s,
    border-color 0.12s;
}

.tile.chosen .tile-check {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--on-accent);
}

.tile.chosen .tile-media {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* Already in the stack: visible, so the grid keeps the shape you remember,
   but not choosable. */
.tile.already {
  cursor: default;
}

.tile.already .tile-media {
  opacity: 0.4;
}

.tile.already .tile-check {
  background: var(--sunken);
  border-color: var(--hairline);
  color: var(--faint);
}

.tile.already:hover .tile-media {
  transform: none;
  box-shadow: var(--card-rest);
}

/* A bar that exists only once something is picked, and says what will
   happen to how many. A floating "+" would leave the count nowhere. */
.addbar {
  position: fixed;
  left: 50%;
  bottom: var(--s5);
  transform: translateX(-50%);
  z-index: 200;
  display: flex;
  align-items: center;
  gap: var(--s3);
  width: min(520px, calc(100vw - var(--s5) * 2));
  padding: var(--s3) var(--s3) var(--s3) var(--s5);
  border-radius: var(--r-full);
  background: var(--raised);
  border: 1px solid var(--hairline);
  box-shadow: var(--z3);
  animation: addbar-in 0.2s cubic-bezier(0.22, 0.9, 0.24, 1);
}

@keyframes addbar-in {
  from {
    opacity: 0;
    transform: translate(-50%, 10px);
  }
}

.addbar-count {
  font-size: var(--t-small);
  font-weight: var(--w-med);
  font-variant-numeric: tabular-nums;
}

.field-inline input {
  height: 36px;
  width: min(260px, 46vw);
  padding-inline: var(--s3);
  background: var(--raised);
  border: 1px solid var(--hairline);
  border-radius: var(--r-ctl);
  font: inherit;
  font-size: var(--t-small);
  color: var(--ink);
}

.field-inline input:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-wash);
}

.empty-actions {
  display: flex;
  gap: var(--s2);
  justify-content: center;
  flex-wrap: wrap;
}

/* The way to the rest of them. Quiet, because it is a fallback for a shelf
   that ran out of room rather than a thing to reach for. */
.shelf-all {
  font-size: var(--t-small);
  color: var(--muted);
  text-decoration: none;
}

.shelf-all:hover {
  color: var(--accent);
}

/* On its own page the shelf is an inventory, so it wraps. */
#stacks-page .shelf {
  margin-top: var(--s6);
}

/* ============================================================
   SETTINGS
   ============================================================ */

.settings-list {
  display: flex;
  flex-direction: column;
  max-width: 620px;
  margin-top: var(--s6);
}

.setting {
  display: flex;
  align-items: flex-start;
  gap: var(--s5);
  padding: var(--s5) 0;
  border-bottom: 1px solid var(--hairline);
}

.setting:first-child {
  padding-top: 0;
}

.setting-text {
  flex: 1;
  min-width: 0;
}

.setting-label {
  font-size: var(--t-body);
  font-weight: var(--w-med);
}

.setting-blurb {
  margin: var(--s1) 0 0;
  font-size: var(--t-small);
  color: var(--muted);
  line-height: 1.55;
}

.toggle {
  flex: none;
  position: relative;
  width: 44px;
  height: 26px;
  padding: 0;
  margin-top: 2px;
  border: 0;
  border-radius: var(--r-full);
  background: var(--hairline-strong);
  cursor: pointer;
  transition: background 0.16s ease-out;
}

.toggle.on {
  background: var(--accent);
}

.toggle-knob {
  position: absolute;
  top: 3px;
  left: 3px;
  width: 20px;
  height: 20px;
  border-radius: var(--r-full);
  background: #fff;
  box-shadow: var(--z1);
  transition: transform 0.16s cubic-bezier(0.22, 0.9, 0.24, 1);
}

.toggle.on .toggle-knob {
  transform: translateX(18px);
}

/* ============================================================
   CARD VIEW
   One component, two homes: the page at /card/{id} and the
   overlay on the collection. Nothing here is a form — see
   editable.js.
   ============================================================ */

.cardview {
  display: flex;
  flex-direction: column;
  gap: var(--s4);
  min-width: 0;
}

.cv-head {
  display: flex;
  flex-direction: column;
  gap: var(--s1);
}

.cv-title {
  margin: 0;
  font-size: var(--t-display);
  line-height: 1.1;
  letter-spacing: -0.022em;
}

.cardview.compact .cv-title {
  font-size: var(--t-head);
}

.cv-sub {
  margin: 0;
  font-size: var(--t-lead);
  color: var(--muted);
  line-height: 1.35;
}

.cardview.compact .cv-sub {
  font-size: var(--t-small);
}

.cv-tools {
  display: flex;
  align-items: center;
  gap: var(--s2);
}

.icon-btn.danger:hover {
  color: var(--danger);
  border-color: var(--danger);
  background: var(--danger-wash);
}

.cv-stacks {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s2);
}

/* The grade, as a badge.
 *
 * Sized to its content rather than stretched across the column, tinted by
 * tier, with the numeral carrying the weight. A grade is the one fact about
 * a slab you read from across a room, so it is allowed to look like a mark
 * rather than a row of a table.
 */
.cv-grade {
  display: flex;
  align-items: center;
  gap: var(--s3);
}

/* Centred by construction, not by nudging.
 *
 * The parts were laid out on `align-items: baseline`, which is the right
 * answer for text of one size and the wrong one here. 11px small caps, 13px
 * small caps and a 25px numeral sharing a baseline means the numeral's whole
 * mass hangs above it, so the cluster sat low in its own box: the padded
 * content band ran 338.7–364.7px and the numeral's box centre was 350.0,
 * "MINT" 354.5, "PSA" 355.5. Three parts, three different centres, 5.5px
 * apart, all of them below the box's own 351.7.
 *
 * Part of that is the <select>: its baseline comes from its inner box rather
 * than from the text you can see, so "PSA" landed a further 3.75px down from
 * the numeral it was meant to sit level with. No amount of baseline nudging
 * fixes that, because the offset is the control's, not the text's.
 *
 * Two rules settle it. `align-items: center` centres each part's box, and
 * `line-height: 1` on every part makes each box exactly its own font-size
 * tall. In a line-height:1 box the half-leading is negative and symmetric,
 * which puts the optical centre of cap-height glyphs at ~0.505 of the box
 * whatever the size — and every character in this badge is a capital or a
 * digit, none of them descend. So centring the boxes centres the glyphs, at
 * 11px and at 24px alike, with no offsets to go stale when a size changes.
 *
 * Everything is on the design system's scales now; none of it was. 25px
 * type, an 11px gap, an 11px radius and 9/14/9/13 padding are five values
 * that appear nowhere else in this file. min-height holds the outline
 * steady on a raw card, where the numeral is hidden and the badge would
 * otherwise shrink.
 */
.cv-grade-badge {
  display: inline-flex;
  align-items: center;
  gap: var(--s3);
  min-height: 40px;
  padding: var(--s2) var(--s3);
  border-radius: var(--r-ctl);
  background: var(--tier-wash);
  box-shadow: inset 0 0 0 1px var(--tier-edge);
}

/* The wording and the numeral are one statement — the slab says "MINT 9",
   not "MINT" and "9" — so they bind tighter than the gap around the rule.
   That is what makes the two gaps differ on purpose rather than by
   accident: 12px either side of the rule, 8px inside the mark. */
.cv-grade-mark {
  display: inline-flex;
  align-items: center;
  gap: var(--s2);
}

/* Where the house stops and the grade starts. Inked from --tier-ink rather
   than --tier-edge, which is transparent on the low tiers and on a raw
   card — the divider has the same job in all of them. */
.cv-grade-rule {
  flex: none;
  width: 1px;
  height: var(--s4);
  background: var(--tier-ink);
  opacity: 0.25;
}

/* The grader is a <select>. Laying it out as a flex item of its own span
   removes the line box it otherwise sits in, so the span is exactly as tall
   as the control and nothing depends on a strut. */
.cv-grade .editable-select {
  display: flex;
  align-items: center;
}

.cv-grade-by {
  font-size: var(--t-micro);
  line-height: 1;
  font-weight: var(--w-display);
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--tier-ink);
  opacity: 0.72;
}

/* Letter-spacing is added after every letter, including the last one, so
   "PSA" carries 0.99px of empty track on its right that the 12px gap then
   adds to. Measured, the ink sat 15.4px from the rule on the left and
   13.1px on the right; taking the track back evens it to 14.4 / 13.1, and
   what is left is the two glyphs' own sidebearings. Beats .editable-select's
   `margin: 0`, hence the two-class selector. */
.cv-grade .cv-grade-by {
  margin-inline-end: -0.09em;
}

.cv-grade-word {
  font-size: var(--t-small);
  line-height: 1;
  font-weight: var(--w-med);
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--tier-ink);
  opacity: 0.86;
}

/* A slab with a number but no wording is uncommon and real, and .editable's
   1.5em minimum turned that case into a 30px hole in the middle of the
   badge. Narrow enough not to read as a gap, wide enough to still hover and
   click, because it is the only way to type the wording in. */
.cv-grade-word.is-empty {
  min-width: 10px;
}

.cv-grade-num {
  font-size: var(--t-head);
  line-height: 1;
  font-weight: var(--w-display);
  letter-spacing: -0.03em;
  font-variant-numeric: tabular-nums;
  color: var(--tier-ink);
}

/* .editable keeps a 1.5em floor so an empty value is still worth aiming at.
   At 24px that is 36px of box for 15px of "9", and every pixel of the slack
   landed between the numeral and the badge's right edge: measured, 12.9px
   of ink-to-edge on the left against 27.3px on the right. The numeral never
   renders empty — it falls back to "–" — so it does not need the floor.
   Two classes because .editable's own rule is further down the file. */
.cv-grade .cv-grade-num {
  min-width: 0;
}

/* The tiers. A 10 and a 6 are different objects to a collector; this is the
   only place in the app that colours by value, which is what keeps it
   meaning something. */
.cv-grade {
  --tier-ink: var(--muted);
  --tier-wash: var(--sunken);
  --tier-edge: transparent;
}

.cv-grade.tier-gem {
  --tier-ink: #9a6b04;
  --tier-wash: rgba(214, 158, 10, 0.13);
  --tier-edge: rgba(214, 158, 10, 0.26);
}

.cv-grade.tier-mint {
  --tier-ink: #17724a;
  --tier-wash: rgba(31, 138, 85, 0.11);
  --tier-edge: rgba(31, 138, 85, 0.22);
}

.cv-grade.tier-good {
  --tier-ink: #2a53b8;
  --tier-wash: rgba(47, 95, 208, 0.1);
  --tier-edge: rgba(47, 95, 208, 0.2);
}

.cv-grade.tier-low,
.cv-grade.tier-none {
  --tier-ink: var(--muted);
  --tier-wash: var(--sunken);
  --tier-edge: transparent;
}

/* Nothing to celebrate on a raw card: the badge becomes a quiet invitation
   to say who graded it. */
.cv-grade.ungraded .cv-grade-badge {
  background: none;
  box-shadow: inset 0 0 0 1px var(--hairline);
}

.cv-grade.ungraded .cv-grade-num {
  display: none;
}

/* Nothing to divide once the grade is gone, and no wording to leave a slot
   for. What is left is "Not graded" in a hairline box, which is the whole
   of the invitation. */
.cv-grade.ungraded .cv-grade-rule,
.cv-grade.ungraded .cv-grade-word.is-empty {
  display: none;
}

[data-theme="dark"] .cv-grade.tier-gem {
  --tier-ink: #e8b73c;
}

[data-theme="dark"] .cv-grade.tier-mint {
  --tier-ink: #5cc78d;
}

[data-theme="dark"] .cv-grade.tier-good {
  --tier-ink: #8fb2ff;
}

/* Only the <select> inherits, and only from the span it sits in -- naming
   the spans here too set them to `inherit` at a higher specificity than
   their own rules, which collapsed the numeral back to body size. */
/* Tighter than the default editable select: inside the badge the parts sit
   close together, and the usual 4px of padding either side put the control's
   box on top of the word beside it. */
.cv-grade .editable-select select {
  font: inherit;
  letter-spacing: inherit;
  color: inherit;
  text-transform: inherit;
  /* `font: inherit` carries line-height too, but this one is load-bearing —
     the badge centres on every part's box being exactly its font-size tall
     — so it is stated rather than assumed. */
  line-height: 1;
  padding-block: 3px;
}

/* One hover chip, the same shape on all three parts.
 *
 * .editable's 5px inset is sized for the spec rows, where a value has a
 * column to itself. In here the parts sit 8px apart, so the word's chip
 * came within half a pixel of the numeral and read as a smear joining the
 * two. And .editable-select zeroes padding and margin outright, so the
 * grader had no inset at all. 3px, on whichever box actually paints. */
.cv-grade .cv-grade-word,
.cv-grade .cv-grade-num,
.cv-grade .editable-select select {
  padding-inline: 3px;
  margin-inline: -3px;
  border-radius: 4px;
}

/* The wash itself. The generic .editable-select select:hover paints
   --sunken, which on a tinted badge is an opaque grey patch rather than a
   hint; and the span behind the control would lay a second wash under the
   first, so the control carries it alone. */
.cv-grade .cv-grade-word:hover,
.cv-grade .cv-grade-num:hover,
.cv-grade .editable-select select:hover {
  background: rgba(20, 23, 28, 0.05);
}

/* A darker wash on an already-dark badge is no wash at all. */
[data-theme="dark"] .cv-grade .cv-grade-word:hover,
[data-theme="dark"] .cv-grade .cv-grade-num:hover,
[data-theme="dark"] .cv-grade .editable-select select:hover {
  background: rgba(255, 255, 255, 0.07);
}

/* Measures the selected option so a select can be as wide as its own text. */
.editable-sizer {
  position: absolute;
  visibility: hidden;
  white-space: pre;
  pointer-events: none;
  font: inherit;
  letter-spacing: inherit;
  text-transform: inherit;
}

.editable-select {
  position: relative;
}

/* Rookie / Autograph / Relic — checkboxes, not labels. The reasoning for
   the shape, and for keeping the unset ones on screen, is with FLAGS in
   cardview.js. */
.cv-flags {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s2);
}

.flag {
  display: inline-flex;
  align-items: center;
  gap: var(--s2);
  padding: var(--s1) var(--s3) var(--s1) var(--s2);
  border-radius: var(--r-full);
  border: 1px solid var(--hairline);
  /* Not --raised. A white pill on the sheet is brighter than the accent
     wash beside it, so the false marks were the ones that caught the eye
     first — the row read loudest where it had least to say. */
  background: none;
  font: inherit;
  font-size: var(--t-small);
  /* The box is the tallest thing in here, so pinning the text to its own
     size lets the chip come out at a whole 26px instead of 29.5. */
  line-height: 1;
  /* One weight in both states. Switching to --w-med when set would reflow
     the row under the cursor on every click, and the box already carries
     the difference. */
  font-weight: var(--w-body);
  color: var(--muted);
  cursor: pointer;
  box-shadow: none;
  transition:
    background 0.12s ease-out,
    border-color 0.12s ease-out,
    color 0.12s ease-out;
}

.flag-box {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: none;
  width: var(--s4);
  height: var(--s4);
  border-radius: var(--r-media);
  border: 1.5px solid var(--hairline-strong);
  /* The tick is stroked in currentColor, so the box's own colour is the
     whole of its on/off state — one property, and it fades rather than
     snaps. */
  color: transparent;
  transition:
    background 0.12s ease-out,
    border-color 0.12s ease-out,
    color 0.12s ease-out;
}

.flag-box svg {
  width: 11px;
  height: 11px;
}

.flag.on {
  background: var(--accent-wash);
  border-color: transparent;
  color: var(--accent);
}

.flag.on .flag-box {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--on-accent);
}

.flag:hover {
  border-color: var(--hairline-strong);
  color: var(--ink);
}

/* Hovering an empty box ghosts the tick in. It is the shortest possible
   answer to "what does clicking this do", and it costs a line. */
.flag:not(.on):hover .flag-box {
  border-color: var(--faint);
  color: var(--hairline-strong);
}

.flag.on:hover {
  border-color: transparent;
  color: var(--accent);
}

.cv-spec {
  display: grid;
  grid-template-columns: 104px minmax(0, 1fr);
  gap: 0 var(--s4);
  margin: 0;
}

.cv-spec dt,
.cv-spec dd {
  margin: 0;
  padding: var(--s2) 0;
  font-size: var(--t-body);
  min-height: 22px;
  border-top: 1px solid var(--hairline);
}

.cv-spec dt {
  color: var(--muted);
  font-size: var(--t-small);
  padding-top: calc(var(--s2) + 2px);
}

.cv-spec dt:first-child,
.cv-spec dt:first-child + dd {
  border-top: 0;
}

.cardview.compact .cv-spec {
  grid-template-columns: 88px minmax(0, 1fr);
}

.cardview.compact .cv-spec dt,
.cardview.compact .cv-spec dd {
  font-size: var(--t-small);
  padding: 6px 0;
}

/* A disclosure, so it has to look like one in both directions. It used to
   delete itself on the way open, which is why there was no way back. */
.cv-more {
  align-self: flex-start;
  display: inline-flex;
  align-items: center;
  gap: var(--s1);
  border: 0;
  background: none;
  box-shadow: none;
  padding: var(--s1) 0;
  font: inherit;
  font-size: var(--t-small);
  color: var(--muted);
  cursor: pointer;
}

.cv-more:hover {
  color: var(--accent);
}

.cv-more-chev {
  display: inline-flex;
  transition: transform 0.15s ease-out;
}

.cv-more[aria-expanded="true"] .cv-more-chev {
  transform: rotate(90deg);
}

/* ---------- editable text ----------
   A value looks like a value. The only thing that marks it as editable is a
   faint wash on hover, and a caret once you are in it. A permanent box
   around every field is the form this replaced. */

.editable {
  position: relative;
  border-radius: 5px;
  margin-inline: -5px;
  padding-inline: 5px;
  cursor: text;
  transition: background 0.12s ease-out;
  outline: none;
  min-width: 1.5em;
  display: inline-block;
}

.editable:hover {
  background: var(--sunken);
}

.editable.editing {
  background: var(--sunken);
  box-shadow: 0 0 0 2px var(--accent-wash);
  cursor: text;
}

.editable.is-empty {
  color: var(--faint);
}

.editable:focus-visible {
  box-shadow: 0 0 0 2px var(--accent-wash);
}

.cv-title.editable {
  margin-inline: -8px;
  padding-inline: 8px;
}

/* A select that reads as text until you go near it. */
.editable-select {
  padding: 0;
  margin: 0;
}

.editable-select select {
  font: inherit;
  font-size: inherit;
  color: inherit;
  background: none;
  border: 0;
  border-radius: 5px;
  padding: 2px 4px;
  margin-inline: -4px;
  cursor: pointer;
  appearance: none;
}

.editable-select select:hover {
  background: var(--sunken);
}

.editable-select.is-empty select {
  color: var(--faint);
}

/* The whole of the save UI. Pinned to the right edge of its row, which is
   always on screen -- offsetting from the value put it off the side of a
   phone, where the clipped green text read as a stray line. */
.cv-spec dd,
.cv-head {
  position: relative;
}

.saved-flash {
  position: absolute;
  right: 0;
  top: 50%;
  transform: translateY(-50%) translateX(3px);
  padding-left: var(--s3);
  font-size: var(--t-micro);
  color: var(--good);
  white-space: nowrap;
  /* Long values run under it, so it carries the surface it sits on. */
  background: linear-gradient(90deg, transparent, var(--raised) 12px);
  opacity: 0;
  pointer-events: none;
  transition:
    opacity 0.16s ease-out,
    transform 0.16s ease-out;
}

.saved-flash.in {
  opacity: 1;
  transform: translateY(-50%);
}

.toast-action {
  margin-left: var(--s3);
  border: 0;
  background: none;
  box-shadow: none;
  padding: 0;
  font: inherit;
  font-size: var(--t-small);
  font-weight: var(--w-med);
  color: var(--accent);
  cursor: pointer;
}

.toast-action:hover {
  text-decoration: underline;
}

/* ============================================================
   ADDER
   Filling a stack from inside it. A strip above the grid, so
   the thing you are filling stays on screen — see stackadd.js.
   ============================================================ */

.adder {
  display: flex;
  flex-direction: column;
  gap: var(--s3);
  margin-top: var(--s5);
  padding: var(--s4);
  border-radius: var(--r-panel);
  background: var(--sunken);
  animation: adder-in 0.18s cubic-bezier(0.22, 0.9, 0.24, 1);
}

@keyframes adder-in {
  from {
    opacity: 0;
    transform: translateY(-6px);
  }
}

.adder-bar {
  display: flex;
  align-items: center;
  gap: var(--s3);
  padding-inline: var(--s3);
  height: 40px;
  background: var(--raised);
  border: 1px solid var(--hairline);
  border-radius: var(--r-ctl);
  color: var(--faint);
}

.adder-bar input {
  flex: 1;
  min-width: 0;
  border: 0;
  background: none;
  font: inherit;
  font-size: var(--t-body);
  color: var(--ink);
  outline: none;
}

.adder-bar .icon-btn {
  width: 28px;
  height: 28px;
  border: 0;
  background: none;
}

/* One scrolling row. A wrapping grid here would push the stack itself off
   the screen, which is the thing this strip exists to keep in view. */
.adder-results {
  display: flex;
  gap: var(--s3);
  overflow-x: auto;
  padding-bottom: var(--s2);
  min-height: 132px;
}

.adder-card {
  flex: none;
  width: 86px;
  /* The captions are nowrap+ellipsis, which needs a definite width to clip
     against -- without it they ran straight over the neighbouring card. */
  max-width: 86px;
  min-width: 0;
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 2px;
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  text-align: left;
  cursor: pointer;
  box-shadow: none;
}

.adder-card img {
  width: 86px;
  height: 118px;
  object-fit: cover;
  border-radius: var(--r-media);
  box-shadow: var(--card-rest);
  transition:
    transform 0.16s cubic-bezier(0.22, 0.9, 0.24, 1),
    box-shadow 0.16s;
}

.adder-card:hover img {
  transform: translateY(-3px);
  box-shadow: var(--card-lift);
}

/* It is on its way into the grid below; fading it out is the handoff. */
.adder-card.adding {
  opacity: 0.35;
  pointer-events: none;
}

.adder-card-name,
.adder-card-set {
  display: block;
  width: 86px;
  max-width: 86px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: var(--t-micro);
}

.adder-card-name {
  margin-top: var(--s1);
  font-weight: var(--w-med);
}

.adder-card-set {
  color: var(--muted);
}

.adder-note {
  display: grid;
  place-items: center;
  width: 100%;
  font-size: var(--t-small);
  color: var(--faint);
}

/* A pressed toggle among ordinary buttons. */
/* Scoped to the stack page's Edit toggle, which is what it was written
   for. As a bare `button.on` it reached every component that marks a
   selected state with .on -- and the stack picker's rows are buttons, so
   each ticked row was getting an accent tint and accent text from a rule
   about a completely different control. That is what made the picker a
   block of blue after its own tint had been removed. */
.stack-head-actions button.on {
  color: var(--accent);
  border-color: var(--accent);
  background: var(--accent-wash);
}

/* ============================================================
   EMPTY STATES
   An empty collection is the beginning of one, not a failure
   report. Each of these opens the door it is talking about.
   ============================================================ */

.blank {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: var(--s3);
  padding: var(--s8) var(--s5) var(--s9);
  max-width: 460px;
  margin-inline: auto;
}

/* Three blank cards in the fan's own geometry: a picture of what the page
   becomes, rather than an icon of a thing that is missing. */
.blank-art {
  position: relative;
  width: 132px;
  height: 168px;
  margin-bottom: var(--s4);
}

.blank-art span {
  position: absolute;
  inset: 0;
  border-radius: var(--r-media);
  background: var(--raised);
  border: 1px solid var(--hairline);
  transform-origin: 50% 100%;
}

.blank-art span:nth-child(1) {
  transform: rotate(-11deg) translateY(-2px);
  opacity: 0.55;
}

.blank-art span:nth-child(2) {
  transform: rotate(4deg);
  opacity: 0.8;
}

.blank-art span:nth-child(3) {
  transform: rotate(-2deg) translateY(-6px);
  box-shadow: var(--card-rest);
}

.blank h2 {
  margin: 0;
  font-size: var(--t-head);
}

.blank p {
  margin: 0;
  color: var(--muted);
  line-height: 1.6;
}

.blank .btn.big {
  margin-top: var(--s3);
  height: 46px;
  padding-inline: var(--s6);
  font-size: var(--t-body);
}

.blank-aside {
  font-size: var(--t-small);
  color: var(--faint);
}

/* ---------- stack picker ---------- */

/* ---------- stack picker ----------
   A menu, not a form. It had a heavy focus ring nested inside a grey pill
   and an empty checkbox in front of every name, which is three containers
   and a form control to answer "which stack". */

.pk {
  display: flex;
  flex-direction: column;
  padding: 0;
}

/* The search is the panel's header, not a box inside it.
   It is focused the instant the panel opens, so a bordered input with an
   accent ring made the loudest thing on screen a control nobody had typed
   in yet. The caret is the focus indicator. */
.pk-search {
  display: flex;
  align-items: center;
  gap: var(--s3);
  height: 42px;
  padding-inline: var(--s4);
  border-bottom: 1px solid var(--hairline);
  color: var(--faint);
}

.pk-search input {
  flex: 1;
  min-width: 0;
  border: 0;
  background: none;
  font: inherit;
  font-size: var(--t-body);
  color: var(--ink);
}

.pk-search input:focus,
.pk-search input:focus-visible {
  outline: none;
  box-shadow: none;
}

.pk-search input::placeholder {
  color: var(--faint);
}

.pk-list {
  display: flex;
  flex-direction: column;
  max-height: 260px;
  overflow-y: auto;
  padding: 6px;
}

/* Rows sit directly against each other. They read as a list of names, so
   there is nothing between them to separate. */
.pk-row {
  display: flex;
  align-items: center;
  gap: var(--s3);
  width: 100%;
  height: 32px;
  padding-inline: 10px;
  border: 0;
  border-radius: 7px;
  background: none;
  box-shadow: none;
  font: inherit;
  font-size: var(--t-body);
  color: var(--ink);
  cursor: pointer;
  text-align: left;
}

.pk-row:hover:not(:disabled) {
  background: var(--sunken);
}

.pk-row:disabled {
  cursor: default;
  color: var(--muted);
}

.pk-name {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.pk-meta {
  font-size: var(--t-micro);
  color: var(--faint);
  font-variant-numeric: tabular-nums;
}

/* A selected row used to tint its background, colour its text AND show a
   check -- three signals for one bit, which with most rows ticked turned
   the panel blue. The check carries it alone. */
.pk-check {
  display: grid;
  place-items: center;
  width: 14px;
  flex: none;
  color: var(--accent);
  opacity: 0;
  transform: scale(0.6);
  transition:
    opacity 0.12s,
    transform 0.14s cubic-bezier(0.22, 0.9, 0.24, 1);
}

.pk-row.on .pk-check {
  opacity: 1;
  transform: none;
}

.pk-new {
  height: 40px;
  margin: 0;
  border-top: 1px solid var(--hairline);
  border-radius: 0;
  padding-inline: var(--s4);
  color: var(--muted);
}

.pk-new:hover {
  background: var(--sunken);
  color: var(--accent);
}

.pk-plus {
  display: grid;
  place-items: center;
  width: 14px;
  flex: none;
}

.pk-none {
  padding: var(--s5) var(--s3);
  font-size: var(--t-small);
  color: var(--faint);
  text-align: center;
}

/* The trigger reacts, because a tick in a list you are about to close is
   easy to miss and the thing you were looking at is the card. */
@keyframes did-it {
  0% {
    transform: scale(1);
  }
  35% {
    transform: scale(1.18);
  }
  100% {
    transform: scale(1);
  }
}

.icon-btn.just-did,
button.just-did {
  animation: did-it 0.42s cubic-bezier(0.22, 0.9, 0.24, 1);
  color: var(--good);
  border-color: var(--good);
}

/* The re-crop page: the editor wants room, so it gets the page. */
.recrop-stage {
  position: relative;
  height: calc(100vh - 220px);
  min-height: 460px;
  margin-top: var(--s5);
}

/* The crop editor, running inside whatever is already showing the card --
   the figure on /card/{id}, the stage card in the overlay.
   
   The host keeps the size it already had. It used to be given its own
   dimensions for cropping, which is what made the card jump and shrink the
   moment you pressed the button: the picture was replaced by a surface
   measured from something else. The editor now opens at the picture's exact
   rect, so nothing moves.
   
   `overflow: visible` is the one thing that does change -- the corner
   handles straddle the edge by design and would otherwise be clipped. */
.card-page-figure.is-cropping,
.stage-card.is-cropping {
  position: relative;
  overflow: visible;
  /* room under the card for the controls, which sit clear of it */
  margin-bottom: 68px;
}

/* The original stays in the layout, holding the box open, and is only made
   invisible once the canvas behind it is painted. Removing it would collapse
   the very geometry the editor was measured against. */
.card-page-figure.is-cropping .card-frame {
  box-shadow: none;
}

/* Done / Cancel for the in-place crop. They sit under the surface rather
   than in a panel, because the crop can run anywhere the card is shown and
   the panel is exactly what it does without.

   Fully clear of the card, not overlapping its lower edge. They were at
   -6px, which put them across the bottom of the picture -- over the part of
   a slab that carries the set name, and directly on top of the two corner
   handles you are most likely to be dragging. */
.crop-bar {
  position: absolute;
  /* left/top are set from the card's rect in cropinplace.js -- the host's
     own box is not reliably the same shape as the picture inside it. */
  transform: translateX(-50%);
  display: flex;
  gap: var(--s2);
  z-index: 950;
  padding: 6px;
  border-radius: var(--r-ctl);
  background: var(--raised);
  box-shadow: var(--z2);
  white-space: nowrap;
}

/* ============================================================
   BREADCRUMB
   How you got here, and the way back. Built from a `from`
   parameter rather than history, so it survives a reload and
   a pasted link — see breadcrumb.js.
   ============================================================ */

.crumbs {
  display: flex;
  align-items: center;
  gap: 2px;
  margin-bottom: var(--s5);
  font-size: var(--t-small);
  line-height: 1;
  min-height: 20px;
}

.crumb {
  display: inline-flex;
  align-items: center;
  padding: 3px 6px;
  margin-inline: -6px;
  border-radius: 6px;
  color: var(--muted);
  text-decoration: none;
  white-space: nowrap;
  max-width: 22ch;
  overflow: hidden;
  text-overflow: ellipsis;
  transition:
    background 0.12s,
    color 0.12s;
}

a.crumb:hover {
  color: var(--ink);
  background: var(--sunken);
}

a.crumb:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

/* Where you are. Not a link, so it does not invite a click that reloads the
   page you are already on. */
.crumb.here {
  color: var(--ink);
  font-weight: var(--w-med);
  margin-inline: 0;
  padding-inline: 0;
}

.crumb-sep {
  flex: none;
  color: var(--faint);
  margin-inline: 5px;
}

@media (max-width: 760px) {
  .crumbs {
    margin-bottom: var(--s4);
  }

  /* On a phone the trail is one line of a narrow screen, so the middle
     crumbs give up their room before the one you are on does. */
  .crumb {
    max-width: 12ch;
  }
}

/* ============================================================
   SIGN IN
   One page, one button. Reached only when auth is configured
   and nobody is signed in — see auth.py.
   ============================================================ */

.login-page {
  display: grid;
  place-items: center;
  min-height: 100vh;
  padding: var(--s5);
}

.login {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: var(--s3);
  max-width: 360px;
}

.login-mark {
  color: var(--accent);
  margin-bottom: var(--s2);
}

.login h1 {
  margin: 0;
  font-size: var(--t-head);
  letter-spacing: -0.022em;
}

.login-sub {
  margin: 0;
  color: var(--muted);
}

/* Not `.btn primary`.
 *
 * Google's mark is drawn for a white or near-black surface -- the two
 * button styles their brand terms actually specify. On this app's indigo
 * accent the four brand colours go muddy and the blue arm of the G nearly
 * disappears into the fill. The plain surface button is the shape everyone
 * already recognises as "sign in with Google", and it is the one page in
 * the app with a single action, so it does not need accent colour to say
 * where to click. */
.login-google {
  margin-top: var(--s4);
  height: 46px;
  padding-inline: var(--s5);
  font-size: var(--t-body);
  font-weight: var(--w-med);
}
.login-google:hover {
  box-shadow: var(--z2);
}

/* The mark keeps its own colours in both themes: `--raised` is white in
   light and near-black in dark, which are the two surfaces it is drawn
   for. */
.login-google svg {
  flex: none;
}

.login-note {
  margin: var(--s3) 0 0;
  font-size: var(--t-small);
  color: var(--faint);
  line-height: 1.55;
}

.login-error {
  margin: 0;
  padding: var(--s3) var(--s4);
  border-radius: var(--r-ctl);
  background: var(--danger-wash);
  color: var(--danger);
  font-size: var(--t-small);
  line-height: 1.5;
}

/* ------------------------------------------------------------------ *
 * account avatar + menu
 * ------------------------------------------------------------------ */

/* The slot is empty whenever auth is off, so it must contribute no width
   and no gap of its own -- otherwise the header is subtly wider on a
   machine that has no account at all. */
.masthead-acct:empty {
  display: none;
}

.acct-btn {
  display: inline-flex;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
  border-radius: var(--r-full);
  /* The ring is on the button rather than the image so the monogram and
     the photo are the same shape and size. */
  box-shadow: 0 0 0 1px var(--hairline);
  transition:
    box-shadow 0.14s,
    transform 0.14s;
}
.acct-btn:hover {
  box-shadow: 0 0 0 1px var(--hairline-strong);
}
.acct-btn:active {
  transform: scale(0.96);
}
.acct-btn:focus-visible {
  outline: none;
  box-shadow:
    0 0 0 1px var(--raised),
    0 0 0 3px var(--accent);
}

/* The face: a monogram with the photo laid over it. Both are absolutely
   the same circle, so a photo that never loads leaves the letters showing
   rather than a broken-image glyph. */
.acct-face {
  position: relative;
  display: grid;
  place-items: center;
  width: 30px;
  height: 30px;
  border-radius: var(--r-full);
  overflow: hidden;
  background: var(--sunken);
  flex: none;
}
.acct-face-lg {
  width: 40px;
  height: 40px;
}

.acct-initials {
  font-size: var(--t-micro);
  font-weight: var(--w-med);
  color: var(--muted);
  letter-spacing: 0.02em;
  user-select: none;
}
.acct-face-lg .acct-initials {
  font-size: var(--t-small);
}

.acct-photo {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: inherit;
}

/* --- the menu --- */

.acct-menu {
  display: flex;
  flex-direction: column;
  padding: var(--s2);
  gap: var(--s1);
}

.acct-who {
  display: flex;
  align-items: center;
  gap: var(--s3);
  padding: var(--s2);
  /* A rule rather than a gap: the identity is a caption for the action
     below it, not a separate region. */
  border-bottom: 1px solid var(--hairline);
  margin-bottom: var(--s1);
  padding-bottom: var(--s3);
}

.acct-who-text {
  display: flex;
  flex-direction: column;
  min-width: 0; /* so a long address truncates instead of widening the panel */
  gap: 1px;
}

.acct-name {
  font-size: var(--t-small);
  font-weight: var(--w-med);
  color: var(--ink);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.acct-email {
  font-size: var(--t-micro);
  color: var(--faint);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.acct-item {
  display: flex;
  align-items: center;
  gap: var(--s3);
  padding: var(--s2) var(--s3);
  border-radius: var(--r-ctl);
  font-size: var(--t-small);
  color: var(--ink);
  text-decoration: none;
  cursor: pointer;
}
.acct-item:hover {
  background: var(--sunken);
}
.acct-item svg {
  flex: none;
  color: var(--muted);
}
