/* public/shell.css — extracted from landing.html .topnav block, class-renamed */

/* LANDING-LAYOUT-SHIFT — the room the bar will take, held until it exists.
   shell.js prepends <header id="appShell"> into <body> about 130 ms after first paint, so without this every page below it
   drops by the bar's height (224px on a phone, where its rows stack). --shell-reserve is the bar's rendered height per
   breakpoint, measured on dev 2026-10-10 on /shop, /g/mtg and /myorders.html (the heights step at 650, 1030 and 1350px);
   a page whose bar differs sets its own values in its <style> (landing.html does: its bar is shorter above 1030px).
   tests/e2e/the-shell-reserve-matches-the-bar.spec.js fails when the bar and this number differ by more than 2px at 390/1280,
   so the next header change cannot quietly bring the shift back. A browser without :has() (Safari before 15.4) drops the rule:
   no reserve, the page behaves as it did before. */
:root { --shell-reserve: 224px; }
@media (min-width: 650px) { :root { --shell-reserve: 167px; } }
@media (min-width: 1030px) { :root { --shell-reserve: 74px; } }
@media (min-width: 1350px) { :root { --shell-reserve: 58px; } }
body:not(:has(#appShell)) { padding-top: var(--shell-reserve); }
/* The bar itself starts at that height too: at 1030px and up it paints at 58px and grows to ~76px as its
   tabs and search settle, which moved everything under it twice. The floor is 1px under the reserve: a bar that settles
   at 57.8px under a 58px floor pushes every line below it 0.2px, which re-draws all its text anti-aliased differently
   (the visual gate read that as a 1% diff on /landing.html at 1280). A page's own --shell-reserve must equal its settled bar. */
#appShell { min-height: calc(var(--shell-reserve) - 1px); }

#appShell {
  display: flex;
  align-items: center;
  padding: 0.7rem 2rem;
  background: #111827;
  border-bottom: 1px solid #1F2937;
  position: sticky;
  top: 0;
  z-index: 200;
  font-family: 'Inter', system-ui, sans-serif;
}

.shell-brand {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  font-weight: 800;
  font-size: 1.15rem;
  color: #F8FAFC;
  text-decoration: none;
}

/* BETA badge — sits beside the shop name on every page the shell draws.
   Amber rather than the accent colour: it is a notice, not a feature, and it must not read as
   part of the shop's branding. A sibling of .shell-brand and not a child, because updateBrand()
   replaces that element's innerHTML the moment branding resolves and would wipe it. */
.shell-beta {
  display: inline-flex;
  align-items: center;
  padding: 0.12rem 0.45rem;
  margin-left: 0.1rem;
  border: 1px solid #B45309;
  border-radius: 4px;
  background: rgba(180, 83, 9, 0.18);
  color: #FBBF24;
  font-size: 0.6rem;
  font-weight: 800;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  white-space: nowrap;
  cursor: help;
}

.shell-tabs {
  display: flex;
  gap: 1.2rem;
  margin-left: 2rem;
}
.shell-tab {
  color: #94A3B8;
  font-size: 0.85rem;
  font-weight: 500;
  padding: 0.3rem 0;
  /* 44px is the tap floor. "All" is a short word and measured 35px wide against it —
     the one control in this header below the floor, and the one a customer taps to come back
     from a filtered view. Width only; the height was already 44. */
  min-width: 44px;
  text-align: center;
  border-bottom: 2px solid transparent;
  transition: all 0.2s;
  text-decoration: none;
}
.shell-tab:hover, .shell-tab.active {
  color: #F8FAFC;
  border-color: #6366F1;
}

.shell-right {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: 1rem;
}

.shell-cart-btn {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.4rem 1rem;
  background: #1E293B;
  border: 1px solid #334155;
  border-radius: 8px;
  color: #F8FAFC;
  font-size: 0.82rem;
  font-weight: 500;
  cursor: pointer;
  transition: background 0.2s;
  text-decoration: none;
}
.shell-cart-btn:hover { background: #334155; }

/* Day and night (lib/shell.js attachModeToggle). Night matches the cart button beside it; day
   is theme.css's html[data-mode="day"] block. The 44px finger floor is the coarse-pointer
   `button` rule below. */
.shell-mode-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0.4rem 0.6rem;
  background: #1E293B;
  border: 1px solid #334155;
  border-radius: 8px;
  color: #F8FAFC;
  font-size: 0.82rem;
  line-height: 1;
  cursor: pointer;
  transition: background 0.2s;
}
.shell-mode-btn:hover { background: #334155; }
.shell-mode-btn:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.shell-mode-btn[hidden] { display: none; }
.shell-cart-count {
  background: #4F46E5; /* 6.29:1 under white; #6366F1 was 4.47 */
  color: #fff;
  font-size: 0.65rem;
  font-weight: 700;
  padding: 0.15rem 0.4rem;
  border-radius: 10px;
  min-width: 1.2rem;
  text-align: center;
}

/* Sized to match the cart and My Orders beside it, not to its own taste.
   Measured at 1204px wide before this: cart 34px tall with 8px corners, Sign In 31px with 7px,
   sitting a pixel lower. Three controls in one slot, three sizes — small enough that nobody
   could name it and visible enough that Henry circled it. The avatar is already 34px. */
.shell-sign-btn {
  padding: 0.4rem 1rem;
  background: #4F46E5; /* 6.29:1 under white; #6366F1 was 4.47 */
  color: #fff;
  border: 1px solid transparent;
  border-radius: 8px;
  font-weight: 600;
  font-size: 0.82rem;
  line-height: 1.25;
  cursor: pointer;
  transition: background 0.2s;
  text-decoration: none;
}
.shell-sign-btn:hover { background: #4338CA; }

/* Account slot — fixed width to avoid reflow on sign-in -> avatar swap */
.shell-account {
  position: relative;
  min-width: 7rem;
  display: flex;
  justify-content: flex-end;
}

.shell-user-btn {
  width: 34px;
  height: 34px;
  border-radius: 50%;
  border: 2px solid #334155;
  background: #1E293B;
  color: #F8FAFC;
  cursor: pointer;
  overflow: hidden;
  padding: 0;
  font-size: 1rem;
  display: flex;
  align-items: center;
  justify-content: center;
}
.shell-avatar { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }

/* Dropdown. Its words take their colour from theme.css's tokens, never a literal: day mode turns
   the panel white (theme.css, the sheets rule), and a literal #F8FAFC on the items kept My Orders,
   Edit Profile and the Manage links near-white on it (Henry, 2026-09-19). At night --text is #e0e0e0
   and --text-muted #9aa4b8 on this #1E293B, 11.08:1 and 5.83:1 (the old #F8FAFC read 13.98).
   tests/frontend/the-account-menu-reads-in-day-mode.test.js holds these rules to the tokens. */
.shell-dropdown {
  position: absolute;
  top: calc(100% + 0.5rem);
  right: 0;
  min-width: 200px;
  background: #1E293B;
  border: 1px solid #334155;
  border-radius: 10px;
  box-shadow: 0 8px 32px rgba(0,0,0,0.5);
  z-index: 300;
  padding: 0.5rem 0;
}
.shell-dropdown[hidden] { display: none; }
.shell-dropdown-header { padding: 0.8rem 1rem; }
.shell-dropdown-name { color: var(--text); font-weight: 600; font-size: 0.9rem; }
.shell-dropdown-role { color: var(--text-muted); font-size: 0.75rem; }
.shell-dropdown-sep { border: none; border-top: 1px solid rgba(255,255,255,0.1); margin: 0.3rem 0; }
.shell-dropdown-section { padding: 0.3rem 1rem; color: var(--text-muted); font-size: 0.7rem; font-weight: 600; text-transform: uppercase; }
.shell-dropdown-item {
  display: block;
  width: 100%;
  padding: 0.5rem 1rem;
  background: none;
  border: none;
  color: var(--text);
  font-size: 0.85rem;
  text-align: left;
  cursor: pointer;
  text-decoration: none;
}
.shell-dropdown-item:hover { background: rgba(255,255,255,0.05); }
.shell-logout { color: var(--danger); }

/* The games stay reachable at every width.
 *
 * This hid the whole switcher below 640px, reasoning that "the games are still reachable from the
 * tiles on the front page". Two things were wrong with that.
 *
 * Every iPhone this shop serves is 375 to 430 points wide — 12 through 15, which is where its
 * traffic is — so the switcher was hidden from every customer rather than from an edge case. And
 * the reasoning only holds on the front page: from the market, a game page or My Orders there was
 * no way to reach another game at all.
 *
 * It was a fair decision when the switcher was a row that grew by one tab per game. It is one
 * 67px button now, and 67px is affordable on a 375px screen in a bar that already wraps to two
 * rows. So the row stays and takes a line of its own, with tighter padding — what goes is the
 * spacing around the labels, not the way to reach a game. */
@media (max-width: 640px) {
  /*
   * The overflow this used to say was gone is back, one block down, and the menu survives it now.
   *
   * 39eef78b (08-22) removed `overflow-x: auto` from this row and wrote that it had never been
   * needed: games add items to the Games menu rather than tabs, so the row is All · Games ▾ ·
   * Accessory — 196px of content in a 366px box at 430. That is true on `/`. It is not true on
   * `/g/mtg`, where the button carries the game's name instead of the word Games: 166px at 320,
   * and the row measures 344px in a 320px box. 5ac9b33e (09-05) measured that and put the scroll
   * back on `#shellBelow .shell-tabs` further down this file, which wins on specificity and
   * re-created the clip this comment was still claiming did not exist.
   *
   * Both measurements were honest; they were taken on different pages. What neither could say is
   * that a box with overflow on one axis cannot leave the other visible, so the Games panel was
   * clipped by the element it hangs from and tapping Games did nothing at all — `elementFromPoint`
   * at the first item's centre returned the hero heading, not the item. The scroll stays, because
   * removing it puts Accessory out of reach at 320 and a control put out of reach is not a fix.
   * The panel leaves the box instead: see `.shell-games-menu` in the 640 block below.
   */
  .shell-tabs {
    order: 3;
    width: 100%;
    gap: 0.3rem;
  }
  .shell-tabs .shell-tab { padding: 0.4rem 0.6rem; font-size: 0.82rem; white-space: nowrap; }
}

@media (max-width: 768px) {
  #appShell { padding: 0.7rem 1rem; }
}

/* ── Cart drawer ─────────────────────────────────────────────────────────────
   Slides in over the current page. The cart is global state, so it is reachable
   from any storefront page rather than living at the bottom of the market. */

.cart-drawer-backdrop {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.55);
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.25s ease, visibility 0.25s ease;
  z-index: 400;
}
.cart-drawer-backdrop.open { opacity: 1; visibility: visible; }

/* Room at the foot of the drawer for the cookie notice — see `public/lib/cookie-notice.js`.
 *
 * Measured in Chrome on a first visit, before this line existed: **Checkout →** sat at y=708 in
 * an 800px viewport with the notice occupying 692..800, and `elementFromPoint` at the button's
 * own centre returned `cookie-notice-inner`. Not a stacking question — the notice's z-index is
 * 10000 against this drawer's 401 — and not scrollable past either, because the drawer is
 * exactly `100vh` with `scrollHeight === clientHeight`. The button under it was **Checkout →**,
 * and the one under that was Clear cart. Same at 1280x800, 375x812 and 320x640.
 *
 * `padding-bottom` and not `height: calc(100vh - …)`: a background paints to the border box, so
 * padding lifts the foot while the drawer's own dark panel still reaches the bottom of the
 * screen and meets the notice with no seam. The body is `flex: 1; overflow-y: auto`, so it
 * absorbs the reduction and the foot lands exactly on the notice's top edge.
 *
 * `box-sizing` is stated rather than inherited: `height: 100vh` means the difference between
 * border-box and content-box is the whole point of this rule, and `game.html`, `landing.html`
 * and `preorder.html` load no stylesheet but this one. */
.cart-drawer {
  position: fixed;
  top: 0;
  right: 0;
  height: 100vh;
  height: 100dvh;
  box-sizing: border-box;
  padding-bottom: calc(var(--cookie-notice-height, 0px) + env(safe-area-inset-bottom, 0px));
  width: min(400px, 100vw);
  background: #111827;
  border-left: 1px solid #1F2937;
  box-shadow: -12px 0 40px rgba(0, 0, 0, 0.5);
  transform: translateX(100%);
  transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1);
  z-index: 401;
  display: flex;
  flex-direction: column;
  font-family: 'Inter', system-ui, sans-serif;
}
.cart-drawer.open { transform: translateX(0); }

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

.cart-drawer-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 1rem 1.2rem;
  border-bottom: 1px solid #1F2937;
  flex-shrink: 0;
}
.cart-drawer-title { color: #F8FAFC; font-weight: 700; font-size: 1rem; }
.cart-drawer-close {
  background: none; border: none; color: #94A3B8;
  font-size: 1rem; cursor: pointer; padding: 0.2rem 0.4rem; border-radius: 6px;
}
.cart-drawer-close:hover { background: #1E293B; color: #F8FAFC; }

.cart-drawer-body { flex: 1; overflow-y: auto; padding: 0.6rem; }
.cart-drawer-empty { color: var(--text-muted); text-align: center; padding: 3rem 1rem; font-size: 0.9rem; }

.cart-drawer-line {
  display: flex; gap: 0.7rem; align-items: center;
  padding: 0.7rem 0.6rem;
  border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
/* One rule for the picture and for the slot that stands in for it, so the two cannot drift and a
   basket line is the same height either way. The box was already shared; what the placeholder
   lacked was anything inside it — it was a bare 44x61 slab of #1E293B, which reads as a picture
   that failed rather than as a card with none. The dashed edge and the centring are here rather
   than restated in `cartDrawer.js`, matching what `b318dfa` did for the checkout review rows.
   `overflow: hidden` because "No image" at 0.62rem is 40px wide against a 44px box once the
   1px border and padding are taken off, and a card set to a longer word must not push out of
   the slot. */
.cart-drawer-line img, .cart-drawer-noimg {
  width: 44px; height: 61px; object-fit: cover; border-radius: 4px;
  background: #1E293B; flex-shrink: 0;
}
.cart-drawer-noimg {
  box-sizing: border-box;
  display: flex; flex-direction: column;
  align-items: center; justify-content: center; gap: 0.1rem;
  background: #111827;
  border: 1px dashed rgba(255, 255, 255, 0.18);
  color: #94A3B8;
  font-size: 0.62rem; line-height: 1.1; text-align: center;
  overflow: hidden;
}
.cart-drawer-info { flex: 1; min-width: 0; }
/* Full names here too — see the note on .card-name in style.css. This is the last screen before
   payment, and the one place two printings of the same card sit next to each other in a single
   list, which is exactly when an ellipsis makes them look like a duplicate line. */
.cart-drawer-name {
  color: #F8FAFC; font-size: 0.82rem; font-weight: 600; line-height: 1.25;
  overflow-wrap: anywhere;
}
.cart-drawer-meta {
  color: var(--text-muted); font-size: 0.68rem; margin-top: 0.15rem;
  overflow-wrap: anywhere;
}
.cart-drawer-price { color: #10B981; font-size: 0.82rem; font-weight: 700; margin-top: 0.25rem; }

.cart-drawer-qty { display: flex; align-items: center; gap: 0.25rem; flex-shrink: 0; }
.cart-drawer-qty button {
  width: 24px; height: 24px; border-radius: 5px;
  background: #1E293B; border: 1px solid #334155; color: #F8FAFC;
  font-size: 0.8rem; cursor: pointer; line-height: 1;
  display: flex; align-items: center; justify-content: center;
}
.cart-drawer-qty button:hover:not(:disabled) { background: #334155; }
.cart-drawer-qty button:disabled { opacity: 0.35; cursor: not-allowed; }
.cart-drawer-qty span { color: #F8FAFC; font-size: 0.8rem; min-width: 1.3rem; text-align: center; }
.cart-drawer-rm { border-color: transparent !important; color: #ef5350 !important; }
.cart-drawer-rm:hover { background: rgba(239, 83, 80, 0.12) !important; }

/* The two buttons on a SAVED line in the drawer. Henry, 2026-09-20: both arrived with no class and
   an inline style, so no sheet could reach them and the browser drew its own white button on the
   night ground -- and would have drawn the same wrong button by day. This lives in shell.css, not
   style.css, because public/ckd.html draws the drawer and loads shell.css alone.

   Written in tokens on purpose. theme.css redefines the whole token set under
   html[data-mode="day"]:not([data-backoffice]), so a rule spelled in var()s follows both modes and
   needs no day-mode twin -- unlike .cart-drawer-qty button above, whose literals each need one.

   A tier, not one flat pair: "Move to cart" is what that row is for, "Remove" undoes the save. So
   the first is a fill and the second an outline that stays quiet until it is pointed at. That is
   deliberately NOT what .cart-drawer-rm above does -- that one carries its red ink at rest and adds
   only a wash on hover -- because this Remove sits beside a primary fill, and a second coloured
   control in the same row reads as a second action rather than an undo. */
.cart-drawer-wish-move,
.cart-drawer-wish-rm {
  min-height: 36px;
  padding: 0 0.7rem;
  border-radius: var(--radius-sm);
  font-family: inherit;
  font-size: 0.75rem;
  font-weight: 600;
  line-height: 1.2;
  cursor: pointer;
  transition: background 0.2s, color 0.2s, border-color 0.2s;
}
.cart-drawer-wish-move {
  flex: 1;
  background: var(--primary);
  border: 1px solid var(--primary);
  color: var(--primary-ink);
}
/* No lighter-primary token exists, and inventing one would be a palette. Brightness moves the
   token in whichever direction its own mode set it, so one line covers day and night. */
.cart-drawer-wish-move:hover:not(:disabled) { filter: brightness(1.12); }
.cart-drawer-wish-move:disabled { opacity: 0.35; cursor: not-allowed; }
.cart-drawer-wish-rm {
  flex: 0 0 auto;
  background: transparent;
  border: 1px solid var(--border-strong);
  color: var(--text-muted);
}
/* The wash and the border carry "destructive"; the ink does not, and must not. --danger as text
   on its own 14% wash measures 3.18:1 over the night ground -- the same fault the pressed state of
   .wish-btn shipped with this evening and the pixel reader caught. The token set has --primary-ink
   for precisely this problem and gives the semantic colours no equivalent, so --danger is a ground
   and a border colour here, never a text colour. Pinned in
   tests/frontend/a-storefront-button-is-never-the-browsers-default.test.js. */
.cart-drawer-wish-rm:hover {
  background: var(--danger-soft);
  border-color: var(--danger);
  color: var(--text);
}

.cart-drawer-foot {
  padding: 1rem 1.2rem;
  border-top: 1px solid #1F2937;
  flex-shrink: 0;
}
.cart-drawer-total {
  display: flex; justify-content: space-between; align-items: baseline;
  margin-bottom: 0.8rem;
}
.cart-drawer-total span { color: #94A3B8; font-size: 0.85rem; }
.cart-drawer-total strong { color: #10B981; font-size: 1.2rem; font-weight: 800; }
.cart-drawer-checkout {
  width: 100%; padding: 0.7rem; background: #4F46E5; color: #fff;
  border: none; border-radius: 8px; font-weight: 700; font-size: 0.9rem; cursor: pointer;
  transition: background 0.2s;
}
.cart-drawer-checkout:hover:not(:disabled) { background: #4338CA; }
.cart-drawer-checkout:disabled { opacity: 0.4; cursor: not-allowed; }
.cart-drawer-clear {
  width: 100%; margin-top: 0.5rem; padding: 0.5rem;
  /* Left as a literal on purpose. `.cart-drawer-clear` is a real <button>
     (`public/lib/cartDrawer.js:109`), and CARDOLION-P2 rule 4 is mechanical: the selector names
     an interactive element, so the text-emphasis ramp does not apply to it — not because this
     text is unimportant, but because a shade separated by what the selector IS stays checkable
     and one separated by what the text MEANS does not. A button ink needs a control token the
     ramp does not have yet (`--primary-ink` or a new one); eleven sites of this shape are held
     back for it. #64748B is 3.73 on #111827 — below 4.5, above 3.0 — and that is the honest
     state until the control token exists, not a passing binding. */
  background: none; border: none; color: #64748B; font-size: 0.78rem; cursor: pointer;
}
.cart-drawer-clear:hover { color: #ef5350; }

/* Checkout step 3: the QR save is the order page's button (Henry 2026-09-24, "ให้ในกรอบแดงในภาพ 2
   เหมือนภาพ 1"). myorders.html draws `.upload-btn` from its own inline sheet; the checkout anchor
   (public/market.js offerQrFile) takes the same class, and this rule gives it the same face. Scoped to
   #paymentBank on purpose: myorders.html loads this file too, and a bare .upload-btn here would
   restyle its buttons. Ink is --primary-ink, not myorders' literal #1a1a2e, which is 2.8:1 on the
   day primary. Display is left to this rule; the script only ever sets display:none to hide it.
   Pinned in tests/frontend/a-shopper-can-save-the-checkout-qr.test.js. */
#paymentBank .upload-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  width: fit-content;
  margin: 0.8rem auto 0;
  padding: 0.6rem 1.5rem;
  background: var(--primary);
  color: var(--primary-ink);
  border: none;
  border-radius: 6px;
  font-size: 0.9rem;
  font-weight: 600;
  text-decoration: none;
  cursor: pointer;
  box-sizing: border-box;
}
#paymentBank .upload-btn:hover { filter: brightness(1.12); }
#paymentBank .upload-btn:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
@media (max-width: 768px) {
  #paymentBank .upload-btn { width: 100%; }
}

/* ── Foil shimmer for storefront cards ───────────────────────────────────────
   The market's version wraps the image in .foil-shimmer-wrap, but the landing
   card uses an absolutely-positioned image inside a padding-ratio box, so a
   wrapping inline-block would collapse the layout. Here the effect is painted
   on the existing .img-wrap instead, which is already position:relative.

   The gradient duplicates the one in style.css. That file is design-locked, so
   it cannot be moved out; the keyframes are renamed to avoid colliding when
   both stylesheets load on the same page. Fold them together when the design
   lock is retired. */

.card .img-wrap.is-foil::after {
  content: '';
  position: absolute;
  inset: 0;
  background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200"><defs><linearGradient id="g" x1="0%25" y1="0%25" x2="100%25" y2="100%25"><stop offset="0%25" style="stop-color:rgb(255,0,128);stop-opacity:0.3"/><stop offset="16%25" style="stop-color:rgb(255,200,0);stop-opacity:0.3"/><stop offset="33%25" style="stop-color:rgb(0,255,100);stop-opacity:0.3"/><stop offset="50%25" style="stop-color:rgb(0,150,255);stop-opacity:0.3"/><stop offset="66%25" style="stop-color:rgb(128,0,255);stop-opacity:0.3"/><stop offset="83%25" style="stop-color:rgb(255,100,200);stop-opacity:0.3"/><stop offset="100%25" style="stop-color:rgb(255,200,0);stop-opacity:0.3"/></linearGradient></defs><rect fill="url(%23g)" width="200" height="200"/></svg>');
  background-size: 300% 300%;
  mix-blend-mode: color-dodge;
  opacity: 0.55;
  z-index: 1;
  pointer-events: none;
  animation: shellFoilGradient 4s ease infinite;
}

@keyframes shellFoilGradient {
  0%   { background-position: 0% 0%; }
  25%  { background-position: 100% 0%; }
  50%  { background-position: 100% 100%; }
  75%  { background-position: 0% 100%; }
  100% { background-position: 0% 0%; }
}

@media (prefers-reduced-motion: reduce) {
  .card .img-wrap.is-foil::after { animation: none; }
}

/* Surge foil — the WebGL overlay in surge-foil.js targets this class via a
   MutationObserver. The landing card's .img-wrap is already position:relative with a
   fixed aspect box, so it needs no extra geometry; the .is-foil shimmer stays underneath
   as the fallback when Three.js fails or is blocked. */
.card .img-wrap.surge-foil-wrap { overflow: hidden; }

/* ─── CAROUSEL ───────────────────────────────────────────────────────────────
   Styles for public/lib/carousel.js. They live here rather than in a page's
   <style> block because two pages use them — landing.html and game.html — and
   because public/style.css is design-locked (steering/contracts.md §4).

   The cell width is set inline by the script, not here: how many fit is a
   measurement, and CSS cannot express "five, or fewer, but never under 120px". */
.carousel {
  position: relative;
  display: flex;
  align-items: center;
  gap: 0.5rem;
}
.carousel-viewport {
  flex: 1;
  overflow: hidden;
}
.carousel-track {
  display: flex;
  /* transition is set by the script so prefers-reduced-motion can drop it */
  will-change: transform;
}
.carousel-cell {
  /* flex-basis and max-width are assigned per item by carousel.js */
  padding: 0 0.5rem;
  box-sizing: border-box;
}
.carousel-arrow {
  flex: 0 0 auto;
  width: 2.2rem;
  height: 2.2rem;
  border-radius: 50%;
  border: 1px solid #334155;
  background: #1E293B;
  color: #F8FAFC;
  font-size: 1.1rem;
  line-height: 1;
  cursor: pointer;
  transition: background 0.2s, border-color 0.2s;
}
.carousel-arrow:hover:not(:disabled) {
  background: #6366F1;
  border-color: #6366F1;
}
.carousel-arrow:focus-visible {
  outline: 2px solid #818CF8;
  outline-offset: 2px;
}
.carousel-arrow:disabled {
  opacity: 0.3;
  cursor: default;
}
@media (max-width: 768px) {
  .carousel-arrow { width: 1.9rem; height: 1.9rem; font-size: 1rem; }
  .carousel-cell { padding: 0 0.3rem; }
}
@media (prefers-reduced-motion: reduce) {
  .carousel-track { transition: none !important; }
}

/* ── Search in the global bar ────────────────────────────────────────────────
   Search used to exist only in the landing page's hero, so from the market, a game page or
   My Orders there was no way to search without going home first. In the bar it is on every
   storefront page, and the hero is freed for what the shop wants to advertise.

   It is the flexible element between the game tabs and the account controls, so .shell-right
   no longer needs margin-left:auto to push itself over. */
.shell-search {
  flex: 1 1 auto;
  max-width: 420px;
  margin-left: 2rem;
  display: flex;
  align-items: center;
  background: rgba(255,255,255,0.06);
  border: 1px solid rgba(255,255,255,0.14);
  border-radius: 8px;
  overflow: hidden;
}
.shell-search input {
  flex: 1;
  min-width: 0;
  padding: 0.45rem 0.7rem;
  background: transparent;
  border: none;
  color: #F8FAFC;
  font-size: 0.82rem;
}
.shell-search input::placeholder { color: var(--text-faint); }
.shell-search input:focus { outline: none; }
.shell-search button {
  padding: 0.45rem 0.8rem;
  background: #4F46E5;
  border: none;
  color: #fff;
  cursor: pointer;
  font-size: 0.82rem;
}
.shell-search button:hover { background: #4F46E5; }

/* The search gives up width when the bar is full, down to 10rem.
   Left at the flex default `min-width: auto` it was floored at its input's placeholder width
   (257px) and could not shrink, so when the Wishlist button joined the bar (ada0420c) the signed-in
   bar measured 1364px at a 1280 screen: the account button sat 84px off the edge and opening its
   menu scrolled the page sideways. The floor is 10rem, not 0: at 0 it measured 2px at 1025.
   The input sits in a plain <div> from lib/clearable.js, so the div flexes and the input fills it
   (the phone rule below explains the same trap at 375). Signed in, document width at 1025/1280/1366:
   1223/1280/1366 before ada0420c, 1364/1364/1366 after it, 1211/1280/1366 with this.
   tests/frontend/the-signed-in-bar-fits-a-1280-screen.test.js holds these rules. */
.shell-search { min-width: 10rem; }
.shell-search > div { flex: 1 1 0; min-width: 0; }
.shell-search input { width: 100%; box-sizing: border-box; }

/* The account controls sit at the right edge of the bar.
 *
 * They had `margin-left: 1rem`, on the reasoning — written in the comment above `.shell-search`
 * — that the flexible search element would absorb the spare width and push them over. It
 * cannot: `.shell-search` is capped at `max-width: 420px`, so past that width it stops growing
 * and nobody claims the rest. All the slack collected at the far right and the whole bar sat
 * bunched against the left, while every other row on the page spanned the full width.
 *
 * `auto` gives the leftover space to this margin instead, which is what the mobile rule below
 * has always done. The search keeps its cap: a search field stretched across a 2560px monitor
 * is not an improvement. */
#appShell .shell-right { margin-left: auto; }

@media (max-width: 1024px) {
  /* Search keeps its place on a phone — it is the main way to find anything there, and the
     game tabs are already hidden at this width. It wraps to its own line rather than being
     squeezed to nothing beside the cart.

     Raised from 768 to 1024 after measuring the band above it. Every child of the bar is a flex
     item with the default min-width:auto, so each one is floored at its own min-content and none
     of them can give: at 1024 they add up to 1036px of content for 1009px of bar. Nothing wraps,
     so the overflow lands on the last item — the account group — and Sign In sat 41px off the
     right edge of the page, with the whole document scrolling sideways to reach it.

     Dropping the search to its own line here is the same answer this block already gave the
     phone, not a new layout: the top row becomes brand + tabs + controls, 704px of content, and
     it fits from 769 up. */
  #appShell { flex-wrap: wrap; }
  .shell-search { order: 3; flex-basis: 100%; margin: 0.6rem 0 0; max-width: none; }
  .shell-search input { min-height: 40px; }
  /* `margin-left: auto` is the base rule now, so it is not repeated here. It used to differ:
     the desktop bar had 1rem and only the phone pushed the controls to the edge. */
}

/* ── Autocomplete under the bar's search ─────────────────────────────────────
   The dropdown is absolutely positioned at top:100% of its wrapper, so the search box must
   not clip it. `overflow: hidden` above was there to round the ends of a flex row, and it
   sheared the suggestions down to a sliver inside the box — landing.html had already learned
   this and solved it the same way. The rounding moves onto the end children instead.

   Suggestion rows carry a thumbnail, a name, a set line and a price, which do not fit in a
   420px bar. The list is allowed to be wider than the input it hangs from, and to grow to the
   right, since the box sits left of centre in the bar. */
.shell-search { overflow: visible; }
.shell-search input { border-radius: 8px 0 0 8px; }
.shell-search button { border-radius: 0 8px 8px 0; }

.shell-search .ac-dropdown {
  min-width: 380px;
  width: max-content;
  max-width: min(560px, calc(100vw - 2rem));
  border-radius: 8px;
  border-top: 1px solid rgba(255,255,255,0.2);
  margin-top: 0.35rem;
  /* Above the sticky bar's own stacking context (#appShell is z-index 200). */
  z-index: 300;
}

@media (max-width: 768px) {
  /* Full width on a phone: the bar is already wrapped, and a 380px minimum would overflow
     a 375px viewport. */
  .shell-search .ac-dropdown { min-width: 0; width: 100%; max-width: none; }
}

/* ═══ TOUCH TARGETS ═══
 *
 * The same 44px floor style.css has carried since the storefront was built — copied here
 * because the pages that load shell.css do not load style.css, so every control on
 * /landing.html and /g/:game was exempt from it. Those are the two pages a customer meets
 * first and browses on a phone: the category tabs, the trend tabs, the "Browse All" links,
 * the game and set tiles, and the Add button on every card.
 *
 * The selector list is this page family's own controls, not style.css's, because the two sets
 * share almost no class names — copying that rule verbatim would have looked like a fix and
 * matched nothing. `.add-btn` appears in both and is deliberately repeated.
 *
 * 44px is the figure both Apple and Google publish as the minimum comfortable target, and
 * `pointer: coarse` is what distinguishes a finger from a mouse — a desktop visitor keeps the
 * compact layout.
 */
@media (hover: none) and (pointer: coarse) {
  button,
  .cta,
  .tab,
  .trend-tab,
  .section-link,
  .add-btn,
  .cart-drawer-wish-move,
  .cart-drawer-wish-rm,
  .game-tile,
  .set-tile,
  #paymentBank .upload-btn {
    min-height: 44px;
    min-width: 44px;
  }

  /* Links laid out inline need the height to actually apply. */
  a.cta,
  a.section-link {
    display: inline-flex;
    align-items: center;
  }

  input,
  select,
  textarea {
    font-size: 16px; /* Below 16px iOS zooms the whole page on focus. */
    min-height: 44px;
  }

  /* The global search sets its own 40px above; a finger gets the full target. */
  .shell-search input { min-height: 44px; }
}

/* ═══ MY ORDERS ═══
 *
 * Sits beside the cart and matches it, because they are the same kind of thing to a shopper:
 * one is what they are about to buy, the other is what they already bought. It lived two
 * clicks inside the avatar menu, which is a strange place for the question customers ask most
 * often after paying.
 *
 * Below 640px the label drops and the icon carries it, so the bar does not wrap on a phone —
 * the same treatment the cart count already gets.
 */
.shell-orders-btn {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.4rem 1rem;
  background: #1E293B;
  border: 1px solid #334155;
  border-radius: 8px;
  color: #F8FAFC;
  font-size: 0.82rem;
  font-weight: 500;
  text-decoration: none;
  white-space: nowrap;
  cursor: pointer;
  transition: background 0.2s;
}
.shell-orders-btn:hover { background: #334155; }

/* `display: flex` above beats the browser's own `[hidden] { display: none }`, so without this
   the button is visible to a signed-out visitor — the attribute is set and does nothing. The
   same trap `.shell-dropdown[hidden]` above exists to avoid. */
.shell-orders-btn[hidden] { display: none; }

/* The Wishlist button (lib/shell.js wishButtonHtml) wears .shell-cart-btn, so it needs only these:
   [hidden] where lib/wishlist.js is absent, and its label dropped on a phone below. */
.shell-wish-btn[hidden],
#appShell .shell-wish-btn[hidden] { display: none; }

/* Below 1440 the word "Wishlist" goes and the heart and count stay; the button's aria-label still
   names it (wishLabel). With the word, the signed-in bar needs ~80px more than 1280 has. */
@media (max-width: 1439px) {
  .shell-wish-label { display: none; }
}

@media (max-width: 640px) {
  .shell-orders-label { display: none; }
  .shell-wish-label { display: none; }
  .shell-orders-btn { padding: 0.4rem 0.7rem; }
}

/* ─── PER-GAME ROWS IN THE MARKET ────────────────────────────────────────────
   The market's per-game rows became carousels, and the tiles stopped lining up: the
   price and the Add button sat at a different height in every card, because a card
   with a variant line ("0001 - Borderless") or a FOIL badge is taller than one without.

   The flat grid never had this — a CSS grid stretches its items, so every tile in a row
   is the height of the tallest and `.card-item .card-condition { margin-top: auto }` in
   style.css has slack to push the bottom block down with. A flex track stretches the
   *cell*, but the tile inside it is a block box sized by its own content, so that slack
   was never there.

   Two lines give it back. Scoped to `.game-row` on purpose: the landing page and the game
   pages put a different tile in their carousels, and neither was asked to change.

   Here rather than in style.css because that file is design-locked, which is also why the
   carousel's own styles live in this file (see the CAROUSEL block above). */
.game-row .carousel-cell {
  display: flex;
}
.game-row .carousel-cell > .card-item {
  flex: 1 1 auto;
  min-width: 0;
}

/* ── Games menu, and Contact us ───────────────────────────────────────────────
 *
 * Two menus that hang off the bar. The games used to sit in it one beside the next, which reads
 * fine at two and stops working at seven — and seven is the near plan (Star Wars Unlimited,
 * Cookie Run, Battle of Talingchan, Lorcana, Pokémon TCG Thailand, on top of Magic and Riftbound).
 * A row that grows every time the shop takes on a game eventually pushes the search box off the
 * bar; one control that stays the same width does not.
 *
 * Both are positioned against their own wrapper rather than the bar, so neither moves when the
 * brand name beside them is replaced once branding resolves.
 */
.shell-games, .shell-contact { position: relative; display: inline-flex; }

.shell-games-btn {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  background: none;
  border: none;
  font: inherit;
  cursor: pointer;
}

.shell-caret { font-size: 0.7em; opacity: 0.7; }

.shell-games-menu,
.shell-contact-menu {
  position: absolute;
  top: calc(100% + 0.45rem);
  min-width: 13rem;
  padding: 0.35rem;
  border-radius: 10px;
  background: var(--surface, #171727);
  border: 1px solid var(--border, rgba(255, 255, 255, 0.12));
  box-shadow: 0 12px 30px rgba(0, 0, 0, 0.45);
  z-index: 300;
}

.shell-games-menu { left: 0; }
/* Right-aligned: it sits near the end of the bar, and a left-aligned menu would run off-screen. */
.shell-contact-menu { right: 0; }

.shell-games-menu[hidden],
.shell-contact-menu[hidden] { display: none; }

.shell-games-menu a,
.shell-contact-menu a,
.shell-contact-text {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.5rem 0.6rem;
  border-radius: 7px;
  color: var(--text, #e8e8f0);
  text-decoration: none;
  font-size: 0.86rem;
  white-space: nowrap;
}

.shell-games-menu a:hover,
.shell-contact-menu a:hover { background: rgba(255, 255, 255, 0.07); }

.shell-games-menu a.active { color: var(--primary, #4fc3f7); font-weight: 600; }

/* The menu items are the tap targets, and an `<a>` is covered by neither coarse floor in this
   file: the `button` rule floors min-height and min-width, the `input, select, textarea` rule
   floors font-size and height, and an anchor matches neither. At 0.86rem inside 0.5rem padding
   these come out ~32px, against a 44 floor the rest of the chrome already keeps. They are already
   `display: flex; align-items: center`, so a min-height grows the box without moving the label.
   Contact us is floored in the same breath because it is the same rule group with the same gap. */
@media (hover: none) and (pointer: coarse) {
  .shell-games-menu a,
  .shell-contact-menu a,
  .shell-contact-text { min-height: 44px; }
}

.shell-contact-text { color: var(--muted, #9aa0b4); }

.shell-contact-btn {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.45rem 0.8rem;
  border-radius: 8px;
  background: rgba(255, 255, 255, 0.06);
  border: 1px solid var(--border, rgba(255, 255, 255, 0.12));
  color: var(--text, #e8e8f0);
  font: inherit;
  font-size: 0.85rem;
  cursor: pointer;
}

.shell-contact-btn:hover { background: rgba(255, 255, 255, 0.1); }

/*
 * The words go first on a narrow screen, not the button.
 *
 * A shopper on a phone needs the shop's phone number more than a desktop visitor does, not less —
 * so the label collapses to the icon and the control stays reachable, rather than the whole thing
 * being hidden to save room.
 */
@media (max-width: 900px) {
  .shell-contact-label { display: none; }
  .shell-contact-btn { padding: 0.45rem 0.6rem; }
}

/* ── Product detail, on tapping a sealed box or an accessory ──────────────────
 *
 * A single carries its own description — the set, the printing and the collector number are all
 * on the card. A sealed product does not: "Collector Booster Box" could be any of six things, and
 * the language, the pack count and whether it is the Thai printing are what a buyer is deciding
 * on. Those were typed in at intake and shown nowhere.
 *
 * Picture left, facts right, as asked. Below 700px they stack, because side by side on a phone
 * gives each of them half of not very much.
 */
/* The bottom padding carries the cookie notice — see `public/lib/cookie-notice.js`.
 *
 * Measured before this line existed: with a description of any length the panel reaches its
 * `max-height` and **Add to Cart**, the last control in it, landed at y=683 in an 800px viewport
 * against a notice starting at 692. `elementFromPoint` at the button's centre returned the
 * notice. Scrolling the panel does not help — the button is the last thing in the scroll, so
 * the end of the scroll is where it stays. Same at 375x812 (y=695) and 320x640 (y=545).
 *
 * Padding here rather than `bottom:` so the scrim still covers the whole viewport; the flex
 * centring then happens inside the reduced content box, which lifts the panel clear. */
.pm-overlay {
  position: fixed;
  inset: 0;
  z-index: 900;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1.25rem;
  padding-bottom: calc(1.25rem + var(--cookie-notice-height, 0px));
  background: rgba(6, 6, 12, 0.72);
  backdrop-filter: blur(3px);
}

.pm-overlay[hidden] { display: none; }

.pm-panel {
  position: relative;
  width: min(56rem, 100%);
  /* `90%` and not `90vh`, and the two are the same number until the cookie notice is on screen.
     The overlay's content box is the space actually available, so a percentage shrinks with it;
     a `vh` does not, and a panel taller than its flex container overflows equally at both ends —
     which would push the ✕ off the top while lifting the buttons off the notice. */
  max-height: min(90%, 44rem);
  overflow-y: auto;
  background: var(--surface, #171727);
  border: 1px solid var(--border, rgba(255, 255, 255, 0.12));
  border-radius: 14px;
  box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5);
}

.pm-close {
  position: absolute;
  top: 0.7rem;
  right: 0.7rem;
  width: 2rem;
  height: 2rem;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  border: 1px solid var(--border, rgba(255, 255, 255, 0.14));
  background: rgba(0, 0, 0, 0.35);
  color: var(--text, #e8e8f0);
  font-size: 0.85rem;
  cursor: pointer;
  z-index: 1;
}

.pm-close:hover { background: rgba(0, 0, 0, 0.6); }

.pm-body {
  display: grid;
  grid-template-columns: minmax(0, 5fr) minmax(0, 6fr);
  gap: 1.5rem;
  padding: 1.5rem;
}

.pm-figure {
  display: flex;
  align-items: flex-start;
  justify-content: center;
}

.pm-figure img {
  width: 100%;
  max-height: 26rem;
  object-fit: contain;
  border-radius: 10px;
  background: rgba(0, 0, 0, 0.25);
}

.pm-noimg {
  width: 100%;
  aspect-ratio: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 3rem;
  border-radius: 10px;
  background: rgba(255, 255, 255, 0.05);
}

.pm-detail {
  display: flex;
  flex-direction: column;
  gap: 0.7rem;
  min-width: 0;
}

.pm-kind {
  align-self: flex-start;
  font-size: 0.68rem;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  padding: 0.2rem 0.55rem;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.08);
  color: var(--muted, #9aa0b4);
}

.pm-name {
  margin: 0;
  font-size: 1.3rem;
  line-height: 1.3;
  text-wrap: balance;
  color: var(--text, #fff);
}

.pm-price {
  font-size: 1.35rem;
  font-weight: 700;
  color: var(--accent, #66bb6a);
}

.pm-stock { font-size: 0.88rem; color: var(--muted, #9aa0b4); }

.pm-desc {
  /* The line breaks somebody typed at intake are part of what they wrote. */
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  font-size: 0.94rem;
  line-height: 1.6;
  color: var(--text, #e8e8f0);
  padding: 0.85rem 0.95rem;
  border-radius: 9px;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--border, rgba(255, 255, 255, 0.08));
}

/* Said plainly, so a shop that has not written one sees that rather than a page that looks broken. */
.pm-desc-empty { color: var(--muted, #9aa0b4); font-style: italic; }

.pm-add {
  margin-top: auto;
  padding: 0.8rem 1rem;
  border: none;
  border-radius: 9px;
  background: var(--primary, #4fc3f7);
  color: #06111a;
  font-size: 0.98rem;
  font-weight: 700;
  cursor: pointer;
}

.pm-add:hover:not(:disabled) { filter: brightness(1.08); }
.pm-add:disabled { opacity: 0.45; cursor: not-allowed; }

/*
 * A family's options — Wave G of docs/cards/HENRY-ELEVEN-BEFORE-1.5.md. One row per option: its
 * own picture, what it is called, and its price with whether any is left. The whole row is the
 * radio's label, so the picture and the words are the hit target, not only the dot.
 */
.pm-options {
  margin: 0;
  padding: 0;
  border: none;
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  min-width: 0;
}

.pm-options legend {
  padding: 0;
  margin-bottom: 0.4rem;
  font-size: 0.88rem;
  color: var(--muted, #9aa0b4);
}

.pm-option {
  display: grid;
  grid-template-columns: auto 2.5rem minmax(0, 1fr) auto;
  align-items: center;
  gap: 0.6rem;
  padding: 0.4rem 0.6rem;
  border-radius: 9px;
  border: 1px solid var(--border, rgba(255, 255, 255, 0.08));
  background: rgba(255, 255, 255, 0.04);
  cursor: pointer;
}

.pm-option:hover { border-color: var(--primary, #4fc3f7); }
.pm-option:has(input:checked) { border-color: var(--primary, #4fc3f7); }
.pm-option:focus-within { outline: 2px solid var(--primary, #4fc3f7); outline-offset: 2px; }
.pm-option input { margin: 0; accent-color: var(--primary, #4fc3f7); }

.pm-option-thumb {
  width: 2.5rem;
  height: 2.5rem;
  object-fit: contain;
  border-radius: 6px;
}

.pm-option-thumb.pm-noimg { font-size: 1.1rem; }

.pm-option-label {
  min-width: 0;
  overflow-wrap: anywhere;
  font-size: 0.92rem;
  color: var(--text, #e8e8f0);
}

.pm-option-meta {
  font-size: 0.85rem;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  color: var(--muted, #9aa0b4);
}

.pm-qty:empty { display: none; }
.pm-qty .qty-picker { display: block; width: 100%; }

@media (hover: none) and (pointer: coarse) {
  .pm-option { min-height: 44px; }
  .pm-qty .qty-picker { min-height: 44px; }
}

@media (max-width: 700px) {
  .pm-body { grid-template-columns: 1fr; gap: 1.1rem; padding: 1.15rem; }
  .pm-figure img { max-height: 15rem; }
  .pm-add { margin-top: 0.3rem; }
}

@media (prefers-reduced-motion: reduce) {
  .pm-overlay { backdrop-filter: none; }
}

/* ── The width a phone actually has ───────────────────────────────────────────
 *
 * Measured on the front page at 375 points, which is the narrow end of this shop's traffic —
 * iPhone 12 through 15 are 375 to 430. Of those 375, a hundred and sixty-eight went to things
 * that are not the content:
 *
 *   64px   `.section { padding: 2rem }`, a desktop figure with no mobile answer at all
 *   104px  two 44px carousel arrows and the gaps beside them
 *
 * That left a game tile 207px in a 375px screen — Henry, looking at it: "render มั่วมาก มีที่ตั้ง
 * เยอะทำไมไม่ใช้".
 *
 * The arrows keep their 44px. That floor is the one thing here that is not negotiable — it is
 * what makes them hittable with a thumb — so instead of shrinking them they move on top of the
 * track, which is where a carousel arrow belongs on a phone anyway. The track gets the full width
 * and the arrows sit over its edges.
 *
 * `.section` is declared inline in landing.html and game.html, both of which are design-locked,
 * so this overrides from here rather than moving their padding and re-blessing two hashes for a
 * change that shows nothing new on a desktop.
 */
@media (max-width: 640px) {
  body .section { padding: 1rem; }

  .carousel { position: relative; gap: 0; }

  .carousel-arrow {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    z-index: 2;
    /* Over a card rather than beside it, so it needs its own ground to stay readable. */
    background: rgba(15, 23, 42, 0.82);
    backdrop-filter: blur(2px);
  }

  .carousel-arrow:first-of-type { left: 0; }
  .carousel-arrow:last-of-type { right: 0; }

  /* Nothing beside the track any more, so it takes the row. */
  .carousel-viewport { width: 100%; }
}

/* The bar's own controls were left out of the 44px floor above.
 *
 * That list was written as "this page family's own controls" and named the tabs, the trend tabs,
 * the Browse All links and the cards' Add buttons — everything on the page, and nothing on the
 * bar above it. Measured on a phone: the cart 33px tall, Sign In 31px, the shop name 30px. They
 * are the three controls a shopper reaches for most, on every page, and they were the smallest
 * targets on the screen.
 *
 * The footer's links are deliberately not here. They are words inside sentences — a mail address,
 * a phone number, "what we store and why" — and 44px around each one would space a paragraph out
 * into a list. The floor is for things that are targets; those are prose that happens to be
 * clickable.
 */
@media (hover: none) and (pointer: coarse) {
  .shell-brand,
  .shell-cart-btn,
  .shell-orders-btn,
  .shell-sign-btn,
  .shell-contact-btn,
  .shell-user-btn {
    min-height: 44px;
    display: inline-flex;
    align-items: center;
  }
}

/* ── The shop's mark ──────────────────────────────────────────────────────────
 *
 * A shop logo is usually a PNG with no background and black lettering, and this bar is nearly
 * black — so the letters disappear into it. Henry, sending his: "แต่มี background สีขาวหน่อย
 * พอดีมันเป็นแบบ PNG no background".
 *
 * The front page already stands the same file on a white rounded plate. This makes the bar agree
 * with the hero rather than each of them deciding separately.
 */
/* The .brand-mark rules moved into lib/brandMark.js, which injects them.
 *
 * They were here, and the back office does not load this file — pos.html and the rest load
 * style.css and stock.css. So the white plate applied on the storefront and nowhere else, and the
 * shop's cut-out logo sank into the dark bar on the till. A mark that carries its own appearance
 * cannot be drawn wrongly by a page that forgot a stylesheet. */

/*
 * The shop's name, present for a screen reader and not drawn.
 *
 * Used when a wide logo already carries the name — printing it beside the logo says the same
 * thing twice and takes the room that made the logo worth uploading. The link still has to say
 * where it goes, so the text stays in the markup.
 */
.is-hidden-visually {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* ═══ THE QUANTITY DROPDOWN ═══
 *
 * Henry, item 1 of 26 Aug: *"เวลาซื้อ ทีให้เลือกจำนวนแบบ dropdown ก่อน และ add to cart ตามจำนวน
 * dropdown ที่เลือก มีผลกับทุกหน้าที่ user ทั่วไปเห็น"* — pick the quantity first, then Add to Cart
 * adds that many, on every page an ordinary shopper sees.
 *
 * Declared here because this is the one stylesheet all three storefront card grids load —
 * `/shop` (index.html), the front page and `/g/:gameId`. `style.css` is design-locked and
 * landing.html's own `<style>` block is hashed with it, so neither could carry this without a
 * re-baseline; `shell.css` is not locked and needs none. The control itself is built at runtime
 * by `lib/card.js`, and `tests/design-lock.test.js` strips `<script>` before hashing, so no page
 * markup moved for any of this.
 *
 * It is a sibling of the Add button rather than a wrapper around it, and that is deliberate.
 * Every one of these tiles is `display: flex; flex-direction: column`, so a sibling flows into
 * place above the button with nothing restructured — while a wrapper would have taken the
 * button's place in the column and broken two rules that are locked: landing.html positions its
 * button absolutely until `(hover: none)`, and sizes it `calc(100% - 1.6rem)` against the card.
 * The same reasoning `lib/clearable.js` records for refusing to wrap a search input: a shared
 * stylesheet may say what a control looks like; it may not decide another page's layout.
 */
.qty-picker {
  width: 100%;
  margin-top: 0.6rem;
  padding: 0.4rem 0.6rem;
  background: #111827;
  border: 1px solid #334155;
  border-radius: 6px;
  color: #F8FAFC;
  font-size: 0.78rem;
  font-weight: 600;
  cursor: pointer;
}
.qty-picker:focus { outline: 2px solid #6366F1; outline-offset: 1px; }

/* `.card-item` on /shop is a column flex box that centres its children; `width: 100%` above
   already resolves against the tile's content box, so the select fills the column without this
   file naming `.card-item` at all. It deliberately does not: every `.card-item` selector in
   shell.css is scoped under `.game-row`, because landing.html loads this stylesheet and renders
   `.card` instead — `tests/frontend/landing-tiles-open-the-detail-view.test.js` holds that line,
   and it is what lets landing.html's design-lock hash stay where it is. */

/*
 * The front page hides its Add button until the card is hovered, and shows it outright on a
 * touch screen. The dropdown belongs to that button and has to keep the same company — a
 * quantity picker floating over a card with no way to buy it is worse than neither.
 *
 * Only `.card` is matched, which is landing.html's and game.html's tile class. `.card-item` on
 * /shop has no hover-reveal and is left alone.
 */
.card .qty-picker { display: none; }
/*
 * Focus reveals it too, for the same reason the `(hover: none)` block below exists. A keyboard
 * visitor never fires `:hover`, and a box that is `display: none` cannot take focus — so before
 * this line the picker was not hard to reach, it could not be reached at all. Exactly the defect
 * the touch rule below was written for, one audience over.
 *
 * Written as a second selector on the same declaration rather than as a rule of its own, so the
 * two reveals cannot drift apart, and so the hide above keeps a single thing to undo.
 *
 * This completes `bc50eadd`, which gave landing.html's tile a `tabindex` and revealed the Add
 * button beside it — in landing.html's own inline stylesheet, which has no rule for the picker at
 * all. This file is the picker's only source, so until now a keyboard customer on the front page
 * could buy, but could not choose how many.
 *
 * game.html links this file too, and the sentence that stood here said its tiles could not take
 * focus yet, so the new selector had nothing to fire on there. That was wrong twice, and two
 * other rooms had quoted it before anyone checked it.
 *
 * `lib/card.js` builds no tile. `renderProductCard` at `game.html:486` does; `lib/card.js`
 * contributes only the picker (`lib/card.js:620`, called from `game.html:518`) — which is what
 * the note at the head of this block says, and that note is right.
 *
 * And the selector never needed a `tabindex` here. `.card .add-btn` at `game.html:109` sets no
 * `display: none`, so that page’s Add to Cart is a real `<button>`, visible and focusable at
 * rest — the focusable descendant `:focus-within` asks for. It has fired on game.html since the
 * moment this rule landed. Mastery measured it on dev at `1a4f3fcc`: picker `none` at rest,
 * `block` with that button focused.
 *
 * `0a81236c` then gave the tile its own `tabindex`, for ORDER rather than reach: the picker is
 * rendered before the button, so a forward tab met Add to Cart and never met the quantity. The
 * rest is tracked in `docs/cards/ADD-TO-CART-IS-UNREACHABLE-BY-KEYBOARD.md`.
 */
.card:hover .qty-picker,
.card:focus-within .qty-picker { display: block; }
/*
 * The picker and the front page's Add button were drawn on top of each other (Henry 2026-09-30,
 * "ปุ่มมันซ้อน"). landing.html takes that button out of the flow — `.card .add-btn { position:
 * absolute; bottom: 0.8rem; right: 0.8rem }` — and the reveal above puts the picker in the flow as
 * the tile's LAST child, so the two meet at the same bottom-right corner: measured in Chrome at
 * 1280px on the dev data, the button and the select intersect in 88 x 18 px, day and night. A
 * phone does not show it, because the `(hover: none)` block below already flows both.
 *
 * So on a screen that can hover, a button that FOLLOWS a picker joins the flow after it, at the
 * inset it had (0.8rem below, 0.8rem in from the right, pushed to the right edge by `auto`), and
 * the tile grows by the button's height while it is hovered, as it already grows by the picker's.
 * `not all and (hover: none)` is the exact complement of landing.html's own `@media (hover: none)`,
 * so every screen gets exactly one of the two layouts.
 *
 * Scope is the selector, not a page check: the picker has to be a direct child of the tile and the
 * button must come after it. game.html puts the select inside `.info` and its button is already in
 * the flow, so it is not reached; a tile that can offer nothing has no picker and is not reached
 * either. landing.html's `<style>` is design-locked, which is why the fix lives here.
 * Card: docs/cards/ADD-BUTTON-OVER-QTY.md. Proof: the-hover-add-button-clears-the-qty-picker.test.js.
 */
@media not all and (hover: none) {
  .card > .qty-picker ~ .add-btn { position: static; margin: 0.4rem 0.8rem 0.8rem auto; }
}
/*
 * The same corner, a tile with no picker (Henry 2026-10-01 ~12:2x, a screenshot: "Choose options"
 * on top of "From ฿ 1,000", sealed band). A family tile carries no quantity select, so the rule
 * above, which keys on a picker before the button, never reached it, and the button kept
 * landing.html's `.card .add-btn { position: absolute; bottom: 0.8rem; right: 0.8rem }`. The
 * price is the last line of `.info`, which is padded 0.8rem at the bottom: the button's anchor
 * and the price's resting place are one line. Measured in Chrome at 1280 on the dev data, hovered:
 * the button (112.6 x 24.8) and the price box intersect in 2791 px2 on 4 of 4 tiles, and "From ฿ 500"
 * loses 363 px2 of its glyphs; a short price ("฿ 10,000") is not touched, which is why a one-price
 * family looked fine. A phone is clear because `(hover: none)` in landing.html flows the button.
 *
 * So on a screen that can hover, a family button joins the flow after `.info`, at the inset it
 * had, exactly as the rule above does; the tile grows by the button's height (and its margins)
 * while it is hovered, as a tile with a picker already does. `[data-family]` is what marks it (the
 * only `.add-btn` that opens a popup instead of adding), and `.card >` keeps it to landing's tile:
 * /shop's `.card-item` has no `.card` class and its button is already in the flow. The Pre-Order
 * link and the Add to Cart tiles do not carry the attribute and are not reached.
 * Card: docs/cards/FAMILY-BUTTON-OVER-PRICE.md. Proof: the-family-tiles-choose-options-button-clears-the-price.test.js.
 */
@media not all and (hover: none) {
  .card > .add-btn[data-family] { position: static; margin: 0.4rem 0.8rem 0.8rem auto; }
}
@media (hover: none) {
  /* Matching landing.html's own touch rule for the button beneath it, so the two line up:
     static flow, inset from the card's edge by the same 0.8rem. game.html's button is already
     full-width inside `.info`, and `width: auto` there would leave a stub. */
  .card .qty-picker {
    display: block;
    width: calc(100% - 1.6rem);
    margin: 0 0.8rem 0.4rem;
  }
  /* game.html pads `.info` rather than positioning the button, so its select must not be inset
     a second time. */
  .card .info .qty-picker { width: 100%; margin: 0.6rem 0 0; }
}

/* A finger needs the same 44px floor the touch block above gives every other control here. */
@media (hover: none) and (pointer: coarse) {
  .qty-picker { min-height: 44px; font-size: 16px; }
}

/* ═══════════════════════════════════════════════════════════════════════════════════════════
 * THE STOREFRONT FILTER PANEL
 * ═══════════════════════════════════════════════════════════════════════════════════════════
 *
 * Henry: "advance filter ถูกปรับให้รีบนะเพราะทุกคนบ่นละ" — customers cannot filter the shop and are
 * saying so. He showed a reference layout with the panel down the left, opening when tapped.
 *
 * It lives here rather than in style.css because style.css is hash-locked by
 * tests/design-lock.test.js and shell.css is not — but that is not the only reason and not the
 * best one. shell.css is *the storefront chrome*: the header, the game tabs, the cart button,
 * the card tile's touch rules. A panel that frames the shop belongs with them, and index.html
 * already loads this file.
 *
 * The controls inside #filterFields are drawn from a declaration and this stylesheet must stay
 * ignorant of which ones exist — every rule below selects on the panel's own structure
 * (.flt-field, .flt-label, .flt-input) and on nothing named after a field or a game. Adding
 * Aspect or Colour must need no rule here, which is the same acid test the renderer is held to.
 *
 * ── 2026-08-29: shut at every width, and it slides ──────────────────────────────────────────
 *
 * Henry's words were **"ในภาพแรกที่วงไว้ ให้มันอยู่ฝั่งซ้ายมือ กดแล้วเลื่อนเปิดขึ้นมา"** — on the left, and it
 * slides open *when it is pressed*. What shipped obeyed him on a phone and ignored him on a
 * desktop, where the panel was a permanent 260px column with no trigger at all, and he said so.
 *
 * Two things that column cost, both measured in Chrome at 1280x900 on /shop?game=riftbound
 * before this change:
 *
 *   - it took 260px of every page load for ever: panel 260px at x=97, grid 806px at x=377;
 *   - it did not fit. Riftbound offers **eleven** controls today — set, price, foil, sort,
 *     rarity, card_type, domain, tag, energy_cost, power_cost, might — which is four more than
 *     the seven Henry was complaining about, because `ede38de` stopped discarding tcgcsv's
 *     extended attributes on 28 August and the panel started drawing what arrived. Every future
 *     attribute makes this worse, which is why the fix cannot be a taller box. The column's
 *     `max-height: calc(100vh - 6rem)` gave it 802px of box for 1,323px of content, so `domain`
 *     was sliced through the middle at the fold, `tag`, `energy_cost`, `power_cost` and `might`
 *     were below it, and SEARCH and RESET sat 464px past the bottom edge inside a nested
 *     scrollbar. Star Wars: Unlimited was the same shape: 802px of box, 1,213px of content,
 *     four controls and both buttons out of sight. **A filter a shopper cannot see is a filter
 *     that does not exist.**
 *
 * So there is now one panel at every width and one behaviour to get right: a sheet fixed to the
 * left edge, translated off-screen, brought back by `.is-open`. Not a push that re-lays-out the
 * grid — measured at 1280 over 24 tiles, a track change costs 0.6ms of forced layout per toggle
 * against 0ms for the transform, and more to the point it re-wraps the shelf under the shopper
 * while they are reaching for a control. At 375px a push is not even available: 340px of panel
 * would leave 35px of grid.
 *
 * Full viewport height rather than a box that stops at 802px, because that box was the defect.
 * Eleven controls do not fit on any screen, so the panel must scroll; what it must not do is
 * scroll *inside* a short box that is itself scrolling with the page. `.filter-head` is sticky
 * so the way out never scrolls away, and `overscroll-behavior: contain` stops a flick at the end
 * of the list from carrying on into the shelf behind.
 */

/* One column. The panel is `position: fixed` and out of flow at every width now, so there is no
   track to reserve for it and the shelf gets the whole page — which is the 260px back.

   Kept as a named block rather than deleted: it is the element `#searchResults` lives in, the
   `display: block` is load-bearing against the `display: grid` this used to be, and the note
   below is the reason nobody should put the track back.

   If it ever does become two columns again, `minmax(0, 1fr)` and never `1fr`: a `1fr` track has
   `min-width: auto`, so a wide child — a long card name, a table — pushes it past its share and
   the whole page grows a horizontal scrollbar. That is the exact mechanism behind /orders.html
   measuring 701px wide at 375px (docs/FRONTEND-SURVEY.md §7.3). */
.shop-layout { display: block; }

.filter-panel {
  position: fixed;
  top: 0;
  left: 0;
  bottom: 0;
  z-index: 300;
  /* 86vw so a narrow phone keeps a strip of the shelf visible behind the sheet — the shopper can
     see they are on top of something and that tapping it gets them back. 340px so the widest
     screen does not stretch a column of `<select>`s across a third of the page. */
  width: min(86vw, 340px);
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: 1rem;
  border: 0 solid rgba(255, 255, 255, 0.12);
  border-right-width: 1px;
  background: #111827;
  transform: translateX(-100%);
  /* `visibility` and not `transform` alone. A panel parked at translateX(-100%) is off-screen but
     still in the tab order and still read by a screen reader, so a keyboard shopper on a shut
     shop used to tab through eleven controls they could not see. It matters far more now than it
     did when this was a phone-only sheet, because now the panel is shut on every screen.
     The 0s delay on the way out is what lets the slide finish before the panel disappears. */
  visibility: hidden;
  transition: transform 0.22s ease, visibility 0s linear 0.22s;
}

.filter-panel.is-open {
  transform: translateX(0);
  visibility: visible;
  transition: transform 0.22s ease, visibility 0s;
}

/* The title bar stays put while the controls scroll under it, so the ✕ is never the thing that
   scrolled away. Negative margins bleed its background over the panel's own 1rem padding;
   without them the first control appears to slide out from under a floating strip.

   `top: -1rem` and not `top: 0`, and the difference is visible. A sticky box is held so that its
   **margin** box stays inside the scrollport, and this one's top margin is -1rem, so `top: 0`
   parks the border box 16px down and leaves a 16px slot above the bar with the controls sliding
   past inside it. Measured at 1280 open and scrolled to the bottom: head at y=16, panel at y=0.
   Offsetting `top` by the same -1rem puts the border box back on the panel's edge. */
.filter-head {
  position: sticky;
  top: -1rem;
  z-index: 2;
  margin: -1rem -1rem 0.75rem;
  padding: 0.75rem 1rem;
  background: #111827;
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}

/* Room at the foot of the sheet for the cookie notice.
 *
 * Measured in Chrome at 375x812 on a first visit: `.cookie-notice` is `position: fixed`, 375x183
 * at y=630, `z-index: 10000` — above this panel's 300, so it is not a stacking question and the
 * buttons are genuinely unreachable, not merely hidden. SEARCH and RESET landed at y=745..789
 * completely underneath it, with no way to scroll to them because the sheet was exactly
 * viewport-height with nothing to scroll.
 *
 * It only bites a visitor who has not dismissed the notice yet, which is exactly the visitor
 * arriving from a promotion.
 *
 * Padding rather than a shorter sheet: the notice's height depends on how its text wraps, so a
 * `bottom` offset tuned to 183px would be wrong at 320px and wrong again the day the wording
 * changes. Padding inside a scrolling box costs a shopper who has dismissed the notice nothing
 * but some empty space below the last control, and it cannot be too small without also being
 * visibly wrong. It applies at every width now, because the sheet does.
 *
 * ── 2026-08-29: the 15rem is gone, and the reasoning above is why. ──
 *
 * That paragraph is right that a tuned `bottom` offset is a guess. What it did not say is that
 * `15rem` is the same guess with a margin of error added, and the argument it makes for padding
 * makes no case for *that number*. The question it never asked was which **other** fixed panels
 * sit under the same notice; the answer was three more — the cart drawer's Checkout →, the
 * sealed-product modal's Add to Cart, and the checkout modal's Confirm Payment — and each would
 * have arrived at its own hand-tuned constant.
 *
 * The notice now publishes its measured height as `--cookie-notice-height`, so this reserves
 * exactly the strip the notice occupies and exactly nothing when there is no notice: 1rem of
 * ordinary padding for a shopper who has pressed "Got it", where it used to be 240px of dead
 * scroll below RESET for everybody, for ever. See `public/lib/cookie-notice.js`, which also
 * explains why the fallback in the `var()` is zero and why the declared default is 15rem. */
.filter-panel { padding-bottom: calc(1rem + var(--cookie-notice-height, 0px) + env(safe-area-inset-bottom, 0px)); }

/* The backdrop is a sibling the script inserts, not a pseudo-element: a tap has to land on
   something that can carry a listener. It exists only while the sheet is open. */
.filter-backdrop {
  position: fixed;
  inset: 0;
  z-index: 299;
  background: rgba(0, 0, 0, 0.55);
}

/* A panel with nothing in it is hidden rather than drawn empty — the same rule the renderer
   applies to a single field. An empty box down the left tells a shopper the shop is broken. */
.filter-panel.is-empty .filter-fields,
.filter-panel.is-empty .filter-actions { display: none; }

.filter-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 0.75rem;
}

.filter-title {
  font-size: 0.95rem;
  font-weight: 700;
  color: #F8FAFC;
}

/* There is something to close at every width now, so this is drawn at every width. It is also
   the element the script moves focus to when the sheet opens, which is why it must never be
   `display: none` on a screen where the sheet can be opened. */
.filter-close {
  display: block;
  min-width: 44px;
  min-height: 44px;
  padding: 0 0.4rem;
  border: none;
  background: none;
  color: #cbd5e1;
  font-size: 1.6rem;
  line-height: 1;
  cursor: pointer;
}

/* The panel's own search box drops the row layout .search-bar gives it elsewhere: in a 260px
   column the input and a button side by side leave neither usable. */
.filter-panel .search-bar { display: block; margin-bottom: 0.75rem; }
.filter-panel .search-bar input { width: 100%; box-sizing: border-box; padding: 0.6rem 0.8rem; font-size: 0.9rem; }

.filter-fields { display: flex; flex-direction: column; gap: 0.85rem; }

.flt-field { display: flex; flex-direction: column; gap: 0.3rem; }

.flt-label {
  font-size: 0.75rem;
  font-weight: 700;
  letter-spacing: 0.02em;
  color: #94A3B8;
  text-transform: none;
}

.flt-input {
  width: 100%;
  box-sizing: border-box;
  padding: 0.5rem 0.6rem;
  border: 1px solid rgba(255, 255, 255, 0.2);
  border-radius: 8px;
  background: rgba(255, 255, 255, 0.08);
  color: #fff;
  font-size: 0.85rem;
  font-family: inherit;
}

.flt-input:focus { outline: none; border-color: var(--primary, #4fc3f7); }

.flt-range { display: flex; align-items: center; gap: 0.4rem; }
.flt-range .flt-input { min-width: 0; }
.flt-range-dash { color: var(--text-faint); }

.flt-multi { display: flex; flex-direction: column; gap: 0.25rem; max-height: 12rem; overflow-y: auto; }

.flt-check {
  display: flex;
  align-items: center;
  gap: 0.45rem;
  color: #E2E8F0;
  font-size: 0.85rem;
  cursor: pointer;
}

.flt-check input { accent-color: var(--primary, #4fc3f7); }

.filter-actions { display: flex; gap: 0.5rem; margin-top: 1rem; }

.filter-search, .filter-reset {
  flex: 1;
  padding: 0.6rem 0.5rem;
  border-radius: 8px;
  font-size: 0.85rem;
  font-weight: 700;
  font-family: inherit;
  cursor: pointer;
}

.filter-search { border: none; background: var(--primary, #4fc3f7); color: var(--primary-ink); }
.filter-reset { border: 1px solid rgba(255, 255, 255, 0.25); background: transparent; color: #E2E8F0; }

/* ── The trigger ───────────────────────────────────────────────────────────────────────────
 *
 * The only way into the panel, on every screen. It sits directly above the shelf, under the
 * category tabs, left-aligned where the eye lands — the same place on a desktop as on a phone,
 * because a control that moves between widths is a control somebody has to learn twice.
 *
 * It is deliberately in the flow rather than pinned. A pinned trigger would follow a shopper
 * down 5,573px of Riftbound grid, which is a real convenience; it would also be a second fixed
 * element under a sticky #appShell whose height is not constant, and 768px is the width where
 * exactly that kind of change broke the nav before. Findability on arrival is the thing being
 * bought here, and a control above the first row of cards is found on arrival.
 */
.filter-toggle {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  /* 44px, which is the floor every touch rule in this repository asks for and which
     docs/FRONTEND-SURVEY.md §7.4 found the nav links failing to reach. `inline-flex` and not
     `inline`: min-height does not apply to a non-replaced inline box, which is precisely why
     those rules were inert. */
  min-height: 44px;
  padding: 0 1rem;
  margin-bottom: 1rem;
  border: 1px solid rgba(255, 255, 255, 0.25);
  border-radius: 8px;
  background: rgba(255, 255, 255, 0.08);
  color: #E2E8F0;
  font-size: 0.9rem;
  font-weight: 600;
  font-family: inherit;
  cursor: pointer;
}

/* A funnel, drawn rather than borrowed. Unicode has no funnel emoji — the gear this
   replaced meant "settings", and the nearest triangle glyphs render inconsistently or
   not at all on a Thai Android. currentColor so it follows the button in both themes. */
.filter-toggle-icon { flex: 0 0 auto; vertical-align: -2px; }

.filter-toggle:hover { border-color: rgba(255, 255, 255, 0.45); }

/* Open, or narrowing something. Both are states a shopper should be able to see without
   reading — `is-active` is the one that matters, because it is the state the panel is in while
   it is not on screen to speak for itself. */
.filter-toggle[aria-expanded="true"],
.filter-toggle.is-active { border-color: var(--primary, #4fc3f7); color: var(--primary, #4fc3f7); }

/* How many controls are narrowing the shelf, written on the control that undoes them.
 *
 * The reason is the complaint this whole panel answers: Henry asked for **one** place to filter
 * from, and f8cae0f took the foil and sort dropdowns out of the search bar to get there. The
 * number goes on the trigger, where noticing it and reaching for the fix are the same gesture.
 *
 * ── The chip row this comment used to argue against now exists, 2026-09-05 ──
 *
 * It read: "A chip row is a second place — a shopper would be removing filters in one part of the
 * page and adding them in another." **That was the right worry and the wrong conclusion.**
 *
 * Measured on the real markup: removing one filter took **three** taps — open the sheet, change
 * the control, dismiss it — and takes **one** on a chip. This badge said *how many* and never
 * *which*, so a shopper wanting to drop one had to reopen the panel to find out what they had
 * chosen. **A second place to ADD filters would be the defect this warned about; a place to
 * REMOVE them is this trigger's own job finished.**
 *
 * The two cannot disagree: the chips read `activeFilterCount()` — the same notion of "active" this
 * badge reads — and removing a chip fires the panel's own `onChange`. **One source, one apply
 * path**, which is what makes it a completion rather than the duplication feared here.
 * `docs/cards/THE-FILTER-PANEL-FEELS-ODD.md` carries the decision; `8824c6e` built it. */
.filter-count {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 1.35rem;
  height: 1.35rem;
  padding: 0 0.35rem;
  border-radius: 999px;
  background: var(--primary, #4fc3f7);
  color: var(--primary-ink);
  font-size: 0.75rem;
  font-weight: 800;
  line-height: 1;
}

/* `display: inline-flex` above would otherwise beat the UA's `[hidden] { display: none }`. */
.filter-count[hidden] { display: none; }

/* A finger needs the same floor everywhere, and the panel is the same sheet everywhere now, so
   these are no longer inside a width query. It costs the desktop panel some height — which it
   pays for out of a scroll it needs anyway at eleven controls — and it buys the shopper on a
   touch laptop the same targets as the shopper on a phone. */
.flt-input { min-height: 44px; }
.flt-check { min-height: 44px; }
.filter-search, .filter-reset { min-height: 44px; }
.filter-panel .search-bar input { min-height: 44px; }

@media (max-width: 900px) {
  /* 16px on the inputs because iOS Safari zooms the whole page when a focused field is smaller,
     and a zoomed page is a page the shopper then has to scroll sideways. Kept behind the query
     because it is a phone-browser workaround, not a touch rule. */
  .flt-input { font-size: 16px; }
}

@media (prefers-reduced-motion: reduce) {
  /* No slide, and no delayed visibility either — with the transition gone the panel must appear
     and disappear on the same tick as the class, or it would stay in the tab order for 220ms
     after closing. */
  .filter-panel,
  .filter-panel.is-open { transition: none; }
}

/* ── S1 · the phone header is one 56px row ───────────────────────────────────────────────────────
   Measured 2026-09-05 in system Chrome at 375x812, on /landing.html, /shop and /g/mtg: the bar was
   165px signed out and 179px signed in, identical at scrollY 0, 600 and 2000 — four stacked rows of
   25, 31, 42 and 34px, a fifth of the screen, permanently.

   And the height was not buying correct tap targets. Every one of the ten controls was under the
   44px floor: brand 122x25, tabs 31 tall, search input 40, cart 33, My Orders 42x32. So this is not
   "remove three good rows"; it is one row whose targets reach 44 for the first time.

   `#shellBar` and `#shellBelow` are `display: contents` everywhere else, so above this breakpoint
   their children sit in #appShell's flex exactly as they always did and the desktop bar is
   unchanged. Only here do they become boxes.

   Explicitly not shrink-on-scroll: a header that animates its own height reflows the list under a
   reader's thumb. The bar is one height at every scroll position. The tabs are reachable again by
   tapping the brand, which scrolls to the top rather than navigating when the page is scrolled —
   see attachBrandToTop() in lib/shell.js. */
.shell-shopname { display: none; } /* phone-only: the desktop brand already prints the name */

@media (max-width: 640px) {
  #appShell {
    display: block;
    position: static;
    /* The fixed bar is out of flow, so the header reserves its height here. Without this the
       row below it starts underneath the bar and its first tab is untappable.
       112px, not 56px: SHELL-NARROW-HEADER put the search on its own second line inside
       `#shellBar` (below), so the bar is two rows tall now, not one — measured
       `#shellBar.getBoundingClientRect().height` at 280/320/360/390/640, identical at all five.
       104 (56 + 44 + the 8px `gap`) left `#shellBelow` starting 8px above the bar's real bottom
       edge, hidden under it; 112 is the measured height itself. */
    padding: 112px 0 0;
  }

  /* `fixed`, not `sticky`. A sticky child sticks only inside its own parent box, and #appShell is
     168px tall — measured: the bar held its 56px and scrolled away at top=-489 by scrollY 600,
     which is the same as not being pinned at all. */
  #shellBar {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
    min-height: 56px;
    padding: 0.4rem 0.5rem;
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    z-index: 200;
    background: #111827;
    border-bottom: 1px solid #1F2937;
  }

  /* 44px is reached here, not preserved: nothing in this bar met the floor before. */
  #shellBar .shell-brand {
    min-width: 44px;
    min-height: 44px;
    flex: 0 0 auto;
    justify-content: center;
    padding: 0;
  }
  /* The wordmark leaves the sticky row: a square icon is the brand at 375, and the name is still in
     the markup for a screen reader. The row below carries the shop's identity in full. */
  #shellBar .shell-brand .shell-brand-name { display: none; }
  /* STORE_01 (a): the row below now really does carry the name. One line, cut with an ellipsis so a
     long name cannot push the tabs off the row; the tabs keep their own scroll. */
  #shellBelow .shell-shopname:not([hidden]) {
    display: block;
    flex: 1 1 0;
    min-width: 2.5rem;
    max-width: min(9rem, 40%);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-weight: 600;
    color: var(--text, #e5e7eb);
  }

  /* SHELL-NARROW-HEADER: its own full-width second line, not the leftover of the icon row.
     `flex: 1 1 0` on the shared row gave the input whatever brand + wish + cart left — at 280
     that was 21px for the whole form, 43 for the input, and the submit button, wish and cart
     overlapped each other by up to 44px (`docs/cards/SHELL-NARROW-HEADER.md`). Brand, wish and
     cart together are 219px at 280 (measured) against a 248px row budget with room to spare, so
     they keep the top line unmoved; `order: 5` plus `flex-basis: 100%` on a wrapped flex
     container is what pushes the search onto its own line below them instead. */
  #shellBar .shell-search {
    order: 5;
    flex: 1 1 100%;
    max-width: none;
    margin: 0;
    min-width: 0;
  }
  /* `min-width: 0` on the input, not only on the form.
     Without it the input keeps a min-content width set by its placeholder — measured 211px inside a
     190px form — and overflows onto the submit button, whose centre then belongs to the input:
     `elementFromPoint` returned #shellSearchInput and a tap could not reach the button at all. Its
     rectangle was a correct 44x44 the whole time, which is why the card asks for the hit and not the
     box. */
  /* `width: 0` with `box-sizing: border-box`, not merely `min-width: 0`.
     The base rule is `flex: 1; min-width: 0` with `0.7rem` of horizontal padding and no
     `box-sizing`, so the padding sits outside the flex-computed width and the field ends up wider
     than the form that holds it — measured 211px inside 190px, overflowing onto the submit button.
     `elementFromPoint` at the button’s centre returned the input, and a tap could not reach the
     button at all, while its rectangle read a correct 44x44 throughout. */
  /* `width: 100%`, because the input is not a child of the form.
     `lib/clearable.js` wraps it in a plain block <div> to hang the clear button and the
     autocomplete off, so the form flexes that wrapper and the flex rules on the input itself do
     nothing. Left alone the input keeps its intrinsic width — measured 211px inside a 144px
     wrapper — and spills across the submit button: `elementFromPoint` at the button’s centre
     returned the input and a tap could not reach it, while its rectangle read a correct 44x44
     throughout. Filling the wrapper is what actually constrains it. */
  #shellBar .shell-search > div { flex: 1 1 0; min-width: 0; }
  #shellBar .shell-search input {
    min-height: 44px;
    /* The card's floor, belt-and-suspenders: the full-width second line already leaves the
       input far more than this at every width from 280 up, but the number is a stated
       acceptance criterion, not just an outcome of the layout above. */
    min-width: 96px;
    width: 100%;
    box-sizing: border-box;
  }
  #shellBar .shell-search button { min-width: 44px; min-height: 44px; }

  #shellBar .shell-cart-btn {
    min-width: 44px;
    min-height: 44px;
    flex: 0 0 auto;
    justify-content: center;
  }

  /* What is read on arrival, and scrolls away with the page. */
  #shellBelow {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.4rem;
    padding: 0.5rem 1rem;
    background: #111827;
    border-bottom: 1px solid #1F2937;
  }
  /* The tabs scroll inside their own row, so the page never does.
     /g/mtg carries a Games button 166px wide at 320, and with the two tabs beside it the row is
     344px — measured: document.scrollWidth 344 against clientWidth 320, a page that slides
     sideways under a thumb. Contained here rather than by shrinking the tabs, because 44px is a
     floor and this row is where the taller targets were just bought. */
  #shellBelow .shell-tabs {
    flex: 1 1 100%;
    min-width: 0;
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
  }
  #shellBelow .shell-tabs::-webkit-scrollbar { display: none; }

  /* The Games panel hangs out of the bottom of that 44px row, and the row above now clips on both
     axes. The scroll is load-bearing (see the note at the top of this file), so the panel is what
     moves: `position: fixed` takes its containing block from the viewport and is not clipped by an
     ancestor's overflow. Nothing here re-captures it — no ancestor of #shellGamesMenu carries
     transform, filter, perspective, contain or will-change, and #appShell is static.

     `top` and `left` are `auto` deliberately. A fixed box with both auto falls at its static
     position, which is under the button and unclipped, so the menu is never worse than it is now
     if the script does not run; shell.js then writes exact clamped values from the button's rect.
     `calc(100% + 0.45rem)` from the absolute rule had to go: on a fixed box that percentage
     resolves against the viewport, which puts the panel below the fold.

     60vh with its own scroll because ten games at the 44px floor is 447px of items — taller than
     the space below the row on a 320-wide phone. */
  .shell-games-menu {
    position: fixed;
    top: auto;
    left: auto;
    max-height: 60vh;max-height: 60dvh;
    overflow-y: auto;
    overscroll-behavior: contain;
  }
  #shellBelow .shell-tab,
  #shellBelow #shellGamesBtn,
  #shellBelow .shell-sign-btn,
  #shellBelow .shell-contact-btn,
  #shellBelow .shell-orders-btn,
  #shellBelow .shell-user-btn { min-height: 44px; }
  #shellBelow .shell-tab { display: inline-flex; align-items: center; }
  /* 40px wide, the last control under the floor after the split. */
  /* Width as well as height. These three were 40, 42 and 34 wide against the 44 floor — the
     height rule above fixed one axis and left the other, which is the same half-fix the “All” tab
     got when its width went 35 -> 44 and its height stayed 31. Both axes, measured. */
  #shellBelow .shell-contact-btn,
  #shellBelow .shell-orders-btn,
  #shellBelow .shell-user-btn { min-width: 44px; }
  #appShell #shellBelow .shell-right { margin-left: auto; }
}

/* ── S1B · the tap floor between the phone and the desktop ────────────────────
   Card `docs/cards/S1B-THE-TABLET-HEADER-KEPT-THE-DEFECT.md`.

   S1 bought the 44px floor for the phone inside `@media (max-width: 640px)`. Above that
   breakpoint the old header was left exactly as it was — not regressed, just not reached — and
   measured at 768 it is **0 of 9 controls at 44 in both axes**: the brand 25 tall, every tab and
   the Games button 31, the search button 31, Sign In 31, Contact 40x34, the cart 34, the search
   input 40. Every one of those heights was measured twice, on two different fields, and matched.

   **This is the floor and nothing else.** The tablet's height is not a complaint — 109px of a
   1024px viewport is 11% — so the four rows stay four rows and no wrapper is regrouped here. That
   is the difference between this card and S1, and consolidating would be answering a question the
   tablet did not ask.

   ## Why the range stops at 1024

   `min-width: 641px` alone would carry the floor onto the desktop too, where the header is a
   single 58px row nobody has measured against 44. Growing it is very possibly right, and it is
   not this card: S1's regression happened precisely because a rule was believed to apply at a
   width nobody had measured. So the range ends at 1024, which is not a new seam — the block above
   already turns at 1024 for the wrap — and 1025 up is left byte-identical, verified rather than
   assumed.

   ## Why `#appShell` is on every selector

   The wrap block above sets `.shell-search input { min-height: 40px }` for this same range. A
   bare `.shell-search input` here would tie with it and lose on source order only, which is the
   kind of rule that survives until somebody moves a block. The id makes it win on specificity,
   which is a reason rather than a position. */
@media (min-width: 641px) and (max-width: 1024px) {
  /* The height floor. The brand, the cart and the Games button are already flex rows with their
     contents centred, so they need only the height; the rest are laid out below. */
  #appShell .shell-brand,
  #appShell .shell-tab,
  #appShell .shell-games-btn,
  #appShell .shell-search input,
  #appShell .shell-search button,
  #appShell .shell-contact-btn,
  #appShell .shell-orders-btn,
  #appShell .shell-user-btn,
  #appShell .shell-cart-btn,
  #appShell .shell-sign-btn { min-height: 44px; }

  /* A tab and a Sign In button are inline boxes: a min-height on them reserves the space and
     leaves the text at the top of it, so the control grows and the words do not move with it.
     Centring is what makes the taller box a taller *target* rather than taller padding. */
  #appShell .shell-tab,
  #appShell .shell-search button,
  #appShell .shell-contact-btn,
  #appShell .shell-orders-btn,
  #appShell .shell-user-btn,
  #appShell .shell-sign-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
  }

  /* Both axes, measured. Contact is 40px wide at 768 — the one control here short on width as
     well as height, and the half-fix that gives it 44 tall and leaves it 40 wide is the same one
     the "All" tab got on the phone, where the width was raised and the height was not. */
  #appShell .shell-contact-btn,
  #appShell .shell-orders-btn,
  #appShell .shell-user-btn,
  /* 43.61px: 12.8px of padding either side of one 🔍 glyph. Short by four tenths of a pixel,
     which is invisible and is still not 44 — and the card's rule is that 44 is reached, not
     approached. Named here rather than bought with padding, so the next person who changes the
     glyph does not silently drop back under. */
  #appShell .shell-search button { min-width: 44px; }

  /* The same trap `.shell-orders-btn[hidden]` above exists to avoid, one specificity step up.
     That guard is a bare `.shell-orders-btn[hidden]` — (0,2,0) — which beats the plain
     `display: flex` it was written against and the coarse-pointer block's `display: inline-flex`,
     but **not** a selector carrying `#appShell`. The first draft of this block had one, and a
     signed-out visitor at 768 was handed a My Orders button linking to a page that would bounce
     them: measured as 121x44 appearing in the control list, which also wrapped `.shell-right` onto
     its own row and took the header from 109 to 167px. One defect, visible as two numbers. */
  #appShell .shell-orders-btn[hidden],
  #appShell .shell-user-btn[hidden],
  #appShell .shell-contact-btn[hidden] { display: none; }
}

/* Storefront colour tokens (Design, 2026-09-19). A storefront page names these instead of a literal
   colour, so day mode reaches it: Henry saw ckd.html's lede stay near-white on the day ground (1.39:1)
   and preorder.html's back link at 2.79:1, because both pages wrote their own dark palette as
   literals. Night values ARE those literals (the storefront's slate + indigo, the same ones this file
   writes for the shell), so night mode is unchanged; day values are theme.css's day tokens.
   tests/frontend/a-storefront-page-follows-day-mode.test.js holds both pages to these. */
:root {
  --sf-bg: #0B0F19;
  --sf-surface: #1E293B;
  --sf-surface-2: #111827;
  --sf-surface-3: #334155;
  --sf-border: #334155;
  --sf-rule: #1F2937;
  --sf-text: #F8FAFC;
  --sf-text-soft: #CBD5E1;
  --sf-action: #6366F1;
  --sf-action-hover: #4F46E5;
  --sf-action-ink: #ffffff;
  --sf-action-text: #A5B4FC;
  --sf-success: #10B981;
}
html[data-mode="day"]:not([data-backoffice]) {
  --sf-bg: var(--bg);
  --sf-surface: var(--surface);
  --sf-surface-2: var(--surface-2);
  --sf-surface-3: var(--surface-3);
  --sf-border: var(--border-strong);
  --sf-rule: var(--border);
  --sf-text: var(--text);
  --sf-text-soft: var(--text-muted);
  --sf-action: var(--primary);
  --sf-action-hover: var(--primary);
  --sf-action-ink: var(--primary-ink);
  --sf-action-text: var(--primary);
  --sf-success: var(--success);
}

/* "ใหม่" on a sealed product or accessory listed in the last fortnight
   (docs/cards/NEW-SEALED-AND-ACCESSORY-ON-TOP.md). The brand pill: primary-ink on primary, which
   data/themeContrast.js holds at 4.5:1 or better in both themes. The same box as the other tile
   badges in style.css, in the flow with them, never laid over the price or the stock line. On the
   landing page it takes the corner slot of .card .status-badge, which sets its own box. */
.new-badge {
  display: inline-block;
  padding: 0.2rem 0.6rem;
  border-radius: 10px;
  font-size: 0.7rem;
  font-weight: 700;
  margin-bottom: 0.5rem;
  background: var(--primary);
  color: var(--primary-ink);
  /* No font-family on purpose. "ใหม่" is Thai, and the shop's Inter is Latin-only by
     unicode-range (public/fonts/inter.css) -- /shop does not even link that file. Thai has always
     fallen through to system-ui on every page, so a stack here would declare a face that can
     never paint this word. Measured 2026-09-20: 'Inter' and a nonexistent family give the same
     advance width for this text. If the badge ever carries Latin, revisit then, with a measurement. */
}
/* The landing tile's status corner sets 0.6rem (public/landing.html:169) and outranks .new-badge,
   which left "ใหม่" at 9.6px there against 11.2px on the storefront (walk 2026-09-20). Restated at
   the same specificity, so the badge keeps one size; the other status badges are untouched. */
.card .status-badge.new-badge { margin-bottom: 0; font-size: 0.7rem; }

/* A foil single that is not out yet wears two status badges, PRE-ORDER then FOIL (landing.html and
   game.html, `statusBadgesHtml`). Both are absolutely placed in the same corner, so the second is
   pushed below the first: the first sits at top 0.5rem and is font 0.6rem + 0.2rem padding twice,
   about 1.1-1.4rem tall depending on the fallback font's line height; 2rem leaves a gap in that
   whole range. Both pages' own <style> blocks are design-locked, so the offset lives here. */
.card .status-badge.status-badge-second { top: 2rem; }
/* game.html has no .badge-preorder rule of its own (landing.html does); the same fill, orange with
   white ink, the values landing.html:175 already uses. Scoped to .status-badge so the other
   .badge-preorder uses (my orders, the sealed extension) are untouched. */
.card .status-badge.badge-preorder { background: rgba(249,115,22,0.9); color: #fff; }

/* The Pre-Order button (PREORDER-BUTTON-COLOUR; Henry 2026-10-01 00:1x "อย่าลืมเรื่องสีปุ่มด้วยนะ").
   A button that reads Pre-Order (lib/card.js `addBtnClass`, the label's own answer) carries
   `add-btn-pre` and is filled from `--warning`: the token the PRE-ORDER badge is closest to (/shop
   paints the badge's ink #ffa726, which is --warning at night; the front page and game page paint
   it rgba(249,115,22,.9), which is no token). Ink is `--surface`: the page's own ground turned
   over onto the fill, dark on the amber at night, white on the brown by day. Not `--primary-ink`:
   that is a BRAND token, so a shop whose brand is yellow has a dark one by day, and dark on the
   brown measured 3.18:1 on the dev shop (the first build; the test holds it). By day --warning and
   --surface are the fixed day palette, so the pair cannot drift. Add to Cart never carries the
   class, so it keeps every rule it had. No hex, no rgb() here.

   Three buttons can be a pre-order: a tile's .add-btn, the dialog's .do-add, the page's .cta.
   Weights, counted (tests/frontend/the-pre-order-button-takes-the-badge-colour.test.js holds them):
   night (0,2,1) beats .card .add-btn / .card-item .add-btn (0,2,0) and the bare .do-add/.cta;
   hover (0,3,1) beats their :hover rules (0,3,0); day (0,5,2) beats theme.css's day rules, the
   heaviest of which is (0,5,1) (`:is()` carries its heaviest argument, see public/lib/ckdOffer.js).
   Not `!important`: Design keeps the power to retune it from here. */
body :is(.add-btn, .do-add, .cta).add-btn-pre {
  background: var(--warning);
  color: var(--surface);
}
body :is(.add-btn, .do-add, .cta).add-btn-pre:hover {
  background: var(--warning);
  filter: brightness(0.92);
}
html[data-mode="day"]:not([data-backoffice]) body :is(.add-btn, .do-add, .cta).add-btn-pre:not(:disabled) {
  background: var(--warning);
  color: var(--surface);
}

/* The Retry in the short-feed note (`truncationNote`, public/market/results.js). Not inside the
   `pointer: coarse` floor above: it measured 20 px tall on a phone-width Chrome that reports a
   mouse (docs/cards/SEARCH-SHORT-FEED-NOTE-LOW-CONTRAST.md), and it is a control drawn only when
   a load failed, so a 44 px target costs nothing anywhere else. Colour stays `.tab`'s
   `--text-muted`; the border is what says it is a button. */
.feed-retry {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 44px;
  min-width: 44px;
  padding: 0 1rem;
  margin-inline-start: 0.5rem;
  font-size: 0.85rem;
  border: 1px solid var(--border-strong);
}
