/* Space Grotesk - SIL OFL 1.1, self-hosted (see fonts/LICENSE.txt).
   A variable font: one 22 KB file covers every weight. */
@font-face {
  font-family: "Space Grotesk";
  src: url("/static/fonts/space-grotesk-var.woff2") format("woff2-variations");
  font-weight: 300 700;
  font-style: normal;
  /* fallback, not swap: a short invisible window (100 ms) in which a cached,
     preloaded font always lands, so text never paints in the stand-in face
     and then jumps; on a slow first visit the stand-in still shows. */
  font-display: fallback;
}

/* 3DGarden (ThreeJSbank until rev 23): design system
   Inspired by Nothing's industrial minimalism: pure black and white, hairline
   rules, dot-matrix texture, one red accent used sparingly, and a lot of air.
   Original work - no Nothing fonts, marks or assets are used. */

:root {
  --paper:   #ffffff;
  --ink:     #000000;
  --muted:   #565656;   /* 7.0:1 on white - was #8a8a8a at 3.0:1, below AA */
  --line:    #e4e4e4;
  --panel:   #fafafa;
  --accent:  #d6001c;
  /* The dot matrix: ONE geometry, painted ONLY on stages.
     Rev 12 put it on <body>, which made the whole site one continuous grid  - 
     and put it under every paragraph on that site. It had to be turned down
     twice to survive that, and it still cost readability: body text against a
     dotted ground has a background that is not one colour but two, so the
     muted grey that measures 7.3:1 on paper measures 5.6:1 on the dots, and
     the eye reads the variation as shimmer rather than as texture.

     Rev 13 takes it off the ground and leaves it where it was always doing
     work: behind the models. A texture that appears exactly where a product
     sits reads as a stage; the same texture everywhere reads as wallpaper.
     Confining it also means it no longer has to be quiet, so the ink went back
     UP - louder than rev 12, and the identity is more legible for it.

     --dot-stage the product stages: card thumbnails, collection covers, the
                 viewer, admin previews, and anything marked .dots.
     --dot       kept as the ground weight for any surface that ever wants the
                 quiet version. Nothing paints it by default. */
  --dot:       #e0e0e0;
  --dot-stage: #c9c9c9;

  /* The colour the headline warms to where the pointer light falls. Not the
     accent itself: pure red inside display type reads as a highlighter pen.
     This is the accent taken most of the way to ink, so it looks like light
     catching an anodised surface rather than like a colour change. */
  --sheen: #96101f;

  --dot-r:   1.2px;
  --dot-gap: 10px;      /* the pitch moves with the radius - see CONTEXT.md §13 */

  /* The half-pixel ramp is not decoration. A hard-edged 1px circle lands on
     fractional device pixels and shimmers as the page scrolls, which is
     exactly what you notice when the grid is under text rather than under a
     static hero. Feathering the edge costs nothing and settles it. */
  --dot-grid: radial-gradient(circle at center,
                var(--dot) 0 var(--dot-r),
                transparent calc(var(--dot-r) + 0.5px));

  /* --mono is the label/chrome face: technical, but far more legible at 11-12px
     than a system monospace. Kept under the old name so every existing rule
     picks it up. */
  --mono: "Space Grotesk", ui-monospace, "SF Mono", SFMono-Regular, Menlo, monospace;
  --sans: "Space Grotesk", "Helvetica Neue", Helvetica, Arial, system-ui, sans-serif;

  --gut: clamp(26px, 7vw, 56px);
  --rule: 1px solid var(--line);

  /* Tells the browser which way this page leans, which is what native widgets
     read: the <select> drop-down, scrollbars, the caret, date pickers. Without
     it every one of those renders light regardless of the theme - a white
     option list drawn with white text is why the sort menu was unreadable in
     dark mode. The dark blocks below flip it. */
  color-scheme: light;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --paper: #000000; --ink: #ffffff; --muted: #a3a3a3;
    --line: #232323; --panel: #0b0b0b;
    --dot: #1c1c1c; --dot-stage: #333333; --sheen: #ff4353;
    --wash-ink: .55; --wash-paper: .24;   /* the pearl hover wash on a white / black face */
    color-scheme: dark;
  }
}
:root[data-theme="dark"] {
  --paper: #000000; --ink: #ffffff; --muted: #a3a3a3;
  --line: #232323; --panel: #0b0b0b;
  --dot: #1c1c1c; --dot-stage: #333333; --sheen: #ff4353;
  --wash-ink: .55; --wash-paper: .24;
  color-scheme: dark;
}


* { box-sizing: border-box; }

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

body {
  margin: 0;
  overflow-wrap: break-word;   /* long slugs and emails must not widen the page */
  /* Plain paper. The dot matrix used to be painted here, which put it behind
     every line of text on the site; it now lives only on the stages that hold
     models. To go back to a textured page ground, add the two background-image
     and background-size lines from the .dots rule below - nothing else has to
     change, and the tokens are still defined for it. */
  background-color: var(--paper);
  color: var(--ink);
  font-family: var(--sans);
  font-size: 15px;
  line-height: 1.55;
  letter-spacing: -0.005em;
}

img, canvas, video { max-width: 100%; }

a { color: inherit; text-decoration: none; }
a:hover { color: var(--accent); }

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

h1, h2, h3 { font-weight: 500; letter-spacing: -0.03em; margin: 0; line-height: 1.05; }
h1 { font-size: clamp(38px, 7vw, 84px); }
h2 { font-size: clamp(24px, 3.4vw, 40px); }
h3 { font-size: 18px; letter-spacing: -0.02em; }

p { margin: 0 0 1em; }

.label {
  font-family: var(--mono);
  font-size: 12px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.11em;   /* looser tracking needs less of it at 12px */
  color: var(--muted);
  font-feature-settings: "tnum";
}

.mono {
  font-family: var(--mono);
  font-size: 13px;
  font-weight: 500;
  letter-spacing: 0.01em;
  font-feature-settings: "tnum";   /* keeps figures aligned in tables */
}
.dim  { color: var(--muted); }
.accent { color: var(--accent); }

/* ---------- layout ---------- */

/* The page gutter, and the reason it is locked with !important.
   Views set their vertical rhythm with an inline style, and a `padding:`
   SHORTHAND there silently resets left and right to 0. That ran 21 sections
   edge-to-edge, which is invisible on a desktop and awful on a phone. Views
   now use `padding-block`, and this makes the gutter unbreakable next time
   somebody reaches for the shorthand. */
.wrap { max-width: 1320px; margin: 0 auto; padding-inline: var(--gut) !important; }
.rule { border-top: var(--rule); }

.skip {
  position: absolute; left: -9999px; top: 0; z-index: 100;
  background: var(--paper); border: var(--rule); padding: 12px 18px;
  font-family: var(--mono); font-size: 12px; font-weight: 500;
  letter-spacing: 0.08em; text-transform: uppercase;
}
.skip:focus { left: 12px; top: 12px; color: var(--ink); outline: 2px solid var(--accent); outline-offset: 2px; }
main:focus { outline: none; }
.stack > * + * { margin-top: 14px; }

/* ---------- stages ----------
   A stage is a cell a model sits *on*: a card thumbnail, a collection cover, a
   viewer, an admin preview. Everything else on the page simply shows the ground
   painted on <body>.

   Two rules hold this together, and both are load-bearing:

   1. A stage is OPAQUE. It sets its own background-colour before painting the
      grid, so its grid never overlays the page grid. Two identical grids at
      different origins do not read as one texture - they beat against each
      other and produce visible moiré. Opacity is what makes the offset
      invisible, and it is why no transparent element may paint the grid.
   2. A stage redefines --dot rather than using a second gradient. Custom
      properties substitute at use time, so --dot-grid picks up the stage weight
      here for free - the same mechanism that already gives dark mode its dots
      without a second declaration. One gradient exists, in one place. */
.dots, .card-thumb, .collection-thumbs, .viewer {
  --dot: var(--dot-stage);
  background-color: var(--paper);
  background-image: var(--dot-grid);
  background-size: var(--dot-gap) var(--dot-gap);
}

/* Stages that live inside a surface which tints on hover take their colour from
   it instead, so the thumbnail still follows the card. `inherit` is safe here
   and only here: both parents set an opaque background-colour explicitly. */
.card-thumb, .collection-thumbs, .thumb-cell, .facet-thumb { background-color: inherit; }

/* ---------- nav ---------- */

.nav {
  position: sticky; top: 0; z-index: 50;
  display: flex; align-items: center; gap: 28px;
  padding: 16px var(--gut);
  background: color-mix(in srgb, var(--paper) 88%, transparent);
  backdrop-filter: blur(12px);
  border-bottom: var(--rule);
}

.logo {
  font-family: var(--mono);
  /* Drawn to Vincent's reference (2026-11-02, "it should exactly look like
     that"), measured on it with the letters' cap height as the unit: the mark
     1.4em, 0.35em from the text, the letters 0.13em apart, the text centred on
     the mark by its capitals. Every size below is in em, so the footer's small
     logo and the bar's big one are the same drawing. */
  font-size: 14px; font-weight: 500;
  letter-spacing: 0.13em; text-transform: uppercase;
  display: flex; align-items: center; gap: 0.35em; flex: none;
}
/* Trimmed to the capitals, so `align-items: center` centres them on the mark
   rather than centring the whole line box (which sits lower). */
.logo > span { text-box: trim-both cap alphabetic; }
/* The mark, in place of the red dot the wordmark used to carry. The dot lives
   on elsewhere as a bullet - on .label, in the footer columns - so the two are
   now distinct things: a bullet is punctuation, this is the brand. */
.logo::before {
  content: ""; width: 1.4em; height: 1.4em; flex: none;
  background: url("/static/img/logo.png") center / contain no-repeat;
}
.logo:hover { color: inherit; }
/* Much bigger in the top bar (2026-11-02, Vincent: "increase drastically the
   size of the 3DGARDEN and logo in the top barre"): was 14px with a 20px mark. */
.nav .logo { font-size: 26px; }
@media (max-width: 760px) { .nav .logo { font-size: 20px; } }
/* On the smallest phones the logo and the coffee button still share the
   first row (at 20px the button fell to a row of its own below 400px). */
@media (max-width: 420px) { .nav .logo { font-size: 17px; } }
@media (max-width: 350px) { .nav .logo { font-size: 15px; } }

/* Experiences, beside the logo (2026-10-30). The letters wear the same pearl
   foil as the buttons' holo contour (ice, aqua, lilac, pink, champagne between
   steel greys), always sliding, so the eye goes to it. The gradient repeats
   every 200% and moves exactly one tile, so the loop has no seam. A light
   theme gets deeper tints of the same hues: pale pearl on white is unreadable. */
.nav-exp {
  --exp-foil: linear-gradient(90deg,
    #e8f2ff, #8a94a2 8%, #aef0e6 17%, #f7f9fc 26%, #c9bdff 36%, #7c8696 45%,
    #ffc9e4 55%, #fff1d2 64%, #8a94a2 73%, #bde4ff 82%, #f4f6f9 91%, #e8f2ff);
  position: relative; isolation: isolate; flex: none;
  display: inline-flex; align-items: center; gap: 8px;
  padding: 8px 16px 8px 13px; border-radius: 999px;
  background: color-mix(in srgb, var(--ink) 6%, var(--paper));
  font-family: var(--mono); font-size: 13px; font-weight: 600;
  text-transform: uppercase; letter-spacing: 0.1em; white-space: nowrap;
  box-shadow: 0 0 22px -6px rgba(201, 189, 255, .45), 0 0 34px -12px rgba(174, 240, 230, .5);
  transition: transform .2s ease, box-shadow .2s ease,
              --fx .35s cubic-bezier(.2, .8, .3, 1), --fy .35s cubic-bezier(.2, .8, .3, 1), --fa .5s cubic-bezier(.2, .8, .3, 1);
}
/* The buttons' holo contour, always on and always turning (on the buttons it
   only shows on hover). Same paint, same pointer-follow from ui-fx.js. */
.nav-exp::before {
  content: ""; position: absolute; inset: -1px; border-radius: inherit; padding: 1.5px;
  pointer-events: none; z-index: -1;
  background:
    radial-gradient(circle at var(--fx) var(--fy), rgba(255, 255, 255, .95) 0, rgba(255, 255, 255, .35) 14px, rgba(255, 255, 255, 0) 40px),
    conic-gradient(from calc(var(--fa) + var(--holo-spin)) at var(--fx) var(--fy),
      #e8f2ff, #7c8696 7%, #aef0e6 15%, #f7f9fc 23%, #c9bdff 32%, #6c7584 41%,
      #ffc9e4 51%, #fff1d2 60%, #8a94a2 69%, #bde4ff 79%, #f4f6f9 89%, #e8f2ff);
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor; mask-composite: exclude;
  animation: exp-spin 4s linear infinite;
}
/* Its own keyframes: holo-spin lives inside the fine-pointer media query,
   and this one turns on touch screens too. */
@keyframes exp-spin { to { --holo-spin: 360deg; } }
.nav-exp:hover, .nav-exp.on {
  transform: translateY(-1px);
  box-shadow: 0 0 26px -4px rgba(201, 189, 255, .6), 0 0 40px -10px rgba(174, 240, 230, .65);
}
.nav-exp:hover::before { animation-duration: 1.6s; }
.nav-exp-icon { flex: none; color: var(--exp-icon, #c9bdff); }
.nav-exp span {
  background: var(--exp-foil) 0 0 / 200% 100% repeat-x;
  -webkit-background-clip: text; background-clip: text;
  color: transparent; -webkit-text-fill-color: transparent;
  animation: exp-foil 6s linear infinite;
}
@keyframes exp-foil { to { background-position: -200% 0; } }
.nav-exp:hover span, .nav-exp.on span { animation-duration: 2.4s; }
:root[data-theme="light"] .nav-exp,
:root:not([data-theme="dark"]) .nav-exp,
:root[data-theme="light"] .hero-foil,
:root:not([data-theme="dark"]) .hero-foil {
  --exp-icon: #6a4fd1;
  --exp-foil: linear-gradient(90deg,
    #3d6fb8, #4a5563 8%, #138c80 17%, #5b6b8a 26%, #6a4fd1 36%, #3f4856 45%,
    #c4377d 55%, #b07a12 64%, #4a5563 73%, #2f7fc4 82%, #56607a 91%, #3d6fb8);
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .nav-exp,
  :root:not([data-theme="light"]) .hero-foil {
    --exp-icon: #c9bdff;
    --exp-foil: linear-gradient(90deg,
      #e8f2ff, #8a94a2 8%, #aef0e6 17%, #f7f9fc 26%, #c9bdff 36%, #7c8696 45%,
      #ffc9e4 55%, #fff1d2 64%, #8a94a2 73%, #bde4ff 82%, #f4f6f9 91%, #e8f2ff);
  }
}
/* "Handcrafted." in the home headline wears the same moving foil (2026-11-02,
   Vincent: "the same effect as the experiences button"). The pearl tones are
   the dark-theme default; the theme rules above swap them like the button's. */
.hero-foil {
  --exp-foil: linear-gradient(90deg,
    #e8f2ff, #8a94a2 8%, #aef0e6 17%, #f7f9fc 26%, #c9bdff 36%, #7c8696 45%,
    #ffc9e4 55%, #fff1d2 64%, #8a94a2 73%, #bde4ff 82%, #f4f6f9 91%, #e8f2ff);
  background: var(--exp-foil) 0 0 / 200% 100% repeat-x;
  -webkit-background-clip: text; background-clip: text;
  color: transparent; -webkit-text-fill-color: transparent;
  animation: exp-foil 6s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
  .hero-foil { animation: none; }
  .nav-exp span, .nav-exp::before { animation: none; }
  .nav-exp:hover, .nav-exp.on { transform: none; }
}
@media (max-width: 760px) { .nav-exp { font-size: 11px; letter-spacing: 0.08em; padding: 7px 13px 7px 11px; gap: 7px; } }
/* Too narrow for logo, Experiences, coffee and burger on one line (about
   560px): the bar breaks after the coffee button (the ::after, a zero-height
   full-width item) and Experiences leads the second line with the burger at
   its right end. (The bar already wrapped from 360 to 414px before this link:
   the burger fell under the logo.) */
@media (max-width: 560px) {
  /* Half the row gap: the empty break line sits between two gaps. */
  header.nav { row-gap: 6px; }
  .nav::after { content: ""; order: 2; flex-basis: 100%; height: 0; }
  .nav-exp { order: 3; margin-right: auto; }
  .nav .burger { order: 4; }
  .nav .nav-links { order: 5; }
}

/* The Experiences page: big 16:9 thumbnails, the whole card is the link. */
.exp-grid { display: grid; gap: 22px; grid-template-columns: repeat(auto-fill, minmax(min(420px, 100%), 1fr)); }
.exp-card { display: flex; flex-direction: column; border: var(--rule); border-radius: 14px; overflow: hidden;
  background: var(--paper); color: inherit; transition: transform .2s ease, box-shadow .2s ease; }
.exp-card:hover { color: inherit; transform: translateY(-3px); box-shadow: 0 22px 50px -24px rgba(0, 0, 0, .55); }
.exp-thumb { position: relative; display: block; aspect-ratio: 16 / 9; overflow: hidden; background: var(--panel); }
.exp-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform .5s ease; }
.exp-card:hover .exp-thumb img { transform: scale(1.04); }
.exp-play { position: absolute; left: 18px; bottom: 18px; width: 48px; height: 48px; border-radius: 50%;
  display: grid; place-items: center; background: var(--accent); color: #fff;
  box-shadow: 0 8px 24px -6px rgba(0, 0, 0, .5); }
.exp-body { display: flex; flex-direction: column; gap: 8px; padding: 18px 20px 20px; }
.exp-title { font-size: 22px; }
@media (prefers-reduced-motion: reduce) { .exp-card, .exp-thumb img { transition: none; } .exp-card:hover, .exp-card:hover .exp-thumb img { transform: none; } }

.nav-links { display: flex; gap: 22px; margin-left: auto; align-items: center; }
.nav-links a { font-family: var(--mono); font-size: 13px; font-weight: 500;
                text-transform: uppercase; letter-spacing: 0.07em; }
.nav-links a.on { color: var(--accent); }
/* Sign out is a form because it has to be a POST - see layout.php. The button
   inherits the link treatment rather than the .btn pill, so the row still reads
   as one run of nav links and not five links and a widget. */
.nav-links form.signout { display: flex; align-items: center; }
.linkish {
  font: inherit; font-family: var(--mono); font-size: 13px; font-weight: 500;
  text-transform: uppercase; letter-spacing: 0.07em;
  background: none; border: 0; padding: 0; cursor: pointer; color: inherit;
}
.linkish:hover { color: var(--accent); }

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

.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 8px;
  font-family: var(--mono); font-size: 13px; font-weight: 600;
  text-transform: uppercase; letter-spacing: 0.08em;
  padding: 13px 26px; border: 1px solid var(--ink);
  background: var(--ink); color: var(--paper);
  border-radius: 999px; cursor: pointer;
  transition: background .15s, color .15s, border-color .15s;
}
.btn:hover { background: var(--accent); border-color: var(--accent); color: #fff; }
/* Paper, not transparent. A control is an object sitting ON the page, and the
   moment the hero's grid shows through one it stops reading as an object and
   starts reading as a hole cut in the texture. Same reason the search field and
   the ticker below paint their own ground. */
.btn.ghost { background: var(--paper); color: var(--ink); }
.btn.ghost:hover { background: var(--ink); color: var(--paper); border-color: var(--ink); }
.btn.small { padding: 8px 16px; font-size: 11px; }
.btn[disabled] { opacity: .4; pointer-events: none; }

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

/* The one place on the open page that still carries the texture.
   You meet the hero once, its type is enormous, and it is where the brand has
   to land - so the grid earns its keep here in a way it does not under a table
   of file sizes. It paints at the QUIET --dot weight rather than the stage
   weight, because unlike a thumbnail it does have running text over it. */
.hero {
  /* The bottom of the hero is one dot row shorter than it looks: the last pitch
     of the gap is a MARGIN rather than padding, and a background does not paint
     on margins. Same total spacing as before, one fewer row of dots - the one
     that used to sit right at the boundary and get clipped mid-dot. */
  padding: clamp(56px, 11vw, 130px) 0 calc(clamp(40px, 7vw, 80px) - var(--dot-gap));
  margin-bottom: var(--dot-gap);
  position: relative;
  background-color: var(--paper);
  background-image: var(--dot-grid);
  background-size: var(--dot-gap) var(--dot-gap);
}

/* The eyebrow above the headline: full-contrast ink and a red marker, so it
   reads as a statement rather than fine print. */
.hero .label, .label.strong {
  font-size: 13px;
  color: var(--ink);
  display: inline-flex; align-items: center; gap: 10px;
}
.hero .label::before, .label.strong::before {
  content: ""; width: 7px; height: 7px; border-radius: 50%;
  background: var(--accent); flex: none;
}
.hero h1 { max-width: 15ch; position: relative; }

/* ---------- the pointer light ----------

   A single light source that follows the cursor across the hero. It does two
   things at once, which is what stops it reading as a gimmick: it pools in the
   dot field, so the texture behaves like a surface being lit rather than a
   pattern printed on one; and where it crosses the headline the type warms
   toward the accent, the way light catches a machined edge.

   Everything here is additive and every part of it fails safe. The coordinates
   default to a resting position, the base headline is always painted in solid
   ink underneath, and the highlight only appears once JS has put `is-live` on
   the hero - so no pointer, no script or no support means the plain hero, not
   a broken one. */
.hero { --mx: 50%; --my: 38%; --hx: -40%; --hy: 50%; }

/* The canvas that hero-fluid.js draws the disturbed lattice on. It sits under
   the content and takes no pointer events - the hero itself still hears the
   mouse. */
.hero-fx { position: absolute; inset: 0; z-index: 0; pointer-events: none; }
.hero > .wrap { position: relative; z-index: 1; }

/* While the fluid is running the canvas IS the dot field, so the CSS one is
   taken away - two lattices, one of them displaced, would read as doubled.
   The class goes on only when the sim wakes and comes off the moment it
   settles, at which point the two are identical and the swap cannot be seen. */
.hero.is-fluid { background-image: none; }

@media (prefers-reduced-motion: no-preference) {
  /* The specular on the type.
  
     Painted on the headline ITSELF, not on a copy of it stacked on top. The
     copy was the obvious way to get a fade - opacity on an overlay - but two
     identical glyph renderings composited together darken each other's
     antialiased edges, and the headline picked up a faux-bold outline. One
     element, one rendering, no halo.

     Gated on support because `color: transparent` is doing the work: without
     `background-clip: text` to cut the gradient to the letterforms, an
     unsupporting browser would paint a coloured box over an invisible
     headline. And at rest the gradient's centre sits off the element (see the
     --hx default), so every glyph resolves to plain var(--ink). */
  @supports ((background-clip: text) or (-webkit-background-clip: text)) {
    .hero.is-live h1 {
      /* Tight. A wide falloff tints half the headline and reads as a colour
         change; a narrow one reads as a highlight travelling over a surface,
         which is the whole idea. */
      background-image: radial-gradient(300px circle at var(--hx) var(--hy),
                          var(--sheen) 0%, var(--ink) 46%);
      -webkit-background-clip: text;
              background-clip: text;
      color: transparent;
    }
  }
}
.hero-sub { max-width: 46ch; margin-top: 26px; color: var(--muted); font-size: 17px; }
.hero-cta { display: flex; gap: 12px; margin-top: 38px; flex-wrap: wrap; }

/* The ticker is a panel, not part of the ground: it paints paper so the hero
   grid stops at its rules rather than running through the numbers. */
/* A closed panel, not an open band.
   With rules only top and bottom, the paper simply stopped at the content edge
   and the hero's dot field resumed against a raw cut. A hairline all the way
   round makes that boundary deliberate - the same way a card terminates - and
   gives the texture something to stop at. */
.ticker {
  display: flex; gap: 0; border: var(--rule);
  margin-top: clamp(40px, 7vw, 80px);
  background-color: var(--paper);
}
/* A right gutter as well as a left one. Each cell used to have padding only on
   the side its rule is on, so a value ran to the very end of its cell - and in
   the last cell that end is where the hero's dot field starts again, with
   nothing between the two. The left inset still differs from the right because
   the first cell deliberately has none: its label lines up with the headline. */
/* A right gutter as well as a left one. Each cell used to have padding only on
   the side its rule is on, so a long value ran to the very end of its cell  - 
   and in the last cell that end is where the hero's dot field starts again. */
.ticker div { flex: 1; padding: 22px 24px; border-left: var(--rule); }
.ticker div:first-child { border-left: 0; }
.ticker strong { display: block; font-size: 30px; font-weight: 500; letter-spacing: -0.03em; }

/* ---------- grids ---------- */

/* The seams live on the cards, not on the container.

   They used to be the container's own background showing through a 1px gap,
   which is elegant right up until a row is not full: the tracks no card covers
   paint --line across the rest of the row, so one model in a four-up grid reads
   as a large grey slab rather than as a sparse shelf. Borders on the children
   draw the same hairlines and stop where the cards stop. */
/* 208px, not 260px. Somebody shopping for a bollard scans dozens of thumbnails
   and judges most of them in well under a second, so how many fit above the
   fold is a real part of how good the library feels. At 260 the wrap held four
   columns and about two and a half rows on a laptop; at 208 it holds five and
   nearly four, without the thumbnail getting too small to judge a model by. */
/* `min(208px, 50%)` rather than `min(208px, 100%)`, and the 50% is the whole
   change: the track floor is 208px until 208px is more than half the row, at
   which point it becomes half the row exactly. So the grid can never fall to
   one column. A phone is 335px of content inside the gutter, 81px short of two
   208px tracks, and the old floor spent that shortfall by dropping to a single
   full-width card - one model per screen, and a scroll to see the next. Two
   167px cards read a whole shelf at a glance, which is what a browse grid is
   for. Above 416px of content nothing moves: 208px is under half by then, so
   the floor is the same number it always was. */
.grid {
  display: grid; gap: 0; margin: 0;
  border-top: var(--rule); border-left: var(--rule);
  grid-template-columns: repeat(auto-fill, minmax(min(208px, 50%), 1fr));
}
.grid > * { border-right: var(--rule); border-bottom: var(--rule); }

/* A closed shelf, unlike /browse. The library page is a list that ends where
   the results end, so a short last row is honest there. The home strip is a
   fixed-size display, and a lone card hanging under a full row reads as a
   layout fault rather than as "and one more".
   Ten cards are asked for, which is two whole rows while the shelf is five
   wide. Ten divides by five, two and one but not by four or three, so in those
   two bands the tail is taken away and comes back as the window widens.
   The column count is spelled out here rather than left to auto-fill on
   purpose: the count and the rule that trims the tail have to agree, and
   auto-fill's answer is not something a media query can ask for. The widths
   below are where auto-fill would have dropped a column anyway - 208px per
   track plus the grid's own 1px left border, against the clamped gutter - so
   the cards keep the sizes they had. Nothing here is free to drift: change the
   limit in index.php and every :nth-child below must be re-derived. */
/* Two below 727px, not one below 485px: the shelf follows the browse grid,
   which no longer collapses to a single column either. Ten cards over two
   columns is five full rows, so the tail rule below has nothing to trim here
   - 10 divides by 2 as cleanly as it divides by 5. */
.grid-shelf { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@media (min-width:  727px) { .grid-shelf { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (min-width:  945px) { .grid-shelf { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
@media (min-width: 1153px) { .grid-shelf { grid-template-columns: repeat(5, minmax(0, 1fr)); } }

/* Ranged, not cascading, so that widening the window never has to undo a
   `display: none` it cannot know the original value of. */
@media (min-width: 727px) and (max-width:  944px) {   /* 3 columns -> 3 rows of 3 */
  .grid-shelf:not(.grid-shelf-3) > :nth-child(n+10) { display: none; }
}
@media (min-width: 945px) and (max-width: 1152px) {   /* 4 columns -> 2 rows of 4 */
  .grid-shelf:not(.grid-shelf-3) > :nth-child(n+9) { display: none; }
}

/* The three-row shelf: Latest additions on the home page (2026-10-30, Vincent:
   "we got 2 rows : let's add one"). Fifteen cards, three rows of five; the
   narrower bands trim to whole rows the same way: 12 over four columns, 9 over
   three, 14 over two. Change its limit in index.php and re-derive these. */
@media (max-width: 726px) { .grid-shelf-3 > :nth-child(n+15) { display: none; } }
@media (min-width: 727px) and (max-width:  944px) { .grid-shelf-3 > :nth-child(n+10) { display: none; } }
@media (min-width: 945px) and (max-width: 1152px) { .grid-shelf-3 > :nth-child(n+13) { display: none; } }

.card {
  background: var(--paper); padding: 0; display: flex; flex-direction: column;
  transition: background .15s;
}
.card:hover { background: var(--panel); }
.card:hover .card-thumb::after { border-color: var(--accent); }

.card-thumb {
  aspect-ratio: 4 / 3; position: relative;
  display: grid; place-items: center;   /* the grid itself comes from the stage rule */
}
.card-thumb::after {
  content: ""; position: absolute; inset: 11px;   /* scaled with the tile */
  border: 1px solid var(--line); transition: border-color .15s;
}
.card-thumb span {
  font-family: var(--mono); font-size: 12px; font-weight: 500; letter-spacing: 0.1em;
  text-transform: uppercase; color: var(--muted);
  background: var(--paper); padding: 5px 10px; position: relative;
}
.card-body { padding: 13px 14px 15px; border-top: var(--rule); }
.card-body h3 { margin-bottom: 3px; font-size: 16px; }
.card-body p { font-size: 13px; }
.card-meta { display: flex; gap: 6px; margin-top: 9px; flex-wrap: wrap; }
/* The tag is a label on a card, not a control: it can afford to be smaller
   here than the same component is on a model page. */
.card-meta .tag { font-size: 10px; padding: 3px 7px; letter-spacing: 0.06em; }

.tag {
  font-family: var(--mono); font-size: 11px; font-weight: 500; text-transform: uppercase;
  letter-spacing: 0.07em; padding: 4px 9px; border: var(--rule); color: var(--muted);
}
.tag.on { border-color: var(--accent); color: var(--accent); }

/* ---------- filters ---------- */

.filters { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; padding: 26px 0; }

/* The home page's tag strip, grouped by kind.
   A row per kind, hairline-ruled, with the kind's name in the same mono label
   the spec tables use: the four groups have to read as four answers to four
   questions rather than as four more chips. The label column is fixed so the
   chips of every group start on one vertical line - ragged left edges are what
   makes a grouped list read as a jumble again. */
.tag-groups { margin-top: 18px; border-bottom: var(--rule); }
.tag-group {
  display: grid; grid-template-columns: 104px minmax(0, 1fr);
  gap: 0 20px; align-items: baseline;
  padding-block: 6px; border-top: var(--rule);
}
.tag-group .filters { padding: 12px 0; }
@media (max-width: 560px) {
  /* The label goes above its chips rather than shrinking a column that a long
     kind name would break out of anyway. */
  .tag-group { grid-template-columns: minmax(0, 1fr); gap: 0; padding-block: 14px 4px; }
  .tag-group .filters { padding: 10px 0 8px; }
}
.chip {
  font-family: var(--mono); font-size: 12px; font-weight: 500; text-transform: uppercase;
  letter-spacing: 0.07em; padding: 8px 15px; border: var(--rule); border-radius: 999px;
  color: var(--ink);
}
.chip.on { background: var(--ink); color: var(--paper); border-color: var(--ink); }
/* The end of the run: still drawn, so the control keeps its shape, but plainly
   not a link. */
.chip.is-off { color: var(--muted); opacity: .45; }

.pager { display: flex; align-items: center; gap: 14px; padding: 30px 0 0; flex-wrap: wrap; }

/* One control in the light configurator: label and current value on a row, the
   slider under them, the explanation under that. The value is what makes a
   slider legible - "0.45" beside "Environment" says more than the handle does. */
.light-row { margin-bottom: 16px; }
.light-row label {
  font-family: var(--mono); font-size: 12px; font-weight: 500;
  letter-spacing: 0.06em; text-transform: uppercase;
}
.light-row output {
  float: right; font-family: var(--mono); font-size: 12px; color: var(--muted);
}
.light-row input[type=range] { width: 100%; margin: 6px 0 2px; accent-color: var(--accent); }
.light-row span { display: block; font-size: 11px; line-height: 1.45; }
.chip:hover { border-color: var(--ink); color: inherit; }
.chip.on:hover { background: var(--accent); border-color: var(--accent); color: #fff; }

/* ---------- viewer ---------- */

.viewer {
  position: relative; aspect-ratio: 16 / 10; border: var(--rule);
  overflow: hidden;                     /* the grid itself comes from the stage rule */
}
.viewer canvas { display: block; width: 100%; height: 100%; touch-action: none; }

/* ---------- animation timeline ----------
   A transport bar under the stage: play, scrub, and the clip if there is more
   than one. Hairlines and mono numerals, so it reads as part of the spec sheet
   rather than as a media player dropped onto the page. */
.timeline {
  display: flex; align-items: center; gap: 12px;
  margin-top: 12px; padding: 9px 12px; border: var(--rule);
  background-color: var(--paper);
}
.timeline[hidden] { display: none; }

.tl-play {
  display: inline-flex; align-items: center; justify-content: center;
  width: 30px; height: 30px; flex: none; padding: 0;
  border: var(--rule); border-radius: 999px;
  background: var(--paper); color: var(--ink); cursor: pointer;
  transition: color .15s, border-color .15s;
}
.tl-play:hover { color: var(--accent); border-color: var(--accent); }
.tl-play:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* The scrubber takes whatever is left, because it is the control being used. */
.timeline input[type=range] { flex: 1; min-width: 80px; accent-color: var(--accent); }

.tl-time {
  font-size: 12px; color: var(--muted); flex: none;
  font-variant-numeric: tabular-nums;   /* or the bar twitches as digits change */
}


.tl-loop {
  display: inline-flex; align-items: center; gap: 6px; flex: none;
  font-family: var(--mono); font-size: 11px; font-weight: 500;
  letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted);
  cursor: pointer;
}
.tl-loop input { accent-color: var(--accent); }

@media (max-width: 560px) {
  /* The clip name and the loop toggle wrap onto their own line before the
     scrubber gets squeezed down to something you cannot aim at. */
  .timeline { flex-wrap: wrap; }
  .timeline input[type=range] { flex: 1 1 100%; order: 3; }
}

/* Viewer controls. Same 36px circle as .fav - see the "one shape per verb"
   rule in the design notes - stacked in the corner opposite the status HUD so
   the two never collide while the model is still loading. */
.viewer-tools {
  position: absolute; top: 12px; right: 12px; z-index: 2;
  display: flex; flex-direction: column; gap: 8px;
}
/* `display` in an author stylesheet beats the UA rule behind the `hidden`
   attribute, so without this the buttons appear before viewer.js has bound
   them - visible, clickable and inert. */
.viewer-tools[hidden] { display: none; }
.vtool {
  display: inline-flex; align-items: center; justify-content: center;
  width: 36px; height: 36px; padding: 0; flex: none;
  border: var(--rule); border-radius: 999px;
  background: var(--paper); color: var(--muted);
  cursor: pointer;
  transition: color .15s, border-color .15s, transform .12s;
}
.vtool:hover { color: var(--accent); border-color: var(--accent); }
.vtool:active { transform: scale(.92); }
.vtool[aria-pressed="true"] { color: var(--accent); border-color: var(--accent); }
.vtool:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* Fullscreen hands the element the whole screen, so the 16/10 box has to let
   go of its aspect ratio or it letterboxes itself inside the display. */
.viewer:fullscreen { aspect-ratio: auto; width: 100%; height: 100%; border: 0; }
.viewer:fullscreen canvas { height: 100%; }

@media (pointer: coarse) {
  .viewer-tools { top: 10px; right: 10px; gap: 6px; }
  .vtool { width: 42px; height: 42px; }
}
.viewer-hud {
  position: absolute; left: 14px; bottom: 14px;
  font-family: var(--mono); font-size: 12px; font-weight: 500; letter-spacing: 0.06em;
  text-transform: uppercase; color: var(--muted);
  background: var(--paper); border: var(--rule); padding: 7px 12px;
  display: flex; gap: 9px; align-items: center;
}
/* `display: flex` above outranks the UA rule behind the `hidden` attribute, the
   same trap .viewer-tools already documents. It matters now because the status
   readout is hidden rather than removed once a model is up: it stays in the DOM
   so a colourway that fails to load has somewhere to say so. */
.viewer-hud[hidden] { display: none; }
.viewer-hud::before {
  content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--muted);
}

/* ---------- colourways ----------
   The swatch row sits bottom-left, in the corner the loading readout vacates,
   so nothing has to move to make room for it. Pills, like every other chip on
   the site, because a colourway is a choice among a small named set and that is
   what a pill means here. The dot is the colour; the word is the name, and the
   word is what a screen reader gets - a hex value is not a description. */
.viewer-colours {
  position: absolute; left: 14px; bottom: 14px; z-index: 2;
  display: flex; flex-wrap: wrap; gap: 6px;
  max-width: calc(100% - 28px);
}
.viewer-colours[hidden] { display: none; }

.vswatch {
  --vswatch: var(--muted);          /* a colourway with no swatch set */
  position: relative;
  display: inline-flex; align-items: center; gap: 7px;
  padding: 6px 12px 6px 8px;
  border: var(--rule); border-radius: 999px;
  background: var(--paper); color: var(--muted);
  font-family: var(--mono); font-size: 11px; font-weight: 500;
  letter-spacing: 0.06em; text-transform: uppercase;
  cursor: pointer;
  transition: color .15s, border-color .15s;
}
.vswatch:hover { color: var(--accent); border-color: var(--accent); }
/* Buttons carry aria-pressed and links carry aria-current: the viewer's
   swatches toggle a view, the collection page's navigate to one. Both mean
   "this is the one you are looking at", so both get the same ink. */
.vswatch[aria-pressed="true"], .vswatch[aria-current="true"] {
  color: var(--ink); border-color: var(--ink);
}
.vswatch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.vswatch-dot {
  width: 11px; height: 11px; flex: none; border-radius: 50%;
  background: var(--vswatch);
  /* A ring, so a white colourway is still visible on paper and a black one on
     ink - the same reason .swatch carries one. */
  box-shadow: 0 0 0 1px var(--line);
}

/* On a small stage the names would wrap to three rows over the model. The dot
   grows to stay a touch target and the word is hidden VISUALLY only, so it is
   still the button's accessible name and still its tooltip. */
@media (max-width: 560px) {
  .viewer-colours { left: 10px; bottom: 10px; gap: 5px; }
  .vswatch { padding: 8px; }
  .vswatch-dot { width: 14px; height: 14px; }
  .viewer-colours .vswatch-name {
    position: absolute; width: 1px; height: 1px;
    overflow: hidden; clip-path: inset(50%); white-space: nowrap;
  }
}

/* The same control on a collection page, where it is a row of links above the
   grid rather than an overlay on a stage. */
.colour-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
@media (pointer: coarse) {
  .vswatch { padding: 9px 14px 9px 10px; }
  .viewer-colours .vswatch { padding: 9px; }
}

/* The upload screen: the form, and the preview given room beside it. The
   preview is the one control there that has to be looked at properly before the
   button is pressed, and in the old shared screen it was a 380px rail. */
.upload-cols {
  display: grid; gap: 40px;
  grid-template-columns: repeat(auto-fit, minmax(min(360px, 100%), 1fr));
  align-items: start;
}

/* A block's pager sits under its own table, so it is smaller and quieter than
   the page-level one and does not need the full width. */
.pager-small { justify-content: flex-start; gap: 10px; margin-top: 12px; padding: 0; border: 0; }

/* A fragment target that must not end up under the sticky nav.
   Anywhere a real link reloads the page to change what it is showing - an
   analytics block being paged, a collection's colour being switched - the link
   carries `#id` so the browser lands on the thing that changed instead of at
   the top. Without the offset the nav sits on top of whatever it scrolled to,
   which defeats the point of having scrolled there. */
.blk { scroll-margin-top: 84px; }

/* `.table-wrap > .table` carries a 560px min-width, which is right for the wide
   tables it was written for and wrong for a two-column block sitting in half a
   column: every small table grew a horizontal scrollbar it could not fill. The
   full-width tables - the contributor split, most-viewed - are outside
   .stat-cols and keep it. */
.stat-cols .table-wrap > .table { min-width: 0; }

/* The library filter bar borrows .searchbar's shape and needs three things it
   does not have: room for a select and two buttons beside the field, a floor
   under the field so it cannot be squeezed to nothing (flex-shrink on an input
   with no min-width will take it to zero), and permission to wrap on a narrow
   admin column. */
.admin-filter { max-width: none; flex-wrap: wrap; margin: 0 0 18px; }
.admin-filter input[type=search] { min-width: 200px; }
.admin-filter select { flex: none; max-width: 220px; }

/* ---------- admin: the library grid ----------
   Cards, not rows. One row per model only works while the library is small: at
   a few hundred it is a page of scrolling with one model per screenful, and the
   thumbnail - the fastest way to recognise a model - was a 60px cell at the far
   left. Same auto-fill shape as the public grid, at a size that fits the card's
   three stacked parts. */
.admin-grid {
  display: grid; gap: 1px;
  grid-template-columns: repeat(auto-fill, minmax(min(240px, 50%), 1fr));
  background: var(--line); border: var(--rule);
}
.admin-card {
  background-color: var(--paper);
  display: flex; flex-direction: column;
}
/* An unpublished model is a draft and should read as one at a glance, without
   having to find the word on the card. */
.admin-card.is-draft .admin-card-thumb { opacity: .55; }
.admin-card.is-draft .admin-card-body strong::after {
  content: " · draft"; color: var(--accent); font-family: var(--mono);
  font-size: 11px; letter-spacing: .06em; text-transform: uppercase;
}

/* The whole picture is the checkbox's label, so selecting a model is a click on
   the model rather than on a 13px square beside it. */
.admin-card-pick { display: block; position: relative; cursor: pointer; }
.admin-card-pick input {
  position: absolute; top: 8px; left: 8px; z-index: 2;
  accent-color: var(--accent); margin: 0;
}
.admin-card-thumb {
  display: grid; place-items: center; aspect-ratio: 4 / 3;
  position: relative; overflow: hidden;   /* the grid comes from the stage rule */
  transition: opacity .15s;
}
.admin-card-thumb img { width: 100%; height: 100%; object-fit: contain; padding: 10px; }
/* The selected state, drawn on the picture rather than only on the tiny box:
   a grid of forty cards needs the answer to "which are picked" to be readable
   from across the room. */
.admin-card-tick {
  position: absolute; inset: 0; pointer-events: none;
  border: 2px solid transparent; transition: border-color .15s, background .15s;
}
.admin-card-pick input:checked ~ .admin-card-tick {
  border-color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.admin-card-pick input:focus-visible ~ .admin-card-tick { border-color: var(--ink); }
.admin-card-picklabel {
  position: absolute; width: 1px; height: 1px;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap;
}

.admin-card-body {
  padding: 11px 12px 6px; border-top: var(--rule);
  display: flex; flex-direction: column; gap: 4px; flex: 1;
}
.admin-card-body strong { font-size: 14px; line-height: 1.25; }
.admin-card-body .mono { font-size: 11px; }
.admin-card-body .row-chips { margin-top: 2px; }
.admin-card-body .tag { font-size: 10px; padding: 3px 6px; }
.admin-card-actions {
  display: flex; flex-wrap: wrap; gap: 6px;
  padding: 0 12px 12px;
}
.admin-card-actions .btn.small { padding: 6px 11px; font-size: 11px; }

/* ---------- admin: batch selection ---------- */

.batch-bar {
  border: var(--rule); background-color: var(--paper);
  padding: 16px 18px; margin-top: 18px;
  display: flex; flex-direction: column; gap: 14px;
}
.batch-bar-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.batch-bar-actions { display: flex; gap: 10px; flex-wrap: wrap; }
.batch-bar .btn[disabled] { opacity: .4; cursor: not-allowed; }

/* ---------- analytics ----------
   No charting library: the CSP is script-src 'self' with no nonce, so a chart
   would be a dependency and a script tag for something CSS draws. Both shapes
   here are bars, and a bar is a box with a width. */

/* Two columns of tables on a wide screen, one on a phone. auto-fit, so a lone
   block spans rather than sitting in half a row. */
.stat-cols {
  display: grid; gap: 34px 44px;
  grid-template-columns: repeat(auto-fit, minmax(min(340px, 100%), 1fr));
}

/* The share bar that sits under a number in a table cell. Deliberately thin and
   in the muted ink: it is there to make a column scannable at a glance, not to
   be the thing you read. */
.statbar {
  display: block; height: 3px; margin-top: 5px;
  background: var(--muted); min-width: 2px;
}

/* Per-day activity. Bars grow from the baseline, and a day with nothing gets no
   bar at all rather than a 1px one - an empty day should read as empty. */
.daychart {
  display: flex; align-items: flex-end; gap: 2px;
  height: 110px; padding: 0 1px;
  border-bottom: var(--rule);
}
.daybar { flex: 1 1 0; display: flex; align-items: flex-end; height: 100%; min-width: 3px; }
.daybar > span { display: block; width: 100%; background: var(--ink); }
.daybar:hover > span { background: var(--accent); }

/* The admin's list of colourways. Hairline rows like .sort-row, because it is
   the same kind of object: a short list of things belonging to one shelf. */
.colour-list { list-style: none; margin: 0 0 4px; padding: 0; border-top: var(--rule); }
.colour-item {
  display: flex; align-items: center; gap: 12px;
  padding: 11px 2px; border-bottom: var(--rule);
}
.colour-item-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.colour-item-main strong { font-size: 14px; }
.colour-item-main .mono { font-size: 12px; }
.colour-item form { margin: 0; }
.colour-chip {
  --vswatch: var(--muted);
  width: 20px; height: 20px; flex: none; border-radius: 50%;
  background: var(--vswatch); box-shadow: 0 0 0 1px var(--line);
}

/* The native picker, given the same box as every other field so the form does
   not suddenly change materials halfway down. */
input[type=color] {
  width: 46px; height: 34px; padding: 3px; vertical-align: middle;
  border: var(--rule); background: var(--paper); cursor: pointer;
  margin-right: 10px;
}
.viewer-hud[data-kind="busy"]::before  { background: var(--accent); animation: pulse 1.1s ease-in-out infinite; }
.viewer-hud[data-kind="error"]         { color: var(--accent); }
.viewer-hud[data-kind="error"]::before { background: var(--accent); }
@keyframes pulse { 50% { opacity: .25; } }

.detail { display: grid; grid-template-columns: 1fr 340px; gap: 40px; padding-block: 40px 80px; }
@media (max-width: 900px) { .detail { grid-template-columns: 1fr; } }

.spec { width: 100%; border-collapse: collapse; font-size: 13px; }
.spec th, .spec td { text-align: left; padding: 11px 0; border-bottom: var(--rule); vertical-align: top; }
.spec th { font-family: var(--mono); font-size: 12px; text-transform: uppercase;
           letter-spacing: 0.07em; color: var(--muted); font-weight: 500; width: 42%; }

/* ---------- forms ---------- */

.form { max-width: 420px; }
.field { margin-bottom: 18px; }
.field label { display: block; margin-bottom: 7px; }
input[type=text], input[type=email], input[type=password], input[type=search],
input[type=file], textarea, select {
  width: 100%; padding: 12px 14px; font: inherit; font-size: 14px;
  background: var(--paper); color: var(--ink);
  border: 1px solid var(--line); border-radius: 0;
}
/* Pointer focus loses the ring and keeps the darkened border; keyboard focus
   keeps the ring. `:focus` alone took the ring away from both, which left a
   keyboard user with a 1px border change as their only cue. */
/* The options in an open drop-down are painted by the platform, not by the
   rules above, so they need saying explicitly - and they must be said in real
   colours rather than inherited, or a themed page ends up with the OS's idea
   of a background under our idea of a text colour. */
option { background-color: var(--paper); color: var(--ink); }

input:focus, textarea:focus, select:focus { outline: none; border-color: var(--ink); }
input:focus-visible, textarea:focus-visible, select:focus-visible {
  outline: 2px solid var(--accent); outline-offset: 2px;
}
textarea { min-height: 96px; resize: vertical; }
input[type=file] { padding: 10px; font-family: var(--mono); font-size: 12px; }

.flash { padding: 13px 16px; border: var(--rule); margin-bottom: 20px; font-size: 14px; }
.flash.error { border-color: var(--accent); color: var(--accent); }
.flash.ok { border-color: #12b76a; }

.divider { display: flex; align-items: center; gap: 14px; margin: 26px 0; }
.divider::before, .divider::after { content: ""; flex: 1; border-top: var(--rule); }

/* ---------- pricing ---------- */

.plans { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr));
         gap: 1px; background: var(--line); border: var(--rule); }
/* Each card is a column: the body grows, the CTA is pushed to the bottom, so
   buttons line up across all three no matter how many bullets each one has. */
.plan {
  background: var(--paper); padding: 34px 30px;
  display: flex; flex-direction: column;
}
.plan-body { flex: 1 1 auto; }
.plan-cta  { margin-top: 26px; }
.plan.featured { background: var(--panel); }
.price { font-size: 52px; letter-spacing: -0.04em; margin: 18px 0 4px; }
.plan ul { list-style: none; padding: 0; margin: 24px 0 0; font-size: 14px; }
.plan li { padding: 9px 0 9px 20px; border-top: var(--rule); position: relative; }
.plan li::before { content: ""; position: absolute; left: 0; top: 17px;
                   width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }

/* ---------- tables (admin) ---------- */

.table { width: 100%; border-collapse: collapse; font-size: 13px; }
.table th { font-family: var(--mono); font-size: 11px; text-transform: uppercase;
            letter-spacing: 0.08em; color: var(--muted); font-weight: 600; text-align: left; }
.table th, .table td { padding: 12px 14px 12px 0; border-bottom: var(--rule); }
.table tr:hover td { background: var(--panel); }

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

.footer { border-top: var(--rule); padding: 44px 0 60px; margin-top: 80px; }
.footer-cols { display: flex; gap: 60px; flex-wrap: wrap; }
.footer-cols nav { display: flex; flex-direction: column; gap: 9px; }

.empty { padding: 80px 0; text-align: center; color: var(--muted); }

@media (max-width: 720px) {
  .nav { gap: 14px; }
  .nav-links { gap: 14px; }
  .ticker { flex-wrap: wrap; }
  .ticker div { flex: 1 1 50%; border-left: 0; padding-left: 0; }
}

/* ---------- thumbnails ---------- */

/* Thumbnails are transparent WebP, so the dot grid shows through behind the
   model - the placeholder texture becomes part of the finished card. */
.card-thumb img {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: contain;
  /* Small, because the render itself now fits the frame (see frame.js). It
     used to be 22px on top of a render that only filled ~45% of the image,
     which is how the model ended up a third of the size of its own card. */
  padding: 10px;
}

.thumb-preview {
  border: var(--rule);
  aspect-ratio: 4 / 3;
  display: grid; place-items: center;
  margin-bottom: 8px;
  overflow: hidden;
}
.thumb-preview img { width: 100%; height: 100%; object-fit: contain; padding: 14px; }
/* `display: grid` above outranks the UA rule behind the `hidden` attribute, so
   without this the empty stage is drawn before there is anything to put in it  - 
   the same trap .viewer-tools and .viewer-hud each document. It shows on the
   upload screen, where the box is large and its emptiness is the first thing
   on the page. */
.thumb-preview[hidden] { display: none; }

.thumb-cell {
  width: 64px; height: 48px;
  border: var(--rule);
  display: grid; place-items: center;
  overflow: hidden;
}
.thumb-cell img { width: 100%; height: 100%; object-fit: contain; padding: 4px; }

/* admin: forms sit next to a summary panel; stack them on narrow screens */
@media (max-width: 860px) {
  .edit-cols, .admin-cols { grid-template-columns: 1fr !important; }
}

/* ---------- taxonomy: collections, tags, facets ---------- */

/* One search field + button, sharing a hairline box so it reads as a single
   control rather than two. */
.searchbar {
  display: flex; gap: 10px; align-items: center;
  margin: 26px 0 6px; max-width: 560px;
}
.searchbar input[type=search] {
  flex: 1; border-radius: 999px; padding: 11px 20px;
  border: var(--rule); background: var(--paper); color: var(--ink);
  font-family: var(--sans); font-size: 15px;
}
.searchbar input[type=search]:focus { outline: none; border-color: var(--ink); }
.searchbar input[type=search]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.searchbar .btn { flex: none; }
/* In the hero (2026-11-02, Vincent: "bigger, larger, under all of that"): the
   whole width of the page, under the headline and the showcase, and a size up
   from the /browse field. The field and its button are one height (Vincent:
   "search button size is off compared to search barre"), and the button
   reads like the hero's own (same type). Above the showcase, whose canvas
   reaches down behind it. */
.searchbar.hero-search {
  --search-h: 60px;
  position: relative; z-index: 2;
  max-width: none; margin: var(--hero-gap) 0 0; gap: 12px;
}
.hero-search input[type=search] { height: var(--search-h); padding: 0 28px; font-size: 18px; }
.hero-search .btn { height: var(--search-h); padding: 0 34px; }
@media (max-width: 760px) {
  .searchbar.hero-search { --search-h: 52px; }
  .hero-search input[type=search] { padding: 0 22px; font-size: 16px; }
  .hero-search .btn { padding: 0 26px; }
}
/* Stacked under the full-width hero buttons on a phone: their height too. */
@media (max-width: 480px) { .searchbar.hero-search { --search-h: 48px; } }
/* Under it: what the files open in. A label, then the names, each after a
   coloured dot: plain text, no border, no hover, so it reads as information
   and not as a row of buttons. Same left edge and the same rhythm
   (--hero-gap) as the rest of the hero. */
.hero-compat {
  position: relative; z-index: 2;
  display: flex; flex-wrap: wrap; align-items: center; gap: 10px 22px;
  margin: calc(var(--hero-gap) * .75) 0 0;
}
.compat-list { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 20px; margin: 0; padding: 0; list-style: none; }
.compat-item {
  --dot: var(--accent);
  display: inline-flex; align-items: center; gap: 8px;
  font-size: 15px; font-weight: 600; color: var(--ink);
}
.compat-item::before {
  content: ""; width: 10px; height: 10px; border-radius: 50%; flex: none;
  background: var(--dot);
}
.compat-item.is-more { font-weight: 400; color: var(--muted); }
.compat-item.is-more::before { display: none; }
/* The names arrive one after another, each dot with a little bounce. */
@media (prefers-reduced-motion: no-preference) {
  .compat-item { animation: compat-in .5s cubic-bezier(.3, 1.5, .5, 1) both; animation-delay: calc(.35s + var(--n, 0) * 80ms); }
  .compat-item::before { animation: compat-dot .6s cubic-bezier(.3, 1.8, .5, 1) both; animation-delay: calc(.45s + var(--n, 0) * 80ms); }
  @keyframes compat-in { from { opacity: 0; transform: translateY(8px); } }
  @keyframes compat-dot { from { transform: scale(0); } }
}
@media (max-width: 560px) {
  .compat-item { font-size: 14px; }
}

/* Browse = filter rail + grid. The rail collapses above the grid on narrow
   screens rather than being hidden: it is the primary way to navigate. */
.browse { display: grid; grid-template-columns: 220px minmax(0, 1fr); gap: 44px; padding-block: 26px 80px; }
.facets { display: flex; flex-direction: column; gap: 30px; }
.facet { border-top: var(--rule); padding-top: 16px; }
.facet-list { display: flex; flex-direction: column; margin-top: 12px; }
.facet-link {
  display: flex; justify-content: space-between; align-items: baseline; gap: 10px;
  padding: 7px 0; font-size: 14px; border-bottom: var(--rule);
}
.facet-link:last-child { border-bottom: 0; }
.facet-link.on { color: var(--accent); font-weight: 500; }
.count {
  font-family: var(--mono); font-size: 11px; color: var(--muted);
  font-weight: 400; margin-left: 6px;
}

/* ---------- browse: the mobile filter panel ----------
   Off-screen but focusable, exactly like .nav-toggle: a display:none checkbox
   cannot be reached by keyboard, and the label is the only control there is. */
.facets-toggle {
  position: absolute; width: 1px; height: 1px;
  opacity: 0; margin: 0; pointer-events: none;
}
/* Wide-screen defaults: there the rail is always present, so neither the
   button nor the close control has anything to do. */
.facets-btn, .facets-done { display: none; }

.facets-btn {
  align-items: center; justify-content: space-between; gap: 10px;
  padding: 13px 18px; border: 1px solid var(--ink); cursor: pointer;
  font-family: var(--mono); font-size: 12px; font-weight: 600;
  text-transform: uppercase; letter-spacing: 0.08em;
  user-select: none;
}
.facets-btn::after {
  content: ""; width: 7px; height: 7px; flex: none;
  border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor;
  transform: translateY(-2px) rotate(45deg); transition: transform .2s;
}
.facets-toggle:checked ~ .facets-btn { background: var(--ink); color: var(--paper); }
.facets-toggle:checked ~ .facets-btn::after { transform: translateY(1px) rotate(225deg); }
.facets-toggle:focus-visible ~ .facets-btn { outline: 2px solid var(--accent); outline-offset: 2px; }
/* One label at a time, so the button says what tapping it will do. */
.facets-btn-shut, .facets-toggle:checked ~ .facets-btn .facets-btn-open { display: none; }
.facets-toggle:checked ~ .facets-btn .facets-btn-shut { display: inline; }

.facets-done { width: 100%; justify-content: center; margin-top: 4px; }

.browse-head {
  display: flex; align-items: center; justify-content: space-between;
  gap: 16px; flex-wrap: wrap; padding-bottom: 18px; border-bottom: var(--rule); margin-bottom: 24px;
}
.active-filters { display: flex; gap: 8px; flex-wrap: wrap; }

/* Sort sits at the far end of the head row, so the count stays the first thing
   read and the control stays out of the way until it is wanted. */
.sortbar { display: flex; align-items: center; gap: 10px; margin: 0; margin-left: auto; }
.sortbar select {
  /* Paper, not transparent. A <select> paints its own drop-down from its own
     background, so a transparent one leaves the option list with no ground. */
  border: var(--rule); background-color: var(--paper); color: var(--ink);
  font-family: var(--mono); font-size: 12px; font-weight: 500; letter-spacing: 0.06em;
  padding: 7px 10px; border-radius: 999px;
  /* Sized to its longest option. The form rule gives every select width:100%,
     which made Sort and Show split the bar and cut "Newest first" short. */
  width: auto; flex: none;
}
@media (max-width: 720px) {
  .sortbar { margin-left: 0; width: 100%; }
  .sortbar #sort { flex: 1; }
}

.chip.small { padding: 5px 12px; font-size: 11px; }

/* Colour tags carry their colour. A ring rather than a bare square so pale
   swatches stay visible on white and dark ones on black. */
.swatch {
  display: inline-block; width: 9px; height: 9px; border-radius: 50%;
  margin-right: 6px; vertical-align: baseline;
  box-shadow: 0 0 0 1px var(--line);
}
.tag.swatched { display: inline-flex; align-items: center; }

/* Collection cards: a three-up thumbnail strip instead of one hero image, so
   the card previews the shelf rather than a single model on it. */
/* Collection cards carry a three-up cover strip and a sentence of blurb, so
   they need more room than a model tile and keep the wider track.

   auto-FIT, not auto-fill, and this is the fix for covers nobody could see.
   The library has three shelves; a 1208px wrap divided by a 260px floor is
   four tracks, so auto-fill kept two of them EMPTY and squeezed the real cards
   into 302px each. Three covers inside that is 99px apiece, less the padding  - 
   a picture of a barrel about the size of the word "barrel". auto-fit collapses
   the tracks nothing lands in, so the shelves that exist share the whole row
   and each cover is roughly twice the size. It costs nothing once the library
   outgrows the row: with enough shelves to fill it, auto-fit and auto-fill are
   the same rule. */
/* The home strip sets --shelf-cols from how many collections it is showing
   (home.php), so the two rows it renders are always full. Everywhere else
   (/collections) the grid stays fluid: --shelf-cols is unset there and the
   fallback auto-fit applies. */
.collections-grid { grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr)); }
.collections-grid[style*="--shelf-cols"] { grid-template-columns: repeat(var(--shelf-cols), minmax(0, 1fr)); }
/* Three covers cut apart on the DIAGONAL (Vincent). One coordinate system
   for everything, the strip itself, so the cut and the line cannot disagree:

   Each seam leans from (x + 5%, top) to (x - 5%, bottom), x = 1/3 and 2/3 of
   the strip. Every cover is laid over the bounding box of its own slanted
   panel (so its model is centred in that panel) and clipped to the panel,
   expressed in that box's own percentages. The seams are drawn on the exact
   same lines by ::before / ::after, clipped to a 2px band. A model that
   reaches a seam is cut by it; nothing is cut by anything else.

     panel 1  box 0 -> 38.33%      clip x: 0..100% top, 0..73.91% bottom
     panel 2  box 28.33 -> 71.67%  clip x: 23.08..100% top, 0..76.92% bottom
     panel 3  box 61.67 -> 100%    clip x: 26.09..100% top, 0..100% bottom
*/
.collection-thumbs {
  aspect-ratio: 16 / 9; position: relative; overflow: hidden; display: block;
  /* Always the dark theme's stage, in the light theme too (Vincent): the
     tones are dark spotlights and the seams are light lines, so the covers
     are drawn for black. The dark tokens are set here, on the cover itself,
     and --dot-grid is rebuilt because the :root one already holds the light
     dot colour. */
  --paper: #000000; --panel: #0b0b0b; --ink: #ffffff; --muted: #a3a3a3;
  --line: #232323; --dot: #333333; --dot-stage: #333333;
  --dot-grid: radial-gradient(circle at center,
                #333333 0 var(--dot-r),
                transparent calc(var(--dot-r) + 0.5px));
  background-color: var(--paper);
}
.collection-thumbs img {
  position: absolute; top: 0; height: 100%; padding: 0;
  object-fit: cover; object-position: center 50%;
}
.collection-thumbs img:nth-child(1) { left: 0;       width: 38.334%; clip-path: polygon(0 0, 100% 0, 73.91% 100%, 0 100%); }
.collection-thumbs img:nth-child(2) { left: 28.333%; width: 43.334%; clip-path: polygon(23.08% 0, 100% 0, 76.92% 100%, 0 100%); }
.collection-thumbs img:nth-child(3) { left: 61.666%; width: 38.334%; clip-path: polygon(26.09% 0, 100% 0, 100% 100%, 0 100%); }

/* The two seams, on the same lines as the cuts. Light, because they sit over
   renders as often as over the dot grid. */
.collection-thumbs::before, .collection-thumbs::after {
  content: ""; position: absolute; inset: 0; z-index: 1; pointer-events: none;
  background: color-mix(in srgb, var(--ink) 55%, transparent);
}
.collection-thumbs::before { clip-path: polygon(38.333% 0, calc(38.333% + 2px) 0, calc(28.333% + 2px) 100%, 28.333% 100%); }
.collection-thumbs::after  { clip-path: polygon(71.667% 0, calc(71.667% + 2px) 0, calc(61.667% + 2px) 100%, 61.667% 100%); }

/* Two covers: one seam through the middle. */
.collection-thumbs:has(img:nth-child(2):last-child) img:nth-child(1) { width: 55%; clip-path: polygon(0 0, 100% 0, 81.82% 100%, 0 100%); }
.collection-thumbs:has(img:nth-child(2):last-child) img:nth-child(2) { left: 45%; width: 55%; clip-path: polygon(18.18% 0, 100% 0, 100% 100%, 0 100%); }
.collection-thumbs:has(img:nth-child(2):last-child)::before { clip-path: polygon(55% 0, calc(55% + 2px) 0, calc(45% + 2px) 100%, 45% 100%); }
.collection-thumbs:has(img:nth-child(2):last-child)::after { content: none; }

/* One cover: the whole strip, no seams. */
.collection-thumbs:has(img:only-child) img { left: 0; width: 100%; clip-path: none; }
.collection-thumbs:has(img:only-child)::before,
.collection-thumbs:has(img:only-child)::after { content: none; }

/* background-COLOR, not the `background` shorthand: the shorthand resets
   background-image, which is the dot grid the stage rule paints, so hovering a
   collection card used to wipe its own texture out. */
.collection-card:hover .collection-thumbs { background-color: var(--panel); }

/* ---------- admin: sortable lists ---------- */

.sortable { list-style: none; margin: 0; padding: 0; border-top: var(--rule); }
.sort-row {
  display: flex; align-items: center; gap: 14px;
  padding: 12px 4px; border-bottom: var(--rule); background: var(--paper);
}
.sort-row:hover { background: var(--panel); }
.sort-row.dragging { opacity: .35; }
.sort-row.is-new { border-left: 2px solid var(--accent); padding-left: 10px; }

.grip { cursor: grab; color: var(--muted); font-size: 15px; user-select: none; }
.grip:active { cursor: grabbing; }

.sort-main { display: flex; flex-direction: column; gap: 3px; flex: 1; min-width: 0; }
.sort-main strong { font-weight: 500; font-size: 14px; }
.sort-main .mono { font-size: 11px; }

.sort-actions { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
.row-chips { display: flex; gap: 5px; flex-wrap: wrap; margin-top: 4px; }
.sortable-empty:empty { display: none; }

/* An unsaved reorder is invisible otherwise - nothing is written until submit. */
.btn.is-dirty { background: var(--accent); border-color: var(--accent); color: #fff; }

.add-row { display: flex; gap: 10px; align-items: center; margin-bottom: 18px; }
.add-row select { flex: 1; }

/* ---------- admin: collection pills ---------- */

.pick-grid { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.pick {
  display: inline-flex; align-items: center; gap: 7px;
  border: var(--rule); border-radius: 999px; padding: 6px 13px;
  font-size: 12px; cursor: pointer; user-select: none;
}
.pick:hover { border-color: var(--ink); }
.pick input { margin: 0; accent-color: var(--accent); }
/* :has() styles the pill from the box inside it - the checked state has to be
   readable at a glance across 60 tags, not one checkbox at a time. */
.pick:has(input:checked) { background: var(--ink); color: var(--paper); border-color: var(--ink); }
.pick:has(input:checked) .swatch { box-shadow: 0 0 0 1px var(--paper); }
.pick:has(input:focus-visible) { outline: 2px solid var(--accent); outline-offset: 2px; }

.inline-form { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.inline-form input, .inline-form select { padding: 7px 10px; font-size: 12px; }

@media (max-width: 980px) {
  .browse { grid-template-columns: 1fr; gap: 20px; }
  .facets { flex-direction: row; flex-wrap: wrap; gap: 22px; }
  .facet { flex: 1 1 260px; }
  .facet-list { flex-direction: row; flex-wrap: wrap; gap: 8px; }
  .facet-link { border-bottom: 0; border: var(--rule); border-radius: 999px; padding: 6px 13px; font-size: 12px; }

  /* The rail becomes a panel, not a column: closed by default so the grid is
     the first thing under the search box, and reopened by the button above it.
     Fifteen collection cards and four tag blocks used to come first. */
  .facets-btn { display: flex; }
  .facets { display: none; }
  .facets-toggle:checked ~ .facets { display: flex; }
  .facets-done { display: inline-flex; }
}

@media (max-width: 720px) {
  .sort-row { flex-wrap: wrap; }
  .sort-actions { width: 100%; justify-content: flex-end; }
}

/* ---------- admin: tag entry ---------- */

/* Chips and the typing caret share one box, so the whole thing reads as a
   single field rather than a widget bolted next to an input. */
.tag-wrap { position: relative; }

.tag-field {
  display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
  border: 1px solid var(--line); background: var(--paper);
  padding: 8px 10px; min-height: 46px; cursor: text;
}
.tag-field:focus-within { border-color: var(--ink); }

.tag-chip {
  display: inline-flex; align-items: center; gap: 6px;
  background: var(--ink); color: var(--paper);
  border-radius: 999px; padding: 5px 6px 5px 12px;
  font-size: 12px; line-height: 1; white-space: nowrap;
}
.tag-chip .swatch { box-shadow: 0 0 0 1px var(--paper); }

.tag-x {
  border: 0; background: none; color: inherit; cursor: pointer;
  font-size: 15px; line-height: 1; padding: 0 4px; opacity: .6;
  font-family: var(--sans);
}
.tag-x:hover { opacity: 1; color: var(--accent); }

.tag-entry {
  flex: 1; min-width: 140px;
  border: 0 !important; padding: 4px 2px !important;
  background: transparent; color: var(--ink);
  font: inherit; font-size: 14px; width: auto !important;
}
.tag-entry:focus { outline: none; }

/* Suggestions overlay the page rather than pushing the form around while
   someone types. */
.tag-menu {
  position: absolute; z-index: 20; left: 0; right: 0; top: 100%;
  margin: -1px 0 0; padding: 0; list-style: none;
  background: var(--paper); border: 1px solid var(--ink);
  max-height: 260px; overflow-y: auto;
}
.tag-option {
  display: flex; align-items: center; gap: 8px;
  padding: 9px 12px; font-size: 13px; cursor: pointer;
  border-bottom: var(--rule);
}
.tag-option:last-child { border-bottom: 0; }
.tag-option:hover, .tag-option.on { background: var(--ink); color: var(--paper); }
.tag-option:hover .swatch, .tag-option.on .swatch { box-shadow: 0 0 0 1px var(--paper); }
.tag-option-meta { margin-left: auto; font-size: 11px; opacity: .6; }

/* The unused-tag pills carry their own delete button. */
.chip .tag-x { margin-left: 2px; }

.tag-table td { vertical-align: middle; }
.tag-table .inline-form { gap: 6px; }

/* ==========================================================================
   Mobile
   The layout is fluid first: grids use auto-fill with min(), so most of the
   site reflows without help. What follows is the part that cannot be derived
   from the container width, in one place rather than scattered per component.
   ========================================================================== */

/* The burger is a checkbox and a label: no script, so it works under the
   strict CSP and before app.js has loaded. Hidden entirely on wide screens. */
.nav-toggle {
  position: absolute; width: 1px; height: 1px;
  opacity: 0; margin: 0; pointer-events: none;
}
.burger {
  display: none; margin-left: auto; flex: none;
  width: 42px; height: 42px; cursor: pointer;
  flex-direction: column; align-items: center; justify-content: center; gap: 5px;
  border: var(--rule); border-radius: 999px;
}
.burger span {
  display: block; width: 16px; height: 1.5px; background: var(--ink);
  transition: transform .2s, opacity .2s;
}
.nav-toggle:focus-visible ~ .burger { outline: 2px solid var(--accent); outline-offset: 2px; }
.nav-toggle:checked ~ .burger span:nth-child(1) { transform: translateY(6.5px) rotate(45deg); }
.nav-toggle:checked ~ .burger span:nth-child(2) { opacity: 0; }
.nav-toggle:checked ~ .burger span:nth-child(3) { transform: translateY(-6.5px) rotate(-45deg); }

/* A wide table cannot be made narrow honestly, so it scrolls inside its own
   box instead of widening the page. */
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.table-wrap > .table { min-width: 560px; }

/* Section headings with a link on the right: the link drops below rather than
   crushing the heading. */
.sec-head {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 12px; flex-wrap: wrap; margin-bottom: 22px;
}

@media (max-width: 760px) {
  .nav { flex-wrap: wrap; gap: 12px; padding: 12px var(--gut); }
  .burger { display: flex; }

  .nav-links {
    display: none; width: 100%; margin-left: 0;
    flex-direction: column; align-items: stretch; gap: 0;
    padding-bottom: 6px;
    max-height: calc(100vh - 70px);
    max-height: calc(100dvh - 70px);
    overflow-y: auto;
  }
  .nav-toggle:checked ~ .nav-links { display: flex; }
  .nav-links a { padding: 15px 2px; border-top: var(--rule); font-size: 14px; }
  /* The stacked menu gives every row a rule and full width; the sign-out form
     is a row like any other, so it takes the same treatment as the links. */
  .nav-links form.signout { width: 100%; }
  .nav-links .linkish { padding: 15px 2px; border-top: var(--rule); font-size: 14px;
                        width: 100%; text-align: left; }
  .nav-links a.btn { border: 1px solid var(--ink); margin-top: 14px; align-self: flex-start; }
  .nav-links a.btn.small { padding: 12px 22px; font-size: 12px; }
}

/* 16px is the threshold below which iOS Safari zooms the page on focus, and
   the zoom is never undone. Every field goes up, not just the obvious ones. */
@media (max-width: 760px) {
  input[type=text], input[type=email], input[type=password], input[type=search],
  input[type=file], textarea, select,
  .searchbar input[type=search], .tag-entry, .inline-form input, .inline-form select {
    font-size: 16px;
  }
  .inline-form input, .inline-form select { padding: 9px 12px; }
}

@media (max-width: 760px) {
  /* A 16:10 viewer on a phone is a letterbox: 4:3 gives the model real height
     without pushing the specs below the fold. */
  .viewer { aspect-ratio: 4 / 3; }
  .viewer-hud { left: 10px; bottom: 10px; font-size: 11px; padding: 6px 10px; }

  .detail { gap: 28px; padding-block: 26px 60px; }
  .hero-sub { font-size: 16px; margin-top: 20px; }
  .hero-cta { margin-top: 28px; }
  .ticker strong { font-size: 24px; }
  .ticker div { padding: 16px 0 16px 16px; }

  .plan { padding: 26px 22px; }
  .price { font-size: 42px; }

  .footer { padding: 34px 0 46px; margin-top: 54px; }
  .footer-cols { gap: 30px; }

  .searchbar { margin: 18px 0 6px; }
  .add-row { flex-wrap: wrap; }
  .add-row select { flex: 1 1 100%; }
  .empty { padding: 54px 0; }
}

@media (max-width: 480px) {
  /* Full-width CTAs: the thumb reaches the whole row, and two half-width
     pills on one line are what breaks first as labels grow. */
  .hero-cta { flex-direction: column; align-items: stretch; }
  .hero-cta .btn { width: 100%; }
  .searchbar { flex-wrap: wrap; }
  .searchbar input[type=search] { flex: 1 1 100%; }
  .searchbar .btn { width: 100%; }
  .card-thumb::after { inset: 12px; }
  .card-thumb img { padding: 8px; }
}

/* Touch, not width: a mouse can hit a 30px pill, a finger needs ~44px.
   Applied by input type so a narrow desktop window keeps the tighter chrome. */
@media (pointer: coarse) {
  .btn.small { padding: 11px 18px; font-size: 12px; }
  .chip { padding: 10px 16px; }
  .chip.small { padding: 8px 14px; }
  .tag-x { padding: 0 9px; font-size: 17px; }
  .grip { font-size: 19px; padding: 0 4px; }
  .sort-row { padding-block: 14px; }
  .pick { padding: 9px 15px; }
}
/* Only once the facets have become pills - on a wide touch screen they are
   still full-width rows, where the whole row is already the target. */
@media (max-width: 980px) and (pointer: coarse) {
  .facet-link { padding: 10px 16px; }
}
/* The mobile menu sets its own row height below 760px; this is for a touch
   screen wide enough to still show the inline nav. */
@media (min-width: 761px) and (pointer: coarse) {
  .nav-links a, .nav-links .linkish { padding-block: 6px; }
}

/* Gesture hints: one of the pair is always hidden. */
.hint-coarse { display: none; }
@media (pointer: coarse) {
  .hint-fine   { display: none; }
  .hint-coarse { display: inline; }
}

/* ---------- categories ----------
   Collections are the shop window, so on a phone they are pictures, not a run
   of text links: two cover cards per row, the same treatment on /browse, on
   /collections and on the home page. The thumbnail markup is always rendered
   and hidden here, because the wide-screen rail wants a plain text row. */
.facet-thumb { display: none; }
.facet-name { min-width: 0; }

@media (max-width: 980px) {
  .facet--collections .facet-list {
    display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 1px; background: var(--line); border: var(--rule);
    margin-top: 12px;
  }
  .facet--collections .facet-link {
    flex-direction: column; align-items: stretch; gap: 0;
    padding: 0; border: 0; border-radius: 0;
    background: var(--paper); font-size: 14px;
  }
  .facet--collections .facet-link.on { background: var(--panel); }

  /* Spans both columns and stays a pill: "All collections" is a reset, not a
     shelf, and giving it a cover would imply it is one. */
  .facet--collections .facet-all {
    grid-column: 1 / -1; padding: 11px 16px; text-align: center;
    border-bottom: var(--rule); font-family: var(--mono);
    font-size: 12px; text-transform: uppercase; letter-spacing: 0.07em;
  }

  .facet-thumb {
    display: grid; place-items: center; aspect-ratio: 4 / 3;
    position: relative; overflow: hidden;   /* the grid comes from the stage rule */
  }
  .facet-thumb img { width: 100%; height: 100%; object-fit: contain; padding: 14px; }
  .facet--collections .facet-name {
    display: block; padding: 11px 13px 0; font-weight: 500; line-height: 1.25;
  }
  .facet--collections .count { display: block; margin: 2px 0 12px; padding: 0 13px; }
}

/* Two-up collection cards on a phone, rather than one full-width card that
   turns fifteen shelves into a very long scroll. */
@media (max-width: 760px) {
  .collections-grid, .collections-grid[style*="--shelf-cols"] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  /* Three rows of three on the home page is nine: odd, so two-up drops the last. */
  .collections-grid.shelf-odd > :last-child { display: none; }
  /* The three covers, as on desktop (Vincent: a phone showed only the first;
     that rule is gone), in a taller frame so each slice keeps some width. */
  .collection-thumbs { aspect-ratio: 4 / 3; }
  .collections-grid .card-body { padding: 12px 13px 15px; }
  .collections-grid .card-body h3 { font-size: 15px; }
  .collections-grid .card-body p { display: none; }
}

/* Below 380px two columns would be 150px wide including padding: one is
   honest at that width. */
@media (max-width: 379px) {
  .collections-grid,
  .facet--collections .facet-list { grid-template-columns: minmax(0, 1fr); }
}

/* ---------- favourites ----------
   The card is now a box with a link inside it rather than one big anchor, so
   the heart can sit over the corner without nesting a button in a link. */
.card { position: relative; }
.card-link { display: flex; flex-direction: column; height: 100%; }
.card-link:hover { color: inherit; }

.fav-form { margin: 0; }
/* Heart and its companion circle travel together, so a popover anchored to
   the group lands under whichever one opened it. */
.save-group { display: inline-flex; align-items: center; gap: 8px; position: relative; }
/* Only over a card. Scoped, because the same partial renders on the model
   page, where an absolute group would fly to the corner of the page and land
   under the sticky nav. */
.card .save-group { position: absolute; top: 10px; right: 10px; z-index: 2; }

.fav {
  display: inline-flex; align-items: center; justify-content: center;
  width: 36px; height: 36px; padding: 0; flex: none;
  border: var(--rule); border-radius: 999px;
  background: var(--paper); color: var(--muted);
  cursor: pointer;
  transition: color .15s, border-color .15s, background .15s, transform .12s;
}
.fav:hover { color: var(--accent); border-color: var(--accent); }
.fav.on    { color: var(--accent); border-color: var(--accent); }
.fav:active { transform: scale(.92); }
/* Outline when it is an invitation, solid once it is yours. */
.fav.on .heart path { fill: currentColor; }
.fav:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* ---------- the save popover ----------
   A <details>, so open and close are native and survive the script failing.
   The summary is the second circle; the panel hangs off the group. */
.save-pop { position: relative; }
.save-pop > summary { list-style: none; }
.save-pop > summary::-webkit-details-marker { display: none; }
.save-pop > summary::marker { content: ""; }
.fav-add { color: var(--ink); }
.fav-add:hover { color: var(--accent); }
.save-pop[open] > summary { color: var(--paper); background: var(--ink); border-color: var(--ink); }

.save-panel {
  position: absolute; z-index: 30; top: calc(100% + 10px); right: 0;
  width: min(300px, calc(100vw - 32px));
  padding: 16px 18px 18px;
  border: 1px solid var(--ink); background: var(--paper);
  box-shadow: 0 18px 40px rgba(0, 0, 0, .16);
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .save-panel { box-shadow: 0 18px 40px rgba(0, 0, 0, .6); }
}
:root[data-theme="dark"] .save-panel { box-shadow: 0 18px 40px rgba(0, 0, 0, .6); }

.fav-share { color: var(--ink); }
.fav-share:hover { color: var(--accent); }

.save-panel-title { margin: 0 0 10px; }
.save-panel-hint { margin: 0; font-size: 13px; line-height: 1.45; }
.save-panel .pick-grid { margin-top: 0; max-height: 210px; overflow-y: auto; }
.save-panel-row { margin: 12px 0 0; }

/* The link is there to be read and grabbed, so it is monospaced and it does
   not pretend to be editable even though it is a real input. */
.share-link {
  font-family: var(--mono); font-size: 12px;
  padding: 8px 10px; cursor: text;
}
.share-links { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
/* "Copied" is one character wider than "Copy"; without this the row twitches. */
.share-pop [data-share-copy] { min-width: 78px; }
.lists-picker .add-row input { flex: 1; min-width: 0; }
.lists-picker .pick span { line-height: 1.2; }

/* Anchored to the right edge of the group, so on a phone it opens inward
   rather than off the side of the screen. On a card the group already sits at
   the top-right corner, which is the same anchor. */
@media (max-width: 420px) {
  .save-panel { width: min(300px, calc(100vw - 24px)); }
}

@media (pointer: coarse) {
  .fav { width: 42px; height: 42px; }
  .card .save-group { top: 8px; right: 8px; gap: 6px; }
}

/* ---------- model page head ----------
   Title left, save right, both aligned to the top so a two-line title does not
   drag the circles down with it. */
.detail-head {
  display: flex; align-items: flex-start; justify-content: space-between;
  gap: 14px; margin-bottom: 18px;
}
.detail-head-main { min-width: 0; }
.detail-head h2 { margin: 12px 0 0; }
.detail-cta { margin: 22px 0 26px; }
.detail-cta .mono { margin: 12px 0 0; }
/* The download as a split button: the file, and an arrow opening the other
   way to get it (without Draco, for Godot). Panel styled like the nav menu. */
.dl-split { position: relative; display: flex; gap: 2px; }
.dl-split.is-inline { display: inline-flex; max-width: 100%; }
.dl-split .dl-main { flex: 1; }
.dl-split.has-menu .dl-main { border-radius: 999px 0 0 999px; }
.dl-toggle { flex: none; padding: 13px 18px 13px 16px; border-radius: 0 999px 999px 0; }
.dl-toggle svg { transition: transform .2s ease; }
.dl-split.open .dl-toggle svg { transform: rotate(180deg); }
.dl-menu {
  position: absolute; top: calc(100% + 8px); right: 0; z-index: 30;
  min-width: 100%; padding: 6px;
  background: var(--paper); border: var(--rule); border-radius: 12px;
  box-shadow: 0 22px 50px rgba(0, 0, 0, .35);
  opacity: 0; visibility: hidden; transform: translateY(-6px);
  transition: opacity .16s ease, transform .16s ease, visibility 0s linear .16s;
}
.dl-split.open .dl-menu { opacity: 1; visibility: visible; transform: none; transition: opacity .16s ease, transform .16s ease, visibility 0s; }
.dl-menu button {
  display: flex; flex-direction: column; gap: 3px; width: 100%; text-align: left;
  padding: 11px 12px; border: 0; border-radius: 8px; background: none; color: var(--ink);
  font-family: var(--mono); font-size: 13px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
  cursor: pointer;
}
.dl-menu button small { font-size: 12px; font-weight: 400; letter-spacing: 0; text-transform: none; color: var(--muted); }
.dl-menu button:hover, .dl-menu button:focus-visible { background: var(--panel); color: var(--accent); }
.dl-menu button[aria-busy="true"] { cursor: progress; color: var(--muted); }
@media (prefers-reduced-motion: reduce) {
  .dl-toggle svg, .dl-menu { transition: none; }
}
aside .spec { margin: 0 0 22px; }

/* ---------- API docs ----------
   A code block cannot be made narrow honestly, so it scrolls inside its own
   box rather than widening the page on a phone. Same rule as .table-wrap. */
.code {
  margin: 16px 0 0; padding: 16px 18px;
  border: var(--rule); background: var(--panel);
  overflow-x: auto; -webkit-overflow-scrolling: touch;
  font-family: var(--mono); font-size: 12.5px; line-height: 1.7;
  white-space: pre; tab-size: 2;
}
.code code { font: inherit; color: inherit; }

/* The one moment a key is readable. Accented, because it is the only chance
   to copy it and a missed copy means making another. */
.key-reveal {
  margin-top: 16px; padding: 16px 18px;
  border: 1px solid var(--accent); background: var(--panel);
}


/* ---------- runtime cost + frame stats (rev 14) ---------- */

/* Seven controls do not fit one column on a short viewer, and a phone in
   landscape is exactly where that bites. Column-wrap-reverse sends the
   overflow INWARD from the right edge rather than off the side of the stage. */
.viewer-tools { max-height: calc(100% - 24px); flex-wrap: wrap-reverse; }

/* Opposite corner from .viewer-hud, which is where loading talks. Lower
   contrast than the status readout on purpose: this is instrumentation the
   visitor asked for, not the page telling them something. */
/* It is a <button> now - the readout doubles as the uncap switch - so it takes
   pointer events back. It used to refuse them so it could never be in the way
   of an orbit drag; what that costs is a 90x26 rectangle in one corner, and
   only for someone who has already turned the frame counter on. Everything
   about how it looks is unchanged, including the fact that it does not look
   like a button until you are over it. */
.perf-hud {
  position: absolute; right: 14px; bottom: 14px; z-index: 2;
  font-family: var(--mono);
  font-size: 11px; letter-spacing: .05em; text-transform: uppercase;
  color: var(--muted); background: var(--paper);
  border: var(--rule); padding: 6px 10px;
  cursor: pointer;
  transition: color .15s, border-color .15s;
}
.perf-hud:hover { color: var(--accent); border-color: var(--accent); }
.perf-hud[aria-pressed="true"] { color: var(--accent); border-color: var(--accent); }
.perf-hud:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.perf-hud[hidden] { display: none; }


@media (max-width: 760px) {
  .perf-hud { right: 10px; bottom: 10px; font-size: 10px; }
}


/* The Google mark on the sign-in button.
   `.btn` is already inline-flex with an 8px gap, so the glyph only needs to be
   told not to shrink - a full-width button stretches its children otherwise and
   the G would go oval. It keeps its own four colours on the black hover fill of
   .btn.ghost, which is required rather than an oversight: see the partial. */
.g-mark { flex: none; display: block; }

/* ---------- cookie consent ----------

   Fixed to the bottom rather than a full-screen modal. A modal that traps the
   page until answered is coercion by layout: it makes "accept" the fastest way
   to read anything, which is exactly the pressure consent is supposed to be
   free of. This sits under the content, is dismissible either way in one click,
   and blocks nothing while it waits. */
.consent {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 90;
  background: var(--paper);
  border-top: var(--rule);
  /* Above the page but never over the whole of it: on a short screen the banner
     scrolls internally rather than swallowing the viewport. */
  max-height: 70vh; overflow-y: auto;
}
.consent-inner {
  max-width: 1320px; margin: 0 auto;
  padding: 18px var(--gut);
  display: flex; flex-wrap: wrap; align-items: flex-start; gap: 16px 28px;
}
.consent-copy { flex: 1 1 420px; min-width: 0; }
.consent-copy p { margin: 0; }
.consent-copy .label { margin-bottom: 6px; }
.consent-copy .mono { font-size: 12.5px; line-height: 1.6; }

/* The two answers, together, equal. Reject is first in the DOM so a keyboard or
   screen-reader user meets it first, and neither is styled to look preferred. */
.consent-actions { display: flex; gap: 10px; flex: 0 0 auto; margin-left: auto; }
.consent-actions .btn { white-space: nowrap; }

.consent-detail { flex: 1 1 100%; order: 3; }
.consent-detail summary {
  cursor: pointer; list-style: none;
  display: inline-flex; align-items: center; gap: 9px; padding: 4px 0;
}
.consent-detail summary::-webkit-details-marker { display: none; }
.consent-detail summary::before {
  content: ""; flex: none; width: 0; height: 0;
  border-left: 5px solid currentColor;
  border-top: 4px solid transparent; border-bottom: 4px solid transparent;
  transition: transform .15s;
}
.consent-detail[open] summary::before { transform: rotate(90deg); }
.consent-detail summary:hover { color: var(--accent); }

.consent-cats { display: grid; gap: 14px; margin-top: 16px; }
.consent-cat {
  display: flex; gap: 12px; align-items: flex-start;
  border: var(--rule); padding: 14px 16px; background: var(--paper);
}
.consent-cat input { margin-top: 3px; flex: none; }
.consent-cat > span { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.consent-cat strong { font-weight: 500; font-size: 14px; }
.consent-cat em { font-style: normal; font-size: 11px; }
.consent-cat .mono { font-size: 12px; line-height: 1.55; }

@media (max-width: 760px) {
  /* Stacked, and the two answers go full width side by side so neither is the
     bigger target. */
  .consent-actions { margin-left: 0; width: 100%; }
  .consent-actions .btn { flex: 1; }
}

/* ---------- admin: the collection editor (rev 18) ---------- */

/* The name, and the one line of fact under it. Sits above the tabs because it
   is true on all three of them. */
.page-head { display: flex; align-items: baseline; gap: 16px; flex-wrap: wrap; margin-top: 14px; }
.page-head h2 { margin: 0; }
.page-head p { margin: 0; }

/* The identity fold. A <details>, so the browser owns the open/closed state and
   no script is needed under a CSP with no nonce. */
.fold { border: var(--rule); background: var(--paper); }
.fold > summary {
  display: flex; align-items: center; justify-content: space-between; gap: 14px;
  padding: 14px 16px; cursor: pointer; list-style: none;
  font-size: 14px; font-weight: 500;
}
.fold > summary::-webkit-details-marker { display: none; }
/* The disclosure triangle, drawn rather than inherited, so it points the same
   way in every browser and turns with the state. */
.fold > summary::after {
  content: ""; flex-shrink: 0;
  width: 7px; height: 7px; margin-left: auto;
  border-right: 1.5px solid var(--muted); border-bottom: 1.5px solid var(--muted);
  transform: rotate(45deg) translate(-2px, -2px);
  transition: transform .15s;
}
.fold[open] > summary::after { transform: rotate(-135deg) translate(-2px, -2px); }
.fold > summary:hover { background: var(--panel); }
.fold[open] > summary { border-bottom: var(--rule); }
.fold-body {
  padding: 20px 16px;
  display: grid; gap: 34px;
  grid-template-columns: minmax(0, 420px) minmax(0, 1fr);
  align-items: start;
}
.fold-danger { border-left: var(--rule); padding-left: 24px; }

/* The three tabs. Underlines rather than pills, so they read as a level BELOW
   the pill nav above them: two rows of identical chips would say the two rows
   are the same kind of choice, and they are not. */
.subtabs { display: flex; gap: 4px; border-bottom: var(--rule); }
.subtab {
  padding: 11px 16px; font-size: 14px; font-weight: 500;
  color: var(--muted); border-bottom: 2px solid transparent; margin-bottom: -1px;
  display: inline-flex; align-items: center; gap: 7px;
}
.subtab:hover { color: var(--ink); background: var(--panel); }
.subtab.on { color: var(--ink); border-bottom-color: var(--accent); }
.subtab .count {
  font-family: var(--mono); font-size: 10px; color: var(--muted);
  border: var(--rule); padding: 1px 5px;
}
.subtab.on .count { border-color: var(--ink); color: var(--ink); }

/* Every tab opens with the same two lines: what this is, and what it does. */
.tab-head { display: flex; justify-content: space-between; gap: 20px; margin-bottom: 20px; }
.tab-head h3 { margin-bottom: 5px; }
.tab-head p { max-width: 74ch; margin: 0; }

.tab-foot {
  display: flex; justify-content: space-between; gap: 30px; flex-wrap: wrap;
  margin-top: 40px; padding-top: 24px; border-top: var(--rule);
}
.tab-foot h3 { margin-bottom: 5px; }
.tab-foot p { margin: 0; max-width: 60ch; }
.tab-foot-action { min-width: 260px; }
.tab-foot-action p { margin-top: 8px; font-size: 12px; }

/* ---------- the shelf, as squares ---------- */

/* Laid out the way the PUBLIC grid is laid out, because that is the thing being
   arranged. Same min(208px, 50%) idiom as .grid: never fewer than two across,
   whatever the screen. */
.sort-grid {
  list-style: none; margin: 0; padding: 0;
  display: grid; gap: 12px;
  grid-template-columns: repeat(auto-fill, minmax(min(190px, 50%), 1fr));
}
.sort-tile {
  position: relative;
  border: var(--rule); background: var(--paper);
  display: flex; flex-direction: column;
  transition: background .15s, border-color .15s;
  cursor: grab;
}
.sort-tile:active { cursor: grabbing; }
/* Nothing inside a tile may start a drag of its own, and a double-click on the
   label must not select text instead of grabbing. Covers images added by script
   as well as the ones PHP prints. */
.sort-tile img { -webkit-user-drag: none; user-select: none; }
.sort-tile-body, .sort-tile-pos { user-select: none; }
.sort-tile:hover { background: var(--panel); border-color: var(--ink); }
.sort-tile.dragging { opacity: .35; }
.sort-tile.is-new { border-left: 2px solid var(--accent); }

/* The position a visitor will see. Absolute so it sits ON the render rather
   than taking a row of its own: the number is a label on the picture, and the
   grid is tight enough that a row per tile would cost more than it says. */
.sort-tile-pos {
  position: absolute; top: 0; left: 0; z-index: 1;
  font-family: var(--mono); font-size: 10px; line-height: 1;
  padding: 5px 7px; background: var(--ink); color: var(--paper);
}
.sort-tile-thumb {
  aspect-ratio: 4 / 3; display: grid; place-items: center; overflow: hidden;
  border-bottom: var(--rule);
  background-image: radial-gradient(var(--dot-stage) 1px, transparent 1px);
  background-size: 8px 8px;
}
.sort-tile-thumb img { width: 100%; height: 100%; object-fit: contain; padding: 8px; }
.sort-tile-body { padding: 9px 10px 10px; display: flex; flex-direction: column; gap: 2px; }
.sort-tile-body strong { font-size: 13px; font-weight: 500; line-height: 1.3; }
.sort-tile-body .mono { font-size: 10px; }
.sort-tile-actions {
  display: flex; gap: 4px; align-items: center; flex-wrap: wrap;
  padding: 0 10px 10px; margin-top: auto;
}
.sort-tile-actions .btn { padding: 4px 9px; font-size: 11px; }

/* ---------- the texture tab ---------- */

/* Three panels, in the order the decisions are taken: whether, which, and what
   else. They collapse to one column before they get narrow enough to squeeze
   the atlas preview. */
.tex-cols {
  display: grid; gap: 20px;
  grid-template-columns: repeat(auto-fit, minmax(270px, 1fr));
  align-items: start;
}
.panel { border: var(--rule); background: var(--paper); padding: 16px; }
.panel > .label { display: inline-flex; margin-bottom: 4px; }
.panel form { margin: 0; }

.tex-store { margin-top: 46px; padding-top: 26px; border-top: var(--rule); }
.tex-store .thumb-cell { width: 56px; height: 42px; }
.tex-store .tag { font-size: 10px; }

/* ---------- the lighting tab ---------- */

.light-cols {
  display: grid; gap: 32px;
  grid-template-columns: minmax(0, 1fr) 340px;
  align-items: start;
}

@media (max-width: 900px) {
  .light-cols, .fold-body { grid-template-columns: 1fr; }
  /* The rule that separated the danger column has to become the rule that
     separates the danger row, or it points the wrong way once stacked. */
  .fold-danger { border-left: 0; border-top: var(--rule); padding-left: 0; padding-top: 22px; }
}

@media (max-width: 560px) {
  .subtabs { gap: 0; }
  .subtab { padding: 11px 12px; font-size: 13px; }
  .tab-foot-action { min-width: 0; width: 100%; }
}

/* ---------- the creator programme (rev 18) ---------- */

/* A state, said in one word, the same everywhere it appears: studio, review
   queue, review page. Outlined rather than filled so a list of them reads as
   labels and not as a row of buttons. */
.status {
  display: inline-flex; align-items: center; gap: 6px; white-space: nowrap;
  font-family: var(--mono); font-size: 10px; letter-spacing: .06em; text-transform: uppercase;
  padding: 3px 8px; border: 1px solid var(--line); color: var(--muted);
}
.status::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.status-live    { color: #12b76a; border-color: #12b76a; }
.status-pending { color: #d99a00; border-color: #d99a00; }
.status-refused, .status-removed { color: var(--accent); border-color: var(--accent); }

/* The programme page: numbered steps. */
.steps { list-style: none; counter-reset: step; margin: 0; padding: 0;
  display: grid; gap: 1px; background: var(--line); border: var(--rule);
  grid-template-columns: repeat(3, minmax(0, 1fr)); }
/* Fixed columns, not auto-fit: six steps in auto-fit made a row of four and a
   row of two, and the two empty tracks showed the grid's line colour as a grey
   block. Three, then two, then one always divide six evenly. */
@media (max-width: 900px) { .steps { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 560px) { .steps { grid-template-columns: 1fr; } }
.steps li { counter-increment: step; background: var(--paper); padding: 22px 20px 22px 58px; position: relative; font-size: 14px; }
.steps li::before { content: counter(step); position: absolute; left: 20px; top: 20px;
  font-family: var(--mono); font-size: 12px; width: 24px; height: 24px; display: grid; place-items: center;
  border: 1px solid var(--ink); }
.steps li strong { display: block; margin-bottom: 4px; }

/* The guide: a readable column. */
.guide { max-width: 72ch; }
.guide h2 { margin: 48px 0 14px; font-size: 24px; scroll-margin-top: 84px; }
.guide h3 { margin: 26px 0 10px; font-size: 17px; }
.guide p, .guide li { line-height: 1.65; }
.guide ul { padding-left: 20px; }
.guide li { margin-bottom: 8px; }
.guide code { font-family: var(--mono); font-size: .9em; }
.guide-toc { display: flex; flex-wrap: wrap; gap: 6px 14px; margin: 26px 0 10px; padding: 14px 0;
  border-top: var(--rule); border-bottom: var(--rule); font-family: var(--mono); font-size: 12px; }
.guide-formula { padding: 14px 16px; border: var(--rule); background: var(--panel); font-size: 13px; }

/* The studio: earnings tiles. */
.stat-row { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(min(200px, 100%), 1fr)); }
.stat-big { font-size: 28px; margin: 10px 0 4px; font-variant-numeric: tabular-nums; }

/* The studio: a creator's collections, one row each. */
.studio-list { border-top: var(--rule); }
.studio-row { display: flex; align-items: center; justify-content: space-between; gap: 16px;
  padding: 16px 4px; border-bottom: var(--rule); color: inherit; }
.studio-row:hover { background: var(--panel); color: inherit; }
.studio-row-main { display: flex; flex-direction: column; gap: 4px; min-width: 0; }

/* Models in the studio and on the review page. Same min(190px, 50%) idiom as
   the public grid, so never fewer than two across. */
.studio-grid { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px;
  grid-template-columns: repeat(auto-fill, minmax(min(190px, 50%), 1fr)); }
.studio-tile { border: var(--rule); background: var(--paper); display: flex; flex-direction: column; }
.studio-tile.is-waiting { border-color: #d99a00; }
.studio-tile-thumb { aspect-ratio: 4 / 3; display: grid; place-items: center; overflow: hidden; border-bottom: var(--rule); }
.studio-tile-thumb img { width: 100%; height: 100%; object-fit: contain; padding: 8px; }
.studio-tile-body { padding: 10px; display: flex; flex-direction: column; gap: 6px; align-items: flex-start; }
.studio-tile-body strong { font-size: 13px; font-weight: 500; }
.studio-tile-actions { display: flex; gap: 4px; flex-wrap: wrap; padding: 0 10px 10px; margin-top: auto; }
.studio-tile-actions form { margin: 0; }
.studio-tile-actions .btn { padding: 4px 9px; font-size: 11px; }

/* How much of its size limit a model uses. Red once it is over. */
.size-bar { width: 100%; height: 4px; background: var(--line); }
.size-bar span { display: block; height: 100%; background: var(--ink); }
.size-bar.is-over span { background: var(--accent); }

/* Submission checklist and review history. */
.review-cols { display: grid; gap: 40px; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: start; }
.checklist { list-style: none; padding: 0; margin: 0 0 18px; }
.checklist li { padding: 8px 0 8px 26px; border-bottom: var(--rule); position: relative; font-size: 14px; }
.checklist li::before { position: absolute; left: 2px; top: 8px; font-family: var(--mono); }
.checklist li.is-ok::before  { content: "\2713"; color: #12b76a; }
.checklist li.is-bad::before { content: "\00d7"; color: var(--accent); }
.history { list-style: none; padding: 0; margin: 0; border-left: var(--rule); }
.history li { padding: 0 0 18px 18px; position: relative; display: flex; flex-direction: column; gap: 2px; }
.history li::before { content: ""; position: absolute; left: -4px; top: 5px; width: 7px; height: 7px; background: var(--muted); border-radius: 50%; }
.history li.history-approved::before { background: #12b76a; }
.history li.history-refused::before, .history li.history-removed::before { background: var(--accent); }
.history li p { margin: 6px 0 0; font-size: 14px; }
.review-atlases { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 12px; }
.review-atlases figure { margin: 0; width: 120px; }
.review-atlases img { width: 120px; height: 120px; object-fit: cover; border: var(--rule); display: block; }

@media (max-width: 900px) {
  .review-cols { grid-template-columns: 1fr; }
}

/* The reason an upload cannot go, shown inside the form above its button. */
.upload-error { margin: 4px 0 14px; font-size: 13px; line-height: 1.5; }

/* ---------- creator programme: prominence and the studio (rev 18) ---------- */

/* Studio tabs: bigger than the admin chips, because they are this section's
   whole navigation. Upload is the primary action, so it is filled. */
.studio-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 22px; border-bottom: var(--rule); padding-bottom: 12px; }
.studio-tab {
  display: inline-flex; align-items: center; gap: 8px;
  padding: 9px 16px; border: var(--rule); font-size: 14px; font-weight: 500; color: var(--ink);
}
.studio-tab:hover { border-color: var(--ink); color: var(--ink); }
.studio-tab.on { background: var(--ink); color: var(--paper); border-color: var(--ink); }
.studio-tab.is-primary:not(.on) { border-color: var(--accent); color: var(--accent); }
.studio-tab .count { font-family: var(--mono); font-size: 11px; opacity: .7; }

/* Collections as cards, each with its next step. */
.collection-cards { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr)); }
.ccard { border: var(--rule); background: var(--paper); display: flex; flex-direction: column; }
.ccard-covers { display: grid; grid-template-columns: repeat(3, 1fr); aspect-ratio: 3 / 1; border-bottom: var(--rule); overflow: hidden; place-items: center; color: inherit; }
.ccard-covers img { width: 100%; height: 100%; object-fit: contain; padding: 6px; }
.ccard-covers .mono { grid-column: 1 / -1; }
.ccard-body { padding: 14px 16px 16px; display: flex; flex-direction: column; gap: 8px; flex: 1; }
.ccard-title { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.ccard-title a { color: inherit; }
.ccard-msg { margin: 0; font-size: 13px; color: var(--accent); }
.ccard-actions { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-top: auto; }
.ccard-actions form { margin: 0; }
.size-bar.is-full span { background: #12b76a; }

.studio-empty { max-width: 640px; }
.studio-steps { margin: 12px 0 0; padding-left: 20px; }
.studio-steps li { margin-bottom: 8px; }

/* Admin Creators: creators against the official catalogue. */
.split-bar { height: 10px; background: var(--line); display: flex; }
.split-bar .split-creators { background: var(--accent); }
.dot-key { display: inline-block; width: 8px; height: 8px; border-radius: 50%; vertical-align: middle; margin-right: 4px; }
.dot-key.creators { background: var(--accent); }
.dot-key.official { background: var(--line); border: 1px solid var(--muted); }

/* The texture field on "New collection" only means something in single-texture mode. */
.new-collection:has(input[name="mode"][value="own"]:checked) .atlas-field { display: none; }

/* Tags on the review page: every tag visible, new ones and changes marked. */
.review-tag-list { display: flex; flex-wrap: wrap; gap: 6px; }
.review-tag { text-transform: none; letter-spacing: 0.02em; padding: 3px 9px; }
.review-tag b { font-weight: 500; color: var(--muted); margin-left: 2px; }
.review-tag i { font-style: normal; font-size: 10px; text-transform: uppercase; color: var(--accent); margin-left: 2px; }
.review-tag.is-new { border-color: var(--accent); }
.review-tag.is-added { border-style: dashed; }
.review-tag.is-removed { text-decoration: line-through; color: var(--muted); opacity: .6; }

/* A model set to be removed from a live collection, in the studio and on review. */
.studio-tile.is-removing { opacity: .55; }
.studio-tile.is-removing:hover { opacity: 1; }

/* ---------- colours (admin texture tab) ----------
   One compact row per colour: a small atlas on the left, the name and facts in
   the middle, the actions on the right. Edit and Add open underneath, their
   fields side by side, so no card is a big image with everything piled below. */
.colours { margin-top: 40px; padding-top: 26px; border-top: var(--rule); }
.colour-cards { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.colour-card {
  border: var(--rule); background: var(--paper);
  display: grid; grid-template-columns: 88px minmax(0, 1fr); gap: 14px; padding: 10px; align-items: start;
}
.colour-card.is-default { border-color: var(--ink); }
.colour-card-tex { display: block; width: 88px; height: 88px; background: var(--line); overflow: hidden; }
.colour-card-tex img { width: 100%; height: 100%; object-fit: cover; display: block; }
.colour-card-body {
  display: grid; grid-template-columns: minmax(0, 1fr) auto; column-gap: 12px; row-gap: 4px; align-items: start;
}
.colour-card-body > * { grid-column: 1; }
.colour-card-title { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.colour-card-title strong { font-size: 14px; }
.colour-card-actions { grid-column: 2; grid-row: 1 / span 3; display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; }
.colour-card-actions form { margin: 0; }
.colour-edit { grid-column: 1 / -1; margin-top: 2px; }
.colour-edit summary, .colour-add summary {
  cursor: pointer; font-family: var(--mono); font-size: 12px; text-transform: uppercase; letter-spacing: .06em;
}
.colour-edit[open] summary { margin-bottom: 10px; }
.colour-add { border: var(--rule); border-style: dashed; padding: 12px; }
.colour-add details[open] summary { margin-bottom: 12px; }
/* The fields of an edit or add form side by side, wrapping when narrow. */
.colour-form {
  margin: 0; display: grid; gap: 12px 16px; align-items: start;
  grid-template-columns: minmax(150px, 1fr) auto minmax(220px, 1.4fr) auto;
  max-width: 900px;
}
.colour-form .field { margin: 0; display: flex; flex-direction: column; gap: 6px; }
.colour-form .field .mono { font-size: 11px !important; }
.colour-form .field select { margin-top: 0 !important; }
.colour-form .btn { width: auto !important; align-self: end; margin-top: 22px; white-space: nowrap; }
@media (max-width: 760px) { .colour-form { grid-template-columns: 1fr 1fr; } }
@media (max-width: 560px) {
  .colour-card { grid-template-columns: 64px minmax(0, 1fr); }
  .colour-card-tex { width: 64px; height: 64px; }
  .colour-card-body { grid-template-columns: minmax(0, 1fr); }
  .colour-card-actions { grid-column: 1; grid-row: auto; justify-content: flex-start; }
}

/* ---------- batch upload ---------- */
.batch-upload-fields {
  display: grid; gap: 18px; align-items: end;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); margin-bottom: 24px;
}
.batch-upload tr.is-ok td:last-child { color: var(--ink); }
.batch-upload tr.is-bad td:last-child { color: var(--accent); }

/* The atlas, laid out like a colour row (see .colour-card). */
.atlas-block { margin-top: 30px; }
.atlas-row .colour-card-body { grid-template-columns: minmax(0, 1fr); }
.atlas-none { display: flex; align-items: center; justify-content: center; font-size: 11px; }
.atlas-form { grid-template-columns: minmax(200px, 1.2fr) minmax(180px, 1fr) auto auto; }
.atlas-form .atlas-reapply { align-self: end; margin: 0; }
@media (max-width: 760px) { .atlas-form { grid-template-columns: 1fr; } }

/* ---------- the shop (rev 18) ---------- */
.plans-tiers { grid-template-columns: repeat(auto-fit, minmax(min(200px, 100%), 1fr)); }
.plans-tiers .plan { padding: 26px 24px; }
.cart-lines { list-style: none; margin: 0; padding: 0; border-top: var(--rule); }
.cart-line {
  display: grid; grid-template-columns: 96px minmax(0, 1fr) auto auto; gap: 16px; align-items: center;
  padding: 14px 0; border-bottom: var(--rule);
}
.cart-thumb { display: block; width: 96px; aspect-ratio: 4 / 3; overflow: hidden; }
.cart-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.cart-main { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.cart-price { font-size: 16px; }
.cart-line form { margin: 0; }
.cart-foot {
  display: flex; justify-content: space-between; align-items: flex-end; gap: 20px; flex-wrap: wrap;
  margin-top: 26px;
}
.cart-foot form { margin: 0; text-align: right; }
.cart-total { font-size: 34px; margin: 6px 0 4px; }
@media (max-width: 560px) {
  .cart-line { grid-template-columns: 72px minmax(0, 1fr); }
  .cart-thumb { width: 72px; }
  .cart-price, .cart-line form { grid-column: 2; }
}
/* Buying on a model or collection page. */
.buy-box { display: flex; flex-direction: column; gap: 10px; }
.buy-box form { margin: 0; }
.buy-box .btn { width: 100%; }
.buy-alt { border-top: var(--rule); padding-top: 12px; margin-top: 4px; }
.price-tag { font-family: var(--mono); font-size: 13px; }

/* Price and basket on a model card, under the link. */
.card-buy {
  display: flex; align-items: center; justify-content: space-between; gap: 8px;
  padding: 0 14px 14px; margin-top: -4px;
}
.card-buy .buy-form { margin: 0; }
.card-buy .btn { padding: 5px 10px; font-size: 11px; }
.detail-price { margin: 0 0 10px; font-size: 15px; }
.detail-price strong { font-size: 20px; }
.btn.is-owned { opacity: .7; }

/* ---------- the basket icon, far right of the bar ---------- */
.cart-icon {
  position: relative; flex: none; display: inline-flex; align-items: center; justify-content: center;
  width: 42px; height: 42px; border: var(--rule); border-radius: 999px; color: var(--ink);
}
.cart-icon:hover, .cart-icon.on { color: var(--accent); border-color: var(--accent); }
.cart-count {
  position: absolute; top: -5px; right: -5px; min-width: 20px; height: 20px; padding: 0 5px;
  border-radius: 999px; background: var(--accent); color: #fff;
  font-family: var(--mono); font-size: 11px; font-weight: 600; line-height: 20px; text-align: center;
}
.cart-icon.bump { animation: cart-bump .45s ease; }
@keyframes cart-bump { 0% { transform: scale(1); } 35% { transform: scale(1.18); } 100% { transform: scale(1); } }
@media (max-width: 760px) {
  .cart-icon { order: 1; margin-left: auto; }
  /* Right-hand side (the basket that used to push it there went with the
     shop in rev 20; without it the burger sat by the logo). */
  .burger { order: 2; margin-left: auto; }
  .cart-icon ~ .burger { margin-left: 0; }
  .nav-links { order: 3; }
}

/* A short message after adding to the basket, bottom right. */
.toast {
  position: fixed; right: 20px; bottom: calc(20px + env(safe-area-inset-bottom, 0px)); z-index: 100;
  max-width: min(420px, calc(100vw - 40px)); padding: 12px 16px;
  background: var(--ink); color: var(--paper); border-radius: 10px;
  font-size: 14px; box-shadow: 0 10px 30px rgba(0, 0, 0, .35);
  opacity: 0; transform: translateY(10px); pointer-events: none; transition: opacity .2s, transform .2s;
}
.toast.on { opacity: 1; transform: none; }
.toast.is-error { background: var(--accent); color: #fff; }

/* ---------- prices you cannot miss ---------- */
.card-buy { padding: 10px 14px 14px; margin-top: 0; border-top: var(--rule); }
.card-price { font-size: 20px; font-weight: 700; letter-spacing: -0.01em; color: var(--ink); }
.card-buy .btn { padding: 7px 12px; font-size: 11px; }
.detail-price { margin: 4px 0 14px; font-family: inherit; font-size: 15px; line-height: 1.5; }
.detail-price strong { display: block; font-size: 34px; font-weight: 700; letter-spacing: -0.02em; line-height: 1.1; }
.collection-price { margin: 6px 0 8px; font-size: 14px; color: var(--muted); }
.collection-price strong { font-size: 22px; font-weight: 700; color: var(--ink); margin-right: 4px; }
.btn.is-in-basket { border-color: var(--accent); color: var(--accent); }

/* A collection's background colour, auto or chosen (admin). */
.tone-pick { display: flex; flex-wrap: wrap; gap: 10px 22px; align-items: center; }
.tone-pick .pick { display: inline-flex; align-items: center; gap: 8px; margin: 0; }
.tone-dot { width: 22px; height: 22px; border-radius: 50%; background: var(--dot); box-shadow: inset 0 0 0 1px rgba(0, 0, 0, .2); }
.tone-pick input[type="color"] { width: 40px; height: 26px; padding: 0; border: 1px solid var(--line); background: none; cursor: pointer; }

/* A category with no models yet: still listed, quieter. */
.chip.is-empty { opacity: .45; }

/* A collection's tone behind its covers (collection_tone): a dark spotlight in
   the collection's own colour, brighter in the middle, with the dot grid kept
   on top. Lightness is low, so the models always read against it, and set
   per tone (--tone-l): warm hues a little lighter, or they read as brown. */
.collection-thumbs[style*="--tone-h"] {
  --lift: 0%;
  background-image:
    var(--dot-grid),
    radial-gradient(120% 95% at 50% 38%,
      hsl(var(--tone-h) var(--tone-s) calc(var(--tone-l, 26%) + var(--lift))),
      hsl(var(--tone-h) var(--tone-edge-s, calc(var(--tone-s) * 0.8)) calc(var(--tone-edge-l, 9%) + var(--lift) / 2)));
  background-size: var(--dot-gap) var(--dot-gap), 100% 100%;
  transition: --lift .3s;
}
.collection-card:hover .collection-thumbs[style*="--tone-h"] { --lift: 5%; }

/* ---------- the home showcase (desktop only) ----------
   The model page's viewer (viewer.js), bare: no frame, no dots of its own (the
   hero's show through), square, the right half of the hero beside the
   headline (2026-11-02: it stood on the ticker's last cell, and the ticker
   went to the bottom of the page). */
.hero-showcase { display: none; }
/* The model takes the middle of its square canvas, so the canvas is out of
   the flow, centred on the text beside it and reaching past it above and
   below: the hero is as tall as the text needs, the model's centre is level
   with the text's, and the gaps between headline, line, buttons and search
   are the margins alone (--hero-gap). Below, it runs behind the search,
   which sits above it. */
@media (min-width: 1100px) {
  .hero.has-showcase { padding-top: 96px; }
  .hero.has-showcase .hero-head {
    position: relative;
    display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 24px;
  }
  .viewer.viewer--bare.hero-showcase {
    /* -57%, not -50%: a little above centre, so the model's feet clear the
       search under the text rather than standing behind it. */
    display: block; position: absolute; right: 0; top: 50%; transform: translateY(-57%);
    width: calc(50% - 12px); aspect-ratio: 1 / 1; margin: 0;
    border: 0; background: none; cursor: grab;
  }
  .viewer.viewer--bare.hero-showcase:active { cursor: grabbing; }
}

/* ---------- the studio's two flows (rev 19) ----------
   My work opens on the choice: a collection, or a single model. Two large
   cards side by side (stacked on a phone); the one that cannot be started
   says why instead of linking. */
.studio-choice { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; margin-top: 16px; }
.studio-choice-card {
  display: flex; flex-direction: column; gap: 8px; padding: 22px 24px 24px;
  border: var(--rule); background: var(--paper); color: inherit;
  transition: border-color .15s, background .15s;
}
.studio-choice-card strong { font-size: 20px; font-weight: 600; line-height: 1.25; }
.studio-choice-card .dim { font-size: 14px; line-height: 1.5; }
.studio-choice-go { margin-top: auto; padding-top: 8px; font-family: var(--mono); font-size: 13px; font-weight: 500; color: var(--accent); }
a.studio-choice-card:hover { border-color: var(--accent); background: var(--panel); color: inherit; }
.studio-choice-card.is-off { opacity: .6; }
@media (max-width: 720px) { .studio-choice { grid-template-columns: 1fr; } }

/* The collection flow's steps: 1 Name it, 2 Add models, 3 Send to review. */
.flow-steps { list-style: none; display: flex; gap: 8px; flex-wrap: wrap; margin: 16px 0 22px; padding: 0; }
.flow-steps li {
  display: inline-flex; align-items: center; gap: 8px; padding: 6px 14px 6px 6px;
  border: var(--rule); border-radius: 999px; font-family: var(--mono); font-size: 12px; font-weight: 500; color: var(--muted);
}
.flow-step-n {
  display: inline-grid; place-items: center; width: 22px; height: 22px; border-radius: 50%;
  border: var(--rule); font-size: 11px;
}
.flow-steps li.is-on { border-color: var(--ink); color: var(--ink); }
.flow-steps li.is-on .flow-step-n { background: var(--ink); border-color: var(--ink); color: var(--paper); }
.flow-steps li.is-done .flow-step-n { background: var(--panel); }

/* Step 3's panel on the add-models page: the progress to the minimum, or the
   Send to review button once the collection can go. */
.flow-review { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 28px; }
.flow-review.is-ready { border-color: var(--accent); }

/* ---------- the reviewer's inspector (rev 19) ----------
   The model page's viewer on the review page: the stage on the left, the
   inspected model's details on the right, previous / next and the downloads
   above. The grid's tile for the model in the viewer is outlined. */
.inspect-head { display: flex; align-items: end; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; }
.inspect-nav { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
.inspect-nav .is-off { opacity: .4; pointer-events: none; }
.inspect-cols { display: grid; gap: 28px; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); align-items: start; }
.inspect-side { border: var(--rule); padding: 16px; }
.studio-tile.is-inspected { border-color: var(--ink); box-shadow: inset 0 0 0 1px var(--ink); }
@media (max-width: 900px) { .inspect-cols { grid-template-columns: 1fr; } }

/* ---------- pricing, rev 20 ---------- */
/* A plan button that cannot be used yet (the launch period): a button's shape,
   no action. */
.btn.is-off { opacity: .45; pointer-events: none; }
.faq { max-width: 70ch; }
.faq h3 { font-size: 17px; margin: 26px 0 6px; }
.faq h3:first-child { margin-top: 0; }
.faq p { margin: 0; }

/* The staff panel at the foot of a model page (rev 21): a way into the admin
   and the category, for admins and the model's provider. Dashed, so it reads
   as a tool and not as part of the product. */
.staff-panel {
  display: flex; flex-wrap: wrap; align-items: center; gap: 10px;
  margin-top: 26px; padding: 14px; border: 1px dashed var(--line);
}
.staff-panel .label { width: 100%; }
.staff-panel .inline-form select { max-width: 220px; }

/* ---------- the thumbnail run (rev 21) ----------
   A panel over the whole page while out-of-date cards are rendered: clear,
   and not something a stray click can stop (thumb-jobs.js). */
.thumb-run {
  position: fixed; inset: 0; z-index: 200; display: grid; place-items: center; padding: 20px;
  background: color-mix(in srgb, var(--paper) 78%, transparent);
  backdrop-filter: blur(3px);
}
.thumb-run-card {
  width: min(460px, 100%); background: var(--paper); border: 1px solid var(--ink);
  padding: 26px 26px 24px; display: flex; flex-direction: column; gap: 12px;
  box-shadow: 0 24px 60px rgba(0, 0, 0, .25);
}
.thumb-run-card h3 { margin: 0; }
.thumb-run-card p { margin: 0; }
.thumb-run-bar { height: 6px; background: var(--line); border-radius: 999px; overflow: hidden; }
.thumb-run-bar span { display: block; height: 100%; width: 0; background: var(--accent); transition: width .3s ease; }
.thumb-run-now { font-size: 12px; min-height: 1.4em; }
.thumb-run-preview { aspect-ratio: 4 / 3; border: var(--rule); display: grid; place-items: center; overflow: hidden; }
.thumb-run-preview img { width: 100%; height: 100%; object-fit: contain; padding: 8px; }
html.thumb-run-open { overflow: hidden; }
.thumb-due { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }

/* ---------- playful UI (rev 21) ----------
   Vincent's picks. Every motion here stands down for prefers-reduced-motion
   (fx-early.js does not mark the page, and the rules below are fenced). */

/* 1: a model card is a game card (rev 21, Vincent: "a 3D effect of the card
   as if it was a real game card, with reflection"). On hover the whole card
   lifts off the grid and tilts toward the pointer, with a shadow under it; the
   render floats a little above the card (translateZ, so it slides against the
   frame as the card turns); a gloss streak crosses the laminate as it tilts,
   and a faint holographic foil plays over the picture. ui-fx.js sets --rx/--ry
   (the tilt), --px/--py (the pointer, 0..100%). Fine pointers only, never for
   reduced motion. Transform and opacity only: nothing lays out again. */
@media (prefers-reduced-motion: no-preference) and (hover: hover) and (pointer: fine) {
  .grid > .card {
    transform-style: preserve-3d;
    transition: transform .45s cubic-bezier(.2, .8, .2, 1), box-shadow .45s ease, background .15s;
  }
  .grid > .card.is-tilt {
    z-index: 5;
    transform: perspective(900px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) scale(1.05);
    transition-duration: .12s, .3s, .15s;
    box-shadow: 0 22px 44px rgba(0, 0, 0, .38), 0 0 0 1px var(--line);
  }
  .grid > .card:not(.collection-card) .card-link,
  .grid > .card:not(.collection-card) .card-thumb { transform-style: preserve-3d; }
  .grid > .card:not(.collection-card) .card-thumb img { transition: transform .45s cubic-bezier(.2, .8, .2, 1); }
  .grid > .card.is-tilt .card-thumb img { transform: translateZ(36px) scale(.96); transition-duration: .2s; }

  /* The gloss: a bright streak whose place follows the tilt, plus a soft
     hotspot under the pointer. Over the whole card, like a laminate. */
  .grid > .card::after {
    content: ""; position: absolute; inset: 0; z-index: 4; pointer-events: none;
    opacity: 0; transition: opacity .35s ease;
    background:
      linear-gradient(115deg, transparent 32%, rgba(255, 255, 255, .26) 46%, rgba(255, 255, 255, .06) 53%, transparent 64%)
        calc(100% - var(--px, 50%)) 0 / 260% 100% no-repeat,
      radial-gradient(circle at var(--px, 50%) var(--py, 50%), rgba(255, 255, 255, .16), transparent 46%);
    mix-blend-mode: screen;
  }
  .grid > .card.is-tilt::after { opacity: 1; }

  /* The foil: a rainbow sheen on the picture, moving with the pointer. */
  .grid > .card:not(.collection-card) .card-thumb::before {
    content: ""; position: absolute; inset: 0; z-index: 1; pointer-events: none;
    opacity: 0; transition: opacity .35s ease;
    background: linear-gradient(125deg,
      hsl(330 90% 70%) 0%, hsl(45 95% 65%) 18%, hsl(150 80% 60%) 36%,
      hsl(200 90% 65%) 54%, hsl(270 85% 70%) 72%, hsl(330 90% 70%) 90%)
      var(--px, 50%) var(--py, 50%) / 300% 300%;
    mix-blend-mode: color-dodge;
  }
  .grid > .card.is-tilt .card-thumb::before { opacity: .16; }
}

/* 6: the box opens (rev 21, Vincent: "the current visuals shape shift in
   cards that go out of the collection frame like a box revealing its
   content"). On hover the three diagonal slices of the cover ARE the cards:
   each one's clip-path morphs from its slanted slice to its whole panel
   (four points to four points, so it interpolates), its corners round, it
   takes the collection's tone behind its model and a white rim, and it rises
   out of the top of the frame, fanned, one after the other, with an
   overshoot. The seams fade; the cover lets them out (overflow visible) and
   the card is lifted over its neighbours. clip-path, transform and colour
   only. Doubled class: beats the two- and one-cover clip rules. */
@media (prefers-reduced-motion: no-preference) and (hover: hover) and (pointer: fine) {
  .collection-card { position: relative; }
  .collection-card:hover { z-index: 6; }
  .collection-card.collection-card:hover .collection-thumbs { overflow: visible; }
  .collection-thumbs > img {
    transition: clip-path .42s cubic-bezier(.3, 1.2, .5, 1), transform .55s cubic-bezier(.3, 1.45, .55, 1),
                border-radius .42s ease, background-color .3s ease, box-shadow .3s ease;
  }
  .collection-thumbs::before, .collection-thumbs::after { transition: opacity .2s ease; }
  .collection-card.collection-card:hover .collection-thumbs > img {
    clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
    border-radius: 9px;
    background-color: hsl(var(--tone-h, 220) var(--tone-s, 12%) calc(var(--tone-l, 26%) - 2%));
    box-shadow: inset 0 0 0 2px rgba(255, 255, 255, .3);
    /* Each risen card's own laminate: a streak that slides with the pointer
       (--px, set on the collection card by ui-fx.js). On an img the
       background shows behind the render, on the card's face. */
    background-image: linear-gradient(115deg, transparent 30%, rgba(255, 255, 255, .24) 46%, rgba(255, 255, 255, .05) 53%, transparent 64%);
    background-size: 260% 100%; background-repeat: no-repeat;
    background-position: calc(100% - var(--px, 50%)) 0;
  }
  /* The game card in 3D (rev 21): the collection card tilts like a model card,
     and its risen cards float at different depths above it (translate3d), so
     they slide against each other as it turns. */
  /* Flat inside, on purpose: in a 3D-preserving collection card Chrome clips
     the risen cards at the cover's edge, so the card tilts as one plane and
     the depth between the risen cards is drawn by their translateZ inside it. */
  .grid > .collection-card { transform-style: flat; }
  /* The risen cards above the card's own gloss (::after, z-index 4): under it
     they read as see-through (Vincent: "semi transparent ... do it opaque").
     They carry their own streak instead. */
  .collection-card.collection-card:hover .collection-thumbs { z-index: 5; }
  .collection-card.collection-card:hover .collection-thumbs > img:nth-child(1) { transform: translate3d(-7%, -20%, 34px) rotate(-9deg) scale(.86); }
  .collection-card.collection-card:hover .collection-thumbs > img:nth-child(2) { transform: translate3d(0, -30%, 56px) scale(.9); transition-delay: 45ms; }
  .collection-card.collection-card:hover .collection-thumbs > img:nth-child(3) { transform: translate3d(7%, -20%, 34px) rotate(9deg) scale(.86); transition-delay: 90ms; }
  .collection-card.collection-card:hover .collection-thumbs > img:only-child { transform: translate3d(0, -16%, 40px) scale(.9); }
  .collection-card:hover .collection-thumbs::before,
  .collection-card:hover .collection-thumbs::after { opacity: 0; }
}

/* 17: menu links get an underline that sweeps in from the left. */
@media (hover: hover) and (pointer: fine) {
  .nav-links a:not(.btn) { position: relative; }
  .nav-links a:not(.btn)::after {
    content: ""; position: absolute; left: 0; right: 0; bottom: -5px; height: 2px;
    background: var(--accent); transform: scaleX(0); transform-origin: left;
    transition: transform .28s cubic-bezier(.2, .8, .2, 1);
  }
  .nav-links a:not(.btn):hover::after { transform: scaleX(1); }
}
@media (prefers-reduced-motion: reduce) { .nav-links a::after { transition: none; } }

/* 13: a number that flips when it changes (ui-fx.js flip()). */
.flip-num { display: inline-grid; overflow: hidden; vertical-align: bottom; line-height: 1.25; }
.flip-num > span { grid-area: 1 / 1; }
.flip-out { animation: flip-out .5s cubic-bezier(.5, 0, .2, 1) forwards; }
.flip-in  { animation: flip-in .5s cubic-bezier(.5, 0, .2, 1) forwards; color: var(--accent); }
@keyframes flip-out { to { transform: translateY(-100%) rotateX(70deg); opacity: 0; } }
@keyframes flip-in  { from { transform: translateY(100%) rotateX(-70deg); opacity: 0; } to { transform: none; opacity: 1; } }

/* 14: the dots of the subscribe celebration. */
.burst { position: absolute; width: 0; height: 0; z-index: 300; pointer-events: none; }
.burst span { position: absolute; left: 0; top: 0; border-radius: 50%; background: var(--accent); }

/* 5: browse to browse is a view transition (fx-early.js skips every other
   one), so the cards slide to their new places when a filter is picked. */
@media (prefers-reduced-motion: no-preference) {
  @view-transition { navigation: auto; }
  ::view-transition-group(*) { animation-duration: .5s; animation-timing-function: cubic-bezier(.2, .8, .2, 1); }
}

/* The model page, rev 21: the two tables side by side under the viewer, the
   aside short, and "More from" right after. */
.detail { padding-block: 40px 40px; }
.detail-specs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 32px; margin-top: 28px; }
.detail-specs .label { display: block; margin-bottom: 6px; }
.detail-specs .spec { margin: 0; }
.detail-more { padding-bottom: 80px; }
@media (max-width: 700px) { .detail-specs { grid-template-columns: 1fr; gap: 22px; } }

/* The count in the headline counts up (ui-fx.js): figures of one width, so
   "models" holds still once the number has its last digit. In the accent
   (2026-11-02, Vincent: "put the amount of models red"). */
.hero-count { font-variant-numeric: tabular-nums; color: var(--accent); }
/* One gap between the headline, the line under it, the buttons and the
   search (2026-11-02, Vincent: "the same margin space"). */
.hero { --hero-gap: clamp(26px, 2.8vw, 40px); }
.hero-copy .hero-sub, .hero-copy .hero-cta { margin-top: var(--hero-gap); }
.hero-copy .hero-sub { margin-bottom: 0; }
/* Measured from the letters, not from the line boxes: the headline's box runs
   well below "Free." and the line's leading adds to the gap, so equal margins
   on the boxes still read as a bigger space under the headline. Trimmed to
   cap height and baseline, the gaps are what the eye measures. (A browser
   without text-box keeps the boxes, a little looser, nothing more.) */
.hero-copy h1, .hero-copy .hero-sub { text-box: trim-both cap alphabetic; }
/* The figures at the bottom of the home page: the section spacing above them
   comes from the section before, so the ticker drops its own. */
.home-ticker { padding-block: 0; }
.home-ticker .ticker { margin-top: 0; }
.hero-sub strong { color: var(--ink); font-weight: 600; }

/* The tag duplicates panel (rev 21). */
.dupe-list { list-style: none; margin: 0; padding: 0; font-size: 14px; }
.dupe-list li { padding: 7px 0; border-top: var(--rule); }

/* ---------- the card reveal, done again (rev 21) ----------
   Vincent: the first one (a script dealing cards in as they came into view,
   0.45 to 0.6 s each) felt slow and "not optimised"; he wanted it playful,
   fast and efficient rather than gone. So, CSS only, from the first paint:
   - on load, cards pop in with a little overshoot, 0.3 s each, 22 ms apart
     for the first ten (about half a second for a whole row of shelves);
   - while scrolling, the render in a card hops up into its frame as the card
     enters the screen, driven by the scroll itself (animation-timeline:
     view()), so it never waits on a timer and runs on the compositor.
   Only opacity and the individual `translate` / `scale` properties animate:
   they compose with the `transform` the hover tilt and the cover opening
   use, and none of them makes the page lay out again. */
@media (prefers-reduced-motion: no-preference) {
  .grid > .card { animation: card-pop .3s cubic-bezier(.34, 1.45, .64, 1) both; }
  .grid > .card:nth-child(2)  { animation-delay: 22ms; }
  .grid > .card:nth-child(3)  { animation-delay: 44ms; }
  .grid > .card:nth-child(4)  { animation-delay: 66ms; }
  .grid > .card:nth-child(5)  { animation-delay: 88ms; }
  .grid > .card:nth-child(6)  { animation-delay: 110ms; }
  .grid > .card:nth-child(7)  { animation-delay: 132ms; }
  .grid > .card:nth-child(8)  { animation-delay: 154ms; }
  .grid > .card:nth-child(9)  { animation-delay: 176ms; }
  .grid > .card:nth-child(10) { animation-delay: 198ms; }
  @keyframes card-pop { from { opacity: 0; translate: 0 10px; scale: .97; } }

  @supports (animation-timeline: view()) {
    .card-thumb img, .collection-thumbs img {
      animation: thumb-hop linear both;
      animation-timeline: view();
      animation-range: entry 0% entry 60%;
    }
    @keyframes thumb-hop { from { opacity: 0; translate: 0 16%; scale: .82; } }
  }
}

/* ---------- the model's own colour behind its game card (rev 21) ----------
   On hover the render's stage fades into a dark spotlight in the model's own
   tone (model_tone, measured from its render), the same recipe as the
   collection covers. --tone-a is registered so the gradient can fade. */
@property --tone-a { syntax: '<number>'; inherits: true; initial-value: 0; }
.card-thumb[style*="--tone-h"] {
  background-image:
    var(--dot-grid),
    radial-gradient(120% 95% at 50% 40%,
      hsl(var(--tone-h) var(--tone-s) calc(var(--tone-l, 26%) + 4%) / var(--tone-a)),
      hsl(var(--tone-h) calc(var(--tone-s) * .8) 10% / var(--tone-a)));
  background-size: var(--dot-gap) var(--dot-gap), 100% 100%;
  transition: --tone-a .35s ease;
}
.grid > .card.is-tilt .card-thumb[style*="--tone-h"] { --tone-a: 1; }


/* ---------- the trading card vibe, part two (rev 21) ---------- */

/* 1: the holo contour of buttons and chips (rev 21, sixth take). After the
   refused foils (rainbow band, rainbow on chrome, silver holo with pastel
   waves) came a polished silver ring on the contour, liked but "not visible
   enough"; Vincent then asked for holographic again, "lightweight", "with
   real distortion of the effect when moving the mouse".

   So: a ring over the border (padding + XOR mask, the face and the text keep
   their own hover colours), painted with a conic gradient of pearl tints
   (ice, aqua, lilac, pink, champagne) between steel greys: iridescent, but
   pale and metallic, never a rainbow. Its centre IS the pointer and its
   start angle follows the pointer too (ui-fx.js sets --fx/--fy/--fa), so the
   colours swing around the contour and stretch and squeeze as the mouse
   moves, the way foil changes with the angle. The three are registered, so
   they ease instead of jumping; a slow spin keeps it alive when the mouse is
   still; a white glare sits where the pointer is. CSS only, one pseudo. */
@property --fx { syntax: '<percentage>'; inherits: true; initial-value: 50%; }
@property --fy { syntax: '<percentage>'; inherits: true; initial-value: 50%; }
@property --fa { syntax: '<angle>'; inherits: true; initial-value: 0deg; }
@property --holo-spin { syntax: '<angle>'; inherits: false; initial-value: 0deg; }
:where(.btn, .chip, .coffee-cta) { position: relative; isolation: isolate; }
.btn, .chip {
  transition: background .15s, color .15s, border-color .15s,
              --fx .35s cubic-bezier(.2, .8, .3, 1), --fy .35s cubic-bezier(.2, .8, .3, 1), --fa .5s cubic-bezier(.2, .8, .3, 1);
}
@media (hover: hover) and (pointer: fine) {
  .btn::before, .chip::before, .coffee-cta::before {
    content: ""; position: absolute; inset: -1.5px; border-radius: inherit; padding: 2.5px;
    pointer-events: none; opacity: 0; transition: opacity .2s ease;
    background:
      radial-gradient(circle at var(--fx) var(--fy), rgba(255, 255, 255, .95) 0, rgba(255, 255, 255, .35) 14px, rgba(255, 255, 255, 0) 40px),
      conic-gradient(from calc(var(--fa) + var(--holo-spin)) at var(--fx) var(--fy),
        #e8f2ff, #7c8696 7%, #aef0e6 15%, #f7f9fc 23%, #c9bdff 32%, #6c7584 41%,
        #ffc9e4 51%, #fff1d2 60%, #8a94a2 69%, #bde4ff 79%, #f4f6f9 89%, #e8f2ff);
    -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    -webkit-mask-composite: xor; mask-composite: exclude;
  }
  .btn:not([disabled]):not(.is-off):hover::before, .chip:not(.is-off):hover::before, .coffee-cta:hover::before {
    opacity: 1; animation: holo-spin 7s linear infinite;
  }
  @keyframes holo-spin { to { --holo-spin: 360deg; } }
}
@media (prefers-reduced-motion: reduce) {
  .btn::before, .chip::before, .coffee-cta::before { animation: none !important; }
}
/* The hover fill (Vincent: no red on hover; "the same iridescent effect but
   more lightweight than the contour"). A button keeps its resting colours
   when hovered, and a faint wash of the same pearl tints, centred on the
   pointer like the ring, plays over its face, under the text (::after,
   z-index -1 inside the button's isolation). Touch screens keep the old
   hover colours, which they only show on tap. */
@media (hover: hover) and (pointer: fine) {
  .btn:not([disabled]):not(.is-off):hover { background: var(--ink); border-color: var(--ink); color: var(--paper); }
  .btn.ghost:not([disabled]):not(.is-off):hover { background: var(--paper); border-color: var(--ink); color: var(--ink); }
  .btn::after {
    content: ""; position: absolute; inset: 0; z-index: -1; border-radius: inherit;
    pointer-events: none; opacity: 0; transition: opacity .25s ease;
    background:
      radial-gradient(circle at var(--fx) var(--fy), rgba(255, 255, 255, .6) 0, rgba(255, 255, 255, 0) 60px),
      conic-gradient(from var(--fa) at var(--fx) var(--fy),
        #e8f2ff, #aef0e6 15%, #f7f9fc 23%, #c9bdff 32%, #ffc9e4 51%, #fff1d2 60%, #bde4ff 79%, #f4f6f9 89%, #e8f2ff);
  }
  /* Stronger on a white face than on a black one (theme tokens: the face of
     a plain button is --ink, of a ghost one --paper). */
  .btn:not([disabled]):not(.is-off):hover::after { opacity: var(--wash-ink, .24); }
  .btn.ghost:not([disabled]):not(.is-off):hover::after { opacity: var(--wash-paper, .55); }
}

/* The legendary plan (3): the polished silver from the take before, faint,
   over the whole card. */
.plans > .plan.featured::before {
  background-image:
    radial-gradient(farthest-corner circle at var(--fx, 50%) var(--fy, 50%),
      rgba(255, 255, 255, .55) 0%, rgba(255, 255, 255, .12) 30%, rgba(255, 255, 255, 0) 60%),
    linear-gradient(105deg, #f4f5f7, #b9bec6 18%, #ffffff 34%, #c9cdd4 50%, #9aa0a9 64%, #f0f2f5 80%, #c3c8cf);
  background-size: 100% 100%, 260% 100%;
  background-position: 0 0, calc(100% - var(--fx, 50%)) 0;
  background-repeat: no-repeat;
}

/* 3: the plans are cards; the yearly one is the legendary holo. They tilt
   like the model cards (ui-fx.js), and the yearly card carries a foil that
   plays all the time, stronger when it is picked up. */
.plans > .plan { position: relative; }
@media (prefers-reduced-motion: no-preference) and (hover: hover) and (pointer: fine) {
  .plans > .plan { transform-style: preserve-3d; transition: transform .45s cubic-bezier(.2, .8, .2, 1), box-shadow .45s ease; }
  .plans > .plan.is-tilt {
    z-index: 5;
    transform: perspective(1100px) rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) scale(1.03);
    transition-duration: .12s, .3s;
    box-shadow: 0 26px 50px rgba(0, 0, 0, .4), 0 0 0 1px var(--line);
  }
  .plans > .plan::after {
    content: ""; position: absolute; inset: 0; pointer-events: none; opacity: 0; transition: opacity .35s;
    background: linear-gradient(115deg, transparent 32%, rgba(255, 255, 255, .18) 46%, transparent 62%)
                calc(100% - var(--px, 50%)) 0 / 260% 100% no-repeat;
    mix-blend-mode: screen;
  }
  .plans > .plan.is-tilt::after { opacity: 1; }
}
/* The legendary plan is a holo card: the foil of (1), over its paper, under
   its text, following the tilt (--px/--py). */
.plans > .plan.featured { --fx: var(--px, 30%); --fy: var(--py, 30%); }
.plans > .plan.featured::before {
  content: ""; position: absolute; inset: 0; pointer-events: none; z-index: 0;
  border-radius: inherit; opacity: .12; transition: opacity .35s;
}
.plans > .plan.featured.is-tilt::before { opacity: .22; }
.plans > .plan.featured > * { position: relative; z-index: 1; }
/* The card's title only (rev 23: the tips card has more labels than that). */
.plans > .plan.featured .plan-body > span.label:first-of-type::after { content: " \00B7 legendary"; color: var(--accent); }
@media (prefers-reduced-motion: no-preference) {
  .plans > .plan.featured:not(.is-tilt)::before { animation: holo-drift 6s ease-in-out infinite alternate; }
  @keyframes holo-drift {
    from { background-position: 0 0, 0% 0; }
    to   { background-position: 0 0, 100% 0; }
  }
}

/* 4: the stat line of a model card, TCG style: Poly, Weight, Moves, Skins,
   floating over the foot of the render on hover (above it in depth when the
   card is tilted). Selectors go through .card-thumb: the older
   `.card-thumb span` rule (the no-render label) would otherwise win. */
.card-thumb .card-stats {
  position: absolute; padding: 0; background: none; text-transform: none; letter-spacing: 0; left: 10px; right: 10px; bottom: 10px; z-index: 3;
  display: flex; gap: 4px; justify-content: center; flex-wrap: wrap;
  pointer-events: none;
}
.card-thumb .card-stats span {
  position: static;
  font-family: var(--mono); font-size: 10px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase;
  padding: 3px 7px; border-radius: 4px;
  background: rgba(8, 8, 10, .72); color: #fff;
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .14);
  backdrop-filter: blur(2px);
}
.card-thumb .card-stats span b { font-weight: 500; opacity: .65; margin-right: 4px; }
@media (hover: hover) and (pointer: fine) {
  .card-thumb .card-stats { opacity: 0; transform: translateY(6px); transition: opacity .2s ease, transform .3s cubic-bezier(.3, 1.4, .6, 1); }
  .grid > .card:hover .card-stats { opacity: 1; transform: translateY(0) translateZ(48px); }
}
@media (hover: none) { .card-thumb .card-stats { display: none; } }

/* 7: a collection page opens like a booster pack (rev 21, done again:
   Vincent wanted it to look like "a reallll cardpack"). tcg.js builds the
   pack; this draws it and the card back.

   The card back (below) is on every card of the shelf while it is face down, ALL of them (html.pack-pending, set by
   fx-early.js before the first paint, and .fd while the pack runs). */
.grid.pack-on > .card, html.pack-pending .grid[data-pack] > .card { transform-style: preserve-3d; animation: none; }
/* Every layer of the face, not only the card's children: the picture is its
   own 3D plane (game card block, translateZ) and would show through the back. */
.grid[data-pack] > .card * { backface-visibility: hidden; }
/* The cards still inside the pack: their places wait, empty. */
.grid.pack-on > .card.in-pack { opacity: 0; }
/* The grid draws its own top and left rules (each card draws its right and
   bottom): with the cards away in the pack those two were left hanging on
   an empty page. They wait for the cards. */
.grid.pack-on, html.pack-pending .grid[data-pack] { border-color: transparent; }
.grid[data-pack] { transition: border-color .4s ease; }
.grid.pack-on > .card.fd, html.pack-pending .grid[data-pack] > .card { transform: perspective(900px) rotateY(180deg); }
.grid.pack-on > .card { transition: transform .6s cubic-bezier(.3, 1.3, .5, 1); }
.grid.pack-done > .card { animation: none; }
/* The back, in the site's own language (Vincent: the ornate red back was "a
   bit too different from the rest of the website"): the paper, the dotted
   stage of the renders, the thin inner frame of a thumbnail, the logo mark
   and the wordmark in the mono caps. Theme tokens, so it follows light and
   dark. */
.grid[data-pack] > .card::before {
  content: "3DGARDEN"; position: absolute; inset: 0; z-index: 7; pointer-events: none;
  transform: rotateY(180deg); backface-visibility: hidden; border-radius: inherit;
  display: flex; align-items: flex-end; justify-content: center; padding-bottom: 22%;
  font-family: var(--mono); font-size: 11px; font-weight: 600; letter-spacing: .24em; color: var(--ink);
  background:
    url("/static/img/logo.png") center 40% / 30px no-repeat,
    radial-gradient(circle, var(--dot-stage, #333) 0 1px, transparent 1.5px) 0 0 / 14px 14px,
    var(--paper);
  box-shadow: inset 0 0 0 11px var(--paper), inset 0 0 0 12px var(--line), inset 0 0 0 1px var(--line);
}

/* The pack (rev 22, fourth drawing, after Vincent's second reference: its
   shape and details, not its colours). One piece of metallised film, lit
   pixel by pixel on canvases by tcg.js (see renderFilm there): the body
   with its print between the film and its gloss (relit every frame with
   WebGL), and the top strip, cut off straight in one piece. Behind the body, the
   mouth (the inside of the back film, seen once the strip is gone) and the
   stack of cards waiting inside. Fixed in the middle of the screen, in 3D. Drawn at 250 x 400 and
   scaled by tcg.js. */
.bpack {
  position: fixed; left: 50%; top: 50%; z-index: 80; width: 250px; height: 400px;
  margin: -200px 0 0 -125px; pointer-events: none; transform-style: preserve-3d;
  --tone: var(--tone-h, 355); --sat: var(--tone-s, 60%);
  filter: drop-shadow(0 30px 40px rgba(0, 0, 0, .55));
}
.bpack-shell { position: absolute; inset: 0; transform-style: preserve-3d; }
.bpack-body { position: absolute; inset: 0; z-index: 1; }
.bpack-film { position: absolute; left: 0; top: 0; width: 250px; height: 400px; display: block; }
/* The reflections: gloss over the ink. */
.bpack-gloss { z-index: 2; }
/* The strip: its height from tcg.js (it follows the seal). */
.bpack-top { position: absolute; z-index: 2; left: 0; top: 0; width: 250px; }
.bpack-top > canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
/* The scissors' path, drawn across from the right as it is cut. */
.bpack-cut {
  position: absolute; z-index: 3; left: 0; width: 250px; height: 1px; transform: scaleX(0); transform-origin: 100% 50%;
  background: linear-gradient(90deg, #fff, rgba(250, 252, 255, .95) 4%, rgba(235, 238, 245, .5));
  box-shadow: 0 0 3px rgba(255, 255, 255, .5);
}
/* Once the strip is off: the inside of the back film rising behind the
   cut edge (drawn by tcg.js, placed and sized there). */
.bpack-mouth { position: absolute; left: 0; width: 250px; display: block; transform: scaleY(0); transform-origin: 50% 100%; }
/* The front's shadow on the cards where they come out of the pack. */
.bpack-shade { position: absolute; opacity: 0; background: linear-gradient(transparent, rgba(0, 0, 0, .55) 75%, rgba(0, 0, 0, .7)); }
/* The cards inside, face down: the card back (below), small. */
.bpack-stack { position: absolute; }            /* placed and sized by tcg.js */
.bpack-card {
  position: absolute; inset: 0;
  background:
    url("/static/img/logo.png") center 40% / 26px no-repeat,
    radial-gradient(circle, var(--dot-stage, #333) 0 1px, transparent 1.5px) 0 0 / 12px 12px,
    var(--paper);
  box-shadow: inset 0 0 0 9px var(--paper), inset 0 0 0 10px var(--line), 0 0 0 1px var(--line), 0 -1px 3px rgba(0, 0, 0, .3);
}

/* The room dims a little while the pack is open, so it reads. */
.bpack-dim { position: fixed; inset: 0; z-index: 79; pointer-events: none;
  background: radial-gradient(ellipse at 50% 50%, rgba(0, 0, 0, .25), rgba(0, 0, 0, .55)); }

/* The print. */
.bpack-print {
  position: absolute; inset: 52px 24px 50px; display: flex; flex-direction: column; align-items: center;
  color: #fff; text-align: center; z-index: 1;
}
.bpack-brand {
  display: flex; align-items: center; gap: 7px;
  font-family: var(--mono); font-size: 12px; font-weight: 700; letter-spacing: .2em;
}
.bpack-brand img { width: 15px; height: 15px; }
.bpack-brand b { color: #ff3350; font-weight: 700; }
/* The renders float on the wrapper: no frame, a soft shadow under them. */
.bpack-art { position: relative; width: 100%; flex: 1; margin: 6px 0 2px; }
.bpack-art::after {
  content: ""; position: absolute; left: 24%; right: 24%; bottom: 9%; height: 9%; border-radius: 50%;
  background: radial-gradient(closest-side, rgba(0, 0, 0, .6), transparent);
}
/* The shelf's first three renders, crossed: the outer two lean out from
   behind, the lead model stands in front of them. */
.bpack-art img {
  position: absolute; left: 14%; top: 10%; width: 72%; height: 76%; object-fit: contain; z-index: 1;
  filter: drop-shadow(0 8px 10px rgba(0, 0, 0, .5));
}
.bpack-art[data-n="3"] img:nth-of-type(1) { transform: translate(-33%, -4%) rotate(-18deg) scale(.74); }
.bpack-art[data-n="3"] img:nth-of-type(2) { transform: translate(33%, -4%) rotate(18deg) scale(.74); }
.bpack-art[data-n="3"] img:nth-of-type(3) { transform: translateY(6%) scale(1.02); z-index: 2; }
.bpack-art[data-n="2"] img:nth-of-type(1) { transform: translate(-16%, -4%) rotate(-12deg) scale(.88); }
.bpack-art[data-n="2"] img:nth-of-type(2) { transform: translate(16%, 4%) rotate(10deg) scale(.88); }
/* Action lines either side, in the collection's tone. */
.bpack-burst { position: absolute; top: 38%; width: 30px; height: 46px; z-index: 0; }
.bpack-burst.l { left: 0; }
.bpack-burst.r { right: 0; transform: scaleX(-1); }
.bpack-burst i {
  position: absolute; right: 0; width: 18px; height: 3px; border-radius: 3px;
  background: linear-gradient(90deg, transparent, hsl(var(--tone) var(--sat) 62%));
  transform-origin: 100% 50%;
}
.bpack-burst i:nth-child(1) { top: 4px; transform: rotate(28deg); }
.bpack-burst i:nth-child(2) { top: 21px; width: 22px; }
.bpack-burst i:nth-child(3) { top: 38px; transform: rotate(-28deg); }
.bpack-title {
  font-size: 23px; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; line-height: 1;
  text-shadow: 0 2px 10px rgba(0, 0, 0, .5);
}
.bpack-count {
  margin-top: 8px; font-family: var(--mono); font-size: 9.5px; font-weight: 600; letter-spacing: .18em;
  text-transform: uppercase; color: rgba(255, 255, 255, .78); max-width: 100%;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* 14: Browse deals its cards (tcg.js): gathered into a pile before a shuffle,
   dealt out from it after. html.deal-pending (fx-early.js) keeps them in the
   pile until the deal starts. */
html.deal-pending .grid > .card { opacity: 0; animation: none; }

/* A view-transition-name flattens an element's 3D (checked in Chrome: a
   card with one showed its front mirrored when turned over, the same card
   without one showed its back). The name only matters at the instant a
   browse filter changes the page, so a card drops it while it is a 3D
   object: tilted, face down, or part of a pack being opened. */
.grid > .card.is-tilt,
.grid.pack-on > .card,
html.pack-pending .grid[data-pack] > .card { view-transition-name: none !important; }
.grid.pack-done > .card { animation: none; }

/* ---------- the upload editor (rev 22) ----------
   Three columns that fit the screen (Vincent: "everything needs to fit in the
   view"): the viewer, the model and its look, the details. The editor is the
   height of the window below the site bar; a column taller than that scrolls
   inside itself, so the viewer and the Upload button are always in view. The
   stage is the site's dotted render stage; the whole editor is a drop target,
   outlined while something is dragged over it. */
.wrap.wrap-wide { max-width: 1720px; }
/* Before the editor's script has run (its first frame), the page must not
   show the old layout: the details form waits hidden (the editor moves it
   into its right column), and the editor's own box is already drawn at its
   final size, so nothing jumps when it mounts. */
[data-model-editor]:not(.medit) {
  height: calc(100dvh - 200px); min-height: 560px; border: var(--rule);
  background-image: radial-gradient(circle, var(--dot-stage, #333) 0 1px, transparent 1.5px);
  background-size: 14px 14px;
}
[data-model-editor]:not(.medit) ~ #upload-form,
[data-model-editor]:not(.medit) ~ #edit-form { display: none; }
/* The edit page's facts under its editor: the card and the file, the texture. */
.edit-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 40px; max-width: 900px; }
.medit {
  display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(300px, 1fr) minmax(300px, 1fr);
  /* The window less the site bar and the admin page's one-line header. */
  height: calc(100dvh - 200px); min-height: 560px;
  border: var(--rule); background: var(--paper);
}
.medit-stage {
  position: relative; min-height: 0; border-right: var(--rule);
  background-image: radial-gradient(circle, var(--dot-stage, #333) 0 1px, transparent 1.5px);
  background-size: 14px 14px;
}
.medit-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.medit-drop[hidden], .medit-busy[hidden] { display: none; }
.medit-drop { position: absolute; inset: 0; display: grid; place-items: center; text-align: center; padding: 30px; }
.medit-drop > div { max-width: 46ch; display: grid; gap: 12px; justify-items: center; }
.medit-drop-title { font-size: 24px; font-weight: 600; margin: 0; }
.medit.is-dragging { outline: 2px dashed var(--accent); outline-offset: -8px; }
.medit-hud {
  position: absolute; left: 12px; bottom: 12px; font-family: var(--mono); font-size: 11px;
  letter-spacing: .08em; text-transform: uppercase; color: var(--muted);
}
.medit-busy {
  position: absolute; inset: 0; display: grid; place-items: center; z-index: 3; padding: 20px; text-align: center;
  background: color-mix(in srgb, var(--paper) 72%, transparent);
  font-family: var(--mono); font-size: 12px; letter-spacing: .08em; text-transform: uppercase;
}
.medit-col { min-width: 0; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
.medit-col + .medit-col { border-left: var(--rule); }
.medit-sec { padding: 16px 18px; display: flex; flex-direction: column; gap: 9px; }
.medit-sec + .medit-sec { border-top: var(--rule); }
.medit-sec:empty { display: none; }
.medit-h { font-size: 12px; font-family: var(--mono); font-weight: 600; letter-spacing: .1em; text-transform: uppercase; margin: 0 0 2px; }
.medit-filerow { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.medit-filerow strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; }
.medit-modes { display: flex; gap: 8px; flex-wrap: wrap; }
.medit-note { font-size: 12px; margin: 0; }
.medit-material { width: 100%; }
.medit-slots { display: grid; gap: 6px; }
.medit-slot {
  display: grid; grid-template-columns: 38px minmax(0, 1fr) auto; gap: 9px; align-items: center;
  padding: 6px; border: var(--rule); transition: border-color .15s, background .15s;
}
.medit-slot.is-over { border-color: var(--accent); background: var(--panel); }
.medit-slot-thumb { width: 38px; height: 38px; border: var(--rule); background: var(--panel); }
.medit-slot-meta { display: grid; gap: 1px; min-width: 0; }
.medit-slot-meta strong { font-size: 12px; }
.medit-slot-meta .mono { font-size: 10px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.medit-slot-actions { display: flex; gap: 4px; }
.medit-slot-actions .btn.small { padding: 5px 9px; font-size: 9px; }
.medit-tray { display: flex; flex-wrap: wrap; gap: 6px; }
.medit-tray .chip { cursor: grab; font-size: 10px; }
.medit-sliders { display: grid; gap: 9px; margin-top: 4px; }
.medit-slider { display: grid; grid-template-columns: 1fr auto; gap: 3px 10px; align-items: center; }
.medit-slider input[type=range], .medit-slider select { grid-column: 1 / -1; width: 100%; accent-color: var(--accent); }
.medit-slider output { font-size: 12px; color: var(--muted); }
.medit-facts { font-size: 12px; margin: 0; }
.medit-status { font-size: 12px; margin: 0 0 12px; }
.medit-status:empty { display: none; }
.medit-details .field { margin-bottom: 14px; }
.medit-details textarea { min-height: 96px; }
/* The collection leaflet: a button, and a list with a search field on top. */
.medit-pick { position: relative; }
.medit-pick-btn {
  width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 10px;
  padding: 10px 12px; border: var(--rule); background: var(--paper); color: var(--ink); cursor: pointer;
  font-family: var(--mono); font-size: 12px; letter-spacing: .06em; text-transform: uppercase; text-align: left;
}
.medit-pick-btn:hover { border-color: var(--ink); }
.medit-pick-caret { color: var(--muted); }
.medit-pop {
  position: absolute; left: 0; right: 0; top: calc(100% + 4px); z-index: 20;
  background: var(--paper); border: 1px solid var(--ink); box-shadow: 0 18px 40px rgba(0, 0, 0, .35);
  display: flex; flex-direction: column; max-height: 320px;
}
.medit-pop[hidden] { display: none; }
.medit-pop input[type=search] { margin: 8px; width: auto; }
.medit-pop-list { list-style: none; margin: 0; padding: 0 0 6px; overflow-y: auto; }
.medit-pop-item { display: flex; align-items: center; gap: 10px; padding: 7px 10px; cursor: pointer; font-size: 13px; }
.medit-pop-item:hover { background: var(--panel); }
.medit-pop-item.on { color: var(--accent); }
.medit-pop-item img, .medit-pop-blank { width: 28px; height: 28px; flex: none; object-fit: cover; border: var(--rule); background: var(--panel); }
.medit-pop-item .mono { margin-left: auto; font-size: 10px; }
.medit-pop-empty { padding: 8px 12px; font-size: 12px; }
@media (max-width: 1180px) {
  .medit { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-rows: 52vh auto; height: auto; }
  .medit-stage { grid-column: 1 / -1; border-right: 0; border-bottom: var(--rule); }
  .medit-col { overflow: visible; }
  .medit-col + .medit-col { border-left: var(--rule); }
}
@media (max-width: 760px) {
  .medit { grid-template-columns: minmax(0, 1fr); grid-template-rows: 380px auto auto; }
  .medit-col + .medit-col { border-left: 0; border-top: var(--rule); }
}

/* A studio row with an action beside it (rev 22: admin, a creator's
   collections, each with Delete). */
.studio-row-wrap { display: flex; align-items: stretch; gap: 10px; }
.studio-row-wrap > .studio-row { flex: 1; min-width: 0; }
.studio-row-wrap > form { display: flex; align-items: center; }

/* The editor's real-world size row: W, H, D, the unit, Reset (rev 22). */
.medit-size { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)) auto auto; gap: 6px; align-items: end; }
.medit-size-field { display: grid; gap: 3px; min-width: 0; }
.medit-size-field .mono { font-size: 10px; }
.medit-size-field input { width: 100%; min-width: 0; padding: 7px 8px; font-family: var(--mono); font-size: 12px; }
.medit-size-unit { padding: 7px 6px; font-size: 12px; }
.medit-size .btn.small { padding: 7px 10px; font-size: 10px; }
.medit-turns { display: flex; flex-wrap: wrap; gap: 6px; }
.medit-turns .btn.small { padding: 7px 10px; font-size: 10px; }

/* ---------- tips and contact (rev 23) ---------- */

/* A coffee card is one form: the amounts, the typed amount, the button, with
   the button pushed to the bottom like every plan card's. */
.tip-form { display: flex; flex-direction: column; }
.tip-form .plan-cta { margin-top: auto; padding-top: 26px; }
.tip-up { font-size: .45em; vertical-align: super; margin-left: 3px; color: var(--muted); }
/* Radio buttons drawn as chips: the input stays in the form and in the tab
   order, only its look is the chip after it. */
.tip-amounts { border: 0; padding: 0; margin: 24px 0 0; min-width: 0; }
.tip-amounts legend { padding: 0; margin-bottom: 10px; }
.tip-amounts input[type=radio] { position: absolute; opacity: 0; width: 1px; height: 1px; pointer-events: none; }
.tip-amounts label.chip { display: inline-block; margin: 0 6px 8px 0; cursor: pointer;
                          border-color: color-mix(in srgb, var(--ink) 28%, transparent); }
.tip-amounts input:checked + label.chip { background: var(--ink); color: var(--paper); border-color: var(--ink); }
.tip-amounts input:focus-visible + label.chip { outline: 2px solid var(--accent); outline-offset: 2px; }
/* A typed amount is the choice: the field is marked as the chips are. */
.tip-custom.on input { border-color: var(--ink); box-shadow: inset 0 0 0 1px var(--ink); }

/* The company field only when "A company" is chosen. */
form:has(#contact-person:checked) .contact-org { display: none; }
.contact-cols { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 420px); gap: 56px; align-items: start; }
@media (max-width: 860px) { .contact-cols { grid-template-columns: minmax(0, 1fr); gap: 34px; } }
/* The field for form-filling scripts: off the page, not display:none (some
   skip fields that are not displayed). */
.contact-trap { position: absolute; left: -10000px; width: 1px; height: 1px; overflow: hidden; }

.contact-list { margin-top: 22px; border-top: var(--rule); }
.contact-msg { padding: 22px 0; border-bottom: var(--rule); }
.contact-msg header { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: baseline; margin-bottom: 10px; }
.contact-msg p { max-width: 80ch; margin: 0 0 14px; overflow-wrap: anywhere; }
.contact-msg.is-done { opacity: .55; }

/* Buy us a coffee: the short words above the three cards. */
.coffee { padding-block: 70px 30px; }
.coffee h2 { margin: 14px 0 6px; }
.coffee-lead { font-size: 20px; margin: 0; }
.coffee .flash { margin-top: 22px; max-width: 60ch; }
.coffee-goal { margin-top: 30px; padding: 18px 22px; background: var(--panel); max-width: 60ch; }
.coffee-goal p { margin: 8px 0 0; font-size: 17px; }
/* Read by screen readers, not shown. */
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }

/* ---------- About (prototype): the team, one round head viewer each ---------- */
.about-team { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 320px)); gap: 56px 64px; justify-content: center; }
.about-person { margin: 0; text-align: center; }
/* Each circle is the person's own bright colour (--head, from the view),
   with the stage dots in a deeper shade of it. */
.about-team .about-head.dots {
  position: relative; aspect-ratio: 1; border-radius: 50%; overflow: hidden;
  background-color: var(--head);
  --dot-grid: radial-gradient(circle at center,
                color-mix(in srgb, var(--head) 72%, #000) 0 var(--dot-r),
                transparent calc(var(--dot-r) + 0.5px));
  cursor: grab; touch-action: pan-y;
}
.about-head:active { cursor: grabbing; }
.about-head canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; opacity: 0; transition: opacity .5s ease; }
.about-head.is-ready canvas { opacity: 1; }
.about-hint {
  display: block; margin-top: 12px;
  font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--muted);
  transition: opacity .3s ease;
}
.about-head:not(.is-ready) + .about-hint, .about-head.is-held + .about-hint { opacity: 0; }
.about-person figcaption { margin-top: 10px; }
.about-person h3 { margin: 0 0 8px; }
.about-person p { margin: 12px auto 0; max-width: 34ch; }

/* ---- Confirm your email (rev 23, app/verify.php) ---- */
.confirm-banner { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px 20px; }
.confirm-banner form { margin: 0; }
.confirm-banner .linkish { color: var(--accent); }
/* A teammate's LinkedIn, under their role. */
.about-link {
  display: flex; width: max-content; align-items: center; gap: 7px; margin: 14px auto 0;
  font-size: 12px; font-weight: 500; letter-spacing: .08em; text-transform: uppercase; color: var(--muted);
}
.about-link:hover { color: var(--accent); }
/* LinkedIn and the portfolio, side by side under the role. */
.about-links { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px 20px; margin-top: 14px; }
.about-links .about-link { margin: 0; }

/* ---- Buy us a coffee: the big button at the right of the header (2026-10-30,
   like Blender's Donate). The accent, filled, with a cup: the one red object
   in the bar, so it is seen without being looked for. ---- */
.coffee-cta {
  /* A set height rather than padding (2026-11-02, bar check): the burger
     beside it on narrow bars takes the same one, so the two pills match. */
  --pill-h: 44px;
  display: inline-flex; align-items: center; gap: 9px; flex: none;
  height: var(--pill-h); padding: 0 20px; border-radius: 999px;
  background: var(--accent); color: #fff; border: 1px solid var(--accent);
  font-family: var(--mono); font-size: 13px; font-weight: 600;
  text-transform: uppercase; letter-spacing: 0.08em; white-space: nowrap;
  box-shadow: 0 6px 22px -8px color-mix(in srgb, var(--accent) 70%, transparent);
  transition: transform .15s, box-shadow .15s, background .15s,
              --fx .35s cubic-bezier(.2, .8, .3, 1), --fy .35s cubic-bezier(.2, .8, .3, 1), --fa .5s cubic-bezier(.2, .8, .3, 1);
}
/* Hovered, it stays red and takes the buttons' iridescent ring (Vincent:
   "keeping the red background ... the same outline iridescent effect"): the
   ::before of .btn and .chip above, which ui-fx.js steers for it too. */
.coffee-cta.on { background: color-mix(in srgb, var(--accent) 86%, #000); }
.coffee-cta:hover, .coffee-cta.on {
  color: #fff;
  transform: translateY(-1px);
  box-shadow: 0 10px 26px -8px color-mix(in srgb, var(--accent) 85%, transparent);
}
.coffee-cta:hover svg { animation: coffee-steam .9s ease-in-out; }
@keyframes coffee-steam { 50% { transform: rotate(-10deg) translateY(-1px); } }
@media (prefers-reduced-motion: reduce) { .coffee-cta, .coffee-cta:hover svg { transition: none; animation: none; transform: none; } }
/* A phone: beside the burger, in front of the menu that drops below. */
@media (max-width: 760px) {
  .coffee-cta { --pill-h: 40px; order: 1; margin-left: auto; padding: 0 15px; font-size: 11px; gap: 7px; }
  .burger { width: 40px; height: 40px; }
  .burger { order: 2; margin-left: 0; }
  .nav-links { order: 3; }
}
@media (max-width: 380px) { .coffee-cta { padding: 0 12px; letter-spacing: 0.04em; } }
/* Between the phone menu and a wide screen, a signed-in admin's bar holds nine
   links and the button: tighter. Never a second line of links (2026-11-02,
   Vincent: "don't create multiple lines of menus: go directly to burger
   menu"): when they do not fit, nav-fit.js puts the bar in burger mode below. */
@media (min-width: 761px) and (max-width: 1200px) {
  .nav { gap: 20px; }
  .nav-links { gap: 16px; }
  .coffee-cta { --pill-h: 42px; padding: 0 16px; font-size: 12px; gap: 7px; }
}
.nav-links a, .nav-links .linkish { white-space: nowrap; }

/* Burger mode at any width: nav-fit.js sets .nav-burger on the bar whenever
   its links would not fit on one line, and the bar takes the phone's menu
   (the max-width: 760px rules above and in the Account menu below, which
   still work on their own without the script). */
/* The rows keep the phone's 12px; along the bar, the gap of a bar this wide
   (20px), not the phone's. */
.nav.nav-burger { flex-wrap: wrap; gap: 12px 20px; }
.nav.nav-burger .coffee-cta { order: 1; margin-left: auto; }
.nav.nav-burger .burger { display: flex; order: 2; margin-left: 0; }
.nav.nav-burger .nav-links {
  display: none; order: 3; width: 100%; margin-left: 0;
  flex-direction: column; align-items: stretch; gap: 0;
  padding-bottom: 6px;
  max-height: calc(100vh - 70px);
  max-height: calc(100dvh - 70px);
  overflow-y: auto;
}
.nav.nav-burger .nav-toggle:checked ~ .nav-links { display: flex; }
.nav.nav-burger .nav-links a { padding: 15px 2px; border-top: var(--rule); font-size: 14px; }
.nav.nav-burger .nav-links a:not(.btn)::after { display: none; }
.nav.nav-burger .nav-links form.signout { width: 100%; }
.nav.nav-burger .nav-links .linkish { padding: 15px 2px; border-top: var(--rule); font-size: 14px;
                                      width: 100%; text-align: left; }
.nav.nav-burger .nav-links a.btn { border: 1px solid var(--ink); margin-top: 14px; align-self: flex-start; }
.nav.nav-burger .nav-links a.btn.small { padding: 12px 22px; font-size: 12px; }
.nav.nav-burger .nav-menu { display: block; }
.nav.nav-burger .nav-menu-trigger { justify-content: space-between; }
.nav.nav-burger .nav-menu-trigger svg { display: none; }
.nav.nav-burger .nav-menu .nav-menu-list, .nav.nav-burger .nav-menu.shut .nav-menu-list {
  position: static; min-width: 0; padding: 0 0 6px 16px;
  background: none; border: 0; border-radius: 0; box-shadow: none;
  opacity: 1; visibility: visible; transform: none !important; transition: none;
}
.nav.nav-burger .nav-menu-list::before { display: none; }
.nav.nav-burger .nav-menu-list a, .nav.nav-burger .nav-menu-list .linkish { padding: 13px 2px; border-radius: 0; border-top: var(--rule); }
.nav.nav-burger .nav-menu-list a:hover, .nav.nav-burger .nav-menu-list .linkish:hover { background: none; }
.nav.nav-burger .nav-menu-list form.signout { margin-top: 0; padding-top: 0; border-top: 0; }

/* ---- the coffee payment window (Stripe Embedded Checkout) ----------------
   Stripe draws its form on white whatever the site's theme, so the panel is
   white too and the page behind is dimmed. views/tips.php, js/coffee-checkout.js. */
.checkout-dialog {
  padding: 0; border: 0; background: transparent; color: #1a1a1a;
  width: min(1040px, calc(100vw - 32px)); max-width: none;
  max-height: calc(100dvh - 32px); margin: auto; overflow: visible;
}
.checkout-dialog::backdrop { background: rgba(0, 0, 0, .72); backdrop-filter: blur(3px); }
.checkout-panel {
  position: relative; background: #fff; border-radius: 18px;
  max-height: calc(100dvh - 32px); overflow: auto; padding: 44px 8px 12px;
  box-shadow: 0 30px 90px rgba(0, 0, 0, .55);
}
.checkout-close {
  position: absolute; top: 10px; right: 10px; z-index: 1;
  width: 36px; height: 36px; display: grid; place-items: center;
  border: 0; border-radius: 50%; background: #f0f0f0; color: #1a1a1a; cursor: pointer;
}
.checkout-close:hover { background: #e2e2e2; }
.checkout-loading { padding: 70px 20px; text-align: center; color: #666; }
html.checkout-open { overflow: hidden; }
@media (max-width: 560px) {
  .checkout-dialog { width: 100vw; max-height: 100dvh; }
  .checkout-panel { border-radius: 0; max-height: 100dvh; min-height: 100dvh; }
}

/* Delete, asked twice without script: the summary opens, the button inside
   does it (admin coffee list). */
.confirm-delete { position: relative; }
.confirm-delete > summary { list-style: none; cursor: pointer; }
.confirm-delete > summary::-webkit-details-marker { display: none; }
.confirm-delete[open] > summary { opacity: .55; }
.confirm-delete form { margin-top: 8px; }

/* ---- the Account menu in the header (layout.php) -------------------------
   Like Sketchfab's: it opens on hover (or focus, or a tap on a touch screen,
   .open from app.js) and the panel hangs flush from the bar's bottom edge.
   --menu-drop is the distance from the trigger's bottom to that edge, measured
   by app.js; 17px (the bar's padding and border) is the fallback. The panel's
   ::before fills that gap, so the pointer can travel down into the panel
   without leaving the menu. */
.nav-menu { position: relative; display: flex; align-items: center; }
.nav-menu-trigger { display: flex; align-items: center; gap: 6px; }
.nav-menu-trigger svg { transition: transform .2s ease; }
.nav-menu-list {
  /* Centred under Account (Vincent: "perfectly centered under account"):
     left 50% and translateX(-50%), kept in every transform below. */
  position: absolute; top: calc(100% + var(--menu-drop, 17px)); left: 50%; z-index: 60;
  min-width: 210px; display: flex; flex-direction: column;
  background: var(--paper); border: var(--rule); border-top: 0;
  border-radius: 0 0 12px 12px; padding: 8px;
  box-shadow: 0 22px 50px rgba(0, 0, 0, .35);
  opacity: 0; visibility: hidden; transform: translate(-50%, -6px);
  /* Closing waits a moment, so a pointer cutting a corner does not shut it. */
  transition: opacity .16s ease .12s, transform .16s ease .12s, visibility 0s linear .28s;
}
.nav-menu-list::before {
  content: ""; position: absolute; left: 0; right: 0; bottom: 100%;
  height: var(--menu-drop, 17px);
}
.nav-menu:hover .nav-menu-list, .nav-menu:focus-within .nav-menu-list, .nav-menu.open .nav-menu-list {
  opacity: 1; visibility: visible; transform: translate(-50%, 0);
  transition: opacity .16s ease, transform .16s ease, visibility 0s;
}
.nav-menu:hover .nav-menu-trigger svg, .nav-menu:focus-within .nav-menu-trigger svg, .nav-menu.open .nav-menu-trigger svg { transform: rotate(180deg); }
/* After Esc, shut until the pointer leaves (app.js). */
.nav-menu.shut .nav-menu-list { opacity: 0; visibility: hidden; transform: translate(-50%, -6px); transition: none; }
.nav-menu.shut .nav-menu-trigger svg { transform: none; }
.nav-menu-list a, .nav-menu-list .linkish {
  display: block; width: 100%; text-align: left;
  padding: 11px 12px; border-radius: 8px;
}
.nav-menu-list a:hover, .nav-menu-list .linkish:hover { background: var(--panel); color: var(--accent); }
.nav-menu-list form.signout { display: block; margin-top: 4px; padding-top: 4px; border-top: var(--rule); }
/* No sweeping underline inside the panel: the row lights up instead. */
.nav-links .nav-menu-list a::after { display: none; }
@media (prefers-reduced-motion: reduce) {
  .nav-menu-trigger svg { transition: none !important; transform: none !important; }
  .nav-menu-list { transition: none !important; transform: translateX(-50%) !important; }
}

@media (max-width: 760px) {
  .nav-menu { display: block; }
  .nav-menu-trigger { justify-content: space-between; }
  .nav-menu-trigger svg { display: none; }
  .nav-menu .nav-menu-list, .nav-menu.shut .nav-menu-list {
    position: static; min-width: 0; padding: 0 0 6px 16px;
    background: none; border: 0; border-radius: 0; box-shadow: none;
    opacity: 1; visibility: visible; transform: none !important; transition: none;
  }
  .nav-menu-list::before { display: none; }
  .nav-menu-list a, .nav-menu-list .linkish { padding: 13px 2px; border-radius: 0; border-top: var(--rule); }
  .nav-menu-list a:hover, .nav-menu-list .linkish:hover { background: none; }
  .nav-menu-list form.signout { margin-top: 0; padding-top: 0; border-top: 0; }
}

/* ---------- Connect to MCP, on the home page (views/home.php) ----------
   2026-11-02, Vincent: "like polyfork.dev, a connect to MCP encart". A
   bordered paper card over the dot grid, like the ticker: the pitch on the
   left, a sample session in a terminal on the right (the API docs' .code
   panel), stacked on a narrow screen. */
.home-mcp { padding-block: 20px 60px; }
.mcp-card {
  display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr);
  gap: clamp(28px, 4vw, 64px); align-items: center;
  border: var(--rule); background-color: var(--paper);
  padding: clamp(24px, 4.2vw, 56px);
}
.mcp-copy h2 { margin-top: 16px; max-width: 16ch; }
.mcp-copy > p { margin: 18px 0 0; max-width: 46ch; line-height: 1.6; }
.mcp-facts { list-style: none; margin: 20px 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
.mcp-facts li { border: var(--rule); padding: 5px 10px; color: var(--ink); font-size: 12px; }
.mcp-copy .hero-cta { margin-top: 28px; }

.mcp-term { margin: 0; border: var(--rule); background: var(--panel); min-width: 0; }
.mcp-term-bar {
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
  padding: 11px 16px; border-bottom: var(--rule);
}
.mcp-term-bar .mono { font-size: 12px; display: inline-flex; align-items: center; gap: 8px; }
.mcp-live { width: 7px; height: 7px; border-radius: 50%; background: #1fae5b; flex: none; }
.mcp-session { padding: 22px 20px 24px; font-size: 14px; line-height: 1.6; color: var(--ink); overflow-x: auto; }
.mcp-session p { margin: 0; }
.mcp-calls {
  display: grid; grid-template-columns: max-content max-content 1fr; gap: 6px 22px;
  margin: 18px 0; padding-left: 14px; border-left: 2px solid var(--line);
}
.mcp-calls span:not(.mcp-tool) { color: var(--muted); white-space: nowrap; }
.mcp-you { color: var(--accent); font-weight: 700; margin-right: 4px; }
.mcp-tool { font-weight: 600; }
.mcp-ok { color: #1fae5b; font-weight: 700; }
.mcp-caret {
  display: inline-block; width: .55em; height: 1.05em; margin-left: 6px;
  vertical-align: -0.15em; background: var(--ink); animation: mcp-caret 1.1s steps(1) infinite;
}
@keyframes mcp-caret { 50% { opacity: 0; } }
@media (prefers-reduced-motion: reduce) { .mcp-caret { animation: none; } }
.mcp-clients {
  display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 16px;
  padding: 12px 16px; border-top: var(--rule);
}
.mcp-clients .mono { font-size: 12px; color: var(--ink); }
@media (max-width: 960px) {
  .mcp-card { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 560px) {
  .mcp-session { font-size: 12.5px; padding: 18px 14px 20px; }
  /* No room for the third column: the results go. */
  .mcp-calls { grid-template-columns: max-content 1fr; gap: 4px 14px; }
  .mcp-calls span:nth-child(3n) { display: none; }
}

/* ---------- Scene preview (/scene) ----------
   Several models together, from the MCP's preview_scene. The model viewer's
   stage, wider, with a quiet loading line in the corner. */
.scene-view { aspect-ratio: 16 / 9; }
.scene-status {
  position: absolute; left: 12px; bottom: 10px; z-index: 2;
  font-size: 12px; color: var(--muted);
}
.scene-status[hidden] { display: none; }
.card.is-missing { opacity: .45; }
@media (max-width: 560px) { .scene-view { aspect-ratio: 4 / 5; } }

/* ---------- Model page: "Use with an AI assistant" ----------
   A folded request to paste into an assistant connected to the MCP server. */
.agent-panel { margin-top: 18px; border-top: var(--rule); padding-top: 12px; }
.agent-panel summary { cursor: pointer; list-style: none; }
.agent-panel summary::-webkit-details-marker { display: none; }
.agent-panel summary::after { content: " +"; }
.agent-panel[open] summary::after { content: " \2212"; }
.agent-panel textarea {
  display: block; width: 100%; margin: 12px 0 10px; padding: 10px 12px;
  font-size: 12px; line-height: 1.5; resize: vertical;
  border: var(--rule); background: var(--panel); color: var(--ink);
}

/* ---------- admin: social studio (views/admin/social.php, js/social/) ---------- */
/* One screen like the upload editor (.medit): the window less the site bar
   and the one-line header, three columns that scroll on their own. */
.social {
  display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(280px, 1fr) minmax(280px, 1fr);
  height: calc(100dvh - 190px); min-height: 600px;
  border: var(--rule); background: var(--paper); margin: 0;
}
.social-main { display: flex; flex-direction: column; gap: 10px; min-width: 0; min-height: 0; padding: 12px; border-right: var(--rule); }
.social-col { min-width: 0; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
.social-col + .social-col { border-left: var(--rule); }
.social-sec { padding: 14px 16px; display: flex; flex-direction: column; gap: 9px; }
.social-sec + .social-sec { border-top: var(--rule); }
.social-h { font-size: 12px; font-family: var(--mono); font-weight: 600; letter-spacing: .1em; text-transform: uppercase; margin: 0; }
.social-field { display: grid; gap: 5px; font-size: 13px; }
.social-field > span { font-family: var(--mono); font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); display: flex; justify-content: space-between; }
.social-field > span b { color: var(--ink); font-weight: 600; }
.social-field select, .social-field input[type="text"], .social-field input[type="search"],
.social-inline select { width: 100%; font: inherit; font-size: 13px; padding: 7px 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--paper); color: var(--ink); }
.social-check { display: flex; gap: 9px; align-items: center; font-size: 13px; cursor: pointer; }
.social-check input { accent-color: var(--accent); width: 15px; height: 15px; }
.social-row { display: flex; justify-content: space-between; align-items: center; gap: 10px; }
.social-links { display: flex; gap: 12px; font-size: 13px; }
.social-seg { display: flex; flex-wrap: wrap; gap: 6px; }
.social-seg label { cursor: pointer; }
.social-seg input { position: absolute; opacity: 0; pointer-events: none; }
.social-seg span { display: inline-grid; padding: 6px 12px; border: 1px solid var(--line); border-radius: 999px; font-family: var(--mono); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; transition: border-color .15s, background .15s, color .15s; }
.social-seg input:checked + span { background: var(--ink); color: var(--paper); border-color: var(--ink); }
.social-seg input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; }

.social-picks { display: grid; grid-template-columns: repeat(auto-fill, minmax(52px, 1fr)); gap: 5px; max-height: 236px; overflow: auto; padding: 2px; }
.social-pick { position: relative; aspect-ratio: 1; border: 1px solid var(--line); border-radius: 9px; background: var(--panel) center / cover no-repeat; cursor: pointer; padding: 0; transition: opacity .15s, border-color .15s, transform .15s; }
.social-pick:hover { transform: translateY(-1px); }
.social-pick:not(.on) { opacity: .35; }
.social-pick.on { border-color: var(--ink); box-shadow: inset 0 0 0 1px var(--ink); }
.social-pick.on::after { content: ""; position: absolute; top: 4px; right: 4px; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 2px var(--paper); }
.social-pick.missing { outline: 2px dashed var(--accent); }
/* The order strip: the chosen models, numbered, dragged into place. */
.social-order { display: flex; flex-wrap: wrap; gap: 5px; min-height: 8px; }
.social-order-item { position: relative; width: 44px; height: 44px; border: 1px solid var(--ink); border-radius: 9px; background: var(--panel) center / cover no-repeat; cursor: grab; padding: 0; }
.social-order-item:active { cursor: grabbing; }
.social-order-item.dragging { opacity: .35; }
.social-order-item span { position: absolute; left: 2px; top: 2px; min-width: 15px; height: 15px; padding: 0 3px; border-radius: 8px; background: var(--ink); color: var(--paper); font: 600 9px/15px var(--mono); text-align: center; }
.social-hint { font-size: 11px; margin: 0; }
.social-results { display: grid; gap: 2px; max-height: 160px; overflow: auto; }
.social-results:empty { display: none; }
.social-results button { text-align: left; font: inherit; font-size: 13px; padding: 5px 8px; border: 0; border-radius: 8px; background: none; color: inherit; cursor: pointer; display: flex; gap: 8px; align-items: center; }
.social-results button:hover { background: var(--panel); }
.social-results img { width: 26px; height: 26px; border-radius: 6px; object-fit: cover; background: var(--panel); }

/* Animations as compact chips under their group; the chosen one's line below. */
.social-presets { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 5px; }
.social-presets-h { grid-column: 1 / -1; font-family: var(--mono); font-size: 10px; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; color: var(--muted); margin-top: 4px; }
.social-blurb { font-size: 12px; line-height: 1.4; margin: 0; min-height: 2.8em; }
.social-themes { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 5px; }
.social-card { text-align: left; font: inherit; border: 1px solid var(--line); border-radius: 10px; padding: 6px 8px; background: var(--paper); color: var(--ink); cursor: pointer; display: grid; gap: 3px; min-width: 0; transition: border-color .15s, box-shadow .15s; }
.social-card:hover { border-color: var(--ink); }
.social-card b { font-family: var(--mono); font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.social-card.on { border-color: var(--ink); box-shadow: inset 0 0 0 1px var(--ink); }
.social-card.on b { color: var(--accent); }
.social-card .swatch { height: 22px; border-radius: 6px; border: 1px solid var(--line); }

.social-stage { position: relative; flex: 1; min-height: 0; background: var(--panel); border-radius: 12px; }
.social-stage canvas { position: absolute; inset: 0; margin: auto; display: block; border-radius: 6px; box-shadow: 0 18px 50px -20px rgba(0, 0, 0, .35); background: #fff; }
.social-veil { position: absolute; inset: 0; display: grid; place-items: center; background: color-mix(in srgb, var(--paper) 70%, transparent); border-radius: 12px; }
.social-veil[hidden] { display: none; }
.social-result { position: absolute; inset: 0; display: grid; grid-template-rows: minmax(0, 1fr) auto; gap: 10px; justify-items: center; padding: 12px; background: var(--panel); border-radius: 12px; }
.social-result[hidden] { display: none; }
.social-result video { max-width: 100%; max-height: 100%; min-height: 0; border-radius: 8px; border: 1px solid var(--line); }
.social-transport { display: flex; gap: 10px; align-items: center; }
.social-transport input { flex: 1; accent-color: var(--accent); }
.social-transport .btn { min-width: 86px; }
.social-format { display: flex; flex-wrap: wrap; gap: 8px 10px; align-items: center; justify-content: space-between; }
.social-inline { display: flex; gap: 8px; align-items: center; font-family: var(--mono); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
.social-inline b { color: var(--ink); }
.social-inline input[type="range"] { width: 76px; accent-color: var(--accent); }
.social-inline select { width: auto; padding: 5px 8px; }
.social-actions { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
.social-status { min-height: 1.3em; text-align: center; margin: 0; font-size: 12px; }
[data-social] [data-when-source][hidden] { display: none; }
@media (max-width: 1100px) {
  .social { grid-template-columns: minmax(0, 1fr) minmax(280px, 1fr); height: auto; }
  .social-main { grid-row: span 2; height: calc(100dvh - 190px); min-height: 600px; position: sticky; top: 0; }
  .social-col { overflow: visible; }
  .social-col + .social-col { border-left: 0; border-top: var(--rule); grid-column: 2; }
}
@media (max-width: 760px) {
  .social { grid-template-columns: 1fr; }
  .social-main { grid-row: auto; height: 80dvh; position: static; border-right: 0; }
  .social-col + .social-col { grid-column: auto; }
  .social-col { border-top: var(--rule); }
}

/* The X queue (/admin/social/posts): the video on the left, its text and time on the right. */
.xq-head { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 18px; align-items: start; }
.xq-status, .xq-inspire { border: var(--rule); border-radius: 14px; padding: 14px 16px; display: grid; gap: 8px; background: var(--paper); }
.xq-line { margin: 0; font-size: 13px; line-height: 1.5; }

.xq-dot { display: inline-block; width: 9px; height: 9px; margin-right: 8px; border-radius: 50%; background: var(--accent); vertical-align: 1px; }
.xq-dot.on { background: #3a9a5b; }
.xq-small { font-size: 12px; line-height: 1.5; margin: 0; }
.xq-top { margin: 0; padding-left: 18px; display: grid; gap: 6px; font-size: 12px; line-height: 1.4; }
.xq-top a { color: inherit; overflow-wrap: anywhere; }
.xq-top .mono { display: block; font-size: 10px; }
.xq-list { margin-top: 22px; display: grid; gap: 14px; }
.xq-post { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 18px; border: var(--rule); border-radius: 14px; padding: 14px; background: var(--paper); scroll-margin-top: 90px; }
.xq-post.is-posted { opacity: .75; }
.xq-video video, .xq-novideo { width: 100%; max-height: 340px; border-radius: 10px; background: var(--panel); display: block; }
.xq-novideo { aspect-ratio: 9 / 16; display: grid; place-items: center; font-size: 11px; }
.xq-body { display: grid; gap: 10px; align-content: start; min-width: 0; }
.xq-body header { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: baseline; }
.xq-badge { font: 600 10px/1 var(--mono); text-transform: uppercase; letter-spacing: .08em; padding: 5px 8px; border-radius: 999px; border: 1px solid var(--line); }
.xq-badge.is-approved { background: var(--ink); color: var(--paper); border-color: var(--ink); }
.xq-badge.is-posted { background: #3a9a5b; color: #fff; border-color: #3a9a5b; }
.xq-badge.is-failed { background: var(--accent); color: #fff; border-color: var(--accent); }
.xq-badge.is-posting, .xq-badge.is-processing { border-color: var(--ink); }
.xq-form { display: grid; gap: 10px; }
.xq-form textarea { width: 100%; font: inherit; font-size: 14px; line-height: 1.45; padding: 9px 11px; border: 1px solid var(--line); border-radius: 10px; background: var(--paper); color: var(--ink); resize: vertical; }
.xq-form input[type="datetime-local"] { font: inherit; font-size: 13px; padding: 6px 9px; border: 1px solid var(--line); border-radius: 10px; background: var(--paper); color: var(--ink); }
.xq-form [data-x-count].over { color: var(--accent); }
.xq-row { display: flex; flex-wrap: wrap; gap: 10px 22px; align-items: end; }
.xq-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.xq-text { margin: 0; font-size: 14px; line-height: 1.5; overflow-wrap: anywhere; }
.xq-error { margin: 0; color: var(--accent); font-size: 13px; overflow-wrap: anywhere; }
.xq-warn { color: var(--accent); }
@media (max-width: 860px) {
  .xq-head { grid-template-columns: 1fr; }
  .xq-post { grid-template-columns: 1fr; }
  .xq-video video { max-width: 240px; }
}
