/* Design tokens, following shadcn/ui's naming over a Radix-style neutral scale.
 *
 * shadcn/ui and Radix primitives are React packages and this application is server-rendered
 * Jinja with no build step, so what is adopted here is the system rather than the code: the
 * token architecture, the palette, and the component anatomy. Nothing below needs a bundler.
 *
 * Every color in this file appears exactly once, here. A rule that wants a color takes a
 * token; a rule that wants a color no token provides is usually a rule that wants a
 * different color. */

:root {
  /* Neutral scale. The page is a faintly cool off-white and cards are pure white, so
   * surfaces separate by luminance rather than by drawing a box around everything. Pure
   * white for the page would leave the cards nowhere to go. */
  --background: hsl(220 20% 98%);
  --foreground: hsl(224 20% 12%);

  --card: hsl(0 0% 100%);
  --card-foreground: var(--foreground);

  --popover: hsl(0 0% 100%);
  --popover-foreground: var(--foreground);

  /* Secondary text. Muted enough to recede, dark enough to pass contrast on white. */
  --muted: hsl(220 16% 95%);
  --muted-foreground: hsl(220 9% 43%);

  --accent: hsl(220 16% 93%);
  --accent-foreground: var(--foreground);

  /* One accent color, for the single most important action on a screen. Darker than the
   * dark-theme blue would be: the same hue needs more depth to hold against white. */
  --primary: hsl(212 92% 45%);
  --primary-foreground: hsl(0 0% 100%);

  --secondary: hsl(220 16% 96%);
  --secondary-foreground: var(--foreground);

  --destructive: hsl(0 72% 46%);
  --destructive-foreground: hsl(0 0% 100%);
  --success: hsl(152 60% 32%);
  --warning: hsl(30 88% 40%);

  --border: hsl(220 14% 90%);
  --border-strong: color-mix(in oklab, var(--border), var(--foreground) 14%);
  --input: hsl(0 0% 100%);
  --ring: hsl(212 92% 45%);
  --placeholder: color-mix(in oklab, var(--muted-foreground), var(--card) 42%);

  /* The four dots on the piece of lettering currently being edited. A token rather than a
   * literal because it is the shop's own chrome drawn on top of somebody's design, and it
   * has to stay legible against every color in the print palette - which is why it is not
   * `--primary`: blue lettering with blue handles is a text with no handles. Red at the
   * operator's request, and its own name rather than `--destructive`: nothing is being
   * deleted, and a token borrowed for its color rather than its meaning is a token that
   * moves when somebody restyles the thing it was named for. */
  --handle: hsl(0 72% 51%);

  /* Interaction states are derived, not picked. Restating a hover color by hand is how a
   * palette drifts: change --primary and every state below moves with it. */
  --primary-hover: color-mix(in oklab, var(--primary), black 12%);

  /* The buy action, and only the buy action: the cart, "Add to Cart", and the one button
   * that starts a bespoke order. Green rather than the blue that carries links, search and
   * navigation, so that the controls which move a product toward an order are never
   * confused with the controls that move you around the shop. Built on --success rather
   * than a fourth hue, because the shop already says green for "this went through".
   * White on it measures 4.7:1, which clears AA for the button label. */
  --commerce: var(--success);
  --commerce-foreground: hsl(0 0% 100%);
  --commerce-hover: color-mix(in oklab, var(--commerce), black 14%);
  --link-hover: color-mix(in oklab, var(--primary), black 16%);
  --header-surface: color-mix(in srgb, var(--card) 72%, transparent);
  --focus-ring: 0 0 0 3px color-mix(in srgb, var(--ring) 20%, transparent);

  /* The 3D stage is a lit room, so it gets two stops of its own rather than borrowing a
   * surface token that means something else. */
  --stage-near: hsl(220 24% 99%);
  --stage-far: hsl(220 18% 92%);

  /* The product's own colors. These mirror the three material presets in viewer.js: a
   * swatch that disagreed with the model beside it would be worse than no swatch, so the
   * two are changed together or not at all.
   *
   * Milk was #35200f against a #1c1009 dark on 2026-08-24, and the two were indications of
   * the same color rather than two colors: side by side as 18px dots nobody could tell
   * them apart. Milk is now genuinely milk. Dark stays dark, because dark couverture really
   * is nearly black, but it moved far enough off milk to be a different swatch.
   *
   * The `-lit` values are the highlight end of each swatch's gradient. They were raw hex
   * inside the rules, which is the one thing the palette section exists to prevent. */
  --cocoa-dark: #241408;
  --cocoa-dark-lit: #4a2c1a;
  --cocoa-milk: #7a4a28;
  --cocoa-milk-lit: #a96b3c;
  --cocoa-white: #e0c08d;
  --cocoa-white-lit: #f2dcb8;

  /* The one place a dark value belongs in a light theme: a scrim laid over photography, so
   * that text on top of it reads whatever the picture underneath happens to be. Not a
   * surface color — nothing is themed with this, and two rules use it: the camera controls
   * and the studio's countdown. */
  --scrim: hsl(220 26% 8% / 0.72);
  --scrim-strong: hsl(220 26% 8% / 0.88);
  --on-scrim: hsl(0 0% 100%);
  --on-scrim-muted: hsl(0 0% 100% / 0.72);

  --radius: 0.7rem;
  --radius-sm: calc(var(--radius) - 4px);
  --radius-lg: calc(var(--radius) + 4px);

  /* Depth is a whisper. On white this matters more, not less: a shadow heavy enough to
   * notice reads as a bevel from 2005. */
  --shadow-sm: 0 1px 2px hsl(220 24% 20% / 0.06);
  --shadow: 0 4px 16px -4px hsl(220 24% 20% / 0.1);
  --shadow-lg: 0 24px 48px -12px hsl(220 24% 20% / 0.14);

  --ease: cubic-bezier(0.22, 0.61, 0.36, 1);
  --duration: 160ms;

  /* Tell the browser the product is light-only. Without this Chrome auto-darkens form
   * controls, scrollbars and any fragment served without a background of its own, which
   * is how an interface ends up half dark against the designer's intent. */
  color-scheme: light;
}

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

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

body {
  margin: 0;
  background: var(--background);
  color: var(--foreground);
  font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto,
               "Helvetica Neue", sans-serif;
  font-size: 15px;
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  font-feature-settings: "cv02", "cv03", "cv04", "cv11";
}

/* Motion confirms rather than performs, and never overrides a stated preference. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}

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

h1, h2, h3 { margin: 0; font-weight: 600; }

h1 {
  font-size: 1.6rem;
  letter-spacing: -0.021em;   /* tighter as size grows, as the eye expects */
  margin-bottom: 0.35rem;
}

h2 {
  font-size: 0.7rem;
  font-weight: 600;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--muted-foreground);
  margin-bottom: 0.85rem;
}

p { margin: 0 0 0.9rem; }
p:last-child { margin-bottom: 0; }

.sub {
  color: var(--muted-foreground);
  margin: 0 0 1.75rem;
  overflow-wrap: anywhere;
}

.hint {
  color: var(--muted-foreground);
  font-size: 0.8125rem;
  line-height: 1.5;
  margin-top: 0.5rem;
}

a {
  color: var(--primary);
  text-decoration: none;
  transition: color var(--duration) var(--ease);
}
a:hover { color: var(--link-hover); }
a:focus-visible { outline: 2px solid var(--ring); outline-offset: 3px; border-radius: 4px; }

code, .mono {
  font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace;
  font-size: 0.86em;
  font-variant-ligatures: none;
}

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

header {
  display: flex;
  align-items: center;
  gap: 1.5rem;
  padding: 0.85rem 1.75rem;
  background: var(--header-surface);
  backdrop-filter: saturate(180%) blur(20px);
  -webkit-backdrop-filter: saturate(180%) blur(20px);
  border-bottom: 1px solid var(--border);
  position: sticky;
  top: 0;
  z-index: 20;
}

header .brand {
  font-weight: 600;
  letter-spacing: -0.015em;
  display: flex;
  align-items: center;
  gap: 0.5rem;
  color: var(--foreground);
}
header .brand:hover { color: var(--foreground); }

header nav {
  display: flex;
  align-items: center;
  gap: 0.35rem;
  margin-left: auto;
  /* A flex item's min-width defaults to its content, so a nav wider than the row makes the
   * *page* wide rather than scrolling inside itself. The workshop's strip is eleven links
   * and overflows any viewport under about 1200px: at 812px - a phone on its side - the
   * body was 1434px and the whole page scrolled sideways. There was a scrolling nav already,
   * but only inside the 640px block, so every width between 641px and 1200px got the
   * overflow and none of the fix. `min-width: 0` is what lets the overflow below engage. */
  min-width: 0;
  overflow-x: auto;
  scrollbar-width: none;
}
header nav::-webkit-scrollbar { display: none; }

header nav a {
  color: var(--muted-foreground);
  font-size: 0.875rem;
  font-weight: 500;
  padding: 0.35rem 0.7rem;
  border-radius: var(--radius-sm);
  transition: background var(--duration) var(--ease), color var(--duration) var(--ease);
}
header nav a:hover { color: var(--foreground); background: var(--accent); }

main { max-width: 1400px; margin: 0 auto; padding: 2rem 1.75rem 4rem; }

.grid-2 {
  display: grid;
  grid-template-columns: 340px minmax(0, 1fr);
  gap: 1.25rem;
  align-items: start;
}
.grid-2 > * { min-width: 0; }
@media (max-width: 900px) { .grid-2 { grid-template-columns: 1fr; } }

/* ----------------------------------------------------------------- card */

.card {
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 1.25rem;
  box-shadow: var(--shadow-sm);
}

a.card {
  display: block;
  color: inherit;
  transition: transform var(--duration) var(--ease),
              border-color var(--duration) var(--ease),
              box-shadow var(--duration) var(--ease);
}
a.card:hover {
  transform: translateY(-2px);
  border-color: var(--border-strong);
  box-shadow: var(--shadow);
}

/* --------------------------------------------------------------- inputs */

label {
  display: block;
  font-size: 0.8125rem;
  font-weight: 500;
  color: var(--foreground);
  margin: 1rem 0 0.4rem;
}

input[type="text"],
input[type="email"],
input[type="password"],
input[type="tel"],
input[type="search"],
input[type="number"],
select,
input[type="file"] {
  width: 100%;
  padding: 0.5rem 0.7rem;
  background: var(--input);
  color: var(--foreground);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  font: inherit;
  font-size: 0.875rem;
  transition: border-color var(--duration) var(--ease), box-shadow var(--duration) var(--ease);
}

input::placeholder { color: var(--placeholder); }

input[type="text"]:focus-visible,
input[type="email"]:focus-visible,
input[type="password"]:focus-visible,
input[type="tel"]:focus-visible,
input[type="search"]:focus-visible,
input[type="number"]:focus-visible,
select:focus-visible,
input[type="file"]:focus-visible {
  outline: none;
  border-color: var(--ring);
  box-shadow: var(--focus-ring);
}

select {
  appearance: none;
  background-image: linear-gradient(45deg, transparent 50%, var(--muted-foreground) 50%),
                    linear-gradient(135deg, var(--muted-foreground) 50%, transparent 50%);
  background-position: calc(100% - 16px) center, calc(100% - 11px) center;
  background-size: 5px 5px, 5px 5px;
  background-repeat: no-repeat;
  padding-right: 2rem;
}

input[type="range"] {
  accent-color: var(--primary);
  background: transparent;
  border: 0;
  padding: 0;
}

input[type="checkbox"] {
  width: auto;
  accent-color: var(--primary);
  margin-right: 0.5rem;
  vertical-align: -2px;
}

label:has(input[type="checkbox"]) { font-weight: 400; }

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

button, .btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.4rem;
  padding: 0.45rem 0.9rem;
  border-radius: var(--radius-sm);
  border: 1px solid var(--border);
  background: var(--secondary);
  color: var(--secondary-foreground);
  font: inherit;
  font-size: 0.875rem;
  font-weight: 500;
  cursor: pointer;
  white-space: nowrap;
  transition: background var(--duration) var(--ease), border-color var(--duration) var(--ease),
              transform var(--duration) var(--ease), opacity var(--duration) var(--ease);
}

/* What `hidden` means for a button, stated where the display is given.
 *
 * The rule above is a type-plus-class selector and the user agent's is a bare
 * `[hidden] { display: none }`, so without this every `<button hidden>` in the shop is
 * painted anyway. It reached the customer twice. "Re-generate Blueprint" stood on the
 * bespoke product page before anybody had uploaded a photo, offering to redo something
 * that had never been done; and `camera.js` ships its button hidden and reveals it only once
 * `navigator.mediaDevices` is known to exist, so a browser with no camera was being offered
 * a control that could not work.
 *
 * Note where this is, because that is the point. The same defect had already been patched
 * three times on individual elements — `.artwork-chosen`, `.camera-in-stage`,
 * `.stage-actions` — and the comment beside the second states the general rule. Three
 * patches for one cause is a defect being treated as three coincidences. This is the
 * declaration that creates the problem, so this is where it is answered. */
button[hidden], .btn[hidden] { display: none; }

button:hover, .btn:hover { background: var(--accent); border-color: var(--border-strong); }
button:active, .btn:active { transform: scale(0.985); }
button:focus-visible, .btn:focus-visible {
  outline: none;
  box-shadow: var(--focus-ring);
}

button.primary, .btn.primary {
  background: var(--primary);
  border-color: var(--primary);
  color: var(--primary-foreground);
  font-weight: 600;
}
button.primary:hover, .btn.primary:hover {
  background: var(--primary-hover);
  border-color: var(--primary-hover);
  color: var(--primary-foreground);
}
button.primary:disabled { opacity: 0.45; cursor: not-allowed; transform: none; }

/* The commerce variant. Same anatomy as .primary, different meaning: see --commerce. */
button.commerce, .btn.commerce {
  background: var(--commerce);
  border-color: var(--commerce);
  color: var(--commerce-foreground);
  font-weight: 600;
}
button.commerce:hover, .btn.commerce:hover {
  background: var(--commerce-hover);
  border-color: var(--commerce-hover);
  color: var(--commerce-foreground);
}
button.commerce:disabled { opacity: 0.45; cursor: not-allowed; transform: none; }

/* An icon inside a button sits on the text baseline optically rather than mathematically,
 * and never grows with a long label. */
.btn-icon {
  width: 1.05em;
  height: 1.05em;
  flex: none;
  margin-top: -0.05em;
}

/* A call to action carries more weight than a toolbar control, so it gets more of
 * everything: padding, type size, and the larger corner radius. */
.btn.lg {
  padding: 0.65rem 1.3rem;
  font-size: 0.9375rem;
  border-radius: var(--radius);
}

.actions {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  flex-wrap: wrap;
  margin-top: 1.15rem;
}

/* Segmented control, Radix toggle-group anatomy. */
.seg {
  display: inline-flex;
  padding: 2px;
  gap: 2px;
  background: var(--muted);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
}
.seg button {
  border: 0;
  background: transparent;
  color: var(--muted-foreground);
  padding: 0.3rem 0.75rem;
  border-radius: calc(var(--radius-sm) - 3px);
}
.seg button:hover { background: transparent; color: var(--foreground); }
.seg button.on {
  background: var(--popover);
  color: var(--foreground);
  box-shadow: var(--shadow-sm);
}

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

table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }

th, td {
  text-align: left;
  padding: 0.6rem 0.7rem;
  border-bottom: 1px solid var(--border);
  white-space: nowrap;
}

th {
  color: var(--muted-foreground);
  font-size: 0.6875rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
}

tbody tr { transition: background var(--duration) var(--ease); }
tbody tr:hover { background: var(--muted); }
tbody tr:last-child td { border-bottom: 0; }

/* ---------------------------------------------------------------- badge */

.pill {
  display: inline-flex;
  align-items: center;
  padding: 0.1rem 0.5rem;
  border-radius: 999px;
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: 0.01em;
  border: 1px solid transparent;
}

/* A tone is named once, then the tint, the border and the text are all mixed from it.
 * Four near-identical rules with hand-picked alphas is how one status ends up looking
 * heavier than the others for no reason. */
.pill.ok   { --tone: var(--success); }
.pill.warn { --tone: var(--warning); }
.pill.bad  { --tone: var(--destructive); }
.pill.run  { --tone: var(--primary); }

.pill.ok, .pill.warn, .pill.bad, .pill.run {
  color: var(--tone);
  background: color-mix(in srgb, var(--tone) 10%, transparent);
  border-color: color-mix(in srgb, var(--tone) 22%, transparent);
}

/* ---------------------------------------------------------------- alert */

.banner {
  padding: 0.85rem 1rem;
  border-radius: var(--radius);
  margin-bottom: 1.25rem;
  border: 1px solid;
  font-size: 0.875rem;
}
.banner.bad  { --tone: var(--destructive); }
.banner.warn { --tone: var(--warning); }

.banner.bad, .banner.warn {
  background: color-mix(in srgb, var(--tone) 7%, transparent);
  border-color: color-mix(in srgb, var(--tone) 25%, transparent);
  /* The text sits on its own tint, so it is darkened rather than left at tone strength. */
  color: color-mix(in oklab, var(--tone), var(--foreground) 45%);
}

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

.viewer {
  width: 100%;
  aspect-ratio: 16 / 10;
  max-height: 62svh;
  background:
    radial-gradient(120% 100% at 50% 0%, var(--stage-near) 0%, var(--stage-far) 75%);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
  position: relative;
}
.viewer canvas { display: block; max-width: 100%; max-height: 100%; }

.viewer-bar {
  display: flex;
  gap: 0.5rem;
  flex-wrap: wrap;
  align-items: center;
  margin-top: 0.75rem;
}
.viewer-bar .spacer { flex: 1; }

.viewer .placeholder {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-direction: column;
  gap: 0.75rem;
  color: var(--muted-foreground);
  font-size: 0.875rem;
  text-align: center;
  padding: 1.5rem;
}

#cut-controls { display: none; gap: 0.4rem; align-items: center; }
#cut-controls select, #cut-controls input[type="range"] { padding: 0.25rem 0.4rem; }

/* -------------------------------------------------------------- process */

.stages { list-style: none; padding: 0; margin: 0; font-size: 0.875rem; }
.stages li {
  display: flex;
  align-items: baseline;
  gap: 0.6rem;
  padding: 0.45rem 0;
  border-bottom: 1px solid var(--border);
}
.stages li:last-child { border-bottom: 0; }
.stages .nm { min-width: 6.5rem; }
.stages .du {
  color: var(--muted-foreground);
  font-size: 0.75rem;
  margin-left: auto;
  font-variant-numeric: tabular-nums;
}
.stages .wr {
  color: var(--warning);
  font-size: 0.78rem;
  padding: 0 0 0.5rem 7.1rem;
  border-bottom: 1px solid var(--border);
}
.stages li.now { color: var(--primary); }
.stages li.todo { color: var(--muted-foreground); opacity: 0.5; }

dl.kv {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 0.45rem 1rem;
  margin: 0;
  font-size: 0.875rem;
}
dl.kv dt { color: var(--muted-foreground); }
dl.kv dd { margin: 0; font-variant-numeric: tabular-nums; }

.progress {
  display: flex;
  flex-direction: column;
  gap: 0.65rem;
  padding: 1rem 1.1rem;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--card);
  margin-bottom: 1.25rem;
  box-shadow: var(--shadow-sm);
}
.progress .top { display: flex; align-items: center; gap: 0.75rem; }
.progress .what { font-weight: 600; }
.progress .note { color: var(--muted-foreground); font-size: 0.84rem; }
.progress .clock {
  margin-left: auto;
  color: var(--muted-foreground);
  font-size: 0.8125rem;
  font-variant-numeric: tabular-nums;
}

.bar { height: 5px; border-radius: 999px; background: var(--muted); overflow: hidden; }
.bar > i {
  display: block;
  height: 100%;
  background: var(--primary);
  border-radius: 999px;
  transition: width 400ms var(--ease);
}
.bar.indeterminate > i { width: 35%; animation: slide 1.4s var(--ease) infinite; }
@keyframes slide { 0% { margin-left: -35%; } 100% { margin-left: 100%; } }

.spinner {
  width: 15px;
  height: 15px;
  flex: 0 0 auto;
  border-radius: 50%;
  border: 2px solid var(--border);
  border-top-color: var(--primary);
  animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

/* ------------------------------------------------------------- readouts */

ul.advice { list-style: none; padding: 0; margin: 0; font-size: 0.875rem; }
ul.advice li {
  display: flex;
  gap: 0.65rem;
  align-items: flex-start;
  padding: 0.7rem 0;
  border-bottom: 1px solid var(--border);
}
ul.advice li:last-child { border-bottom: 0; }
ul.advice .pill { flex: 0 0 auto; margin-top: 1px; }
ul.advice .note { color: var(--muted-foreground); font-size: 0.8125rem; }

.sections { display: flex; flex-wrap: wrap; gap: 0.9rem; }
.sections figure { margin: 0; text-align: center; }
.sections svg {
  background: var(--muted);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  display: block;
}
.sections figcaption {
  color: var(--muted-foreground);
  font-size: 0.72rem;
  margin-top: 0.4rem;
  line-height: 1.45;
}

.photo {
  width: 100%;
  max-height: 380px;
  object-fit: contain;
  background: var(--muted);
  border-radius: var(--radius-sm);
  border: 1px solid var(--border);
  display: block;
}

.compare-grid {
  display: grid;
  gap: 1rem;
  grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
}
.compare-grid > * { min-width: 0; }

pre.tb {
  background: var(--muted);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  padding: 0.75rem;
  overflow-x: auto;
  font-size: 0.72rem;
  color: var(--destructive);
  margin: 0;
}

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

details {
  margin-top: 1.1rem;
  border-top: 1px solid var(--border);
  padding-top: 0.9rem;
}
details summary {
  cursor: pointer;
  font-size: 0.6875rem;
  color: var(--muted-foreground);
  text-transform: uppercase;
  letter-spacing: 0.07em;
  font-weight: 600;
  transition: color var(--duration) var(--ease);
  list-style: none;
}
details summary::-webkit-details-marker { display: none; }
details summary::before {
  content: "›";
  display: inline-block;
  margin-right: 0.4rem;
  transition: transform var(--duration) var(--ease);
}
details[open] summary::before { transform: rotate(90deg); }
details summary:hover { color: var(--foreground); }
details summary:focus-visible { outline: 2px solid var(--ring); outline-offset: 3px; }

/* --------------------------------------------------------------- credit */

.balance {
  font-size: 0.8125rem;
  color: var(--muted-foreground);
  padding: 0.2rem 0.6rem;
  border-radius: 999px;
  border: 1px solid var(--border);
  font-variant-numeric: tabular-nums;
}
.balance.bad {
  color: var(--destructive);
  border-color: color-mix(in srgb, var(--destructive) 35%, transparent);
}

/* --------------------------------------------------------------------- shop
 *
 * Same tokens, different density. The workshop is a dense instrument panel and stays
 * compact; the shop shows one object at a time and is allowed the room to do it. Nothing
 * here introduces a color — only scale, measure and air. */

.shop main {
  max-width: 1040px;
  padding: 3.5rem 1.75rem 6rem;
}

.shop h1 {
  font-size: clamp(2rem, 4vw, 2.75rem);
  line-height: 1.1;
  letter-spacing: -0.03em;
  margin-bottom: 0.6rem;
}

.shop .sub {
  font-size: 1.0625rem;
  max-width: 48ch;
  margin-bottom: 2.75rem;
}

.shop .card {
  padding: 1.75rem;
  border-radius: var(--radius-lg);
}

.shop .compare-grid {
  grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
  gap: 1.25rem;
}

/* A gallery card leads with the name, so it gets body weight rather than the workshop's
 * small-caps section label. */
.shop .compare-grid .card h2 {
  font-size: 1.0625rem;
  font-weight: 600;
  letter-spacing: -0.011em;
  text-transform: none;
  color: var(--foreground);
  margin-bottom: 0.3rem;
}

.shop dl.kv { row-gap: 0.6rem; }

.shop .section-heading {
  margin-top: 3rem;
  margin-bottom: 1rem;
}

.shop .note { margin: 0 0 0.75rem; }
.shop .note:last-child { margin-bottom: 0; }


/* ------------------------------------------------------------------ shop: type */

.shop main { max-width: 1120px; }



.eyebrow {
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--muted-foreground);
  margin: 0 0 0.7rem;
}

/* The one paragraph on a page that is allowed to be larger than body copy. Measure is
 * capped in characters rather than pixels: a lede that runs the full width of a desktop
 * card is unreadable however large the type is. */
.lede {
  font-size: 1.1875rem;
  line-height: 1.5;
  color: var(--muted-foreground);
  max-width: 44ch;
  margin: 0 0 1.75rem;
}

.band { margin-top: 5rem; }
.band > .section-heading:first-child { margin-top: 0; }

/* --------------------------------------------------------------------- shop: hero */

.hero {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 0.95fr);
  gap: 3.5rem;
  align-items: center;
  padding-top: 1.5rem;
}

.hero-copy h1 {
  font-size: clamp(2.25rem, 5vw, 3.5rem);
  line-height: 1.04;
  letter-spacing: -0.035em;
  margin-bottom: 1.1rem;
  max-width: 12ch;
}

.cta-row {
  display: flex;
  gap: 0.65rem;
  flex-wrap: wrap;
  margin-bottom: 1.1rem;
}

.hero-stage { min-width: 0; }

@media (max-width: 880px) {
  .hero { grid-template-columns: 1fr; gap: 2.25rem; }
  .hero-copy h1 { max-width: none; }
}

/* -------------------------------------------------------------------- shop: stage */

/* A figurine is taller than it is wide, so the shop's stage is portrait where the
 * workshop's is landscape. The workshop frames a print bed; the shop frames an object. */
.stage-tall { aspect-ratio: 4 / 5; max-height: 70svh; }

.stage-clean {
  border-color: transparent;
  border-radius: var(--radius-lg);
  background:
    radial-gradient(90% 70% at 50% 18%, var(--card) 0%, var(--stage-far) 100%);
}

.stage-empty {
  display: grid;
  place-items: center;
  padding: 2rem;
}
.stage-empty p {
  margin: 0;
  color: var(--muted-foreground);
  font-size: 0.875rem;
  text-align: center;
  max-width: 22ch;
}

.stage-caption {
  margin: 0.7rem 0 0;
  font-size: 0.8125rem;
  color: var(--muted-foreground);
}

/* -------------------------------------------------------------------- shop: steps */

.steps {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: 2rem;
}

/* Number and heading share a line, and the paragraph hangs under the heading rather than
 * under the digit.
 *
 * The number used to be a large light digit on its own line above the heading. Read across
 * three columns that was fine. Read down a single column, as the stacked layout does, three
 * lone digits with air above and below them made the copy look like an outline of itself:
 * more gap than text, and no line long enough to settle the eye. Inline, each step is a
 * heading and a paragraph, and the digit is an ordinal rather than an event. */
.steps li {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  column-gap: 0.5rem;
  align-items: baseline;
}

.step-index {
  font-size: 1.0625rem;
  font-weight: 600;
  letter-spacing: -0.012em;
  color: var(--muted-foreground);
  font-variant-numeric: tabular-nums;
}
/* The full stop belongs to the arrangement, not to the content: "1." reads as an ordinal
 * on one line and as a stray dot under a heading. Keeping it out of the markup means the
 * template says what the step is numbered, and the stylesheet says how that is written. */
.step-index::after { content: "."; }

.steps h3 {
  font-size: 1.0625rem;
  font-weight: 600;
  letter-spacing: -0.012em;
  margin: 0 0 0.35rem;
}

.steps p {
  grid-column: 2;
  margin: 0;
  color: var(--muted-foreground);
  font-size: 0.9375rem;
}

/* --------------------------------------------------------------- shop: spec row */

.spec-row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
  gap: 1.75rem 2rem;
  margin: 0;
  padding: 1.75rem 0 0;
  border-top: 1px solid var(--border);
}
.spec-row > div { min-width: 0; }
.spec-row dt {
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--muted-foreground);
  margin-bottom: 0.35rem;
}
.spec-row dd {
  margin: 0;
  font-size: 1.0625rem;
  letter-spacing: -0.011em;
}

/* ------------------------------------------------------------------ shop: feature */

.feature {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 0.85fr);
  gap: 3rem;
  align-items: center;
  margin-top: 5rem;
  padding: 2.5rem;
  background: var(--muted);
  border-radius: var(--radius-lg);
}
.feature h2 {
  font-size: 1.6rem;
  font-weight: 600;
  letter-spacing: -0.022em;
  text-transform: none;
  color: var(--foreground);
  margin-bottom: 0.7rem;
}
.feature p { color: var(--muted-foreground); max-width: 46ch; }

.feature-swatches { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; }

.swatch {
  aspect-ratio: 3 / 4;
  border-radius: var(--radius);
  display: flex;
  align-items: flex-end;
  padding: 0.7rem;
  box-shadow: var(--shadow-sm);
}
.swatch span {
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.01em;
  color: hsl(0 0% 100% / 0.92);
}
.swatch-dark  { background: linear-gradient(150deg, var(--cocoa-dark-lit), var(--cocoa-dark)); }
.swatch-milk  { background: linear-gradient(150deg, var(--cocoa-milk-lit), var(--cocoa-milk)); }
.swatch-white { background: linear-gradient(150deg, var(--cocoa-white-lit), var(--cocoa-white)); }
.swatch-white span { color: hsl(28 40% 18% / 0.9); }

@media (max-width: 880px) {
  .feature { grid-template-columns: 1fr; gap: 2rem; padding: 1.75rem; }
}

/* ------------------------------------------------------------------ shop: product */

.product {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 380px;
  gap: 3rem;
  align-items: start;
}

/* The model is the thing being sold, so it stays on screen while the options below it
 * are read. */
/* Sticky, so the thing being sold stays on screen while the options below it are read -
 * and a positioned ancestor, which is what the text editor hangs off when it cannot fit
 * inside the picture. `z-index` so it does, over the thumbnail strip and the caption. */
.product-stage { position: sticky; top: 5.5rem; z-index: 1; }
.product-panel { min-width: 0; }

.product-panel h1 {
  font-size: clamp(1.9rem, 3.2vw, 2.4rem);
  letter-spacing: -0.03em;
  margin-bottom: 0.7rem;
}
.product-panel .lede { font-size: 1.0625rem; margin-bottom: 1.5rem; }

/* The price is the second thing read on this page after the title, so it is sized like it.
 * Tabular figures because the number changes as the options change and digits that shift
 * width make a static price look like it is animating. */
.price {
  font-size: 2.25rem;
  font-weight: 600;
  letter-spacing: -0.03em;
  font-variant-numeric: tabular-nums;
  margin: 0 0 0.35rem;
  /* Right-aligned, with the shipping line under it, so the two read as one block against
   * the option groups below rather than as two more left-aligned paragraphs. */
  text-align: right;
}

.price-note {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 0.4rem;
  margin: 0 0 1.75rem;
  font-size: 0.9375rem;
  font-weight: 500;
  /* --success, not --commerce: green means "this control spends money" and a delivery note
   * is not a control. The same green, said honestly. */
  color: var(--success);
}

/* No delivery line under the price, because the operator switched it off at /admin/rules.
 * The space before the option groups lives on `.price-note` above, so the price has to take
 * it over — otherwise removing one line leaves the chips 0.35rem under the number and the
 * page reads as broken rather than as one line shorter. Matches `.price-note`'s margin
 * exactly, so the options sit in the same place either way and only the line goes. */
.price-alone {
  margin-bottom: 1.75rem;
}

@media (max-width: 940px) {
  .product { grid-template-columns: 1fr; gap: 2.25rem; }
  /* `relative` rather than `static`: it lays out identically, and it keeps the column a
   * containing block for the text editor that hangs off it. Static here put the pane's
   * coordinates against the body - correct arithmetic measured from the column, applied from
   * the page - so on every phone it landed back on top of the design it was avoiding. */
  .product-stage { position: relative; top: auto; }
}

/* -------------------------------------------------------------------- shop: chips */

.option-group { margin-bottom: 1.75rem; }

.option-title {
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--muted-foreground);
  margin: 0 0 0.6rem;
}

.chips { display: grid; gap: 0.5rem; }
.chips-inline { grid-auto-flow: column; grid-auto-columns: 1fr; }

/* Radix radio-group anatomy: the input stays in the accessibility tree and keeps its
 * keyboard behaviour, and the label it sits inside carries the whole visual state. */
.chip {
  position: relative;
  display: flex;
  align-items: center;
  gap: 0.7rem;
  margin: 0;
  padding: 0.7rem 0.85rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--card);
  cursor: pointer;
  font-weight: 400;
  transition: border-color var(--duration) var(--ease), background var(--duration) var(--ease);
}
.chip input {
  position: absolute;
  opacity: 0;
  width: 0;
  height: 0;
  margin: 0;
}
.chip:hover { border-color: var(--border-strong); }
.chip:has(:checked) {
  border-color: var(--primary);
  background: color-mix(in srgb, var(--primary) 5%, transparent);
}
.chip:has(:focus-visible) { box-shadow: var(--focus-ring); }

.chip-body { display: flex; flex-direction: column; gap: 0.1rem; min-width: 0; flex: 1; }
.chip-label { font-size: 0.9375rem; font-weight: 500; }
.chip-meta { font-size: 0.8125rem; color: var(--muted-foreground); }
.chip-price {
  font-size: 0.875rem;
  font-weight: 500;
  color: var(--muted-foreground);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.chip:has(:checked) .chip-price { color: var(--foreground); }

.chip-swatch { justify-content: flex-start; padding: 0.6rem 0.7rem; }
.dot {
  width: 1rem;
  height: 1rem;
  border-radius: 999px;
  flex: none;
  box-shadow: inset 0 0 0 1px hsl(0 0% 0% / 0.12);
}
.dot-dark  { background: var(--cocoa-dark); }
.dot-milk  { background: var(--cocoa-milk); }
.dot-white { background: var(--cocoa-white); }

/* --------------------------------------------------------------- shop: order note */

.order-note {
  padding: 0.95rem 1.05rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--muted);
  margin-bottom: 1.75rem;
}
.order-note-head {
  margin: 0 0 0.35rem;
  font-size: 0.875rem;
  font-weight: 600;
}
.order-note .hint { margin-top: 0; }

.spec-list { padding-top: 1.25rem; border-top: 1px solid var(--border); }
.spec-list .span-2 { grid-column: 1 / -1; margin-top: -0.2rem; }

/* ---------------------------------------------------------------------- shop: faq */

/* The workshop's disclosure is a small-caps toggle inside a dense panel. A shop FAQ is a
 * list of questions somebody reads, so everything about that treatment has to be undone:
 * sentence case, body size, and the workshop's left-hand chevron removed before this one
 * adds its own on the right. */
.faq details {
  margin-top: 0;
  border-top: 0;
  border-bottom: 1px solid var(--border);
  padding: 0;
}
.faq details:first-of-type { border-top: 1px solid var(--border); }
.faq summary::before { content: none; }

.faq summary {
  color: var(--foreground);
  text-transform: none;
  letter-spacing: -0.011em;
  list-style: none;
  cursor: pointer;
  padding: 1.05rem 2rem 1.05rem 0;
  font-size: 1rem;
  font-weight: 500;
  letter-spacing: -0.011em;
  position: relative;
  transition: color var(--duration) var(--ease);
}
.faq summary::-webkit-details-marker { display: none; }
.faq summary:hover { color: var(--primary); }
.faq summary:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: 4px; }

/* A chevron drawn from two borders, rotating on open. Nothing to load, and it inherits
 * the text color so the hover state comes free. */
.faq summary::after {
  content: "";
  position: absolute;
  right: 0.4rem;
  top: 50%;
  width: 0.5rem;
  height: 0.5rem;
  border-right: 1.5px solid currentColor;
  border-bottom: 1.5px solid currentColor;
  opacity: 0.55;
  transform: translateY(-70%) rotate(45deg);
  transition: transform var(--duration) var(--ease);
}
.faq details[open] summary::after { transform: translateY(-30%) rotate(-135deg); }

.faq details p {
  margin: 0;
  padding: 0 3rem 1.3rem 0;
  color: var(--muted-foreground);
  max-width: 70ch;
}

/* --------------------------------------------------------------------- shop: foot */

/* Tightened on 2026-08-25. The footer was 662px tall on the home page — taller than some
   of the sections above it — because the brand block sat full width under the columns and
   every band carried its own generous padding. The brand now sits beside the columns and
   the padding is proportionate to what it separates. */
.site-foot {
  border-top: 1px solid var(--border);
  margin-top: 3.5rem;
  padding: 2rem 1.75rem 1.75rem;
}
.foot-inner { max-width: 1120px; margin: 0 auto; }
.foot-brand {
  margin: 0 0 0.4rem;
  font-weight: 600;
  letter-spacing: -0.015em;
}
.foot-inner .hint { margin-top: 0; max-width: 54ch; }

/* ================================================================== shop: commerce
 *
 * Basket, checkout, accounts and orders. Same tokens throughout; the only new color is
 * the set of order statuses, and those are mixed from the existing semantic tokens with
 * the --tone recipe the pills already use. */

/* ------------------------------------------------------------------- header basket */

/* `.has-items` no longer changes the color: the control is a filled green button whether
 * it holds anything or not, and the count badge is what says it does. Kept as a hook
 * because the template still sets it and something may want it again. */
.cart-link { font-variant-numeric: tabular-nums; }

.cart-count {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 1.25rem;
  height: 1.25rem;
  padding: 0 0.32rem;
  margin-left: 0.15rem;
  border-radius: 999px;
  background: var(--primary);
  color: var(--primary-foreground);
  font-size: 0.6875rem;
  font-weight: 600;
}

/* ------------------------------------------------------------------------- flashes */

.flashes {
  max-width: 1120px;
  margin: 1.25rem auto -1.5rem;
  padding: 0 1.75rem;
  display: grid;
  gap: 0.5rem;
}

.flash {
  --tone: var(--success);
  margin: 0;
  padding: 0.7rem 0.95rem;
  border-radius: var(--radius-sm);
  border: 1px solid color-mix(in srgb, var(--tone) 25%, transparent);
  background: color-mix(in srgb, var(--tone) 8%, transparent);
  color: color-mix(in oklab, var(--tone), var(--foreground) 40%);
  font-size: 0.875rem;
}
.flash-bad { --tone: var(--destructive); }
.flash-warn { --tone: var(--warning); }

/* --------------------------------------------------------------------- form basics */

/* A button that reads as a link. Used where the action is destructive-ish or secondary
 * and a real button would shout: "Remove", "Sign out". */
.linkish {
  border: 0;
  background: transparent;
  padding: 0;
  color: var(--muted-foreground);
  font-size: 0.8125rem;
  text-decoration: underline;
  text-underline-offset: 2px;
  cursor: pointer;
}
.linkish:hover { background: transparent; color: var(--foreground); }
.linkish:focus-visible { box-shadow: var(--focus-ring); border-radius: 3px; }

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.narrow { max-width: 25rem; margin: 0 auto; }
.narrow-wide { max-width: 42rem; }

.stack { display: grid; gap: 0.25rem; }
.stack > .card { margin-bottom: 1rem; }
/* Child, not descendant: as a descendant this also caught a submit button sitting in an
 * .actions row and pushed it out of line with the link beside it. */
.stack > button[type="submit"] { margin-top: 1rem; }

button.full, .btn.full { width: 100%; margin-top: 0.5rem; }

.field-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0 0.75rem;
}
.field-grid .span-2 { grid-column: 1 / -1; }
@media (max-width: 520px) { .field-grid { grid-template-columns: 1fr; } }

.optional {
  font-weight: 400;
  color: var(--muted-foreground);
  font-size: 0.75rem;
}

/* The checkbox and its words are one target, and the box does not stretch to the row. */
.check {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  margin: 0.4rem 0 0.9rem;
  font-size: 0.9375rem;
  font-weight: 400;
  cursor: pointer;
}
.check input { width: auto; flex: none; margin: 0; }
.check.standalone { margin: 0 0 1rem; }

.field-error {
  margin: 0 0 1rem;
  padding: 0.7rem 0.9rem;
  border-radius: var(--radius-sm);
  border: 1px solid color-mix(in srgb, var(--destructive) 28%, transparent);
  background: color-mix(in srgb, var(--destructive) 7%, transparent);
  color: color-mix(in oklab, var(--destructive), var(--foreground) 35%);
  font-size: 0.875rem;
}

/* Wherever the fact that payment is fake needs saying, it is said the same way. It is
 * marked out rather than blended in on purpose: nobody should reach the end of a checkout
 * unsure whether they were charged. */
.mock-banner {
  padding: 0.85rem 1rem;
  border: 1px dashed color-mix(in srgb, var(--warning) 45%, transparent);
  background: color-mix(in srgb, var(--warning) 7%, transparent);
  border-radius: var(--radius-sm);
  margin-bottom: 1.25rem;
}
.mock-banner-head {
  margin: 0 0 0.3rem;
  font-size: 0.8125rem;
  font-weight: 600;
  color: color-mix(in oklab, var(--warning), var(--foreground) 30%);
}
.mock-banner .hint { margin-top: 0; }

/* Checking out as a guest, and the offer of an account.
 *
 * Deliberately not a card and not a warning. It is an aside on the page's own ground, set
 * off by a single rule down its left edge, because it is telling somebody what is already
 * true rather than asking them to do anything. An account is offered here, so the offer
 * must not look more urgent than the checkout it sits above. */
.guest-band {
  border-left: 2px solid var(--border);
  padding: 0.1rem 0 0.1rem 0.9rem;
  margin: 0 0 1.75rem;
  max-width: 62ch;
}
.guest-band.spaced { margin: 2rem 0 1.5rem; }
.guest-band-line {
  margin: 0;
  font-size: 0.9375rem;
  font-weight: 600;
  letter-spacing: -0.01em;
}
.guest-band-hint {
  margin: 0.2rem 0 0;
  font-size: 0.8125rem;
  line-height: 1.55;
  color: var(--muted-foreground);
}

/* The payment notice that used to be here was removed on 2026-08-25 at the operator's
 * instruction, and its rules went with it rather than being left to rot. What it looked like
 * is in History.md if it is ever wanted back: a `--muted` panel, heavier first line, muted
 * copy under it, and the test cards in a `details`. */

/* ------------------------------------------------------------------- the card fields */

/* The brand sits inside the number field, right-aligned, the way it does on a real one.
 * It is a read-out and never a control, so it takes no pointer events and the whole box
 * stays clickable through it. */
.card-number-field { position: relative; display: block; }
.card-number-field input { padding-right: 8.5rem; }
.card-brand {
  position: absolute;
  right: 0.85rem;
  top: 50%;
  transform: translateY(-50%);
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.01em;
  color: var(--muted-foreground);
  pointer-events: none;
  white-space: nowrap;
}

/* Two inputs drawn as one control. The border and the ring live on the wrapper so that
 * focusing either half lights the whole box, which is what makes it read as a single
 * field rather than as two that happen to be adjacent. */
.expiry-field {
  display: flex;
  align-items: center;
  gap: 0.15rem;
  padding: 0 0.85rem;
  border: 1px solid var(--input);
  border-radius: var(--radius-sm);
  background: var(--card);
  transition: border-color var(--duration) var(--ease), box-shadow var(--duration) var(--ease);
}
.expiry-field:focus-within {
  border-color: var(--ring);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--ring) 18%, transparent);
}
.expiry-field input {
  border: 0;
  background: none;
  padding-left: 0;
  padding-right: 0;
  box-shadow: none;
}
.expiry-field input:focus { box-shadow: none; outline: none; }
.expiry-field input[name="card_exp_month"] { width: 2.5ch; }
.expiry-field input[name="card_exp_year"] { width: 5ch; }
.expiry-slash { color: var(--muted-foreground); }

/* -------------------------------------------------------------------------- basket */

.basket {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 320px;
  gap: 2.5rem;
  align-items: start;
}
@media (max-width: 900px) { .basket { grid-template-columns: 1fr; gap: 1.75rem; } }

.basket-lines { display: grid; }

.basket-line {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto 6rem auto;
  align-items: center;
  gap: 1.25rem;
  padding: 1.25rem 0;
  border-bottom: 1px solid var(--border);
}
.basket-line:first-child { border-top: 1px solid var(--border); }

.basket-line-main { min-width: 0; display: flex; gap: 0.75rem; align-items: flex-start; }
.basket-line-words { min-width: 0; }
/* A line's artwork on an order, a receipt or the workshop's copy. Small: it is a reminder
 * of what was chosen, and the workshop opens the full file by clicking it. */
.order-artwork {
  width: 3rem;
  height: 3rem;
  margin-top: 0.35rem;
  object-fit: cover;
  border-radius: var(--radius-sm);
  border: 1px solid var(--border);
  background: var(--muted);
}

/* The print file on the workshop's copy of an order: the design as approved, at print
 * resolution, on a transparent ground.
 *
 * Larger than the upload beside it, and `contain` rather than `cover`, because this is the
 * thing being checked before it goes to a printer - a crop of it would hide exactly the edge
 * somebody is looking at. A light chequer behind it, so a transparent ground reads as
 * transparent rather than as white ink. */
.order-print {
  display: inline-block;
  margin-top: 0.35rem;
  text-decoration: none;
}
.order-print img {
  display: block;
  width: 9rem;
  height: 9rem;
  object-fit: contain;
  border-radius: var(--radius-sm);
  border: 1px solid var(--border);
  background-color: var(--card);
  background-image:
    linear-gradient(45deg, var(--muted) 25%, transparent 25%, transparent 75%, var(--muted) 75%),
    linear-gradient(45deg, var(--muted) 25%, transparent 25%, transparent 75%, var(--muted) 75%);
  background-size: 14px 14px;
  background-position: 0 0, 7px 7px;
}
.order-print .hint { display: block; margin: 0.2rem 0 0; }

.basket-line-art {
  width: 3.25rem;
  height: 3.25rem;
  flex: none;
  object-fit: cover;
  border-radius: var(--radius-sm);
  border: 1px solid var(--border);
  background: var(--muted);
}

/* The design on the garment rather than the picture on its own, since 2026-08-31.
 *
 * `contain`, not the `cover` above it: `cover` crops to fill, which is right for a photograph
 * whose subject is in the middle and wrong for a picture of a t-shirt - the sleeves are the
 * part that says what the thing is. Every blank in this shop is square and so is the frame,
 * so nothing is letterboxed today; the rule is here for the first one that is not.
 *
 * **And it is larger than the photo it replaced.** 3.25rem is enough for a crop of a
 * photograph, whose subject fills the frame. It is not enough for a whole garment: the print
 * is a fraction of the shirt and the shirt is a fraction of the picture, so what somebody is
 * actually checking ends up a few pixels across - a white tee at 52px on a pale ground was an
 * empty box when this was first looked at. The cart line is full width on a phone and the
 * room is there. */
.basket-line-art.on-product,
.order-artwork.on-product {
  object-fit: contain;
  background: var(--card);
}
.basket-line-art.on-product {
  width: 5.5rem;
  height: 5.5rem;
}
.basket-line-title { margin: 0 0 0.2rem; font-weight: 500; }
/* The link to the product, since 2026-08-25. Set in the foreground rather than the link blue:
 * a column of bright blue titles is the loudest thing on a page whose subject is the things
 * themselves, and blue here would be spent on navigation nobody is looking for. It earns its
 * underline on hover, which is where somebody who wants it will be. */
.basket-line-title a {
  color: inherit;
  text-decoration: none;
}
.basket-line-title a:hover {
  color: var(--primary);
  text-decoration: underline;
  text-underline-offset: 2px;
}
.basket-line-title a:focus-visible {
  outline: 2px solid var(--ring);
  outline-offset: 3px;
  border-radius: var(--radius-sm);
}
.basket-line-main .hint { margin-top: 0; }

.qty-form { display: flex; gap: 0.4rem; align-items: center; }
/* Typed selector on purpose: input[type="number"] sets width:100% and outranks a bare
 * class, which stretched the quantity box across the whole row on a phone. */
input[type="number"].qty {
  width: 4.25rem;
  text-align: center;
  font-variant-numeric: tabular-nums;
}
.qty-form { justify-self: start; }

.basket-line-price {
  margin: 0;
  text-align: right;
  font-weight: 500;
  font-variant-numeric: tabular-nums;
}

@media (max-width: 640px) {
  .basket-line { grid-template-columns: 1fr auto; gap: 0.75rem 1rem; }
  .basket-line-main { grid-column: 1 / -1; }
  .basket-line-price { text-align: left; }
}

.basket-summary { position: sticky; top: 5.5rem; }
@media (max-width: 900px) { .basket-summary { position: static; } }

/* -------------------------------------------------------------------------- totals */

.totals {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 0.5rem 1rem;
  margin: 0 0 1.25rem;
  padding-top: 1rem;
  border-top: 1px solid var(--border);
  font-size: 0.9375rem;
}
.totals dt { color: var(--muted-foreground); }
.totals dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; }
.totals .grand {
  padding-top: 0.6rem;
  border-top: 1px solid var(--border);
  font-weight: 600;
  font-size: 1.0625rem;
  color: var(--foreground);
}

.mini-lines {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 0.6rem;
  font-size: 0.875rem;
}
.mini-lines li {
  display: flex;
  justify-content: space-between;
  gap: 1rem;
  align-items: baseline;
}
.mini-lines li > span:first-child { color: var(--muted-foreground); }

/* ------------------------------------------------------------------------ checkout */

.checkout {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 320px;
  gap: 2.5rem;
  align-items: start;
}
.checkout-forms { display: grid; }
.checkout-forms .card { margin-bottom: 1.25rem; }
.checkout-summary { position: sticky; top: 5.5rem; }

@media (max-width: 900px) {
  .checkout { grid-template-columns: 1fr; gap: 1.75rem; }
  .checkout-summary { position: static; }
}

/* ------------------------------------------------------------------------- account */

.account-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  gap: 1.25rem;
  align-items: start;
}

.address {
  margin: 0 0 1.1rem;
  font-size: 0.9375rem;
  line-height: 1.65;
  color: var(--muted-foreground);
}

h2.spaced { margin-top: 1.75rem; }

.foot-signout { margin-top: 1rem; }

/* -------------------------------------------------------------------------- orders */

.order-rows { display: grid; }

.order-row {
  display: grid;
  grid-template-columns: 9rem 7rem minmax(0, 1fr) auto 5.5rem;
  align-items: center;
  gap: 1rem;
  padding: 0.95rem 0.25rem;
  border-bottom: 1px solid var(--border);
  color: inherit;
  transition: background var(--duration) var(--ease);
}
.order-row:first-child { border-top: 1px solid var(--border); }
.order-row:hover { background: var(--muted); color: inherit; }
.order-row:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: 4px; }

.order-number { font-size: 0.8125rem; font-weight: 500; }
.order-when, .order-items { font-size: 0.875rem; color: var(--muted-foreground); }
.order-total { text-align: right; font-weight: 500; font-variant-numeric: tabular-nums; }

@media (max-width: 720px) {
  .order-row { grid-template-columns: 1fr auto; gap: 0.4rem 1rem; }
  .order-when, .order-items { grid-column: 1; }
  .order-total { grid-column: 2; grid-row: 1; }
}

.order-status { justify-self: start; }
.status-placed        { --tone: var(--primary); }
.status-in_production { --tone: var(--warning); }
.status-shipped       { --tone: var(--primary); }
.status-delivered     { --tone: var(--success); }
.status-cancelled     { --tone: var(--muted-foreground); }

.order-status {
  color: var(--tone);
  background: color-mix(in srgb, var(--tone) 10%, transparent);
  border-color: color-mix(in srgb, var(--tone) 22%, transparent);
}

.order-detail {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 0.85fr);
  gap: 1.5rem;
  align-items: start;
}
@media (max-width: 860px) { .order-detail { grid-template-columns: 1fr; } }

/* Money is body text with tabular figures, never the mono face: the mono face is for
 * identifiers where character-by-character comparison matters, and using it for prices
 * makes them read as machine output. */
.money { font-variant-numeric: tabular-nums; white-space: nowrap; }

/* A nav that fits a phone.
 *
 * The header was written for a desktop toolbar: one row, generous gaps, and links that
 * were free to wrap. On a 390 px screen that broke "The figurine" across two lines and
 * pushed the basket off the right edge, which is the one control that must never be
 * unreachable. */
header nav a { white-space: nowrap; }

@media (max-width: 640px) {
  header {
    flex-wrap: wrap;
    gap: 0.5rem 0.75rem;
    padding: 0.7rem 1.1rem;
  }
  /* Four links on the shop spread across the row; seven in the workshop do not fit at
   * all, and wrapping them clipped the last one off the edge. Scrolling handles both:
   * space-between spreads a short nav, and a long one slides. */
  header nav {
    width: 100%;
    margin-left: 0;
    gap: 0.1rem;
    justify-content: space-between;
    flex-wrap: nowrap;
    overflow-x: auto;
    scrollbar-width: none;
  }
  header nav::-webkit-scrollbar { display: none; }
  header nav a { padding: 0.3rem 0.4rem; font-size: 0.8125rem; }

  .shop main { padding: 2.25rem 1.1rem 4rem; }
  .flashes { padding: 0 1.1rem; }
  .site-foot { padding: 2rem 1.1rem 2.5rem; }
}

/* The product description is the only long-form field in the project, and textarea was
 * never in the input selector list either. */
textarea {
  width: 100%;
  padding: 0.5rem 0.7rem;
  background: var(--input);
  color: var(--foreground);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  font: inherit;
  font-size: 0.875rem;
  line-height: 1.55;
  resize: vertical;
  transition: border-color var(--duration) var(--ease), box-shadow var(--duration) var(--ease);
}
textarea:focus-visible {
  outline: none;
  border-color: var(--ring);
  box-shadow: var(--focus-ring);
}

/* ------------------------------------------------------------------ shop: catalogue */

.product-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
  gap: 1.5rem;
}

.product-card {
  display: flex;
  flex-direction: column;
  color: inherit;
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  background: var(--card);
  overflow: hidden;
  box-shadow: var(--shadow-sm);
  transition: transform var(--duration) var(--ease),
              border-color var(--duration) var(--ease),
              box-shadow var(--duration) var(--ease);
}
.product-card:hover {
  transform: translateY(-2px);
  border-color: var(--border-strong);
  box-shadow: var(--shadow);
  color: inherit;
}
.product-card:focus-visible { outline: none; box-shadow: var(--focus-ring); }

/* The 3D canvas mounts straight into this, so it needs a size before the model loads or
 * the card would jump when the figurine appears. */
.product-card-stage {
  display: block;
  position: relative;
  aspect-ratio: 4 / 3;
  background: radial-gradient(90% 70% at 50% 15%, var(--card) 0%, var(--stage-far) 100%);
}
/* The viewer writes an explicit pixel size onto its canvas as an inline style — that is
 * deliberate and load-bearing, since letting it size itself once caused a canvas to grow
 * without limit. Inside a card the card decides the size, so the inline value has to be
 * overridden, and only !important can beat an inline style. */
.product-card-stage canvas {
  position: absolute;
  inset: 0;
  width: 100% !important;
  height: 100% !important;
  display: block;
}

/* Flex items refuse to shrink below their content by default, so a wide canvas or image
 * would drag the card past the width the carousel gave it. */
.product-card,
.carousel-item { min-width: 0; }

/* Without this the photo renders at its natural size and drags the card past the
 * width the grid or the carousel gave it. */
.product-card-stage img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.product-card-empty {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  font-size: 0.8125rem;
  color: var(--muted-foreground);
}

.product-card-body {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  padding: 1.1rem 1.25rem 1.25rem;
}
.product-card.compact .product-card-body { padding: 1.25rem; }

.product-card-title {
  font-size: 1.0625rem;
  font-weight: 600;
  letter-spacing: -0.012em;
}
.product-card-tagline {
  font-size: 0.875rem;
  color: var(--muted-foreground);
  line-height: 1.5;
}
.product-card-price {
  margin-top: 0.35rem;
  font-size: 0.875rem;
  font-weight: 500;
  font-variant-numeric: tabular-nums;
}

/* Operator-written copy on the product page. Sits between the buy controls and the
 * specification, so it needs its own rhythm rather than inheriting the panel's. */
.product-copy {
  margin-bottom: 1.5rem;
  color: var(--muted-foreground);
  font-size: 0.9375rem;
}
.product-copy p { margin-bottom: 0.7rem; }
.product-copy p:last-child { margin-bottom: 0; }

/* ------------------------------------------------------------------ admin: traffic */

/* A bar chart drawn with divs. A dozen bars of one color need no charting library, and
 * adding one for this would be the largest dependency in the project.
 *
 * Each column is three rows: the number, the bar's track, the date. Giving the bar its own
 * track is what stops the tallest bar's number being clipped off the top of the card,
 * which is what happened when the number was positioned over the bar instead. */
.bars {
  display: flex;
  align-items: stretch;
  gap: 0.4rem;
  height: 190px;
}

.bar-slot {
  flex: 1;
  min-width: 0;
  display: grid;
  grid-template-rows: 1rem 1fr 1.6rem;
  justify-items: center;
  gap: 0.2rem;
}

.bar-value {
  font-size: 0.6875rem;
  font-variant-numeric: tabular-nums;
  color: var(--muted-foreground);
  line-height: 1;
}

.bar-track {
  width: 100%;
  max-width: 3rem;
  display: flex;
  align-items: flex-end;
  justify-content: center;
}

.bar {
  width: 100%;
  border-radius: var(--radius-sm) var(--radius-sm) 0 0;
  background: color-mix(in srgb, var(--primary) 78%, transparent);
  transition: background var(--duration) var(--ease);
}
.bar-slot:hover .bar { background: var(--primary); }

/* A day with no traffic gets the baseline, not a short bar. A two-pixel stub in the bar
 * color reads as a small amount of something, and a row of them reads as a trend. */
.bar-none {
  width: 100%;
  height: 1px;
  background: var(--border);
}

.bar-label {
  font-size: 0.6875rem;
  color: var(--muted-foreground);
  white-space: nowrap;
  align-self: start;
}

/* Wide windows: the bars are thinner than their labels, so the labels turn. */
.bars-dense { gap: 0.15rem; }
.bars-dense .bar-slot { grid-template-rows: 0 1fr 2.2rem; }
.bars-dense .bar-label {
  transform: rotate(-55deg) translateX(-0.5rem);
  transform-origin: top right;
  font-size: 0.625rem;
}
.bars-dense .bar { border-radius: 2px 2px 0 0; }

/* A list that is a list of things rather than a bulleted argument. */
ul.plain { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.5rem; }
ul.plain li { line-height: 1.5; }

/* The segmented control is a row of links here, not buttons; it needs the same anatomy. */
.seg .btn {
  border: 0;
  background: transparent;
  color: var(--muted-foreground);
  padding: 0.3rem 0.75rem;
  border-radius: calc(var(--radius-sm) - 3px);
  font-size: 0.875rem;
}
.seg .btn:hover { background: transparent; color: var(--foreground); }
.seg .btn.on {
  background: var(--popover);
  color: var(--foreground);
  box-shadow: var(--shadow-sm);
}


/* A wide table inside a card scrolls itself rather than pushing the page sideways. */
.table-scroll { overflow-x: auto; }

/* --------------------------------------------------------------- shop: product media */

/* Photos and the 3D model are slides in one frame. The model stays mounted while a
 * photo is on top: tearing down a WebGL context and rebuilding it on every thumbnail
 * click costs far more than leaving it in the DOM.
 *
 * The frame takes the shape of the photo on top of it. `--stage-ratio` is written into
 * the markup for the first slide, measured from the file, and moved by the script when
 * another is shown. This was a flat `4 / 5` until 2026-08-25 and letterboxed nearly
 * everything: the catalogue is overwhelmingly square, and the bespoke product leads with a
 * 4:3, which left two fifths of the frame as empty gradient above and below the picture.
 *
 * The fallback in the var() is not decoration. The model, the camera and a photo that
 * could not be measured carry no ratio, and `aspect-ratio: auto` would collapse the frame
 * onto its contents — which for an empty viewer div is nothing at all. */
.media-stage {
  position: relative;
  aspect-ratio: var(--stage-ratio, 4 / 5);
  max-height: 70svh;
  border-radius: var(--radius-lg);
  overflow: hidden;
  background: radial-gradient(90% 70% at 50% 18%, var(--card) 0%, var(--stage-far) 100%);
}
/* Deliberately not transitioned. A 160ms ease on `aspect-ratio` was written here first and
 * removed: the design language allows motion on transform and opacity only, and this is the
 * reason for that rule rather than an exception to it. Animating the ratio relayouts the
 * whole column on every frame, and the caption, the thumbnails and the upload panel slide
 * about underneath while it runs. Swapping a photo should be instant. */

/* While the camera is open the frame goes back to the portrait the sequence is built on.
 *
 * The panel is `inset: 0` on this element and the studio that follows it is a fixed 4/5, so
 * a stage wearing a landscape photo would give a portrait camera a landscape box and
 * then jump shape at the moment of capture — which is the reflow `_camera_stage.html` was
 * written to remove. Taking a photo of a person is a portrait act, and the pose advice
 * asks for a full-height frame, so 4/5 is the right shape here rather than a compromise.
 *
 * In CSS with `:has()` rather than by script, because the panel is opened and closed from a
 * shared module that has no business knowing about product-page geometry, and because a
 * pinned ratio set by script is one that can be left behind. A browser without `:has()`
 * simply keeps the photo's shape, which is untidy rather than broken. */
.media-stage:has(> .camera-portrait:not([hidden])) { aspect-ratio: 4 / 5; }

/* **Keyed on `.camera-portrait`, not on every camera in a stage.** The pin is right where a
 * 4/5 studio follows the camera, and wrong everywhere else: on a product page whose stage is
 * already wearing the shape of a photo, re-ratioing it to 4/5 grows the page by the
 * best part of a hundred pixels the moment the feed opens - which is the reflow this whole
 * arrangement exists to remove, in the opposite direction. There the panel simply fills the
 * stage it is in. */

.media-slide {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: none;
  border: 0;
  border-radius: 0;
  max-height: none;
  aspect-ratio: auto;
}
.media-slide.on { display: block; }
img.media-slide { object-fit: contain; }

.media-thumbs {
  display: flex;
  gap: 0.5rem;
  margin-top: 0.7rem;
  overflow-x: auto;
  scrollbar-width: none;
}
.media-thumbs::-webkit-scrollbar { display: none; }
/* What `hidden` means for this strip, stated where the display is given.
 *
 * The rule above is a class selector and the user agent's is a bare `[hidden]`, so setting
 * the property in the script hid nothing at all: the catalogue strip stayed on screen right
 * through the studio, and on a phone it sat directly under the blueprint reading as part of
 * it. Identical to the countdown's `[hidden]` problem of 2026-08-25 — see .countdown. Any
 * rule that gives an element a display is taking on this obligation with it. */
.media-thumbs[hidden] { display: none; }

.media-thumb {
  flex: none;
  width: 4rem;
  height: 4rem;
  padding: 0;
  overflow: hidden;
  border-radius: var(--radius-sm);
  border: 1px solid var(--border);
  background: var(--card);
  cursor: pointer;
}
.media-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.media-thumb.model { font-size: 0.75rem; font-weight: 600; color: var(--muted-foreground); }
.media-thumb:hover { border-color: var(--border-strong); }
.media-thumb.on { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary); }
.media-thumb:focus-visible { outline: none; box-shadow: var(--focus-ring); }

/* --------------------------------------------------------------------- shop: on offer */

.sale-flag {
  position: absolute;
  top: 0.7rem;
  left: 0.7rem;
  z-index: 1;
  padding: 0.2rem 0.5rem;
  border-radius: var(--radius-sm);
  background: var(--destructive);
  color: var(--destructive-foreground);
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: 0.01em;
}
.sale-flag.large { top: 1rem; left: 1rem; font-size: 0.8125rem; padding: 0.3rem 0.65rem; }

/* The old price is context for the new one, so it recedes rather than competing. */
.was {
  color: var(--muted-foreground);
  font-weight: 400;
  text-decoration-thickness: 1px;
  margin-right: 0.4rem;
}
.price .was { font-size: 1.35rem; }
.product-card-price .was { font-size: 0.8125rem; }
.price .now, .product-card-price .now { color: var(--foreground); }
.price .pill { vertical-align: middle; margin-left: 0.45rem; }

.engraved { font-style: italic; }

/* ------------------------------------------------------------------ shop: carousels */

.carousel-head {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 1rem;
  margin-bottom: 1rem;
}
.carousel-controls { display: flex; gap: 0.4rem; }

.carousel-arrow {
  width: 2rem;
  height: 2rem;
  padding: 0;
  border-radius: 999px;
  font-size: 1.1rem;
  line-height: 1;
  color: var(--muted-foreground);
}
.carousel-arrow:disabled { opacity: 0.35; cursor: default; }

/* Scroll-snap rather than a slider library: the browser already does momentum, keyboard
 * scrolling and touch, and the arrows are one scrollBy call on top. */
.carousel-track {
  display: flex;
  gap: 1.25rem;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
  /* Room for the cards' hover lift, which would otherwise be clipped by the scroller. */
  padding: 0.25rem 0.25rem 0.75rem;
  margin: -0.25rem;
}
.carousel-track::-webkit-scrollbar { display: none; }

.carousel-item {
  flex: 0 0 clamp(230px, 26%, 300px);
  scroll-snap-align: start;
  display: flex;
}
.carousel-item > .product-card { width: 100%; }

/* ---------------------------------------------------------------- admin: image strip */

.image-strip {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
  gap: 1rem;
}

.image-tile {
  margin: 0;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  overflow: hidden;
  background: var(--muted);
}
.image-tile.is-primary { border-color: var(--primary); }
.image-tile img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; display: block; }
.image-tile figcaption { padding: 0.6rem; font-size: 0.75rem; }
.image-tile .actions { margin-top: 0.4rem; flex-wrap: wrap; }
.image-tile button { font-size: 0.75rem; padding: 0.25rem 0.55rem; }

/* ----------------------------------------------------------------------------- brand */

/* A low-poly bust beside the wordmark, as supplied artwork rather than the inline SVG mark
 * this used to be. See templates/_logo.html.
 *
 * Sized by height, with the width left to the intrinsic ratio: the file already carries
 * `width` and `height` attributes measured off disk, so a re-cropped logo changes shape
 * without a rule here having to be edited to match. Three heights, and only three — the
 * masthead is a shop sign, the footer and the workshop are a note of who made the page. */
.logo-lockup {
  height: 1.625rem;
  width: auto;
  flex: none;
  display: block;
}

/* The shop sign. Larger than the same logo in the footer and in the workshop's header,
 * where it is a note of who made the page rather than the name over the door. */
.masthead .brand .logo-lockup { height: 2.375rem; }

header .brand { gap: 0.45rem; }

.foot-brand {
  display: flex;
  align-items: center;
  gap: 0.45rem;
}

/* ------------------------------------------------------------------- shop: the studio */

/* Choosing a photo. A label wrapping a hidden file input, because the native control
 * cannot be styled and its text ("Choose Files") is not the words wanted here. */
/* Choosing a photo and taking one, as two controls of equal weight.
 *
 * The chooser was a large dashed panel and the camera a small button beside it, which read
 * as one real option and an afterthought. They are two ways of doing the same thing, so
 * they are the same control twice: `.btn` supplies the whole appearance, and `.dropzone`
 * only adds what a label wrapping a file input needs — somewhere to hide the input, a focus
 * ring the input can drive, and the states the studio and `?upload=1` set on it. */
.photo-actions { display: flex; flex-wrap: nowrap; gap: 0.5rem; align-items: stretch; }
/* An explicit height on both rather than letting the flex line decide. One of the two is a
 * <label> and the other a <button>, and left to stretch they came out 38px and 60px. */
.photo-action { flex: 1 1 auto; justify-content: center; min-height: 2.5rem; min-width: 0; }

/* The camera is the second way in, not the equal of the first: most people already have the
 * photo. So it takes the width of its own words and the chooser takes the rest, rather
 * than the two splitting the line — which is what they did until 2026-08-28, and at 375px
 * that wrapped them onto two full-width rows that read as one choice made twice.
 *
 * `nowrap` above is what keeps them on one line; `min-width: 0` is what lets the chooser
 * give way instead of pushing the camera off the end. */
.camera-action { flex: 0 0 auto; padding-inline: 0.85rem; }

/* `margin: 0` is load-bearing: the global `label` rule sets `margin: 1rem 0 0.4rem`, which
 * on a flex item shows up as 22px of phantom height on the row and pushes this control 16px
 * below the button beside it. The old dashed-panel version cancelled it too. */
.dropzone { position: relative; margin: 0; }

/* The drop target, as wide as the two buttons above it and about twice their height, so it
 * reads as somewhere to put something rather than as a third button. Dashed, because that
 * is the one border style on this site that means "this is not filled in yet". */
.dropbox {
  display: flex;
  align-items: center;
  justify-content: center;
  margin-top: 0.5rem;
  min-height: 5rem;
  border: 1.5px dashed var(--border);
  border-radius: var(--radius);
  background: var(--card);
  color: var(--muted-foreground);
  font-size: 0.875rem;
  text-align: center;
  transition: border-color var(--duration) var(--ease), background var(--duration) var(--ease),
              color var(--duration) var(--ease);
}
/* The live mockup: a blank photo of the product with the customer's own design drawn
 * on it. The print rectangle is positioned by the template from the category's print_area,
 * because where a print sits on a mug is a fact about mugs rather than about CSS. */
.mockup { position: relative; }
.mockup-blank { display: block; width: 100%; height: auto; }

.mockup-print {
  position: absolute;
  /* The size container for everything drawn inside it, since 2026-08-30.
   *
   * The lettering was sized in `cqw` against `.product-stage`, which is the *column* - so
   * a text's size relative to the print it sits on changed with the width of the page, and
   * the `rem` clamp around it meant that on a desktop the size was decided by the clamp
   * rather than by the proportion at all. That is fine for a decorative preview and fatal
   * for one the workshop reproduces: `figurine.printfile` renders the same design at print
   * resolution, and the two can only agree if the size is a fixed fraction of the print
   * area. This is what makes `cqw` mean that.
   *
   * The element is absolutely positioned with a stated width and height, so the containment
   * this establishes changes no layout. */
  container-type: inline-size;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 4%;
  overflow: hidden;
  /* Nothing to catch a click: the design belongs to the controls underneath, and a
   * rectangle floating over a photo that swallowed clicks would be a puzzle. */
  pointer-events: none;
}
/* Scaled and moved by the customer. Percentages of the print area, because that area is a
 * fraction of a photo here and a rectangle in centimetres in the workshop.
 *
 * `--art-x` and `--art-y` are the distance from the middle, so untouched is 0% - a design
 * from before the picture could be moved draws exactly as it always did. */
.mockup-print { --art-scale: 1; --art-x: 0%; --art-y: 0%; }

/* The barrel on a mug, and it is an **illusion rather than a warp**.
 *
 * A true cylindrical distortion needs an SVG displacement map or a canvas. This is a shading
 * pass - darker toward both vertical edges, where a cylinder turns away from the light -
 * multiplied over artwork that is already multiplied onto the mug's own shading. At the size
 * this preview is looked at it reads as wrapped, and the mockup was never a proof: see
 * StyleGuide.md on what a blank is for.
 *
 * Above the artwork and below the lettering, so a print curves and the words stay legible. */
/* Drawn where `Category.curved` says the surface turns away at its edges, which is mugs.
 * It was keyed on the same number that offered the left/centre/right placement until
 * 2026-08-30, and removing that choice would have taken the barrel with it - a mug is a
 * cylinder whether or not anybody may slide a print around it. */
.mockup-curve {
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  border-radius: 6% / 3%;
  background: linear-gradient(
    to right,
    hsl(28 30% 40% / 0.28) 0%,
    hsl(28 30% 40% / 0.06) 18%,
    hsl(0 0% 100% / 0.1) 46%,
    hsl(28 30% 40% / 0.06) 80%,
    hsl(28 30% 40% / 0.3) 100%
  );
  mix-blend-mode: multiply;
}

/* The five typefaces and the four weights, resolved here rather than in the script.
 *
 * `printtools.js` sets `data-font` and `data-style` to a key out of `figurine.printlayout`,
 * and this is the one place that decides what each key looks like - a stack written in
 * JavaScript would be a second opinion about what "Display" is, and the record on the order
 * stores the key precisely so the two cannot disagree.
 *
 * System stacks only. There is no webfont on this site and nothing here may add one: a
 * request to a font host from a product page is an external dependency on the slowest part
 * of a page nobody can cache. */
.print-text[data-font="serif"] {
  --print-font: "Iowan Old Style", Georgia, "Times New Roman", Times, serif;
}
.print-text[data-font="display"] {
  --print-font: "Haettenschweiler", "Arial Narrow", Impact, "Franklin Gothic Bold", sans-serif;
}
.print-text[data-font="mono"] {
  --print-font: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
}
.print-text[data-font="script"] {
  --print-font: "Snell Roundhand", "Segoe Script", "Brush Script MT", cursive;
}
.print-text[data-style="regular"] { --print-weight: 400; --print-slant: normal; }
.print-text[data-style="italic"] { --print-weight: 400; --print-slant: italic; }
.print-text[data-style="bold-italic"] { --print-weight: 700; --print-slant: italic; }

/* Where the picture will go, drawn before there is one.
 *
 * "Yours" used to be a completely blank garment, which answers none of the question it is
 * there to answer: how big the print is and where on the thing it lands. Withdrawn on
 * `:has()` the moment there is real artwork, rather than by a line of script - one fewer
 * thing that can be left in the wrong state, and right on the first paint. */
.mockup-ghost {
  flex: 1 1 auto;
  min-height: 0;
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 2px dashed hsl(220 14% 46% / 0.55);
  border-radius: var(--radius-sm);
  /* Against the print area since 2026-08-30, where it used to be against the column. The
   * number is retuned to keep the label the size it was: the print area is roughly half the
   * column's width, so the coefficient roughly doubles. */
  font-size: clamp(0.5rem, 3.2cqw, 1rem);
  font-weight: 600;
  letter-spacing: 0.02em;
  color: hsl(220 14% 38% / 0.8);
  text-transform: uppercase;
}
.mockup-print:has(> .art-crop:not([hidden])) > .mockup-ghost,
.mockup-print:has(> .art-crop:not([hidden])) > .mockup-ghost-text { display: none; }

/* Where the lettering would go, said once, before there is any.
 *
 * It withdraws for a picture as well as for a text, which is not obvious and was asked for:
 * this is an answer to "what goes here", and the moment anything at all is in the print area
 * the question has been answered. Leaving it under somebody's photo read as a caption
 * they had not typed.
 *
 * Not draggable, and not the element a real text uses. It was both until 2026-08-29, which
 * taught the wrong thing the moment there could be several texts, each dragged on its own. */
.mockup-ghost-text {
  position: absolute;
  left: 50%;
  top: 88%;
  transform: translate(-50%, -50%);
  max-width: 96%;
  /* Twice the real lettering's 4%, because this is a placeholder rather than a text: it says
   * "words go here" at a size that reads on a phone, and it is gone the moment there is a
   * real one to compare it against. */
  font-size: 8cqw;
  font-weight: 700;
  line-height: 1.15;
  text-align: center;
  color: hsl(220 20% 12%);
  opacity: 0.42;
}
/* Both placeholders withdraw for a real text, since 2026-08-30. The picture's one used to
 * wait for a picture, which left a dashed "YOUR IMAGE" frame standing under the lettering of
 * somebody who had decided to print words and nothing else - a design reading as unfinished
 * when it is finished. Each of these is an answer to "what goes here", so anything at all in
 * the print area answers both. */
.mockup-print:has(.print-text) > .mockup-ghost,
.mockup-print:has(.print-text) > .mockup-ghost-text { display: none; }

/* The trimmed picture: a window, and the picture behind it.
 *
 * **Rebuilt 2026-08-30 at the operator's request, and it reverses the day's earlier choice.**
 * The first version cut a side off and left the rest where it was - `clip-path: inset()` on
 * the picture itself - on the argument that the frame here is a print area on a garment, so
 * "cut the left third off" ought to mean that third is not printed rather than that everything
 * else grows.
 *
 * The operator's example is what settled it: a photo of five people, and you want the one on
 * the left. With a cut that does not re-fit, trimming to that person leaves them as a narrow
 * strip against the left edge of the print, and every way of making them large and centred
 * fights the arithmetic - the scale is about the *picture's* middle, not the strip's, so
 * zooming walks the strip out of the frame. There was no way to do it.
 *
 * So a trim is now a window onto the picture, and what the window holds is fitted into the
 * print area - the way a phone's photo editor has always worked. `.art-crop` is that window
 * and it is what carries the arrangement: the scale, the turn and the position on the canvas
 * all act on the *result* of the trim. The picture inside is sized and offset so the kept part
 * fills the window exactly, which `printtools.js` computes because the aspect of the window
 * changes with every drag and CSS has no way to fit a variable ratio into a box.
 *
 * `overflow: hidden` on the window is the trim itself. */
.art-crop {
  position: relative;
  z-index: 1;
  overflow: hidden;
  flex: 0 0 auto;
  /* Moved with `left`/`top` rather than inside the transform, deliberately: a percentage
   * offset on a positioned element resolves against its containing block - the print area -
   * which is the unit the record stores, while a percentage inside `translate()` would
   * resolve against the window's own box and be multiplied by the scale beside it. */
  left: var(--art-x);
  top: var(--art-y);
  /* Scale then rotate, and **the order is the one the renderer redoes**: a uniform scale and
   * a rotation commute, so this pair is safe either way round, but `figurine.printfile`
   * writes it in this order and a reader comparing the two should find the same sentence. */
  transform: scale(var(--art-scale)) rotate(var(--art-angle, 0deg));
  /* The one element in the print area that can be grabbed, along with the lettering above
   * it: `.mockup-print` gives up its pointer events so a rectangle over a photo is not a
   * puzzle, and this takes them back. `touch-action: none` is what stops a phone treating the
   * drag as a page scroll. */
  pointer-events: auto;
  touch-action: none;
  cursor: grab;
  /* Multiply so the artwork takes the fabric's shading and folds instead of sitting on top
   * of them as a flat sticker. It is the cheapest thing that makes a paste look printed. */
  mix-blend-mode: multiply;
}
.art-crop[hidden] { display: none; }
.art-crop.panning { cursor: grabbing; }

/* The picture behind the window. Absolutely positioned and sized by the script: at no trim it
 * is exactly the window, and at any trim it is larger and offset so the kept part lands in it.
 * `max-width: none` because the sitewide `img` rule caps images at their container. */
.mockup-art {
  position: absolute;
  display: block;
  max-width: none;
  max-height: none;
}

/* Slightly rounded and very slightly narrowed where the surface curves away. Not a warp - the
 * corners of a rectangle on a cylinder are the part the eye checks, and squaring them off is
 * what makes a flat paste look flat. */
.mockup-print.curved .art-crop {
  border-radius: 5% / 3%;
  transform: scale(var(--art-scale)) rotate(var(--art-angle, 0deg)) scaleX(0.97);
}

/* ------------------------------------------------------- the handles on the picture
 *
 * Four corner dots to resize, four edge bars to cut a side off, and a round arrow to turn it.
 * Added 2026-08-30 at the operator's request, and shaped after a phone's own photo editor:
 * corners take both dimensions at once, edges take one side.
 *
 * **Drawn outside `.mockup-print`.** That rectangle has `overflow: hidden`, and at any scale
 * above 100% the picture's edges are outside it - handles clipped by the rectangle they exist
 * to adjust would be handles nobody can reach. `printtools.js` positions this against the
 * print area instead, which is arithmetic it is already doing for the editor pane.
 *
 * The layer itself catches nothing; each handle takes its own events back, the same
 * arrangement `.print-texts` and the lettering already have. */
.art-handles {
  position: absolute;
  /* Above `.stage-entry`, which is pinned to the same bottom-right corner at `z-index: 4`.
   * At any scale over about 140% the rotate arrow lands under "Add text" and cannot be
   * pressed. The handle wins because it belongs to the design being worked on, where the
   * button beside it starts a new one; a 26px dot over the corner of that button leaves most
   * of it pressable, and the reverse leaves none of the arrow. */
  z-index: 5;
  pointer-events: none;
}
.art-handles[hidden] { display: none; }

/* The outline that says which rectangle the handles belong to. A dashed white line over a
 * photo and a solid dark one over pale fabric would be two rules; one white line with a dark
 * shadow under it reads on both, which is what the lettering's own outline does. */
.art-outline {
  position: absolute;
  inset: 0;
  border: 1px dashed hsl(0 0% 100% / 0.9);
  box-shadow: 0 0 0 1px hsl(220 26% 8% / 0.35);
}

.art-handle {
  position: absolute;
  pointer-events: auto;
  touch-action: none;
  background: var(--handle);
  box-shadow: 0 0 0 2px hsl(0 0% 100% / 0.9);
}

/* **44px of target under a 12px dot**, through a transparent `::before` rather than by making
 * the dot bigger. Apple's minimum applies to everything on this page and these are the
 * smallest controls in the shop; a 12px hit area on a phone is a control only a mouse has. */
.art-handle::before {
  content: "";
  position: absolute;
  left: 50%;
  top: 50%;
  width: 44px;
  height: 44px;
  transform: translate(-50%, -50%);
}

/* The corners: resize. Round, because they take both dimensions at once. */
.art-handle.corner {
  width: 12px;
  height: 12px;
  margin: -6px;
  border-radius: 50%;
}
.art-handle.corner.nw { left: 0; top: 0; cursor: nwse-resize; }
.art-handle.corner.ne { right: 0; top: 0; cursor: nesw-resize; }
.art-handle.corner.se { right: 0; bottom: 0; cursor: nwse-resize; }
.art-handle.corner.sw { left: 0; bottom: 0; cursor: nesw-resize; }

/* The edges: cut that side off. Thin bars rather than dots, because a bar has a direction and
 * a dot does not - the shape says which edge is about to move. */
.art-handle.edge { border-radius: 999px; }
.art-handle.edge.n, .art-handle.edge.s {
  left: 50%;
  width: 34px;
  height: 6px;
  margin: -3px 0 0 -17px;
  cursor: ns-resize;
}
.art-handle.edge.e, .art-handle.edge.w {
  top: 50%;
  width: 6px;
  height: 34px;
  margin: -17px 0 0 -3px;
  cursor: ew-resize;
}
.art-handle.edge.n { top: 0; }
.art-handle.edge.s { bottom: 0; margin-top: 0; margin-bottom: -3px; }
.art-handle.edge.w { left: 0; }
.art-handle.edge.e { right: 0; margin-left: 0; margin-right: -3px; }

/* The rotate arrow, off the bottom-right corner so it is not mistaken for the resize dot it
 * sits beside. White on the handle color, and the only handle carrying a glyph - a round
 * arrow is what the operator asked for and it is the one gesture here nothing else suggests. */
.art-rotate {
  position: absolute;
  right: -30px;
  bottom: -30px;
  width: 26px;
  height: 26px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  background: var(--handle);
  color: hsl(0 0% 100%);
  box-shadow: 0 0 0 2px hsl(0 0% 100% / 0.9);
  cursor: grab;
  pointer-events: auto;
  touch-action: none;
}
.art-rotate.turning { cursor: grabbing; }
.art-rotate svg { width: 15px; height: 15px; }
/* The same 44px target the dots get, for the same reason. */
.art-rotate::before {
  content: "";
  position: absolute;
  left: 50%;
  top: 50%;
  width: 44px;
  height: 44px;
  transform: translate(-50%, -50%);
}
/* One piece of lettering, placed from the arrangement rather than pinned to the foot.
 *
 * `position: absolute` inside the print area, so the customer's x and y are a percentage of
 * that area - the one unit that means the same on a photo on screen and on a rectangle
 * in centimetres in the workshop. `translate(-50%, -50%)` makes those coordinates the centre
 * of the words rather than their top-left, which is what somebody dragging them expects.
 *
 * A design carried exactly one of these until 2026-08-29. Each now holds its own color,
 * typeface, weight and size as inline custom properties - per-instance values, set from the
 * record, rather than tokens: the tokens are what those keys resolve to, and they are below.
 *
 * **`pointer-events: auto` is load-bearing.** `.mockup-print` gives them up so a rectangle
 * over a photo is not a puzzle to click through, and these inherited that - so a press
 * meant for the words fell through to the picture and the browser dragged a ghost of the
 * t-shirt instead. Each text takes its own events back; the pictures under it carry
 * `draggable="false"` so nothing native starts under the finger either. */
.print-texts { position: absolute; inset: 0; pointer-events: none; }

.print-text {
  /* The default typeface and weight. The keys that change them are selected further up,
   * beside the other four stacks. */
  --print-font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto,
    Helvetica, Arial, sans-serif;
  --print-weight: 700;
  --print-slant: normal;
  position: absolute;
  z-index: 2;
  left: var(--text-x, 50%);
  top: var(--text-y, 88%);
  transform: translate(-50%, -50%) scale(var(--text-scale, 1)) rotate(var(--text-angle, 0deg));
  max-width: 96%;
  font-family: var(--print-font);
  font-weight: var(--print-weight);
  font-style: var(--print-slant);
  /* **A proportion of the print area, with no clamp**, since 2026-08-30. It was
   * `clamp(0.6rem, 2.1cqw, 2rem)` against the column, and on a desktop the 0.6rem floor won
   * every time - so the words were a device-fixed size on a print area that is any size at
   * all, and the same design read differently on two screens. `figurine.printfile` has to
   * reproduce this exactly, and it can only do that against a number that is a fraction of
   * the thing being printed. 4% is what the old clamp came out at on a desktop tee, so an
   * order arranged before this change looks the same as it did.
   *
   * `PRINT_TEXT_BASE` in `figurine/printfile.py` is the same number and a test pins them. */
  font-size: 4cqw;
  line-height: 1.15;
  text-align: center;
  overflow-wrap: anywhere;
  color: var(--print-ink, hsl(220 20% 12%));
  cursor: grab;
  touch-action: none;
  user-select: none;
  pointer-events: auto;
}
.print-text.dragging { cursor: grabbing; }

/* The four handles that say which text the editor is acting on.
 *
 * Red and round at the operator's request, one at each edge's midpoint. They are markers
 * rather than controls - this is not a resize gesture, and nothing happens if one is pressed
 * beyond the drag the whole text already answers. Drawn outside the box on purpose, so they
 * do not sit on top of the lettering they are pointing at.
 *
 * Counter-scaled: the text is drawn at `scale(var(--text-scale))` and a handle scaled with it
 * would be a dot the size of a full stop at 20% and a blob at 300%. */
.print-handle {
  position: absolute;
  width: 9px;
  height: 9px;
  margin: -4.5px;
  border-radius: 50%;
  background: var(--handle);
  box-shadow: 0 0 0 2px hsl(0 0% 100% / 0.9);
  transform: scale(calc(1 / var(--text-scale, 1)));
  opacity: 0;
  transition: opacity 140ms var(--ease);
}
.print-text.selected .print-handle { opacity: 1; }

/* The rotate arrow on a text, added 2026-08-30 beside the picture's own.
 *
 * Off the bottom-right corner, the same place and the same drawing, because turning a text
 * and turning a picture are one gesture and a customer should not have to learn it twice.
 *
 * Counter-scaled like the four dots: the text is drawn at `scale(var(--text-scale))` and an
 * arrow scaled with it is a speck at 20% and a blob at 300%. **Not counter-rotated**, on
 * purpose - it turns with the text it is turning, which is what says the gesture worked. */
.print-rotate {
  position: absolute;
  right: -22px;
  bottom: -22px;
  width: 20px;
  height: 20px;
  /* It is a `<button>`, so it arrives wearing the sitewide `button, .btn` rule: a border and
   * 0.45rem by 0.9rem of padding, which inside a 20px box leaves the arrow no room at all.
   * The picture's own arrow is a `div` built by the script and needs none of this. */
  padding: 0;
  border: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  background: var(--handle);
  color: hsl(0 0% 100%);
  box-shadow: 0 0 0 2px hsl(0 0% 100% / 0.9);
  transform: scale(calc(1 / var(--text-scale, 1)));
  transform-origin: center;
  opacity: 0;
  cursor: grab;
  pointer-events: none;
  touch-action: none;
  transition: opacity 140ms var(--ease);
}
.print-text.selected .print-rotate { opacity: 1; pointer-events: auto; }
.print-rotate.turning { cursor: grabbing; }
.print-rotate svg { width: 12px; height: 12px; }
/* 44px of target, the same as every other handle on this page. */
.print-rotate::before {
  content: "";
  position: absolute;
  left: 50%;
  top: 50%;
  width: 44px;
  height: 44px;
  transform: translate(-50%, -50%);
}
.print-handle.n { top: -4px; left: 50%; }
.print-handle.s { bottom: -4px; left: 50%; }
.print-handle.w { left: -4px; top: 50%; }
.print-handle.e { right: -4px; top: 50%; }

/* A faint outline while it is being edited, so the handles read as corners of something
 * rather than as four unexplained dots. */
.print-text.selected {
  outline: 1px dashed hsl(0 0% 100% / 0.85);
  outline-offset: 4px;
}

/* Sized against the stage rather than the viewport, so the lettering keeps its proportion
 * to the garment when the column narrows. */
.product-stage { container-type: inline-size; }

/* The picture somebody chose, once there is one. Drawn only when it exists, so nothing
 * reserves empty space on a page where most people will type words instead. */
/* `display: flex` beats the `hidden` attribute, which the UA stylesheet implements as
 * `display: none` — a rule this specific silently un-hides the element. The template hides
 * it until there is a picture, so the attribute has to win. */
.artwork-chosen[hidden] { display: none; }
.artwork-chosen {
  display: flex;
  align-items: center;
  gap: 0.85rem;
  margin: 0.75rem 0 0;
  padding: 0.6rem;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--card);
}
.artwork-chosen img {
  width: 4rem;
  height: 4rem;
  flex: none;
  object-fit: cover;
  border-radius: var(--radius-sm);
  background: var(--muted);
}
.artwork-chosen figcaption {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.15rem;
  font-size: 0.875rem;
  color: var(--muted-foreground);
  min-width: 0;
}

.dropbox.over {
  border-color: var(--primary);
  background: color-mix(in srgb, var(--primary) 5%, transparent);
  color: var(--primary);
}
.dropzone input { position: absolute; opacity: 0; width: 0; height: 0; }
.dropzone:has(:focus-visible) { box-shadow: var(--focus-ring); }
.dropzone.chosen { border-color: var(--primary); color: var(--primary); }

/* The pose advice, shown between pressing one of those two controls and the picker opening.
 * See templates/_pose_gate.html.
 *
 * The only <dialog> on the site, so everything it needs is here rather than in a component
 * layer that has one member. Two notes on the geometry:
 *
 * The poster is square and carries small print, so the panel is as wide as a phone allows
 * and no wider than the picture is worth: past about 30rem the image stops gaining anything
 * and the dialog starts feeling like a page. And the whole thing is capped at the viewport
 * with the picture allowed to shrink, because a square image plus a heading plus two buttons
 * does not fit a landscape phone and a modal that scrolls its own actions off the bottom is
 * a modal nobody can leave.
 *
 * This was `.pose-gate` until 2026-08-29, when the advice stopped being a dialog of its own
 * and the chooser became one. The chrome is unchanged; what it wraps is the three ways in,
 * with the advice above them on the bespoke product. */
.photo-dialog {
  width: min(34rem, calc(100vw - 2rem));
  max-height: calc(100dvh - 2rem);
  padding: 0;
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  background: var(--card);
  color: var(--foreground);
  box-shadow: var(--shadow-lg);
  overflow: hidden;
}

/* A scrim rather than a tint: the page behind is a shop, and a modal that does not visibly
 * take the room over reads as a card that happens to be on top. */
.photo-dialog::backdrop {
  background: var(--scrim);
  backdrop-filter: blur(2px);
}

.photo-dialog-inner {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  padding: 1.25rem;
  max-height: calc(100dvh - 2rem);
  overflow-y: auto;
}

/* The site's own h2 — a quiet uppercase label — and deliberately not a heading of its own
 * size. The poster underneath has a headline printed into it, and two headlines arguing
 * about which is the title of the dialog is worse than one of them being small. */
.photo-dialog h2 { margin: 0; }

/* `min-height: 0` so this is what gives way when the viewport is short, rather than the
 * actions being pushed out of the panel — and `object-fit: contain` because giving way is
 * not the same as being squashed. A flex column shrinks its items along the height, and an
 * `height: auto` image shrunk that way distorts: on a landscape phone the three photos
 * came out visibly flattened. Contained, the drawing keeps its shape and simply gets
 * smaller, with the panel's own white on either side of it. */
/* The advice sits above the three ways in rather than being the whole panel. Its own
 * column so the poster and its line stay together when the dialog scrolls. */
.pose-advice {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  min-height: 0;
}

.pose-advice-art {
  display: block;
  width: 100%;
  height: auto;
  min-height: 0;
  object-fit: contain;
  border-radius: var(--radius);
}

.pose-advice-note {
  margin: 0;
  font-size: 0.875rem;
  line-height: 1.5;
  color: var(--muted-foreground);
}

/* Cancel on the right, where a dialog's actions belong. There is nothing beside it: the
 * things that carry on are the chooser and the camera above, and a second "Done" over them
 * would be a control that confirms a choice already made. */
.photo-dialog-actions {
  display: flex;
  justify-content: flex-end;
  gap: 0.5rem;
}
/* 44px, not the 2.5rem this carried as the pose gate: it is a target in a modal on a
 * phone, and it is the only way out of one. */
.photo-dialog-actions .btn { min-height: 44px; }

/* Motion confirms; it does not perform. Opening is a short fade and a two-pixel rise, and
 * nothing animates for anybody who has asked for less of it. */
@media (prefers-reduced-motion: no-preference) {
  .photo-dialog[open] { animation: photo-dialog-in 160ms var(--ease); }
  .photo-dialog[open]::backdrop { animation: photo-dialog-fade 160ms var(--ease); }
}
@keyframes photo-dialog-in {
  from { opacity: 0; transform: translateY(2px); }
  to { opacity: 1; transform: none; }
}
@keyframes photo-dialog-fade {
  from { opacity: 0; }
  to { opacity: 1; }
}

/* No picture on the page means no button to open this from, so it is not a dialog there -
 * see `_photo_dialog.html`. The one state in which a dialog would be strictly worse than the
 * panel it replaced is the one where it cannot be opened at all. */
.photo-dialog.inline {
  display: block;
  position: static;
  width: auto;
  max-height: none;
  border: 0;
  padding: 0;
  background: none;
  box-shadow: none;
}
.photo-dialog.inline .photo-dialog-inner { padding: 0; overflow: visible; }
.photo-dialog.inline h2,
.photo-dialog.inline .photo-dialog-actions { display: none; }

/* The stage the photo is scanned on. Dark, deliberately: this is the one place in a
 * light interface where a dark ground is right, because the scan is drawn in light and
 * needs something to be light against. */
.studio-stage {
  position: relative;
  aspect-ratio: 4 / 5;
  max-height: 70svh;
  border-radius: var(--radius-lg);
  overflow: hidden;
  background: hsl(220 26% 12%);
}
.studio-stage > img,
.studio-stage > canvas,
.studio-stage > .viewer {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
  border-radius: 0;
  max-height: none;
  aspect-ratio: auto;
}
.studio-stage > img { object-fit: cover; opacity: 0.82; }
.studio-stage > canvas { pointer-events: none; }
.studio-stage > .viewer { background: radial-gradient(90% 70% at 50% 18%, var(--card) 0%, var(--stage-far) 100%); }
.studio-stage canvas { width: 100% !important; height: 100% !important; }

/* Finished: the photo and the figurine side by side rather than one replacing the
 * other. On its own the model is a brown figure; next to the picture it was built from it
 * is a likeness somebody can judge, which is the only question they are actually asking.
 * The overlay stack becomes two panels, and the scan canvas has nothing left to scan. */
/* `1fr auto` is not tidying. The box is capped at 8/5 and `.studio-stage` clips what will
 * not fit, and the two panels used to take their own heights inside that cap: an `<img>`
 * claims its intrinsic height, so a portrait photo came out 419px tall in a 398px box
 * and lost its lower edge — and pushed the status row, the line of copy and the finished
 * progress bar, entirely outside the clip. Nobody saw it because the catalogue is 4:3 and
 * square, and a portrait photo is what a customer actually sends.
 *
 * The row takes what is left after the status, and the panels fill the row. This is the
 * same mistake as the phone layout below, at a different size: content-sized rows inside a
 * fixed ratio. Found 2026-08-28 while measuring the phone fix. */
.studio-stage.done {
  display: grid;
  grid-template-columns: 1fr 1fr;
  grid-template-rows: auto 1fr;
  gap: 0.5rem;
  aspect-ratio: 8 / 5;
  background: none;
}
.studio-stage.done > .stage-chip-row { grid-column: 1 / -1; grid-row: 1; }
.studio-stage.done > img { grid-column: 1; grid-row: 2; }
.studio-stage.done > .viewer { grid-column: 2; grid-row: 2; }
.studio-stage.done > img,
.studio-stage.done > .viewer {
  position: static;
  inset: auto;
  border-radius: var(--radius);
  height: 100%;
  min-height: 0;
  aspect-ratio: auto;
  max-height: none;
}
/* `contain` rather than `cover`: this is the picture the customer is being asked to compare
 * a likeness against, and a crop that takes the head off answers nothing. */
.studio-stage.done > img { object-fit: contain; }
.studio-stage.done > img { opacity: 1; }
.studio-stage.done > canvas { display: none; }

/* Done: the panel goes.
 *
 * It was a full-width row under the pair, carrying a line of copy and a bar at 100%. Both
 * are spent: the bar is measuring a job that has finished, and the sentence describes a
 * picture the reader is already looking at. On a phone the whole panel sat between the
 * figurine and the price, which is the only place on that screen where nothing should.
 *
 * `display: none` and not a removal, because a re-generate needs the countdown and the bar
 * back; `begin()` takes `done` off and the panel returns with them. Changed 2026-08-28 at
 * the operator's request; it was a static row here until then. */
.studio-stage.done > .studio-status { display: none; }

/* The control that swaps which of the two is large. Drawn only where one of them has been
 * reduced to a chip, which is the stacked phone layout below and nowhere else: on a wide
 * screen both panels are already big and there is nothing to swap.
 *
 * Both captions are in the markup and this chooses between them, so the button cannot be
 * left reading "Compare" while it is already comparing. The script sets one class and
 * knows none of these words. */
/* The row of controls that act on the finished pair: compare, and re-upload once the studio
 * has moved the chooser in. Drawn only in the done state — before that it is empty, and an
 * empty flex row inside a stage of absolutely positioned layers still takes a line. */
.stage-chip-row { display: none; }
.studio-stage.done > .stage-chip-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
}

.compare-toggle { display: none; }
.studio-stage.done .compare-off { display: none; }
.studio-stage.done.compare .compare-on { display: none; }
.studio-stage.done.compare .compare-off { display: inline; }

@media (max-width: 620px) {
  /* Two 4:5 panels at phone width are two thumbnails, so the stage stacks. What stacking
   * used to keep was `aspect-ratio`, and that is the defect this block exists to undo.
   *
   * A ratio on a container whose rows are sized by their contents does not divide the box
   * between those rows. It caps the box, the photo claims its intrinsic height inside
   * the cap, and the viewer -- an empty div until three.js mounts a canvas, so with no
   * intrinsic height of its own -- receives the remainder. Measured at 375px: 31px under a
   * landscape photo and **zero** under a portrait one. A phone photo is portrait,
   * so the blueprint the page exists to produce was drawn at no height for very nearly
   * every real customer. See History.md, 2026-08-28.
   *
   * The general rule, which is worth more than this fix: an element with no intrinsic size
   * must never be sized by leftover space alone. The viewer carries its own height here and
   * a floor under it, so nothing above can squeeze it away again.
   *
   * Two columns rather than one: the chip and the control that undoes it belong on the same
   * line, so that the small picture and the words explaining it read as one object. */
  .studio-stage.done {
    grid-template-columns: auto 1fr;
    aspect-ratio: auto;
    max-height: none;
    align-items: center;
    gap: 0.5rem 0.75rem;
  }

  /* The photo, at roughly a fifth of the height it used to take. `contain` rather than
   * `cover`: a chip cropped out of a portrait photo is a slice of somebody's torso,
   * which identifies nobody and answers nothing. */
  .studio-stage.done > img {
    grid-column: 1;
    grid-row: 1;
    height: 5.5rem;
    width: auto;
    max-width: 100%;
    object-fit: contain;
    justify-self: start;
  }
  /* Compare and re-upload, stacked beside the chip. `column` rather than a wrapped row:
   * "Compare with your photo" and "Re-upload photo" do not fit across the 265px left over
   * beside an 88px chip, and letting them wrap makes the number of lines depend on the
   * label, which is the sort of layout that breaks the next time a word is changed. */
  .studio-stage.done > .stage-chip-row {
    grid-column: 2;
    grid-row: 1;
    flex-direction: column;
    align-items: stretch;
    justify-self: stretch;
  }
  .studio-stage.done .compare-toggle { display: inline-flex; }
  /* Taller than the 22rem it was, because the overlay at its foot is two rows deep and was
   * eating a third of the frame. Raised with the camera's framing margin rather than instead
   * of it — see `framing` in viewer.js — since more box and the same fit only makes a bigger
   * figurine behind the same buttons. */
  .studio-stage.done > .viewer {
    grid-column: 1 / -1;
    grid-row: 2;
    height: min(58svh, 25rem);
    min-height: 12rem;
  }

  /* Comparing: the two swap sizes and nothing else moves. The photo takes the row it
   * had as a chip and spans the width; the figurine becomes the chip, keeping its own 4:5
   * so it is a recognisable little object rather than a letterbox. */
  .studio-stage.done.compare > img {
    grid-column: 1 / -1;
    grid-row: 1;
    height: min(52svh, 22rem);
    width: 100%;
  }
  .studio-stage.done.compare > .viewer {
    grid-column: 1;
    grid-row: 2;
    height: 5.5rem;
    min-height: 0;
    width: auto;
    aspect-ratio: 4 / 5;
  }
  .studio-stage.done.compare > .stage-chip-row {
    grid-column: 2;
    grid-row: 2;
  }

  /* Comparing: the controls come out of the overlay and take a row of their own.
   *
   * `.stage-actions` is pinned to the foot of the *stage*, which while comparing is the row
   * holding the figurine chip and the two buttons beside it — so the field and its buttons
   * landed directly on top of them. Nothing was unreachable, but two controls in the same
   * place is a defect whichever one you meant to press.
   *
   * Taking it out of the overlay rather than moving the pin up: the reason the field floats
   * over the blueprint is that the blueprint is the thing it acts on, and while comparing the
   * blueprint is a 5.5rem chip. There is nothing left to float over. */
  .studio-stage.done.compare > .stage-actions {
    position: static;
    grid-column: 1 / -1;
    grid-row: 3;
  }
  .studio-stage.done.compare > .stage-actions .btn,
  .studio-stage.done.compare > .stage-actions .engraving-group input {
    box-shadow: none;
  }
  .studio-stage.done.compare > .stage-actions .engraving-group input {
    border-color: var(--input);
  }
}

/* A phone on its side.
 *
 * `max-height` on a box that also carries an `aspect-ratio` shrinks its **width**. Before
 * this block `.studio-stage` computed to `width: 420px` inside a 741px column on an 812x375
 * viewport: 70svh capped the height at 262px and the 8/5 ratio derived the width back out
 * of the cap. The stage sat in the left half of the screen with the right half empty, and
 * that -- not a missing media query -- is what "turning the phone does not rescale" was.
 *
 * So the pair is broken up rather than re-tuned. The height is stated outright and the
 * ratio is cleared, which leaves the width free to be the full column.
 *
 * `min-width` is not a flourish. A 568x320 device is landscape *and* under the phone
 * breakpoint, and the stacked rules above place their children by row and column, which
 * nothing here resets. The two blocks must never both apply. */
@media (orientation: landscape) and (max-height: 560px) and (min-width: 621px) {
  .studio-stage,
  .media-stage,
  .stage-tall {
    aspect-ratio: auto;
    max-height: none;
    height: min(64svh, 17rem);
  }
  .studio-stage.done {
    grid-template-columns: 1fr 1fr;
    height: auto;
  }
  .studio-stage.done > img,
  .studio-stage.done > .viewer {
    height: min(58svh, 15rem);
    min-height: 0;
  }
  .studio-stage.done > img { object-fit: contain; }
}

/* Taking a photo rather than finding one. The button sits under the file chooser as
 * the second option, not beside it as an equal: most people have a photo already. */
/* The photo group sits under the picture, in the stage column. */
.photo-group { margin-top: 1.5rem; }

/* Emptied once a blueprint exists.
 *
 * Everything in here explains what to send and why: a heading, two hints, a drop target and
 * a note about what happens to the file. All of it is spent the moment a figurine is on the
 * screen, and on a phone it was six lines of it standing between the figurine and the price.
 * The chooser itself is not hidden — `showChooser` has already moved it onto the stage, and
 * what is left behind is the wrapper it sat in.
 *
 * The whole group rather than a list of children, so a line added here later is covered by
 * default instead of being remembered. Added 2026-08-28. */
.photo-group.blueprint-done > *:not(.photo-dialog) { display: none; }

.camera {
  margin-top: 0.75rem;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
  background: hsl(220 26% 12%);
}
.camera video {
  display: block;
  width: 100%;
  aspect-ratio: 4 / 5;
  object-fit: cover;
  /* Mirrored, because an unmirrored preview of your own face reads as somebody else's. The
   * captured frame is not mirrored: the figurine should match the person, not the preview. */
  transform: scaleX(-1);
}
.camera-bar {
  display: flex;
  gap: 0.5rem;
  padding: 0.6rem;
  background: var(--card);
  border-top: 1px solid var(--border);
}

/* ------------------------------------------------------- the camera, inside the stage

   The feed opens over the product picture rather than below the upload buttons. Same box,
   same aspect, so the sequence picture -> camera -> studio never reflows and the page never
   scrolls itself. See the comment in product.html.

   The stage already draws the rounded corner and clips to it, so the panel gives up its own
   border and radius: two rounded rectangles on top of each other read as a seam. */






.camera-in-stage[hidden] { display: none; }

.camera-in-stage {
  position: absolute;
  inset: 0;
  z-index: 3;
  margin: 0;
  border: 0;
  border-radius: 0;
  display: flex;
  flex-direction: column;
}

/* `display: flex` above outranks the user-agent rule behind the `hidden` attribute, which is
   a bare `[hidden] { display: none }` and loses to any class selector. Without this the panel
   was painted over the product photo from the moment the page loaded, and the feed only
   *appeared* to start when the button was pressed. Any rule that gives an element a display
   has to say what `hidden` means for it. */


/* The video takes the room the bar does not, instead of forcing its own 4/5 and overflowing
   a box that is already 4/5. */
.camera-in-stage video {
  flex: 1 1 auto;
  min-height: 0;
  width: 100%;
  height: auto;
  aspect-ratio: auto;
}

/* Floated over the foot of the picture rather than sitting under it, so the controls do not
   eat into the frame the person is composing themselves in. */
.camera-in-stage .camera-bar {
  border-top: 0;
  background: linear-gradient(to top, var(--scrim-strong), transparent);
  padding: 0.9rem;
  justify-content: center;
}

/* The status sits *on* the photo rather than under it. It was a small line beneath the
   stage, which put the one number somebody is actually watching in the least prominent place
   on the screen while a large picture sat above it doing nothing. Over the picture it reads
   as what it is: a progress panel on the thing being worked on. */
.studio-status {
  position: absolute;
  inset: auto 0 0 0;
  z-index: 2;
  padding: 3.5rem 1.1rem 0.9rem;
  background: linear-gradient(to top, var(--scrim-strong) 0%, var(--scrim) 55%, transparent);
  color: var(--on-scrim);
  pointer-events: none;
}

/* The countdown is the one number on the screen and is sized like it. "about" stays in the
 * label because it is an estimate and saying so costs nothing. */
.countdown { display: flex; align-items: baseline; gap: 0.55rem; }

/* `display: flex` beats the user agent's `[hidden] { display: none }` on specificity, so the
   JavaScript that hides an expired countdown was doing nothing and the clock sat frozen at
   "1 seconds left" beside the excuses. Fourth defect of this exact shape in app.css — see
   History.md, `button[hidden]` and the two before it. Any rule setting `display` on an
   element that is ever hidden needs this line. */
.countdown[hidden] { display: none; }
.countdown-time {
  font-size: clamp(2.25rem, 3.4vw, 3.25rem);
  font-weight: 650;
  line-height: 1;
  letter-spacing: -0.035em;
  font-variant-numeric: tabular-nums;
  color: var(--on-scrim);
}
.countdown-label { font-size: 0.875rem; color: var(--on-scrim-muted); }

.studio-message {
  margin: 0.55rem 0 0.7rem;
  font-size: 0.9375rem;
  color: var(--on-scrim-muted);
  min-height: 1.4em;
  transition: opacity var(--duration) var(--ease);
}
.studio-message.fading { opacity: 0; }

/* Once the estimate is spent the clock is gone, and this line is the only thing left saying
   anything. It takes the size the number had, because the panel still has to read as a
   progress panel rather than as a caption under nothing. Not the countdown's full weight:
   these are sentences, and 3rem sentences shout. */
.studio-message.overrun {
  font-size: clamp(1.125rem, 1.7vw, 1.5rem);
  font-weight: 550;
  line-height: 1.3;
  letter-spacing: -0.01em;
  color: var(--on-scrim);
  margin: 0.2rem 0 0.85rem;
  min-height: 2.6em;
}

/* The only thing on the screen reporting measured progress: stages done out of stages
 * total. Kept thin so it does not compete with the countdown. */
.studio-track {
  height: 3px;
  border-radius: 999px;
  background: hsl(0 0% 100% / 0.25);
  overflow: hidden;
}
.studio-fill {
  height: 100%;
  width: 0;
  border-radius: 999px;
  background: var(--primary);
  transition: width 400ms var(--ease);
}

/* ============================================================ shop: a wide catalogue
 *
 * Added 2026-08-23, when the shop stopped selling one thing. Everything here serves the
 * three-row header, the sliding panel, the rail-against-carousels home page, the category
 * and policy pages, and the footer.
 *
 * Same tokens as everything above. The one new family is the category tints, and they are
 * defined once below rather than inside the rules that use them. */

:root {
  /* Category tints. Very pale, because they are the ground behind dark text on a card that
   * has no photo, and on a page whose whole premise is a white gallery wall. Each is a
   * hue at the same low saturation and high lightness, so eight of them in a grid read as
   * one family rather than as a paint chart. */
  --tint-cocoa: hsl(24 44% 94%);
  --tint-indigo: hsl(226 44% 94%);
  --tint-clay: hsl(12 44% 94%);
  --tint-slate: hsl(210 26% 93%);
  --tint-moss: hsl(140 26% 93%);
  --tint-sand: hsl(44 44% 93%);
  --tint-rust: hsl(0 38% 94%);
  --tint-rose: hsl(340 40% 95%);

  /* Ink for a tinted ground. One value: the tints are close enough in lightness that a
   * per-tint foreground would be eight ways of writing near-black. */
  --tint-ink: hsl(224 24% 26%);
}

.tint-cocoa { --tint: var(--tint-cocoa); }
.tint-indigo { --tint: var(--tint-indigo); }
.tint-clay { --tint: var(--tint-clay); }
.tint-slate { --tint: var(--tint-slate); }
.tint-moss { --tint: var(--tint-moss); }
.tint-sand { --tint: var(--tint-sand); }
.tint-rust { --tint: var(--tint-rust); }
.tint-rose { --tint: var(--tint-rose); }

/* ----------------------------------------------------------------- the three-row header
 *
 * The header grew from one row to three. Only the lower two are sticky: all three would
 * take a third of a laptop viewport, and nobody needs the cookie policy pinned to the top
 * of the screen. The old single sticky `header` rule is overridden here rather than edited
 * above, so the admin side keeps the header it had. */

.shop .site-head {
  display: block;
  padding: 0;
  position: static;
  background: none;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  border-bottom: 0;
}

.shop .head-sticky {
  position: sticky;
  top: 0;
  z-index: 20;
  background: var(--header-surface);
  backdrop-filter: saturate(180%) blur(20px);
  -webkit-backdrop-filter: saturate(180%) blur(20px);
  border-bottom: 1px solid var(--border);
}

.utility-bar {
  background: var(--muted);
  border-bottom: 1px solid var(--border);
  font-size: 0.8125rem;
}
.utility-inner {
  max-width: 1320px;
  margin: 0 auto;
  padding: 0.4rem 1.75rem;
  display: flex;
  align-items: center;
  gap: 1.25rem;
}
.utility-links, .utility-account { display: flex; align-items: center; gap: 0.9rem; }
.utility-bar a { padding: 0; border-radius: 0; font-size: inherit; font-weight: 400; }
.utility-bar a:hover { background: none; }
.utility-account { margin-left: auto; }
.utility-bar a { color: var(--muted-foreground); }
.utility-bar a:hover { color: var(--foreground); }
.utility-sep { color: var(--placeholder); }

/* Sign out is a form post, because a GET that ends a session can be fired by an <img> tag
   on somebody else's page. The button is dressed as the links beside it: the visitor is
   choosing between "Orders" and "Sign out", and one of them looking like a control and the
   other like a link would make that choice read as two different kinds of thing. */
.utility-signout { display: flex; }
.utility-bar .linklike {
  background: none;
  border: 0;
  padding: 0;
  margin: 0;
  font: inherit;
  color: var(--muted-foreground);
  cursor: pointer;
  transition: color var(--duration) var(--ease);
}
.utility-bar .linklike:hover { color: var(--foreground); }
.utility-bar .linklike:focus-visible {
  outline: 2px solid var(--ring);
  outline-offset: 3px;
  border-radius: 4px;
}
@media (max-width: 900px) {
  /* The middle note is the first thing to go: every product page and the checkout say the
   * same thing, and the checkout says it where it actually matters. */
}
@media (max-width: 620px) {
  .utility-links { display: none; }
  .utility-account { margin-left: 0; }
}

.masthead-inner {
  max-width: 1320px;
  margin: 0 auto;
  padding: 0.9rem 1.75rem;
  display: flex;
  align-items: center;
  gap: 1.5rem;
}
.masthead .brand {
  font-weight: 600;
  font-size: 1.125rem;
  letter-spacing: -0.015em;
  display: flex;
  align-items: center;
  gap: 0.55rem;
  color: var(--foreground);
  flex: none;
}

/* Search. One field with a scope select welded to its left and the button to its right, as
 * a single object with one focus ring round the whole of it rather than three controls that
 * happen to be adjacent. */
.site-search {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  align-items: stretch;
  background: var(--input);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  transition: box-shadow var(--duration) var(--ease), border-color var(--duration) var(--ease);
}
.site-search:focus-within {
  border-color: var(--ring);
  box-shadow: var(--focus-ring);
}
.site-search .search-scope,
.site-search input[type="search"],
.site-search button {
  border: 0;
  background: none;
  box-shadow: none;
  margin: 0;
  height: auto;
  font-size: 0.9375rem;
}
.site-search .search-scope:focus-visible,
.site-search input[type="search"]:focus-visible,
.site-search button:focus-visible { outline: none; box-shadow: none; }
.site-search .search-scope {
  flex: none;
  max-width: 11rem;
  padding: 0.6rem 0.55rem 0.6rem 0.9rem;
  color: var(--muted-foreground);
  border-right: 1px solid var(--border);
  border-radius: var(--radius) 0 0 var(--radius);
  cursor: pointer;
}
.site-search input[type="search"] {
  flex: 1 1 auto;
  min-width: 0;
  padding: 0.6rem 0.9rem;
  color: var(--foreground);
}
.site-search input[type="search"]::placeholder { color: var(--placeholder); }
/* The fill has to be restated here. The group reset above strips `background` from all three
   controls so the field reads as one object, and `.site-search button` (0,1,1) outranks the
   `.primary` class the markup carries (0,1,0) — while `.primary:hover` (0,2,0) outranks the
   reset. The button was therefore invisible at rest and blue on hover. Found 2026-08-25.
   Stated explicitly rather than by raising `.primary`, because this button is welded into
   the field and owns its own geometry either way. */
.site-search button {
  flex: none;
  padding: 0.6rem 1.4rem;
  border-radius: 0 calc(var(--radius) - 1px) calc(var(--radius) - 1px) 0;
  font-weight: 550;
  background: var(--primary);
  color: var(--primary-foreground);
  cursor: pointer;
  transition: background var(--duration) var(--ease);
}
.site-search button:hover { background: var(--primary-hover); }
.site-search button:active { background: var(--primary-hover); }

/* The field draws one ring round the whole object on :focus-within, which is why the reset
   removes the individual ones. Keyboard focus on the button alone still has to be visible,
   though, or tabbing to it lands somewhere invisible — so it gets an inset ring that reads
   inside the shared border. */
.site-search button:focus-visible {
  outline: 2px solid var(--primary-foreground);
  outline-offset: -4px;
}
/* The cart is a filled control rather than a text link. It is the one thing in the
 * masthead that is not navigation — it holds what you are about to buy — and it carries
 * the commerce green for the same reason "Add to Cart" does. */
.masthead .cart-link {
  flex: none;
  font-size: 0.9375rem;
  padding: 0.55rem 1rem;
  border-radius: var(--radius-sm);
}
.masthead .cart-link .cart-count {
  background: color-mix(in srgb, var(--commerce-foreground) 22%, transparent);
  color: var(--commerce-foreground);
}

@media (max-width: 860px) {
  /* Search drops onto its own line rather than shrinking to a slot nothing fits in. */
  .masthead-inner { flex-wrap: wrap; gap: 0.75rem 1rem; }
  .site-search { order: 3; flex-basis: 100%; }
  .masthead .cart-link { margin-left: auto; }
  .site-search .search-scope { max-width: 7.5rem; }
}

/* The magnifier that stands in for the search row on a phone. Hidden by default and drawn
 * only inside the phone block below, so a wide screen never sees it. */
.search-toggle { display: none; }

/* ------------------------------------------------------------------ the phone's chrome */

/* 80% of this shop's customers arrive on a phone, and until 2026-08-28 nothing here had
 * been looked at on one. Two things in this block are corrections rather than adjustments.
 *
 * **The masthead folds.** Three stacked rows — logo and cart, search, categories — were
 * 175px of chrome at the top of every page: 21% of a 812px portrait viewport and 47% of a
 * landscape one. (`.head-sticky` is named for an intention it does not currently carry; see
 * Specification 28.7.) The search becomes a button, which takes it to about 109px. It is folded
 * and not dropped: the form is the same form and submits the same two fields, the button
 * says what it is to a screen reader, and base.html's `<noscript>` unfolds it for a browser
 * that cannot press it.
 *
 * **Fields are 16px.** Safari zooms the whole page in when a field under 16px takes focus,
 * and does not zoom back out afterwards — so typing a name into the engraving field left
 * the shop magnified and scrolled sideways, with no way back but a pinch. The search box was
 * 15px and the engraving field 14px. This is the one place in the stylesheet where a size is
 * set by what a browser does rather than by the type scale.
 *
 * Two conditions, because a phone on its side is 812px wide and would miss a width-only
 * query entirely -- while being the viewport that can least afford the chrome: 175px of a
 * 375px-tall screen is 47% of it gone before the page starts. Width catches the phone
 * standing up; the short-and-wide pair catches it lying down. */
@media (max-width: 700px), (orientation: landscape) and (max-height: 560px) {
  .search-toggle {
    display: inline-flex;
    flex: none;
    margin-left: auto;
    padding: 0.55rem;
  }
  .masthead-inner .site-search { display: none; }
  .masthead-inner.search-open .site-search { display: flex; }
  /* The cart keeps its place at the end of the row; the magnifier sits before it. */
  .masthead .cart-link { margin-left: 0; }

  .site-search input[type="search"],
  .site-search .search-scope,
  /* The cartoonizer's own text fields. The workshop's inputs are 14px by design - it is a
   * dense workshop - but these two are typed into rather than read, and one of them moves to
   * a product page when this feature does. Safari zooms the page in on a field under 16px
   * and does not zoom back out. */
  #instructions,
  #again-instructions,
  #note,
  /* The per-line instructions box on a product page, added 2026-08-30. A textarea rather
   * than an input, and the rule is the same: Safari zooms on anything under 16px. */
  #line-notes,
  .engraving-group input { font-size: 16px; }

  /* Apple's minimum is 44px square and every one of these was under it: the call to action
   * that ends the whole flow was 38. A thumb is not a mouse pointer, and the control it
   * misses here is the one that spends money. */
  button.lg, .btn.lg,
  .photo-action,
  .masthead .cart-link,
  .compare-toggle,
  .search-toggle { min-height: 44px; min-width: 44px; }
  .utility-bar a, .utility-bar .linklike { padding: 0.35rem 0.2rem; }

  /* There is no dragging a file on a phone, and this was a full block of the screen saying
   * so. The two controls above it do the same job by tapping. */
  .dropbox { display: none; }
}

/* The cart keeps its icon, its green and its count, and puts its words away.
 *
 * Logo, magnifier and a 159px "Shopping Cart" do not fit across 375px, and the masthead
 * wraps: the cart drops to a line of its own and the row that was supposed to get shorter
 * gets taller. Hidden the accessible way rather than deleted — a bare cart glyph is a
 * picture, and a screen reader is still owed the name of the link it is on. */
@media (max-width: 520px) {
  .masthead .cart-link .cart-words {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
  .masthead .cart-link { padding: 0.55rem 0.75rem; gap: 0.3rem; }
}

@media (max-width: 520px) {
  /* On a phone the scope select was taking nearly 40% of the row and the field it belongs
   * to read "Search b". The select is kept rather than hidden — dropping it would silently
   * change what the form submits — but narrowed to just its own label. */
  .site-search .search-scope { max-width: 4.75rem; padding-left: 0.7rem; }
  .site-search button { padding: 0.6rem 1rem; }
}

/* The category bar. Scrolls sideways on a narrow window rather than collapsing into a
 * hamburger: eight names fit, and a menu you have to open to find out what a shop sells is
 * a worse trade than a row that scrolls. */
.category-bar { overflow: hidden; }
.category-inner {
  max-width: 1320px;
  margin: 0 auto;
  padding: 0 1.75rem;
  display: flex;
  align-items: stretch;
  gap: 0.15rem;
  overflow-x: auto;
  scrollbar-width: none;
}
.category-inner::-webkit-scrollbar { display: none; }

.category-tab {
  flex: none;
  display: flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.7rem 0.8rem;
  font-size: 0.875rem;
  font-weight: 500;
  color: var(--muted-foreground);
  white-space: nowrap;
  border-radius: 0;
  border-bottom: 2px solid transparent;
  transition: color var(--duration) var(--ease), border-color var(--duration) var(--ease);
}
.category-tab:hover {
  color: var(--foreground);
  background: none;
  border-bottom-color: var(--border-strong);
}
.category-tab.on { color: var(--foreground); border-bottom-color: var(--primary); }
.category-tab:focus-visible {
  outline: none;
  box-shadow: var(--focus-ring);
  border-radius: var(--radius-sm);
}

/* Deals gets the one accent color, which is the one place on this bar anything is meant to
 * pull the eye. Deliberately not the red a shop of this shape usually paints it: red here is
 * --destructive, and borrowing an error color for a promotion would say something the page
 * does not mean. */
.category-tab.deals { color: var(--primary); font-weight: 600; }
.category-tab.deals:hover { color: var(--primary-hover); border-bottom-color: var(--primary); }
.category-tab.all { color: var(--placeholder); margin-left: auto; }
.category-tab.all:hover { color: var(--foreground); }
@media (max-width: 1100px) { .category-tab.all { margin-left: 0; } }

.deal-count {
  font-size: 0.6875rem;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  padding: 0.1rem 0.35rem;
  border-radius: 999px;
  background: var(--primary);
  color: var(--primary-foreground);
}

/* -------------------------------------------------------------------- the sliding panel */

/* The home page carried a closing card until 2026-08-24, and the page's bottom padding was
   the air beneath it. With the card gone that padding is a blank screen between the last
   rail block and the footer, so the home page hands the job to the footer's own 5rem margin
   instead. Every other page keeps the site rhythm; only the one that lost a section changes. */
.shop.home main { max-width: 1320px; padding-bottom: 0; }

.hero-slider { position: relative; margin-top: 0.5rem; }

.hero-track {
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
  border-radius: var(--radius-lg);
}
.hero-track::-webkit-scrollbar { display: none; }

.hero-slide {
  position: relative;
  flex: 0 0 100%;
  scroll-snap-align: start;
  background: var(--tint, var(--muted));
  padding: 3.5rem 5rem;
  min-height: 20rem;
  display: flex;
  align-items: center;
}

/* The photo fills the whole panel, and the copy sits on the half of it that was
 * composed empty. Cover rather than contain: the artwork is cropped to the panel's shape
 * already, so this is trimming a few pixels off the top and bottom at other widths rather
 * than choosing a crop. The tint underneath is what shows when there is no file. */
.hero-slide-art {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* The right edge of the frame is pinned to the right edge of the panel.
   *
   * The panel's height is set by however far the copy wraps, so a narrow window makes it
   * much taller than the 2.73:1 the artwork was drawn at, and `cover` then trims the sides.
   * Centred, it trims both — and since every one of these frames is composed with its
   * subject in the right third and empty ground on the left, half of that trim comes
   * straight off the goods. Anchored right, all of it comes off the empty side instead. */
  object-position: right center;
}

/* A whisper of the page's own white over the left of the picture, so the copy is legible
 * whatever the generation put there. It is a guarantee rather than a look: the prompt asks
 * for a pale, empty left half and mostly gets one, and "mostly" is not a contrast ratio.
 *
 * **The z-index is the whole guarantee, and it was missing until 2026-08-28.** This and the
 * `<img>` are both absolutely positioned with no stacking order between them, so they paint
 * in tree order — pseudo-element first, picture second, scrim covered. It had never done
 * anything. Nobody saw it because the composition it exists to insure against is the rare
 * one: the left third is usually pale on its own, which is precisely the "mostly" this rule
 * was written not to rely on. The slide with a dark trophy under the headline is what showed
 * it. `.hero-slide-copy` sits above both. */
.hero-slide.has-art::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  background: linear-gradient(
    to right,
    color-mix(in srgb, var(--card) 78%, transparent) 0%,
    color-mix(in srgb, var(--card) 58%, transparent) 34%,
    transparent 66%
  );
}

.hero-slide-copy { position: relative; z-index: 2; max-width: 46ch; }
.hero-slide h2 {
  margin: 0.5rem 0 0.9rem;
  font-size: clamp(1.75rem, 3.6vw, 2.75rem);
  line-height: 1.08;
  letter-spacing: -0.028em;
  text-transform: none;
  color: var(--tint-ink);
}
.hero-slide .eyebrow { color: var(--tint-ink); opacity: 0.62; }
.hero-slide .lede {
  color: var(--tint-ink);
  opacity: 0.82;
  margin-bottom: 1.5rem;
  max-width: none;
}

/* Arrows sit over the panel and are the one place a translucent white is right: a solid
 * button would have to pick a ground, and there are eight of them. */
.hero-arrow {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  width: 2.75rem;
  height: 2.75rem;
  display: grid;
  place-items: center;
  font-size: 1.5rem;
  line-height: 1;
  padding: 0;
  border-radius: 999px;
  border: 1px solid color-mix(in srgb, var(--tint-ink) 12%, transparent);
  background: color-mix(in srgb, var(--card) 82%, transparent);
  color: var(--tint-ink);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
}
.hero-arrow:hover { background: var(--card); }
.hero-arrow.prev { left: 1rem; }
.hero-arrow.next { right: 1rem; }

.hero-dots {
  position: absolute;
  bottom: 1rem;
  left: 0;
  right: 0;
  display: flex;
  justify-content: center;
  gap: 0.45rem;
}
.hero-dot {
  width: 0.5rem;
  height: 0.5rem;
  padding: 0;
  border: 0;
  border-radius: 999px;
  background: color-mix(in srgb, var(--tint-ink) 24%, transparent);
  transition: background var(--duration) var(--ease), transform var(--duration) var(--ease);
}
.hero-dot:hover { background: color-mix(in srgb, var(--tint-ink) 44%, transparent); }
.hero-dot.on { background: var(--tint-ink); transform: scale(1.25); }

/* **The picture never goes away.** It used to be withdrawn below 860px, on the grounds that
 * the copy would cover the subject — but 860px is a narrow desktop window, not a phone, and
 * the slide still has room for both there. What that rule actually did was replace a
 * photo of the goods with a flat tinted panel on the largest element of the home page,
 * at every width most customers use. Changed 2026-08-28 at the operator's request.
 *
 * Measured before choosing where the layout has to change: at 820px and at 640px the copy
 * sits happily beside the subject and the panel reads as it does on a desktop. At 375px it
 * does not — the copy fills the width, and because the subject is composed in the right
 * third of a 2.73:1 frame, `cover` crops it away entirely and the picture becomes a beige
 * background. That is the case the old rule was right about, and hiding the file was the
 * wrong answer to it. */

@media (max-width: 760px) {
  .hero-slide { padding: 2.5rem 1.5rem 3rem; min-height: 17rem; }
  .hero-arrow { display: none; }
}

/* Narrow: the copy loses its body paragraph and keeps the picture behind it.
 *
 * Stacking the two was tried first and rejected by the operator. This does the same job by
 * subtraction: the lede is three or four lines of prose at this width, it is what makes the
 * panel tall, and a tall panel is exactly what makes `cover` crop the subject away. Dropping
 * it shortens the panel, which un-crops the picture, and leaves the eyebrow, the headline and
 * the button — which are the whole offer and the way in.
 *
 * The heading still has to be legible over the artwork, so the scrim stays and is taken
 * further across: at this width the copy is most of the panel rather than its left half. */
@media (max-width: 620px) {
  .hero-slide .lede { display: none; }
  .hero-slide.has-art::before {
    background: linear-gradient(
      to right,
      color-mix(in srgb, var(--card) 82%, transparent) 0%,
      color-mix(in srgb, var(--card) 58%, transparent) 62%,
      color-mix(in srgb, var(--card) 12%, transparent) 100%
    );
  }
}

/* ------------------------------------------------------------ home: rail and carousels */

.home-columns {
  margin-top: 3rem;
  display: grid;
  grid-template-columns: 300px minmax(0, 1fr);
  gap: 3rem;
  align-items: start;
}
.home-columns > * { min-width: 0; }
.home-main > .band:first-child { margin-top: 0; }

@media (max-width: 1040px) {
  /* The rail goes below the products rather than above them. Eleven paragraphs of
   * merchandising ahead of the shop on a phone would bury the shop. */
  .home-columns { grid-template-columns: 1fr; gap: 2rem; }
  .rail { order: 2; }
  .home-main { order: 1; }
}

.rail { display: flex; flex-direction: column; gap: 2rem; }
.rail-block { font-size: 0.9375rem; }
.rail-block p { color: var(--muted-foreground); margin: 0.5rem 0 0; }
.rail-heading {
  margin: 0;
  font-size: 1rem;
  font-weight: 600;
  letter-spacing: -0.012em;
  text-transform: none;
  color: var(--foreground);
}
.rail-more { margin-top: 0.5rem !important; font-weight: 500; }
.rail-links { margin: 0.6rem 0 0; padding-left: 1.1rem; color: var(--muted-foreground); }
.rail-links li { margin-bottom: 0.3rem; }
.rail-block .hint { margin-top: 0.6rem; }

/* The one featured deal, in a frame of its own. */
.deal-card {
  display: block;
  margin-top: 0.75rem;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
  background: var(--card);
  transition: box-shadow var(--duration) var(--ease), transform var(--duration) var(--ease);
}
.deal-card:hover { box-shadow: var(--shadow); transform: translateY(-2px); }
.deal-card:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.deal-card-stage {
  display: grid;
  place-items: center;
  aspect-ratio: 4 / 3;
  background: var(--tint, var(--muted));
}
.deal-card-stage img { width: 100%; height: 100%; object-fit: cover; }
.deal-card-caption {
  display: block;
  padding: 0.8rem 0.9rem;
  background: var(--foreground);
  color: var(--background);
}
.deal-card-title { display: block; font-weight: 550; font-size: 0.9375rem; }
.deal-card-price {
  display: block;
  margin-top: 0.15rem;
  font-size: 0.8125rem;
  opacity: 0.78;
  font-variant-numeric: tabular-nums;
}
.deal-card-price s { opacity: 0.7; }

/* The placeholder tile behind a product with no photo. The category name set large and
 * low-contrast: obviously a placeholder, and it still gives the grid a rhythm. */
/* The tile behind a product with no photo: the category name, centred, in the tint's
 * own ink knocked well back. Loud enough to identify the card, quiet enough to read as a
 * placeholder rather than as a design someone chose instead of a photo. */
.tile-mark {
  padding: 0 1rem;
  text-align: center;
  font-size: 1.0625rem;
  font-weight: 600;
  letter-spacing: -0.01em;
  color: color-mix(in oklab, var(--tint-ink), var(--tint, var(--muted)) 55%);
}
/* The stage positions its image and its old empty state absolutely, so the tile has to be
 * placed the same way rather than relying on the stage being a grid. */
.product-card-stage .tile-mark {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
}
.product-card-stage[class*="tint-"] { background: var(--tint); }

/* The product page's own placeholder, for a product with neither photo nor model. */
.stage-tile {
  /* Positioned so the camera overlay has something to fill. A product with no photos
     still has a camera button, and the tile is its stage. */
  position: relative;
  overflow: hidden;
  display: grid;
  place-content: center;
  gap: 0.4rem;
  border-radius: var(--radius-lg);
  background: var(--tint, var(--muted));
  text-align: center;
}
.stage-tile .tile-mark { font-size: 1.375rem; }
.stage-tile .hint { color: color-mix(in oklab, var(--tint-ink), var(--tint) 42%); }

/* The steps and the one turnable model on the page, side by side.
 *
 * They were two stacked bands until 2026-08-24, with the steps running across three
 * columns. Three short paragraphs side by side leave a wide empty band beneath them, and
 * the model repeated the same copy-left, stage-right shape directly below it. Stacking the
 * steps turns that empty width into height, and height is the shape a standing figurine
 * wants beside it. */
.steps-band.has-stage {
  display: grid;
  grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
  gap: 2.5rem 3rem;
  /* Stretched, not centred. The stage used to carry its own 4/5 aspect ratio, which made
   * it taller than the copy beside it and left the band lopsided whichever way it was
   * aligned. Here the copy sets the height of the row and the stage takes it, so the two
   * columns end level and there is no leftover space to distribute. */
  align-items: stretch;
}
.steps-band.has-stage .showpiece-stage { display: grid; }
.steps-band.has-stage .showpiece-stage .viewer {
  aspect-ratio: auto;
  max-height: none;
  /* A floor, in case the copy beside it is ever short. */
  min-height: 18rem;
}
/* The canvas comes out of flow here, and this is load-bearing rather than tidy. three.js
 * sizes the canvas from the container's client box, so a canvas in normal flow becomes the
 * container's content height and the two then chase each other: the stage keeps whatever
 * height it had and never shrinks to the row. Out of flow, the stage's height comes only
 * from the grid row, which the copy column sets. */
.steps-band.has-stage .showpiece-stage .viewer canvas {
  position: absolute;
  inset: 0;
  margin: auto;
}
.steps-band .steps-copy { min-width: 0; }
.steps-band.has-stage .steps { grid-template-columns: 1fr; gap: 1.5rem; }
.steps-band .showpiece-stage { min-width: 0; }
.steps-band .steps + .btn { margin-top: 1.75rem; }
@media (max-width: 820px) {
  .steps-band.has-stage { grid-template-columns: 1fr; gap: 1.75rem; }
  /* Back to a row of cards once the model is underneath rather than beside them. */
  .steps-band.has-stage .steps {
    grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
    gap: 2rem;
  }
  /* And the stage gets its shape back. Matching the copy's height is what keeps the two
   * columns level; underneath it there is no column to be level with, and a full-width
   * letterbox stands a standing figure in the middle of a lot of nothing. */
  .steps-band.has-stage .showpiece-stage .viewer {
    aspect-ratio: 4 / 5;
    max-height: 70svh;
  }
}

/* --------------------------------------------------------------- category and browsing */

/* Both of these are <header> elements, and the base `header` rule is a sticky flex row
 * built for the one at the top of the page. Without resetting it the category page laid its
 * eyebrow, heading and option summary out as three centred columns. */
.page-head,
/* Undoing the global `header` rule, which was written for the site header and reaches every
   <header> element on the page. This reset already covered display, position, background and
   the border; it missed the padding, which indented every policy title 28px from its own
   column. Found 2026-08-25. */
.policy-head {
  display: block;
  position: static;
  gap: 0;
  padding: 0;
  background: none;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  border-bottom: 0;
}

.page-head {
  background: var(--tint, var(--muted));
  border-radius: var(--radius-lg);
  padding: 2.5rem 2.25rem;
  margin-bottom: 2.5rem;
}
.page-head h1 { margin: 0.4rem 0 0.75rem; max-width: 32ch; }
.page-head .eyebrow, .page-head h1 { color: var(--tint-ink); }
.page-head .lede { color: var(--tint-ink); opacity: 0.82; max-width: 62ch; margin-bottom: 0; }

.option-summary {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
  gap: 1.25rem;
  margin: 1.75rem 0 0;
  padding-top: 1.5rem;
  border-top: 1px solid color-mix(in srgb, var(--tint-ink) 12%, transparent);
}
.option-summary dt {
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.03em;
  text-transform: uppercase;
  color: var(--tint-ink);
  opacity: 0.55;
}
.option-summary dd {
  margin: 0.3rem 0 0;
  font-size: 0.9375rem;
  color: var(--tint-ink);
}
@media (max-width: 620px) { .page-head { padding: 1.75rem 1.35rem; } }

/* A category page that leads with artwork rather than with type.
 *
 * The headline, the sub-copy and the call to action are painted into the picture, which
 * makes two things load-bearing here. The picture is the link, because a painted button
 * that does nothing is worse than no button. And the typeset copy is hidden from the eye
 * but not from the document: `display: none` would leave the page with no heading for a
 * screen reader on exactly the wide screen the artwork was made for.
 *
 * Below 860px the picture goes away entirely. Text baked into a raster at 375px wide is
 * unreadable, and the typeset header it replaced is not. */

.page-head.has-banner { padding: 0; overflow: hidden; }

.page-head-banner {
  display: block;
  width: 100%;
  height: auto;
  border-radius: var(--radius-lg) var(--radius-lg) 0 0;
}

/* The strip under the picture: the option groups, then the call to action at the end of
 * the row. The button was floating over the artwork until 2026-08-24, where it covered the
 * lettering on the plinth — a control on a photo hides whatever the photo put
 * underneath it, and which part that is changes every time the artwork is recut. */
.page-head.has-banner .page-head-body {
  display: flex;
  align-items: center;
  gap: 1.25rem 2rem;
  padding: 1.5rem 2.25rem 1.75rem;
}
.page-head.has-banner .option-summary { flex: 1 1 auto; }
.page-head-action { flex: none; }
.page-head.has-banner .page-head-copy {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
/* The one thing in the hidden copy that must not merely be invisible. Clipped text is
 * still focusable, so tabbing through the page landed on an "Upload a photo" button
 * nobody could see and focus appeared to vanish. It is taken out of the tab order rather
 * than left there, because the banner above is already a link to the same place. */
.page-head.has-banner .page-head-cta { display: none; }
.page-head.has-banner .option-summary {
  margin: 0;
  padding-top: 0;
  border-top: 0;
}

@media (max-width: 860px) {
  .page-head.has-banner { padding: 2.5rem 2.25rem; }
  .page-head-banner { display: none; }
  /* The typeset header comes back below here and brings its own button, so this one would
   * be a second control offering the same thing. */
  .page-head-action { display: none; }
  .page-head.has-banner .page-head-body { display: block; padding: 0; }
  .page-head.has-banner .page-head-copy {
    position: static;
    width: auto;
    height: auto;
    overflow: visible;
    clip-path: none;
    white-space: normal;
  }
  .page-head.has-banner .page-head-cta { display: block; }
  .page-head.has-banner .option-summary {
    margin-top: 1.75rem;
    padding-top: 1.5rem;
    border-top: 1px solid color-mix(in srgb, var(--tint-ink) 12%, transparent);
  }
}
@media (max-width: 620px) {
  .page-head.has-banner { padding: 1.75rem 1.35rem; }

  /* Button and note stack instead of sharing a wrapped flex line. Side by side at 375px the
   * note had nowhere to go: it began level with the middle of the button and its remaining
   * words ran on underneath, so the sentence read as though it were crossing out the
   * control. The button also takes the full width, which is what a phone wants of the one
   * thing on the screen it is being asked to press. */
  .page-head-cta { display: block; }
  .page-head-cta .btn { display: flex; width: 100%; }
  .page-head-cta-note { display: block; margin-top: 0.75rem; }
}

.category-cards {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
  gap: 1rem;
}
.category-card {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
  padding: 1.25rem 1.35rem;
  border-radius: var(--radius);
  background: var(--tint, var(--muted));
  color: var(--tint-ink);
  transition: transform var(--duration) var(--ease), box-shadow var(--duration) var(--ease);
}
.category-card:hover { transform: translateY(-2px); box-shadow: var(--shadow); color: var(--tint-ink); }
.category-card:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.category-card-title { font-weight: 600; letter-spacing: -0.012em; }
.category-card-blurb { font-size: 0.875rem; opacity: 0.78; }

/* ------------------------------------------------------------------------- policies */

.policy-index {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
  gap: 0.75rem;
  margin-top: 2rem;
}
.policy-index-item {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  padding: 1.15rem 1.3rem;
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  color: var(--foreground);
  transition: box-shadow var(--duration) var(--ease), transform var(--duration) var(--ease);
}
.policy-index-item:hover { box-shadow: var(--shadow); transform: translateY(-2px); color: var(--foreground); }
.policy-index-item:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.policy-index-title { font-weight: 600; letter-spacing: -0.012em; }
.policy-index-summary { font-size: 0.875rem; color: var(--muted-foreground); }

/* A policy page was a 68ch column alone on a 1120px page: the prose started 128px right of
   the masthead's logo and the right half of the screen was empty. Fixed on 2026-08-25 by
   aligning the container with the masthead and giving the other policies the empty side. */
.shop.policy-page main { max-width: 1320px; }

.policy-layout {
  display: grid;
  grid-template-columns: minmax(0, 68ch) minmax(0, 1fr);
  gap: 3rem;
  align-items: start;
}

/* A policy is long-form prose, so it gets a measure. Never centred and never justified. */
.policy { max-width: 68ch; }

.policy-aside-inner {
  /* Flush to the right edge rather than floating in the middle of its column, so both sides
     of the page are anchored and the space between reads as a gutter instead of a gap. */
  max-width: 17rem;
  margin-left: auto;
  position: sticky;
  top: 7.5rem;
}

.policy-aside h2 {
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--muted-foreground);
  margin: 0 0 0.75rem;
}

/* A guide's aside carries two groups, and the second heading otherwise sat directly on the
   last link of the first with nothing between them. Space rather than a rule: hierarchy here
   comes from the gap, per the design language. */
.policy-aside h2 + .policy-other { margin-bottom: 1.75rem; }
.policy-aside .policy-other:last-child { margin-bottom: 0; }

.policy-aside .policy-other {
  display: flex;
  flex-direction: column;
  gap: 0.55rem;
  font-size: 0.875rem;
}

.policy-aside .policy-other a { color: var(--muted-foreground); }
.policy-aside .policy-other a:hover { color: var(--foreground); }

@media (max-width: 1000px) {
  .policy-layout { grid-template-columns: minmax(0, 1fr); gap: 2.5rem; }
  .policy-aside-inner {
    max-width: none;
    margin-left: 0;
    position: static;
    padding-top: 1.75rem;
    border-top: 1px solid var(--border);
  }
  .policy-aside .policy-other { flex-direction: row; flex-wrap: wrap; gap: 0.4rem 1.1rem; }
}
.policy-head { margin-bottom: 2.5rem; }
.policy-head h1 { margin: 0.4rem 0 0.75rem; }
.policy-head .lede { margin-bottom: 0.75rem; }
.policy-section { margin-bottom: 2.25rem; }
.policy-section h2 {
  font-size: 1.125rem;
  font-weight: 600;
  letter-spacing: -0.012em;
  text-transform: none;
  color: var(--foreground);
  margin: 0 0 0.75rem;
}
.policy-section p { color: var(--muted-foreground); margin: 0 0 0.9rem; }
.policy-section ul { color: var(--muted-foreground); margin: 0 0 0.9rem; padding-left: 1.2rem; }
.policy-section li { margin-bottom: 0.4rem; }
.policy-foot { padding-top: 1.75rem; border-top: 1px solid var(--border); }
.policy-foot h2 {
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.03em;
  text-transform: uppercase;
  color: var(--muted-foreground);
  margin: 0 0 0.75rem;
}
.policy-other { display: flex; flex-wrap: wrap; gap: 0.4rem 1.1rem; font-size: 0.9375rem; }

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

.site-foot .foot-inner { max-width: 1320px; }

/* Brand on the left, the four link columns filling the rest. `auto-fit` on the columns
   alone was spreading four short lists across the full width and leaving the brand a band
   of its own underneath. */
.foot-top {
  display: grid;
  grid-template-columns: minmax(15rem, 19rem) minmax(0, 1fr);
  gap: 1.5rem 3.5rem;
  padding-bottom: 1.75rem;
}

.foot-columns {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr));
  gap: 1.75rem;
}
/* A nav label, not a heading. These were <h2> until 2026-08-27, which put four
   content-free headings at the end of every document: on a product page that was four of
   the six headings on the page, and a screen reader's heading list was mostly footer. */
.foot-column-label {
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.03em;
  text-transform: uppercase;
  color: var(--muted-foreground);
  margin: 0 0 0.85rem;
}
.foot-column ul { list-style: none; margin: 0; padding: 0; }
.foot-column li { margin-bottom: 0.3rem; }
.foot-column a { font-size: 0.9375rem; color: var(--muted-foreground); }
.foot-column a:hover { color: var(--foreground); }

/* No longer a band of its own, so no rule above it and no padding to separate it from
   something that is now beside it rather than below. */
.foot-brandline { padding: 0; }
.foot-brandline .hint { max-width: none; font-size: 0.8125rem; }
.foot-signout { margin-top: 0.75rem; }

.foot-legal {
  padding-top: 1.25rem;
  border-top: 1px solid var(--border);
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.5rem 1.5rem;
  font-size: 0.8125rem;
}
.foot-copyright { margin: 0; color: var(--muted-foreground); }
.foot-legal-links {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem 1.1rem;
  margin-left: auto;
}
.foot-legal-links a { color: var(--muted-foreground); }
.foot-legal-links a:hover { color: var(--foreground); }
@media (max-width: 860px) { .foot-top { grid-template-columns: 1fr; } }
@media (max-width: 720px) { .foot-legal-links { margin-left: 0; } }

/* ------------------------------------------------------- material swatches, all of them
 *
 * The chip swatch used to have three dots because there were three chocolates. Every
 * material now needs one, or a fabric picker shows three empty circles. Cloth and ceramic
 * borrow the category tints, which is what they are for. */
.dot-combed_cotton   { background: var(--tint-indigo); }
.dot-cotton_poly     { background: var(--tint-slate); }
.dot-organic_cotton  { background: var(--tint-moss); }
.dot-brushed_fleece  { background: var(--tint-sand); }
.dot-ceramic         { background: var(--tint-clay); }
.dot-enamel          { background: var(--tint-slate); }
.dot-cotton_twill    { background: var(--tint-sand); }
.dot-performance_mesh { background: var(--tint-moss); }
.dot-canvas          { background: var(--tint-sand); }
.dot-organic_canvas  { background: var(--tint-moss); }
.dot-waxed_canvas    { background: var(--tint-rust); }
.dot-bamboo_blend    { background: var(--tint-rose); }

/* ------------------------------------------------------- arriving to upload a photo
 *
 * The category page's upload button sends people to the custom product with `?upload=1`, and
 * the page cannot open the file chooser for them: a navigation ends transient user
 * activation and showPicker() raises NotAllowedError. So it rings the control instead, which
 * is what turns "the page loaded somewhere" into "here, click this".
 *
 * Two settle-down rules. It stops after three pulses rather than throbbing forever, because
 * a control that never stops asking reads as an error. And with reduced motion it does not
 * pulse at all — the ring is simply on, which carries the same information. */

.page-head-cta {
  display: flex;
  align-items: center;
  gap: 0.9rem 1.1rem;
  flex-wrap: wrap;
  margin: 1.75rem 0 0;
}
.page-head-cta-note {
  font-size: 0.875rem;
  color: var(--tint-ink);
  opacity: 0.7;
}

.dropzone.calling {
  border-color: var(--primary);
  animation: dropzone-calling 1.1s var(--ease) 3;
}

@keyframes dropzone-calling {
  0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--ring) 0%, transparent); }
  50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--ring) 22%, transparent); }
}

@media (prefers-reduced-motion: reduce) {
  .dropzone.calling {
    animation: none;
    box-shadow: var(--focus-ring);
  }
}

/* ------------------------------------------------------------------ workshop tabs
   The workshop nav became a tab strip on 2026-08-24, when the operator's five screens
   arrived beside the four tools that were already there. The current tab is marked with
   weight and a rule beneath it rather than a filled pill: the header is already a busy
   row, and a block of color in it would compete with the balance readout. */

header nav.tabs a[aria-current="page"] {
  color: var(--foreground);
  font-weight: 600;
  background: var(--accent);
}

header nav.tabs .tab-divider {
  width: 1px;
  align-self: stretch;
  margin: 0.2rem 0.5rem;
  background: var(--border);
  flex: 0 0 auto;
}

/* The picker on the sessions screen, and anywhere else a window is chosen. Small, quiet,
   and the current one is simply the one that is not muted. */
.window-picker {
  display: flex;
  gap: 0.25rem;
  margin: 0 0 1.25rem;
}

.window-picker a {
  color: var(--muted-foreground);
  font-size: 0.8125rem;
  font-weight: 500;
  padding: 0.3rem 0.65rem;
  border-radius: var(--radius-sm);
  transition: background var(--duration) var(--ease), color var(--duration) var(--ease);
}

.window-picker a:hover { color: var(--foreground); background: var(--accent); }

.window-picker a[aria-current="page"] {
  color: var(--foreground);
  background: var(--accent);
  font-weight: 600;
}

/* ---------------------------------------------------------------------- sessions
   A session is a <details>, so the browser does the disclosure and the whole timeline
   stays in the page for the browser's own find. Rows are separated by space and a hairline
   only where two summaries would otherwise touch. */

.session { border-top: 1px solid var(--border); }
.session:first-of-type { border-top: 0; }

/* The generic `details summary` above is a tiny uppercase "show more" label, which is
   right for a disclosure inside a card and wrong for a row of data. Reset it here rather
   than weakening that rule, which several other screens depend on. */
.session > summary {
  display: grid;
  grid-template-columns: 1.1rem 9.5rem minmax(7rem, 0.9fr) minmax(0, 1fr) minmax(0, 1.1fr) auto;
  gap: 0.9rem;
  align-items: baseline;
  padding: 0.6rem 0.5rem;
  cursor: pointer;
  border-radius: var(--radius-sm);
  transition: background var(--duration) var(--ease);
  text-transform: none;
  letter-spacing: normal;
  font-size: 0.875rem;
  font-weight: 400;
  color: var(--foreground);
}

/* The chevron becomes the first grid cell, so the columns beside it line up down the
   whole list instead of each row starting wherever its marker ended. */
.session > summary::before { margin-right: 0; justify-self: center; }
.session > summary:hover { color: var(--foreground); }

.session > summary:hover { background: var(--accent); }
.session > summary:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }

/* ------------------------------------------------------------------- attribution
   Two badges on a session row: where the visit came from, and which campaign. They are the
   only color in a list that is otherwise grey text, because they are the thing the eye is
   meant to catch when scanning a hundred rows. Neither is an accent that means something
   else on this site: --primary navigates and --commerce spends money, so these take the
   quiet muted ground and earn their weight from the border and the letterform instead.
   See Specification 26.1.2 on why a third accent is not introduced here. */
.session-tags {
  display: flex;
  gap: 0.3rem;
  flex-wrap: wrap;
  align-items: baseline;
  min-width: 0;
}

.tag {
  display: inline-block;
  max-width: 12rem;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 0.75rem;
  font-weight: 500;
  line-height: 1.5;
  padding: 0.05rem 0.45rem;
  border-radius: var(--radius-sm);
  border: 1px solid var(--border);
  background: var(--muted);
  color: var(--foreground);
}

/* A campaign is secondary to its source: same shape, less weight, so the pair reads as
   "facebook, and within it summer_launch" rather than as two equal facts. */
.tag-campaign {
  background: transparent;
  color: var(--muted-foreground);
  font-weight: 400;
}

/* What a visit was looked at on. Neutral, deliberately: the source and campaign chips beside
 * it are about money and this one is about a screen, so it must not compete with them for a
 * reader's eye. Three coarse facts, never the User-Agent - see `figurine.devices`. */
.tag-device {
  background: var(--muted);
  color: var(--muted-foreground);
  border-color: var(--border);
}

/* Direct is a real answer, not a missing one, so it is not styled as an absence — but it is
   not an advertisement either, and it should not draw the eye the way a campaign does. */
.tag-direct {
  background: transparent;
  color: var(--muted-foreground);
  font-weight: 400;
  border-style: dashed;
}

/* The traffic-source block inside an opened session. Sits above the timeline because it is
   the context for it: where they came from, then what they did. */
.attribution {
  margin: 0.35rem 0 0.9rem;
  padding: 0.75rem 0.9rem;
  background: var(--muted);
  border-radius: var(--radius-sm);
}

.attribution h3 {
  font-size: 0.75rem;
  font-variant: small-caps;
  letter-spacing: 0.04em;
  color: var(--muted-foreground);
  margin: 0 0 0.5rem;
}

.attr-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
  gap: 0.5rem 1.25rem;
  margin: 0;
}

.attr-grid dt {
  font-size: 0.6875rem;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  color: var(--muted-foreground);
  margin: 0;
}

.attr-grid dd { margin: 0; font-size: 0.8125rem; word-break: break-word; }
.attr-grid .attr-wide { grid-column: 1 / -1; }
.attr-grid .break { word-break: break-all; }

/* ------------------------------------------------------------------ session filters
   Eighteen inputs is a lot of screen, so the whole thing is a <details> that opens itself
   only when something is filtered. Closed, it is one line; open, it is a plain grid with
   room to breathe rather than a dense toolbar. */
.filters > summary {
  font-size: 0.8125rem;
  font-weight: 500;
}

.filter-form {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: 0.75rem 1rem;
  margin-top: 1rem;
}

.filter-form label {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  margin: 0;
}

.filter-form label > span {
  font-size: 0.6875rem;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  color: var(--muted-foreground);
}

.filter-form input,
.filter-form select {
  font-size: 0.8125rem;
  padding: 0.35rem 0.55rem;
  width: 100%;
}

.filter-actions {
  grid-column: 1 / -1;
  display: flex;
  gap: 0.5rem;
  align-items: center;
  margin-top: 0.25rem;
}

.session-when { font-size: 0.8125rem; color: var(--muted-foreground); }
.session-entry { font-size: 0.8125rem; overflow: hidden; text-overflow: ellipsis; }
.session-counts { font-size: 0.8125rem; text-align: right; white-space: nowrap; }

.timeline {
  list-style: none;
  margin: 0.2rem 0 0.9rem;
  padding: 0 0 0 0.2rem;
  border-left: 2px solid var(--border);
}

.timeline li {
  display: flex;
  gap: 0.75rem;
  align-items: baseline;
  padding: 0.22rem 0 0.22rem 0.9rem;
  font-size: 0.8125rem;
}

.timeline-at { color: var(--placeholder); flex: 0 0 auto; }

/* A page view is the spine of the story, so it carries the weight; the actions between
   two views are secondary and read as such. */
.timeline-view .timeline-what { color: var(--foreground); font-weight: 500; }
.timeline-event .timeline-what { color: var(--muted-foreground); }

.timeline-action {
  font-variant: small-caps;
  letter-spacing: 0.02em;
  color: var(--placeholder);
  margin-right: 0.15rem;
}

/* A generation in the timeline. The photo earns a row taller than the others because
   it is the one entry the operator opens something from; everything else on this list is a
   line of text and stays one. */
.timeline-run {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  color: var(--foreground);
}

.timeline-shot img {
  width: 40px;
  height: 40px;
  object-fit: cover;
  border-radius: var(--radius-sm);
  border: 1px solid var(--border);
  display: block;
  transition: box-shadow var(--duration) var(--ease);
}

.timeline-shot:hover img { box-shadow: var(--shadow-sm); }
.timeline-shot:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }

.timeline-run-id { font-size: 0.75rem; margin-left: 0.3rem; }

.timeline-value {
  color: var(--foreground);
  background: var(--muted);
  border-radius: var(--radius-sm);
  padding: 0.05rem 0.35rem;
  margin-left: 0.3rem;
  word-break: break-word;
}

/* The source thumbnail on the generations table. Square, so a portrait and a landscape
   photo do not make the rows different heights. */
.run-source img {
  width: 44px;
  height: 44px;
  object-fit: cover;
  border-radius: var(--radius-sm);
  border: 1px solid var(--border);
  display: block;
}

.run-source { display: flex; align-items: center; gap: 0.4rem; }

@media (max-width: 900px) {
  .session > summary { grid-template-columns: 1.1rem 1fr; gap: 0.15rem 0.6rem; }
  .session > summary::before { grid-row: 1 / span 5; }
  .session-counts { text-align: left; }
}

/* ---------------------------------------------------------------- account heading
   The greeting on the left, sign out on the right. Baseline-aligned to the heading rather
   than centred on the block, so the button sits on the same line as the name instead of
   floating between the name and the address. */
.account-head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 1.5rem;
  flex-wrap: wrap;
}

.account-head h1 { margin-bottom: 0.15rem; }
.account-head form { margin-top: 0.5rem; }

/* ------------------------------------------------------------------- analytics
   Two series on one chart and two accents in the palette, which line up: visits are
   navigation-blue, orders are --commerce green because orders are the money. No third
   color is introduced here; see Specification 26.1.2 for why the green exists at all. */

/* The segmented control was written for <button>; these screens use links inside it so the
   window is in the URL and can be sent to somebody. Same treatment, either element. */
.seg .btn {
  border: 0;
  background: transparent;
  color: var(--muted-foreground);
  padding: 0.3rem 0.75rem;
  border-radius: calc(var(--radius-sm) - 3px);
  font-size: 0.8125rem;
  box-shadow: none;
}
.seg .btn:hover { background: transparent; color: var(--foreground); }
.seg .btn.on {
  background: var(--popover);
  color: var(--foreground);
  box-shadow: var(--shadow-sm);
}

.filter-row {
  display: flex;
  align-items: center;
  gap: 1.25rem;
  flex-wrap: wrap;
  margin-bottom: 1.25rem;
}

.range-form {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  font-size: 0.8125rem;
  color: var(--muted-foreground);
}
.range-form input[type="date"] {
  padding: 0.3rem 0.5rem;
  font-size: 0.8125rem;
  width: auto;
}

/* A caveat about the data, not an error. Muted ground, no border, no icon: it is telling
   the operator what a column means, not warning them about anything. */
.note-line {
  background: var(--muted);
  color: var(--muted-foreground);
  border-radius: var(--radius-sm);
  padding: 0.6rem 0.85rem;
  font-size: 0.8125rem;
  margin: 0 0 1.25rem;
}

.metrics { margin-bottom: 1.5rem; }

.grid-2-even {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
  gap: 20px;
}

/* ------------------------------------------------------------------- right now */

.live-card h2 { display: flex; align-items: center; gap: 0.5rem; }

/* The one moving thing on the page. It marks a panel whose numbers are already out of date
   by the time they are read, which is the only honest way to present a live count. */
.live-dot {
  width: 7px;
  height: 7px;
  border-radius: 999px;
  background: var(--commerce);
  animation: live-pulse 2.4s var(--ease) infinite;
}

@keyframes live-pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.25; }
}

@media (prefers-reduced-motion: reduce) {
  .live-dot { animation: none; }
}

/* The pulse stops, the dot stays. The global rule above collapses the duration to nothing,
 * which would leave it frozen at whatever opacity the keyframe happened to hold - so it is
 * given a definite one rather than a random one. The same treatment `.live-dot` gets. */
@media (prefers-reduced-motion: reduce) {
  .print-pending-dot { animation: none; opacity: 1; }
}

.live-split {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  gap: 1.5rem;
  margin-top: 1rem;
}

.live-split h3 {
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--muted-foreground);
  margin-bottom: 0.5rem;
}

.live-timeline { margin-bottom: 0; max-height: 15rem; overflow-y: auto; }

/* --------------------------------------------------------------- lists and keys */

.plain-rows { list-style: none; margin: 0; padding: 0; }
.plain-rows li {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 1rem;
  padding: 0.32rem 0;
  border-top: 1px solid var(--border);
  font-size: 0.8125rem;
}
.plain-rows li:first-child { border-top: 0; }
.plain-rows .count {
  font-variant-numeric: tabular-nums;
  color: var(--muted-foreground);
  flex: 0 0 auto;
}

.chart-key {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  font-size: 0.75rem;
  color: var(--muted-foreground);
  margin: 0.9rem 0 0;
}
.key-swatch {
  width: 10px;
  height: 10px;
  border-radius: 3px;
  display: inline-block;
}
.key-swatch + .key-swatch { margin-left: 0.9rem; }
.key-visits { background: color-mix(in srgb, var(--primary) 78%, transparent); }
.key-orders { background: var(--commerce); }

/* Orders overlay the visit bar rather than sitting beside it. The question is always "did
   the visits turn into anything", and two separate charts make the eye do that join. */
.bar-track { position: relative; }
.bar-orders {
  position: absolute;
  bottom: 0;
  left: 50%;
  transform: translateX(-50%);
  width: 38%;
  min-height: 3px;
  border-radius: var(--radius-sm) var(--radius-sm) 0 0;
  background: var(--commerce);
}

/* ---------------------------------------------------------------------- funnel */

.funnel { list-style: none; margin: 0 0 1rem; padding: 0; }
.funnel li { padding: 0.5rem 0; }

.funnel-head {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 1rem;
  margin-bottom: 0.3rem;
  font-size: 0.8125rem;
}

.funnel-count { font-variant-numeric: tabular-nums; flex: 0 0 auto; }

.funnel-track {
  display: block;
  height: 8px;
  border-radius: 999px;
  background: var(--muted);
  overflow: hidden;
}

.funnel-fill {
  display: block;
  height: 100%;
  border-radius: 999px;
  background: color-mix(in srgb, var(--primary) 78%, transparent);
}

/* ------------------------------------------------------------------------ rules
   A rule is an explanation with a switch beside it, not a row of toggles. The reading
   matters more than the control: somebody flipping "the shop takes no orders" should have
   just read what that does. */

.rule {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: 2rem;
  align-items: start;
}

.rule-text h2 { margin-bottom: 0.5rem; }
/* One measure for the whole block. The paragraph wrapping at 62ch while the list beside it
   ran the full width of the card read as two different columns. */
.rule-text p, .rule-text ul { max-width: 62ch; }

.rule-effects {
  margin: 0.75rem 0 0;
  padding-left: 1.1rem;
  color: var(--muted-foreground);
  font-size: 0.8125rem;
}
.rule-effects li { margin-bottom: 0.3rem; }

.rule-switch {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 0.6rem;
  flex: 0 0 auto;
}

@media (max-width: 760px) {
  .rule { grid-template-columns: 1fr; gap: 1.25rem; }
  .rule-switch { align-items: flex-start; }
}

/* --------------------------------------------------------------------- marketing
   A merchandising table is read down the order column, so that column is narrow, numeric
   and first after the checkbox. Everything else is context for the row. */

.merch { width: 100%; }
.merch th:first-child, .merch td:first-child { width: 2.5rem; }
.merch th:nth-child(2), .merch td:nth-child(2) { width: 5rem; }
.merch input.rank {
  width: 4rem;
  padding: 0.25rem 0.4rem;
  font-variant-numeric: tabular-nums;
  text-align: right;
}

.merch-add {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
  gap: 0.4rem 1.5rem;
  margin-top: 0.75rem;
}
.merch-add label {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  font-size: 0.8125rem;
  font-weight: 400;
}

.add-more { margin-top: 1rem; }

.field-inline {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  font-size: 0.8125rem;
}
.field-inline select { width: auto; min-width: 22rem; }

/* Sticky, because the rows above it are long enough that the button would otherwise be a
   scroll away from whatever was just changed. */
.save-bar {
  position: sticky;
  bottom: 0;
  display: flex;
  align-items: center;
  gap: 1rem;
  padding: 0.85rem 0;
  background: linear-gradient(to top, var(--background) 70%, transparent);
}

/* ------------------------------------------------------- the generate step, on the photo
   Generation is a second, deliberate act now: choosing a file shows it, and this starts the
   work. Bottom right over the picture, because the picture is what is being confirmed. */

.stage-actions {
  position: absolute;
  left: 0.9rem;
  right: 0.9rem;
  bottom: 0.9rem;
  z-index: 4;
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 0.6rem;
}

/* Needs its own hidden rule: `display: flex` above outranks the bare `[hidden]` the browser
   provides, which is the bug that painted a camera panel over this same stage earlier today. */
.stage-actions[hidden] { display: none; }

/* A shadow rather than a scrim under the row: these are opaque controls sitting on a
   photo, and a gradient band behind them would be furniture the picture has to carry. */
.stage-actions .btn,
.stage-actions .engraving-group input {
  box-shadow: 0 6px 20px hsl(220 26% 8% / 0.28);
}

/* ------------------------------------------------- the engraving field, in two places

   One element, moved between the slot under the upload controls and the stage overlay; see
   `showEngraving` in studio.js. Which presentation it gets is decided by where it is, so the
   markup does not have to know and there is only ever one control with this name.

   Under the upload it is an ordinary option group — heading, field, hint. In the overlay the
   heading and the hint go: a title and a paragraph over somebody's photo is furniture,
   the placeholder carries the label, and `maxlength` carries the limit. */

.engraving-home {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.75rem;
}
.engraving-home .engraving-group { width: 100%; margin: 0; }

.stage-actions .engraving-group {
  flex: 0 1 18rem;
  min-width: 0;
  margin: 0;
}

.stage-actions .engraving-group .option-title,
.stage-actions .engraving-group .hint,
.stage-actions .engraving-group .field-error { display: none; }

.stage-actions .engraving-group input {
  padding: 0.62rem 0.85rem;
  font-size: 0.9375rem;
  background: var(--card);
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
}

.stage-actions .engraving-group input:focus-visible {
  border-color: var(--ring);
  box-shadow: 0 6px 20px hsl(220 26% 8% / 0.28), var(--focus-ring);
  outline: none;
}

@media (max-width: 620px) {
  /* The field takes a line of its own and the buttons share the one under it.
     Side by side there is no width for any of them; stacked outright — which is what this
     was until 2026-08-28 — the finished state is three full-width bars covering half the
     figurine they sit on. A field over a pair of buttons is two rows for up to three
     controls, and the two that belong together stay together. */
  .stage-actions { flex-wrap: wrap; align-items: stretch; }
  .stage-actions .engraving-group { flex: 1 0 100%; }
  .stage-actions .btn { flex: 1 1 auto; }
}


/* ------------------------------------------------------ the two steps to an order */

/* Where you are between a basket and an order. Two steps, and deliberately quiet: this is a
 * wayfinding aid on a page whose job is the thing being bought, so it is set small, in the
 * muted foreground, and given a rule under it rather than a box around it.
 *
 * Numerals in a circle, the step you are on filled. The one behind you is a link and takes
 * the ordinary link color on hover; the one ahead is neither, and is dimmed so that it reads
 * as not-yet rather than as broken. */
.checkout-steps {
  margin: 0 0 1.75rem;
  padding-bottom: 0.9rem;
  border-bottom: 1px solid var(--border);
}
.checkout-steps ol {
  display: flex;
  align-items: center;
  gap: 0.55rem;
  margin: 0;
  padding: 0;
  list-style: none;
  flex-wrap: wrap;
}
.checkout-step {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  font-size: 0.8125rem;
  color: var(--muted-foreground);
}
/* The arrow between the steps. A pseudo-element rather than a character in the markup, so a
 * screen reader reads two step names and not "one arrow two". */
.checkout-step + .checkout-step::before {
  content: "";
  width: 1.25rem;
  height: 1px;
  background: var(--border-strong);
  margin-right: 0.05rem;
}
.checkout-step a {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  color: inherit;
  text-decoration: none;
  border-radius: var(--radius-sm);
}
.checkout-step a:hover { color: var(--primary); }
.checkout-step a:focus-visible { outline: 2px solid var(--ring); outline-offset: 3px; }
.checkout-step-index {
  display: grid;
  place-items: center;
  width: 1.375rem;
  height: 1.375rem;
  border-radius: 999px;
  border: 1px solid var(--border-strong);
  font-size: 0.75rem;
  font-variant-numeric: tabular-nums;
  line-height: 1;
}
.checkout-step.is-current { color: var(--foreground); font-weight: 550; }
.checkout-step.is-current .checkout-step-index {
  background: var(--foreground);
  border-color: var(--foreground);
  color: var(--background);
}
.checkout-step.is-ahead { opacity: 0.55; }

/* ---------------------------------------------------------------- the postage choice */

/* A radio per option, drawn as a row rather than a bare control: the name, the note under it
 * and the price hard right, which is the shape a delivery choice has everywhere. Selection is
 * carried by the ring and the tinted ground, not by a heavier border, so the rows do not
 * shift by a pixel when one is picked. */
.ship-picker { margin: 0 0 1rem; }
/* In the till's summary the picker follows the order lines directly, and without this the
 * legend sat against the last line's engraving. Hierarchy through space, not a divider. */
.mini-lines + .ship-picker { margin-top: 1.1rem; }

/* Under the products on the basket page, since 2026-08-25. Out of the summary card it has no
 * ground of its own, so what separates it from the products is space. A rule was tried and
 * removed: held to the width of a delivery row it stopped short of the line above it, which
 * reads as a broken border rather than a division, and the house rule is that if a border is
 * doing the work the spacing was wrong. Held to that width rather than the whole column
 * because a $20 price hard against the far edge of a wide screen belongs to nothing. */
.basket-lines > .ship-picker {
  margin: 1.5rem 0 0;
  max-width: 28rem;
}
.ship-picker fieldset {
  border: 0;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 0.4rem;
}
.ship-picker legend {
  padding: 0;
  margin-bottom: 0.5rem;
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--muted-foreground);
}
.ship-option {
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: center;
  gap: 0.65rem;
  /* Zeroed deliberately. `label` carries `margin: 1rem 0 0.4rem` in the base rules, which is
     right for a form field with a caption above it and wrong for a row in a list - it added
     22px between options that the fieldset gap below could not undo. The gap is now the only
     thing setting the distance, so the number here is the number on screen. */
  margin: 0;
  padding: 0.3rem 0.7rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--card);
  cursor: pointer;
  transition: background 140ms ease-out, border-color 140ms ease-out;
}
.ship-option:hover { background: var(--secondary); }
.ship-option:has(input:checked) {
  border-color: var(--primary);
  background: color-mix(in srgb, var(--primary) 5%, var(--card));
}
.ship-option:has(input:focus-visible) { box-shadow: var(--focus-ring); }
.ship-option input { margin: 0; accent-color: var(--primary); }
.ship-option-body { display: grid; gap: 0; min-width: 0; }
.ship-option-name { font-size: 0.875rem; line-height: 1.25; color: var(--foreground); }
/* The refrigerated note. Small, muted and on every row - it is a statement about how
 * everything here travels, not a feature of one tier. */
.ship-option-note {
  font-size: 0.75rem;
  line-height: 1.15;
  color: var(--muted-foreground);
}
.ship-option-price {
  font-size: 0.875rem;
  font-variant-numeric: tabular-nums;
  color: var(--foreground);
}
/* A fallback, not a peer of the Checkout button below it. Choosing an option submits this
 * form already, so a second full-width control stacked above the green one would be two
 * things asking to be pressed where there is only one thing to do. Small, right-aligned,
 * and there for whoever has no JavaScript. */
.ship-picker > .btn {
  margin: 0.55rem 0 0 auto;
  display: block;
  font-size: 0.75rem;
  padding: 0.35rem 0.7rem;
}

/* The workshop's side of the same thing: a row per option and a two-field form under it. */
.ship-rows { margin-top: 1.25rem; }
.ship-add {
  display: flex;
  align-items: flex-end;
  gap: 0.75rem;
  flex-wrap: wrap;
  margin-top: 1.25rem;
}
.ship-add label { display: grid; gap: 0.3rem; font-size: 0.8125rem; }
.ship-add input[type="text"] { min-width: 16rem; }
.ship-price-field { display: flex; align-items: center; gap: 0.4rem; }
.ship-price-unit { font-size: 0.8125rem; color: var(--muted-foreground); }
.ship-add input[type="number"] { width: 6rem; }

/* ------------------------------------------------------------------- a country list */

/* The country became a select on 2026-08-25 and has to sit in the address grid as though it
 * were still one of the boxes around it. Only the arrow is added, drawn rather than left to
 * the platform, because the platform's is a different grey on every OS. */
.field-grid select {
  width: 100%;
  appearance: none;
  padding: 0.6rem 2.2rem 0.6rem 0.75rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background-color: var(--input);
  background-image:
    linear-gradient(45deg, transparent 50%, var(--muted-foreground) 50%),
    linear-gradient(135deg, var(--muted-foreground) 50%, transparent 50%);
  background-position: right 1.05rem center, right 0.8rem center;
  background-size: 0.3rem 0.3rem, 0.3rem 0.3rem;
  background-repeat: no-repeat;
  font: inherit;
  color: var(--foreground);
  cursor: pointer;
}
.field-grid select:hover { border-color: var(--border-strong); }
.field-grid select:focus-visible {
  outline: none;
  border-color: var(--ring);
  box-shadow: var(--focus-ring);
}

/* ------------------------------------------------------------------ breadcrumbs, 2026-08-27
   The visible path up. Added with the BreadcrumbList markup: a product page's only internal
   links were the global nav and the footer, so equity arrived from the home page and stopped.

   Muted and small on purpose. It is orientation, not navigation somebody came here to use,
   and hierarchy here comes from weight and space rather than from a box around it. */
/* No width or gutter of its own: it sits inside `main`, which is already the page
   container. Repeating the measurements here would be a second place to change them. */
.breadcrumbs { margin: 0 0 1.25rem; }
.breadcrumbs ol {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.4rem;
  font-size: 0.8125rem;
  color: var(--muted-foreground);
}
.breadcrumbs li { display: flex; align-items: center; gap: 0.4rem; }
.breadcrumbs a { color: var(--muted-foreground); text-decoration: none; }
.breadcrumbs a:hover { color: var(--foreground); text-decoration: underline; }
.breadcrumbs a:focus-visible {
  outline: 2px solid var(--ring);
  outline-offset: 2px;
  border-radius: calc(var(--radius) / 2);
}
.breadcrumbs [aria-current="page"] { color: var(--foreground); }
.breadcrumb-sep { opacity: 0.5; }

/* The line under a category H1. It was the H1 until the head term took that place; it keeps
   the size it had, so nothing about the page looks different, only the outline. */
.page-head-tagline {
  font-size: 1.0625rem;
  color: var(--tint-ink);
  opacity: 0.9;
  margin: 0.35rem 0 0.75rem;
  max-width: 54ch;
}

/* Category prose, below the grid. Measured for reading rather than run to the full width of
   the page: a 1320px line of body copy is unreadable however good the copy is. */
.category-copy { max-width: 68ch; }
.category-copy p { margin: 0 0 1rem; color: var(--muted-foreground); }
.category-copy p:last-child { margin-bottom: 0; }

/* --------------------------------------------------------------- the cartoonize bench
 * A workshop screen, so it is denser than the shop, but it uses the same tokens and the
 * same chip anatomy as the product page's option groups. Nothing here is a new color.
 *
 * Every rule that grants a display states what `hidden` means for it. That trap has caught
 * four elements in this project - the camera panel, the countdown, the thumbnail strip and
 * the drop target - and the fix is always the same line written at the same time. */

.cartoon-pair {
  display: grid;
  gap: 1rem;
  grid-template-columns: 1fr 1fr;
  align-items: start;
}
.cartoon-pair[hidden] { display: none; }

/* The frame has its own height and a floor under it. An <img> that has not loaded and a
 * frame waiting on a job both have no intrinsic size, so a box sized only by its contents
 * collapses - which is exactly how the studio viewer came out at zero pixels on a phone.
 * svh rather than vh: vh is the tall viewport, so a vh box overflows whenever the browser's
 * own chrome is showing. */
.cartoon-frame {
  display: grid;
  place-items: center;
  height: clamp(220px, 46svh, 420px);
  padding: 0.5rem;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--muted);
  overflow: hidden;
}
.cartoon-frame[hidden] { display: none; }
.cartoon-frame img { max-width: 100%; max-height: 100%; object-fit: contain; }

/* A cut-out is only legible against something that is visibly not part of the picture. */
.cartoon-frame.alpha {
  background-color: var(--card);
  background-image: conic-gradient(
    var(--muted) 25%, transparent 0 50%, var(--muted) 0 75%, transparent 0
  );
  background-size: 18px 18px;
}

/* --------------------------------------------------------- the way into the upload
 *
 * Bottom right of the product's picture, which is where this page already puts a control
 * that acts on what is in the frame. Added 2026-08-29 with the chooser dialog: before it,
 * the three ways in took the top of the options panel on every product page whether or not
 * the reader had any intention of uploading anything.
 *
 * **44px unconditionally.** Every other 44px rule in this stylesheet is inside a
 * `max-width: 700px` block, which a landscape phone at 812px misses entirely - that is how
 * the cartoonize button came to measure 38px at 812x375. This is the way into the whole
 * flow and does not get to be small on any screen.
 *
 * The shadow rather than a scrim, the same choice the toolbar and the studio's overlay both
 * make: a gradient band behind one button is furniture the picture has to carry. */
/* The two ways to start a design, bottom right of the picture: upload one, or add words.
 *
 * A row rather than one pinned button since 2026-08-30. "Add text" used to live in the
 * toolbar, which appears only once a picture exists - so a customer who wanted lettering and
 * no picture had no way in at all. Both are what the *frame* offers before there is anything
 * in it, which is why they are the two controls still over the picture now that everything
 * acting on a design has moved below it.
 *
 * The row is `pointer-events: none` and its buttons take theirs back, so the empty half of
 * the row does not swallow a press meant for the picture. */
.stage-entry {
  position: absolute;
  right: 0.7rem;
  bottom: 0.7rem;
  z-index: 4;
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: 0.5rem;
  pointer-events: none;
}
.stage-entry > * { pointer-events: auto; }

.stage-upload, .stage-text {
  min-height: 44px;
  box-shadow: 0 6px 20px hsl(220 26% 8% / 0.28);
}
.stage-upload[hidden], .stage-text[hidden] { display: none; }

/* Both go while the design is put away, and both come back with "Edit". The state lives on
 * the stage rather than on the row, because `studio.js` moves the upload button out of this
 * row on the bespoke page and a rule keyed on the row would stop reaching it. */
.product-stage.design-approved .stage-entry { display: none; }

/* Withdrawn while a camera is open in the same stage.
 *
 * Both are pinned to the foot of it, so a live feed arrived with "Upload Image" sitting
 * beside "Take photo": two ways in offered at once, one of which throws away what is on
 * screen. Reported by the operator 2026-08-29.
 *
 * On `:has()` rather than by script, and keyed on **any** camera rather than the portrait one
 * - the pin above is about a 4/5 studio and this is about a button behind a panel, so they
 * want different keys. Declarative because the panel is opened and closed by a shared module
 * that has no business knowing what furniture a product page keeps in its stage, and because
 * a button hidden by a script is a button that can be left hidden. */
.media-stage:has(> .camera:not([hidden])) .stage-entry { display: none; }

/* `studio.js` moves the upload button into the studio once a photo is chosen - the overlay
 * while it is staged, the chip row once a blueprint exists. It needed a rule here to undo its
 * pinning until 2026-08-30, when the pinning moved onto `.stage-entry` around it: a button
 * carrying no `position` of its own needs nothing undone wherever it is moved to. */

/* -------------------------------------------- arranging the design, under the mockup
 *
 * These sit with the picture of the product rather than in the options panel, at the
 * operator's request: the size of a print and the placing of a word are judged by looking,
 * and a control for them belongs beside the thing being looked at.
 *
 * **They sat *on* the foot of the picture until 2026-08-30**, pinned over it, and they came
 * off at the operator's request. The case is the mug: its print area is 0.41 of a square
 * photo, and five controls at their 44px minimum stood on roughly a third of the design
 * somebody was trying to judge. Under the picture the row costs one line of page and covers
 * nothing.
 *
 * The one control still pinned over the picture is "Upload Image". It acts on the frame
 * rather than on the design inside it, and before there is a design it is the only thing in
 * that frame worth pressing. */
.print-tools {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
  margin-top: 0.6rem;
}
.print-tools[hidden] { display: none; }

/* The row is on the page now rather than on a photo, so nothing here is lifted off a picture
 * by a shadow: the buttons are outlined the way every other button on this site is, by the
 * base `button, .btn` rule. The slider is the one control in the row that is a `<label>`
 * rather than a button, so it is the one that has to be told. The editor pane still floats
 * over the design and keeps its shadow. */
/* **The slider matches the buttons beside it.** `.print-size` is white (`--card`) because it
 * is also drawn inside the text editor pane, which sits on a white ground; in this row that
 * made it the one control with a different fill - visible as a white box between two grey
 * buttons. The row is the odd context, so the override lives here rather than changing the
 * shared rule and moving the difference into the pane instead.
 *
 * The border is here for the same reason: every button in the row gets one from the base
 * `button, .btn` rule and a `<label>` does not. */
.print-tools .print-size {
  border: 1px solid var(--border);
  background: var(--secondary);
  color: var(--secondary-foreground);
}
.text-pane {
  box-shadow: 0 6px 20px hsl(220 26% 8% / 0.28);
}

/* "Uploading your picture", in the slot the toolbar is about to fill.
 *
 * The gap it covers is real: the photo is drawn from the local file at once and the controls
 * wait for the artwork id, which is a whole upload away. Ten seconds of a finished-looking
 * picture with no tools reads as a broken page.
 *
 * Sized and spaced like `.print-hint` below so that the row does not shift when the toolbar
 * replaces it, and `min-height` matched to a control so the space is genuinely held rather
 * than approximately held.
 *
 * **`[hidden]` is written out beside the `display`**, because a rule that gives an element a
 * display takes the user agent's bare `[hidden]` on with it - the script would set the
 * attribute and nothing would happen. That defect has shipped here three times: the countdown
 * in August, the catalogue strip, and the media thumbs. */
.print-pending {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin: 0.6rem 0 0;
  min-height: 44px;
  font-size: 0.8125rem;
  color: var(--muted-foreground);
}
.print-pending[hidden] { display: none; }

/* A dot that breathes. Motion confirms rather than performs: opacity only, no spin, nothing
 * that implies a position in a progress it cannot measure - `fetch` reports no upload
 * progress, so a bar or a percentage here would be an invented number. */
.print-pending-dot {
  flex: none;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--primary);
  animation: print-pending-pulse 1.4s var(--ease) infinite;
}

@keyframes print-pending-pulse {
  0%, 100% { opacity: 0.25; }
  50% { opacity: 1; }
}

/* What a drag cannot say for itself. Muted and small: it is an instruction rather than a
 * control, and it stands beside the row rather than inside it because prose in a flex row of
 * buttons stops the row reading as a row. */
.print-hint {
  margin: 0.4rem 0 0;
  font-size: 0.8125rem;
  color: var(--muted-foreground);
}
.print-hint[hidden] { display: none; }

.print-size {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  /* The sitewide `label` rule stacks form fields with `margin: 1rem 0 0.4rem`, and this is a
     label that joined a row of buttons instead. On a centred flex row uneven margins move a
     box by half their difference, which hung the slider ~5px below everything beside it. */
  margin: 0;
  flex: 1;
  min-width: 0;
  padding: 0.35rem 0.75rem;
  min-height: 44px;
  border-radius: var(--radius-sm);
  background: var(--card);
  font-size: 0.8125rem;
  color: var(--muted-foreground);
}
.print-size[hidden] { display: none; }
.print-size-label { flex: none; }
.print-size input[type="range"] { flex: 1; min-width: 0; accent-color: var(--primary); }

/* The slider gives up its width to the buttons beside it rather than pushing them out of the
 * row - it is the one control here that can be any length and still work.
 *
 * Overridden inside `.print-tools` by the row-arrangement rule above, which gives every
 * control in that row the same share. This basis still governs the copy of `.print-size` in
 * the text editor pane, which is not in a wrapped row. */
.print-size { flex: 1 1 9rem; }

.print-tools .art-cartoonize, .print-tools .primary,
.print-tools .print-restart, .print-tools .print-edit,
.print-tools .art-cutout {
  min-height: 44px;
}

/* **The wrap count is chosen, and every row fills the width.** Asked for on 2026-08-31: five
 * controls were wrapping wherever they happened to stop fitting, which left a short last row
 * and rows of different-width buttons - two things that read as unfinished rather than as a
 * decision.
 *
 * A percentage basis is what makes this deterministic, and it is the only thing that can be:
 * `flex-wrap` packs as many items as fit and cannot be told a count, so the count has to come
 * out of the arithmetic. At `1 1 30%` three fit a row (90% plus two gaps) and the remainder
 * grows to share the next; at `1 1 46%` two fit and the remainder takes the row alone. That
 * gives 3+2 and 2+2+1 for five controls, which is what was asked for, and `flex-grow: 1`
 * means each row is full width whatever landed in it.
 *
 * **Scoped to the state that has five controls**, through `:has()` on the size slider - which
 * is visible exactly when there is a picture. Without that scope the approved state's two
 * buttons and the text-only state's one would stretch across the whole column, which is the
 * opposite of consistent: a lone full-width "Start Over" reads as the primary action.
 *
 * `min-width: 0` because a flex item's minimum is its content by default, and Cartoonize
 * carries two pictures - without this it refuses to shrink to its share and pushes the row
 * wider than the column. That is the defect the workshop's tab strip had at every width
 * between 641px and 1200px. */
.print-tools:has(> .print-size:not([hidden])) > *:not([hidden]) {
  flex: 1 1 30%;
  min-width: 0;
}

/* Two per row rather than three. Three shares of a 375px column is about 110px each, and
 * Cartoonize needs roughly 170px to hold its two pictures and its word - so at three it stops
 * shrinking and takes the row wider than the screen. Measured, not guessed: the switch is
 * where the third share stops being wide enough for the widest control in the row. */
@media (max-width: 620px) {
  .print-tools:has(> .print-size:not([hidden])) > *:not([hidden]) { flex: 1 1 46%; }
}

/* "Remove background". Plain, like the Cartoonize button it sits beside: neither of them is
 * the step forward and neither spends the customer's money, so neither takes an accent.
 *
 * **The 44px above is unconditional, not inside the 700px block.** Every 44px rule this
 * stylesheet scoped by width was missed entirely by a landscape phone, which is 812px wide
 * and has the least height of any viewport the shop is opened in - the cartoonize bench's own
 * button measured 38px there for exactly that reason. */
.art-cutout[hidden] { display: none; }
.art-cutout:disabled {
  opacity: 0.65;
  cursor: progress;
}

/* What the button could not do. Muted like the hint, but it is a failure rather than an
 * instruction, so it takes the destructive token. `role="status"` rather than `alert`: this
 * follows a press the customer made and is read when they look, not something to interrupt
 * them with. */
.print-error {
  margin: 0.4rem 0 0;
  font-size: 0.8125rem;
  color: var(--destructive);
}
.print-error[hidden] { display: none; }

/* The approved state, added 2026-08-30 at the operator's request. "Looks good!" puts the
 * design away and leaves the picture, "Start Over" and "Edit".
 *
 * Everything is removed by one rule on the parent rather than hidden control by control, the
 * same arrangement `.photo-group.artwork-done` uses and for the same reason: a control added
 * to this row later is covered by default rather than being the one thing left standing over
 * an approved design.
 *
 * **The two exemptions are the way out of the state**, which must never be inside what the
 * state hides - the lesson the "Use another picture" link and the photo dialog both taught
 * here. They are exempted in the selector rather than given a `display` of their own, or the
 * exemption would take `[hidden]` with it and `#print-edit` would be visible from the moment
 * the page loaded. */
.print-tools.approved > *:not(.print-restart):not(.print-edit) { display: none; }

/* A button whose whole job is to scroll to the name and the price, drawn only where those
 * are somewhere else on the page.
 *
 * Above 940px `.product` is two columns and the panel is already beside the picture and
 * level with it, so pressing this moved nothing at all and it read as a broken control.
 * Keyed on the same width the grid collapses at, deliberately: two numbers here would drift,
 * and the number is not about a phone but about whether the page is stacked.
 *
 * Both of them - the mockup's and the studio's - because they are the same button doing the
 * same nothing. `#looks-good` also carries `hidden` until a blueprint exists; a rule that
 * only ever sets `display: none` cannot un-hide it. */
@media (min-width: 941px) {
  .scrolls-to-buy { display: none; }
}

/* Leftmost in the row and quiet: it is the only control here that throws work away, so it
 * gets no emphasis and it is nowhere near the button that finishes. */
.print-restart { color: var(--muted-foreground); }

/* The editor for one text, standing beside the text it edits.
 *
 * It was a panel pinned to the foot of the stage until 2026-08-29, when a design stopped
 * carrying exactly one piece of lettering. A pinned panel cannot say which of five texts it
 * is acting on, so this one follows: `printtools.js` sets `left` and `top` from the selected
 * text's own box, below it where there is room and above it otherwise, clamped inside the
 * frame. The four handles on the text are the other half of the same answer.
 *
 * Positioned against the stage rather than the print area, which has `overflow: hidden` - a
 * pane inside that would be cut off by the very rectangle it is editing.
 *
 * Narrow on purpose. It covers part of the thing being judged for as long as it is open, so
 * it holds one column of small controls and a Done, and nothing else. */
.text-pane {
  position: absolute;
  z-index: 6;
  width: min(17rem, calc(100% - 1.4rem));
  display: flex;
  flex-direction: column;
  gap: 0.45rem;
  padding: 0.6rem;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--card);
  box-shadow: var(--shadow-lg);
}
.text-pane[hidden] { display: none; }

.text-pane-row { display: flex; align-items: center; gap: 0.4rem; }
.text-pane input[type="text"] {
  width: 100%;
  min-height: 44px;
  /* 16px, or Safari zooms the whole page in on focus and does not zoom back out. */
  font-size: 1rem;
}
.text-pane-actions { justify-content: flex-end; }
.text-pane-actions .btn { min-height: 44px; }
/* Tighter than the panel it replaced: this one stands over the garment rather than under it,
 * so every row it does not need is a row of the thing being judged.
 *
 * The eight inks on **one** line is the reason for the smaller dot. At 24px they wrapped to
 * two rows, which is 34px of pane spent on a wrap - and the pane's height is what decides
 * whether it can clear the print area at all. */
.text-pane .print-size {
  min-height: 36px;
  padding: 0.15rem 0.6rem;
  /* **`flex: none`, and this one is not tidiness.** `.print-size` carries `flex: 1 1 9rem`
   * so it gives up width to the buttons in the toolbar, which is a *row*. This pane is a
   * *column*, where a flex-basis of 9rem is a **height** - the slider came out 144px tall and
   * took the pane past anything that could fit beside a print area. A flex value written for
   * one axis is a different instruction on the other. */
  flex: none;
}
.text-pane .swatches { flex-wrap: nowrap; gap: 0.3rem; }
.text-pane .swatch-dot { width: 20px; height: 20px; border-width: 2px; }

/* The typeface beside the weight: a native select, which needs no building and is the only
 * one of the two that works the same on every phone, and two toggles that look like what
 * they do. 44px on both, because they are targets in a pane over a picture. */
.print-font {
  flex: 1 1 auto;
  min-width: 0;
  min-height: 44px;
  /* 16px, or Safari zooms the page in on focus and does not zoom back out. */
  font-size: 1rem;
}
.type-toggles { display: flex; flex: none; gap: 0.35rem; }
.type-toggle {
  min-width: 44px;
  min-height: 44px;
  padding: 0 0.6rem;
  font-size: 1rem;
}
/* Pressed is a state, so it looks like one. The same tint the selected option chip uses,
 * rather than a third way of saying "this one". */
.type-toggle[aria-pressed="true"] {
  border-color: var(--primary);
  background: var(--accent);
  color: var(--foreground);
}
.type-toggle b { font-weight: 800; }
.type-toggle i { font-style: italic; font-family: Georgia, serif; }

/* The ink swatches, scoped to the row they sit in.
 *
 * **`.swatch` is two components sharing a class**: these, and the three large material tiles
 * on the home page's feature band, which are `aspect-ratio: 3 / 4` with `0.7rem` of padding.
 * Unscoped, each was taking the other's declarations - an ink dot was arriving inside a
 * padded 3:4 box 57px tall, which is most of why the editor pane could not be made small
 * enough to stand beside a design. Renaming one is the real fix; scoping the smaller of the
 * two is the change that belonged in this commit. */
.swatches { flex-wrap: wrap; gap: 0.4rem; }
.swatches .swatch {
  display: inline-flex;
  margin: 0;
  padding: 0;
  aspect-ratio: auto;
  box-shadow: none;
  cursor: pointer;
}
.swatch[hidden] { display: none; }
.swatch input { position: absolute; opacity: 0; width: 0; height: 0; }
.swatch-dot {
  display: block;
  width: 28px;
  height: 28px;
  border-radius: 999px;
  border: 2px solid var(--border);
  transition: transform var(--duration) var(--ease), border-color var(--duration) var(--ease);
}
.swatch:has(:checked) .swatch-dot { border-color: var(--primary); transform: scale(1.12); }
.swatch:has(:focus-visible) .swatch-dot { box-shadow: var(--focus-ring); }

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* The uploaded picture as a stage, with everything that acts on it sitting on top.
 *
 * It was a 4rem thumbnail in a bordered row: the right size for a receipt and the wrong one
 * for the thing being printed across a shirt. Same arrangement as the bespoke studio's
 * blueprint, and the same reasoning - once the thing exists, the controls belong on it. */
.art-stage {
  display: block;
  margin: 0.85rem 0 0;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--muted);
  overflow: hidden;
}
.art-stage[hidden] { display: none; }

/* What the overlay is pinned to: the picture alone, so the caption below it stays readable
   and its "use another picture" link stays pressable. */
.art-frame { position: relative; display: block; }
.art-frame[hidden] { display: none; }

/* Its own height with a floor under it, never the space left over. An <img> that has not
 * loaded has no intrinsic size, and a box sized by its contents collapses - which is how the
 * blueprint viewer came out at zero pixels on a phone. svh, not vh: vh is the tall viewport,
 * so a vh box overflows whenever the browser's chrome is showing. */
.art-stage img {
  display: block;
  width: 100%;
  height: clamp(240px, 52svh, 460px);
  object-fit: contain;
  background: var(--muted);
}

/* Over the foot of the picture. Pinned rather than placed under it, so the picture is the
 * page and the controls are on it. */
.art-actions {
  position: absolute;
  left: 0.7rem;
  right: 0.7rem;
  bottom: 0.7rem;
  z-index: 2;
  display: flex;
  align-items: center;
  gap: 0.6rem;
}
.art-actions[hidden] { display: none; }

/* A shadow rather than a scrim: these are opaque controls on a photo, and a gradient
 * band behind them would be furniture the picture has to carry. Same choice the studio made. */
.art-actions .btn { box-shadow: 0 6px 20px hsl(220 26% 8% / 0.28); min-height: 44px; }

/* The teaser is the button, so it carries pictures and sits left; "Looks good" is the thing
 * that carries on and sits right, which is the order every other pair on this site uses. */
.art-cartoonize {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.35rem 0.7rem 0.35rem 0.4rem;
}
.art-cartoonize[hidden] { display: none; }
.art-cartoonize .looks-teaser { gap: 0.25rem; }
.art-cartoonize .looks-thumb { width: 30px; height: 30px; }
.art-cartoonize .looks-arrow { width: 12px; height: 12px; }
.art-actions .primary { margin-left: auto; }

.art-caption {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.6rem;
  padding: 0.55rem 0.7rem;
  background: var(--card);
  border-top: 1px solid var(--border);
  font-size: 0.8125rem;
  color: var(--muted-foreground);
}
.art-caption[hidden] { display: none; }

/* Once there is a picture, the words that explained the upload are gone. The whole group
 * rather than a list of children, so a line added later is covered by default - the rule
 * `.photo-group.blueprint-done` already follows on the bespoke product.
 *
 * `.artwork-again` is exempt because it is the way back: it undoes this state, so hiding it
 * with everything else left no route to another picture. It carries no `display` of its own
 * here, deliberately - a rule giving it one would take `[hidden]` with it, and the script
 * hides the line again the moment the picture goes. */
.photo-group.artwork-done > *:not(.art-stage):not(.artwork-again):not(.photo-dialog)
  { display: none; }

/* A row where a customer was told no. Marked because it is the one thing an operator scans a
 * timeline *for* - everything else on it is something that worked. `--destructive` rather than
 * a new token: this is the shop's own chrome saying something went wrong, which is exactly
 * what that token is named for. */
.timeline-trouble .timeline-action,
.timeline-trouble .timeline-value {
  background: color-mix(in oklab, var(--destructive), var(--card) 84%);
  color: var(--destructive);
}
.timeline-trouble { border-left: 2px solid var(--destructive); padding-left: 0.5rem; }

/* ------------------------------------------------------------------ the cartoon wizard
 *
 * A native <dialog>, like the pose advice: Esc, a focus trap, a backdrop and inert content
 * behind it are all things the element already has. */
.wizard {
  width: min(30rem, calc(100vw - 2rem));
  max-height: 88svh;
  padding: 0;
  border: 0;
  border-radius: var(--radius);
  background: var(--card);
  color: var(--foreground);
  box-shadow: 0 24px 70px hsl(220 26% 8% / 0.28);
  overflow: hidden;
}
.wizard::backdrop { background: hsl(220 26% 10% / 0.45); }

.wizard-inner {
  display: flex;
  flex-direction: column;
  gap: 0.9rem;
  padding: 1.1rem;
  max-height: 88svh;
  overflow: hidden;
}
.wizard-inner[hidden] { display: none; }

/* The questions give way, the buttons never do. `min-height: 0` is what lets a flex child
 * shrink below its content and scroll at all - without it the panel grew instead and pushed
 * Cancel and Next off a landscape phone, which has 375px of height and the most need of them.
 * Same note as the pose advice's picture. */
.wizard-body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
}
.wizard-body[hidden] { display: none; }
.wizard-actions { flex: none; }

/* The site's own `h2` is a quiet uppercase label, which is right for a section heading and
 * wrong here: this is the only heading in the dialog and it is what the dialog is called.
 * The pose advice keeps the label style deliberately, because its poster carries a headline
 * of its own - there is no poster here. Written out because `text-transform` inherits. */
.wizard-title {
  margin: 0;
  font-size: 1.0625rem;
  font-weight: 600;
  letter-spacing: -0.01em;
  text-transform: none;
  color: var(--foreground);
}
.wizard-ask { margin: 0 0 0.6rem; font-size: 0.9375rem; font-weight: 500; }

.wizard-step { display: block; }
/* The rule above gives it a display, so `hidden` needs saying for it. That trap has caught
 * four elements in this project already. */
.wizard-step[hidden] { display: none; }

/* A block, not a centred grid. A grid row sized `auto` is sized *by* its item, so the item's
 * `height: 100%` has nothing definite to resolve against and falls back to the picture's own
 * height - which is how a portrait drawing came out 432px tall in a 340px box with its feet
 * clipped off. A block with a stated height gives the child a real percentage base. */
.wizard-result {
  display: block;
  height: clamp(200px, 44svh, 340px);
  border-radius: var(--radius-sm);
  background: var(--muted);
  overflow: hidden;
}
.wizard-result[hidden] { display: none; }
/* An explicit box rather than `max-height: 100%`. A percentage max-height on a centred grid
 * item has no definite base to resolve against, so a portrait picture was drawn at its full
 * height and clipped by the overflow - the drawing came out with its feet cut off. Same
 * arrangement `.art-stage img` uses: state the height, let `contain` letterbox inside it. */
.wizard-result img { display: block; width: 100%; height: 100%; object-fit: contain; }

/* The label over them, added 2026-08-30 at the operator's request. Four buttons under a text
 * field are four things a reader has to work out the purpose of; one word says it. */
.wizard-examples-label {
  margin: 0.7rem 0 0;
  font-size: 0.8125rem;
  color: var(--muted-foreground);
}

.wizard-examples {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  margin-top: 0.4rem;
}
.wizard-examples[hidden] { display: none; }

.wizard-example {
  padding: 0.4rem 0.65rem;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--card);
  color: var(--muted-foreground);
  font-size: 0.8125rem;
  cursor: pointer;
  min-height: 36px;
}
.wizard-example:hover { border-color: var(--border-strong); color: var(--foreground); }
.wizard-example:focus-visible { box-shadow: var(--focus-ring); outline: none; }

/* ------------------------------------------------------------------ the wait, 2026-08-30
 *
 * A ring, a number inside it, and what the shop has to say beside it. Two clocks share the
 * one dial: a countdown from the server's estimate, and - if that is spent - the time that
 * has really passed. See cartoonize.js and figurine.cartoonwaiting.
 *
 * A row rather than a stack, so the copy beside it stays left-aligned: a centred column
 * would put a sentence under a dial and centre it, and paragraphs are never centred here. */
.wait {
  display: flex;
  align-items: center;
  gap: 1rem;
  padding: 0.35rem 0 0.15rem;
}
.wait[hidden] { display: none; }

.wait-dial { position: relative; flex: none; width: 92px; height: 92px; }
.wait-ring { display: block; width: 100%; height: 100%; }
.wait-ring circle { fill: none; stroke-width: 8; stroke-linecap: round; }
.wait-ring-track { stroke: var(--muted); }

/* From twelve o'clock, and stepping once a second. The transition is the house's, not a
 * progress animation: the ring moves when the number does and never between. */
.wait-ring-run {
  stroke: var(--primary);
  transform: rotate(-90deg);
  transform-origin: 50% 50%;
  transition: stroke-dashoffset var(--duration) var(--ease);
}

/* Once the estimate is spent the ring is no longer a proportion of anything, so it stops
 * pretending to be one and simply turns. `cartoonize.js` clears the inline dash properties
 * at the same moment, or they would outrank these. */
.wait.overrun .wait-ring-run {
  stroke-dasharray: 55 272;
  stroke-dashoffset: 0;
  transition: none;
  animation: wait-sweep 1.5s linear infinite;
}
@keyframes wait-sweep {
  from { transform: rotate(-90deg); }
  to { transform: rotate(270deg); }
}
@media (prefers-reduced-motion: reduce) {
  .wait.overrun .wait-ring-run { animation: none; }
}

.wait-time {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 1.375rem;
  font-weight: 600;
  letter-spacing: -0.025em;
  font-variant-numeric: tabular-nums;
  color: var(--foreground);
}

.wait-said { min-width: 0; }
.wait-label { margin: 0; font-size: 0.875rem; color: var(--muted-foreground); }

/* The excuses. They take the weight the label does not, because once they are on screen they
 * are the only thing on the panel with anything to say. */
.wait-message {
  margin: 0.4rem 0 0;
  font-size: 0.9375rem;
  font-weight: 500;
  line-height: 1.35;
  color: var(--foreground);
  min-height: 2.7em;
  transition: opacity var(--duration) var(--ease);
}
.wait-message.fading { opacity: 0; }

.wizard-actions {
  display: flex;
  gap: 0.6rem;
  justify-content: space-between;
  margin-top: 0.2rem;
}
.wizard-actions[hidden] { display: none; }
.wizard-actions .btn { min-height: 44px; flex: 1; }

@media (max-width: 700px) {
  /* Full width, and anchored to the bottom where a thumb is. */
  .wizard { width: 100vw; max-width: 100vw; border-radius: var(--radius) var(--radius) 0 0; }
  .wizard-inner { max-height: 84svh; }
  #cartoon-instructions { font-size: 16px; }

  /* **The teaser images stay on a phone since 2026-08-31, at the operator's request.** They
   * used to be hidden here on the grounds that they are decoration once the word is there.
   * They are not: two pictures and an arrow say what "Cartoonize" does faster than the word,
   * and a control that reads differently on the screen four customers in five use is a
   * control nobody has designed. They shrink instead - 22px rather than 30px - which buys
   * most of the width that hiding them did.
   *
   * The row's arrangement is no longer decided here. See `.print-tools` above: the controls
   * carry a percentage basis so the wrap count is deliberate rather than whatever happened
   * to fit, and every row fills its width. */
  .print-tools .art-cartoonize .looks-thumb { width: 22px; height: 22px; }
  .print-tools .art-cartoonize .looks-arrow { width: 10px; height: 10px; }
  .print-tools .art-cartoonize .looks-teaser { gap: 0.15rem; }
}

/* A phone on its side is 812px wide and has the least height of any viewport this is opened
 * in, so it misses every width-only rule while needing the most help. */
@media (max-height: 500px) {
  .art-stage img { height: clamp(180px, 62svh, 280px); }
  .wizard-result { height: clamp(140px, 50svh, 220px); }
  .wizard-inner { max-height: 92svh; }
}

/* The cartoonizer's default mode: one control, two pictures, everything else folded away.
 *
 * A native <details>, so this works with no JavaScript at all - which matters because the
 * mode exists for traffic arriving from an advertisement, and that is exactly the traffic
 * least likely to wait for a script. See figurine/cartoon/presentation.py. */
.looks {
  /* Opting out of the global disclosure styling near the top of this file, the same way
   * `.faq` has to. That rule dresses a `details` as a small uppercase workshop label with a
   * rule above it and a `›` in front - right for the place it was written for, and wrong for
   * a control whose whole job is to be the most inviting thing in the panel. */
  margin-top: 0;
  border: 1px solid var(--border);
  border-top: 1px solid var(--border);
  padding-top: 0;
  border-radius: var(--radius);
  background: var(--card);
}
.looks[open] { background: var(--background); }

.looks-summary {
  display: flex;
  align-items: center;
  gap: 0.8rem;
  padding: 0.6rem 0.7rem;
  border-radius: var(--radius);
  cursor: pointer;
  /* The default disclosure triangle sits where the pictures want to be, and this control
   * announces itself with them instead. Both properties: WebKit needs the second. */
  list-style: none;
  min-height: 44px;
  transition: background var(--duration) var(--ease);
  /* The three the global rule sets and this one must not inherit. Written out rather than
   * left to specificity: `text-transform` and `letter-spacing` inherit into the spans
   * inside, so overriding the summary's font-size alone leaves CARTOONIZE in small caps. */
  text-transform: none;
  letter-spacing: normal;
  font-size: 1rem;
}
.looks-summary[hidden] { display: none; }
.looks-summary::-webkit-details-marker { display: none; }
/* The global rule puts a rotating `›` in front of every summary. This control announces
   itself with two pictures instead, and both markers would be one too many. */
.looks-summary::before { content: none; }
.looks-summary:hover { background: var(--accent); }
.looks-summary:focus-visible { box-shadow: var(--focus-ring); outline: none; }

/* Before and after, which says what this does faster than the word does. */
.looks-teaser {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  flex: none;
}
.looks-teaser[hidden] { display: none; }

.looks-thumb {
  display: block;
  width: 44px;
  height: 44px;
  border-radius: calc(var(--radius-sm) - 2px);
  object-fit: cover;
  background: var(--muted);
}
.looks-arrow { width: 16px; height: 16px; color: var(--muted-foreground); flex: none; }

.looks-lede { display: flex; flex-direction: column; gap: 0.15rem; min-width: 0; flex: 1; }
.looks-lede[hidden] { display: none; }
.looks-title { font-size: 0.9375rem; font-weight: 600; color: var(--primary); }
.looks-sub { font-size: 0.8125rem; color: var(--muted-foreground); }

.looks-body { padding: 0 0.7rem 0.7rem; }
.looks-body[hidden] { display: none; }

/* ------------------------------------------ what `?cartoonize=1` lands on, before an upload
 *
 * A modal, since the operator saw the first version. As a panel in the options column it was
 * below the fold - an offer somebody has to scroll to find is the same defect the mode
 * already had, one step smaller. Modelled on `.photo-dialog` beside it rather than invented:
 * one shape for the boxes this page opens.
 *
 * `svh`, never `vh`. `vh` is the *large* viewport - the height the page would have with the
 * browser's own chrome gone - so a box sized in it is taller than the screen it is on
 * whenever the URL bar is showing. */
.cartoon-lead {
  width: min(34rem, calc(100vw - 2rem));
  max-height: 88svh;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  background: var(--card);
  color: var(--foreground);
  box-shadow: var(--shadow-lg);
  overflow: hidden;
}

.cartoon-lead::backdrop {
  background: var(--scrim);
  backdrop-filter: blur(2px);
}

/* The looks scroll; the buttons do not. At 812 x 375 the grid alone is taller than the
 * viewport, and without this split the way out sits below the fold *inside* the modal - which
 * is the wizard's own lesson, where Cancel and Next landed there first. */
.cartoon-lead-inner {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  padding: 1.25rem;
  max-height: 88svh;
  overflow: hidden;
}
.cartoon-lead-body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
}

.cartoon-lead-title {
  margin: 0 0 0.2rem;
  font-size: 1.0625rem;
  font-weight: 600;
  letter-spacing: -0.01em;
}
.cartoon-lead .hint { margin: 0 0 0.7rem; }
.cartoon-lead .style-grid { margin: 0; }

/* **44px unconditionally, not inside a width block.** Every other 44px in this stylesheet is
 * inside `@media (max-width: 700px)`, which a landscape phone at 812px misses entirely - the
 * trap that made the bench's own Cartoonize button 38px. These two are the only controls in a
 * modal that opens itself, and one of them is the only visible way out of it. */
.cartoon-lead-actions {
  display: flex;
  justify-content: flex-end;
  gap: 0.5rem;
}
.cartoon-lead-actions .btn { min-height: 44px; }
.cartoon-lead-start { flex: 1 1 auto; }

/* Motion confirms; it does not perform. */
@media (prefers-reduced-motion: no-preference) {
  .cartoon-lead[open] { animation: photo-dialog-in 160ms var(--ease); }
  .cartoon-lead[open]::backdrop { animation: photo-dialog-fade 160ms var(--ease); }
}

/* What "not now" leaves behind. The offer is not withdrawn by being declined once: the
 * toolbar's Cartoonize button does not exist until there is a picture, so without this the
 * feature would be gone for the rest of the visit. */
.cartoon-lead-again {
  width: 100%;
  min-height: 44px;
  justify-content: flex-start;
  gap: 0.5rem;
}
.cartoon-lead-again[hidden] { display: none; }
.cartoon-lead-again .looks-teaser { gap: 0.25rem; }
.cartoon-lead-again .looks-thumb { width: 30px; height: 30px; }
.cartoon-lead-again .looks-arrow { width: 12px; height: 12px; }

/* The style picker: seven tiles, each the same invented face drawn that way.
 *
 * `auto-fill` with a small floor rather than a fixed column count, so the same rule gives
 * three across a 375px phone and seven across a desktop card without a media query deciding
 * it. The tile is the target, so it is comfortably over 44px at every one of those widths. */
.style-grid {
  display: grid;
  /* 76px, not a round 88, and the number is load-bearing. The grid gets 277px inside the
   * card on a 375px phone, so a floor of 88 fits two columns and runs the looks down
   * four rows - the whole screen. 76 fits three (3 x 76 + 2 x 6 = 240), which brings it to
   * three rows, and still fits three on a 360px phone. `auto-fill` then gives a landscape
   * phone all seven in one row without a media query deciding it. */
  /* `auto-fit`, not `auto-fill`: on a wide card `auto-fill` lays down an eighth track that
   * no tile occupies, so the seven sit squeezed against a gap and the longest label breaks
   * onto a third line. auto-fit collapses a wholly empty column and lets the seven stretch.
   * On a phone every column has tiles in it, so nothing collapses and the two behave alike. */
  grid-template-columns: repeat(auto-fit, minmax(76px, 1fr));
  gap: 0.4rem;
}
.style-grid[hidden] { display: none; }

/* Radix radio-group anatomy, the same as `.chip`: the input keeps its place in the
 * accessibility tree and its keyboard behaviour, and the label carries the visual state. */
.style-tile {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  margin: 0;
  padding: 0.25rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--card);
  cursor: pointer;
  font-weight: 400;
  transition: border-color var(--duration) var(--ease), background var(--duration) var(--ease);
}
.style-tile[hidden] { display: none; }
.style-tile input { position: absolute; opacity: 0; width: 0; height: 0; margin: 0; }
.style-tile:hover { border-color: var(--border-strong); }
.style-tile:has(:checked) {
  border-color: var(--primary);
  background: color-mix(in srgb, var(--primary) 5%, transparent);
}
.style-tile:has(:focus-visible) { box-shadow: var(--focus-ring); }

/* Square, and sized by the ratio rather than by a height, so the tile keeps its shape at
 * every column width `auto-fill` produces. No `max-height` anywhere near it: a cap on a box
 * that carries an aspect-ratio derives the width back out of the cap. */
.style-shot {
  display: block;
  aspect-ratio: 1 / 1;
  border-radius: calc(var(--radius-sm) - 3px);
  background: var(--muted);
  overflow: hidden;
}
.style-shot[hidden] { display: none; }
.style-shot img { display: block; width: 100%; height: 100%; object-fit: cover; }

/* What a tile shows when its sample is not on this clone. A tinted square with the initial,
 * never a broken image and never an empty box that reads as a missing option. */
.style-shot-none {
  display: grid;
  place-items: center;
  width: 100%;
  height: 100%;
  color: var(--muted-foreground);
  font-size: 1.5rem;
  font-weight: 600;
}
.style-shot-none[hidden] { display: none; }

.style-name {
  font-size: 0.6875rem;
  font-weight: 500;
  line-height: 1.2;
  text-align: center;
  /* Reserved for two lines. "Classic 2D animation" is the longest, and without a floor the
   * one-line tiles sit shorter than their neighbours and the row reads as ragged. */
  min-height: 2.4em;
  padding-inline: 0.1rem;
}
.style-tile:has(:checked) .style-name { color: var(--primary); }

.style-blurb { margin-top: 0.6rem; min-height: 2.4em; }

.cartoon-caption {
  margin: 0.5rem 0 0;
  font-size: 0.8125rem;
  color: var(--muted-foreground);
}

.cartoon-grid {
  display: grid;
  gap: 0.9rem;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
.cartoon-grid[hidden] { display: none; }

.cartoon-tile {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  padding: 0.5rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--card);
  color: inherit;
  text-decoration: none;
  transition: border-color var(--duration) var(--ease);
}
.cartoon-tile[hidden] { display: none; }
.cartoon-tile:hover { border-color: var(--border-strong); }
.cartoon-tile:focus-visible { box-shadow: var(--focus-ring); outline: none; }

.cartoon-thumb {
  display: grid;
  place-items: center;
  height: 130px;
  border-radius: calc(var(--radius-sm) - 2px);
  background: var(--muted);
  overflow: hidden;
}
.cartoon-thumb[hidden] { display: none; }
.cartoon-thumb img { max-width: 100%; max-height: 100%; object-fit: contain; }

.cartoon-tile-label { font-size: 0.875rem; font-weight: 500; }
.cartoon-tile-meta { font-size: 0.75rem; color: var(--muted-foreground); }

/* The prompt, kept beside every picture. Provenance is the whole reason this shop is allowed
 * to publish invented imagery at all, so it is shown rather than only stored. */
.cartoon-prompt {
  margin: 0;
  padding: 0.7rem 0.85rem;
  border-radius: var(--radius-sm);
  background: var(--muted);
  color: var(--muted-foreground);
  font-size: 0.8125rem;
  line-height: 1.55;
  white-space: pre-wrap;
}

.cartoon-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
  align-items: center;
  margin-top: 1rem;
}
.cartoon-actions[hidden] { display: none; }

/* Apple's minimum target, and the reason the shop's own controls are this tall.
 *
 * Unconditional, not inside a width block. The 44px rules for the shop's controls live in
 * `@media (max-width: 700px)`, so a phone on its side - 812px wide, and the shortest
 * viewport this is opened in - misses every one of them. `.cartoon-go` is the control that
 * spends money, which is exactly the one that was measured at 38px on the checkout in
 * August. It is 44px at every width because there is no width at which it should not be. */
.cartoon-go, .cartoon-actions .btn, .cartoon-actions button { min-height: 44px; }

@media (max-width: 860px) {
  .cartoon-pair { grid-template-columns: 1fr; }
}

/* A phone on its side is 812px wide and has the least height of any viewport this is opened
 * in, so it misses every width-only rule written for "mobile" while needing the most help. */
@media (max-height: 500px) {
  .cartoon-frame { height: clamp(180px, 62svh, 300px); }
  .cartoon-thumb { height: 96px; }

  /* Two columns again, undoing the 860px rule above. This page exists to compare two
   * pictures, and a landscape phone is 812px wide with 375px of height: stacking them there
   * puts the comparison on two screens and makes the reader hold one of them in their head.
   * Side by side is 370px each, which is wider than the whole viewport a portrait phone
   * gives the same pair. This block follows the width block in the file, so it wins. */
  .cartoon-pair { grid-template-columns: 1fr 1fr; }
}


/* The QA Activity toggle, on a session row and an order row.
 *
 * Quiet by default and unmistakable when set: this marks a row as *not real*, and an operator
 * scanning a list of orders has to be able to see at a glance which figures are being left out
 * of the numbers above them.
 *
 * `--destructive` rather than a third accent. It is not a warning, but it is the one state on
 * these screens that says "this is not a customer", and inventing a colour for it would break
 * the two-accent rule for a control that appears on two admin tables. See CLAUDE.md. */
.qa-toggle {
  font-size: 0.75rem;
  padding: 0.2rem 0.5rem;
  min-height: 0;
  color: var(--muted-foreground);
}
.qa-toggle.on {
  border-color: var(--destructive);
  color: var(--destructive);
  font-weight: 600;
}

/* Inside a <summary>, which is a flex row on this screen. `flex: none` so the button keeps its
 * size rather than stretching with the row, and the margin pushes it clear of the badges. */
.session-qa {
  flex: none;
  margin: 0 0 0 auto;
}
