/* 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%);
  /* **A third accent, added 2026-09-17 at the operator's instruction**, and the only thing it
   * is allowed to colour is a clock running out - the coupon's expiry line. The design
   * language says two accents and only two, because blue navigates and `--commerce` green is
   * the buying path; this one says neither, it says *soon*. Recorded as the operator's
   * decision rather than adopted as a general third accent: if a second component wants it,
   * that is a question to raise. Coral rather than `--destructive`, which is the red of an
   * action that destroys something and reads as an error beside a price. */
  --expiring: hsl(6 72% 50%);

  /* A session nobody in the workshop has looked at yet, on /admin/sessions. Asked for on
   * 2026-09-03, shaped after the way a notification list marks what arrived since your last
   * visit.
   *
   * **A third colour on a site that allows two, and it earns the exception by not being an
   * accent.** Blue navigates and green spends money; both say what a control *does*. This
   * says nothing about any control - it is a transient state on a row, it decorates no
   * button, and it is gone the next time the screen is opened. See Specification 26.1.2 on
   * what a third *accent* would have to earn; this is not one.
   *
   * Yellow rather than the existing `--warning`, which was tried first. That token is hue 30
   * and mixing it out to a surface weight gives a pale peach, which reads as a faint error
   * state rather than as "new" - the wrong meaning entirely on a screen where the tinted rows
   * are the ones worth reading. Stated as its own hue rather than pretending an orange is a
   * yellow. Low saturation and very high lightness, because a hundred rows of text still have
   * to be the thing being read on top of it. */
  --unseen: hsl(48 95% 95%);
  --unseen-border: hsl(45 88% 64%);

  --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;

  /* The garment colours, 2026-09-18. Tokens because the design language forbids a colour
   * literal inside a rule, and because `storefront.Color.swatch` carries the *name* of one -
   * so Python never holds a hex value and this file never holds the vocabulary. */
  --garment-natural: #efe9dd;
  --garment-black: #1c1c1e;
  --garment-navy: #2a3550;
  --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 */

/* The two paid balances in the workshop header.
 *
 * Tripo draws the figurines and OpenRouter draws the cartoons, and they are shown apart because
 * they are separate accounts with separate keys that run out separately. See figurine.credit
 * for the two thresholds and why they are dollars rather than units of work.
 *
 * Quiet by default: a healthy balance is a fact the operator does not need to read. The colour
 * is spent only on a real shortage - not on an unread balance and not on a missing key, because
 * a warning that fires on those is a warning nobody reads. */
.credit-bar {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  /* **`flex: none`, and no `min-width: 0`.** This is a sibling of the scrolling tab strip, not
   * an item in it - see the note in base.html. `min-width: 0` is right for the strip, which
   * must be allowed to shrink and scroll; it is wrong here, and putting it on this bar is
   * exactly what collapsed it to zero width the first time. The strip gives up space, the
   * balances keep theirs. */
  flex: none;
}

/* `header nav` carries `margin-left: auto` to push itself right. With the bar beside it that
 * margin now separates the two, and the bar is what sits against the edge. */
header .credit-bar { margin-left: 0.75rem; }

.credit-chip {
  display: inline-flex;
  align-items: baseline;
  gap: 0.3rem;
  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;
  white-space: nowrap;
}

.credit-label {
  font-size: 0.6875rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--placeholder);
}

/* Running low. `--destructive` rather than a third accent: --primary navigates and --commerce
 * spends, and neither is true of a warning. Same token the QA toggle and the Slow badge use. */
.credit-chip.low {
  color: var(--destructive);
  border-color: color-mix(in srgb, var(--destructive) 35%, transparent);
}
.credit-chip.low .credit-label { color: color-mix(in srgb, var(--destructive) 70%, transparent); }

/* Below the critical bar: red and bold, which is the operator's own specification for this
 * tier. The weight is what separates it from the tier above at a glance - two shades of the
 * same red a few dollars apart would be one tier with extra steps. */
.credit-chip.critical {
  color: var(--destructive);
  border-color: var(--destructive);
  background: color-mix(in srgb, var(--destructive) 8%, transparent);
  font-weight: 700;
}
.credit-chip.critical .credit-label {
  color: var(--destructive);
  font-weight: 700;
}

/* No key on this machine, or the balance could not be read. Neither is a shortage, so both
 * recede rather than shouting. */
.credit-chip.quiet {
  opacity: 0.55;
  border-style: dashed;
}

/* **The names are not hidden on a phone, and dropping them was tried first.** A 900px rule
 * hid them to save width - which left two bare amounts side by side and no way to tell which
 * account either belonged to, so the number that matters most became the one nobody could
 * attribute. There is no width at which that is the right trade: the header wraps the bar onto
 * its own line on a narrow screen, and a labelled chip fits there comfortably. */

/* --------------------------------------------------------------------- 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;
  /* A row since 2026-09-07, so the struck price and the saving can stack beside the number
   * rather than trailing after it. `baseline` would hang the column off the big number's
   * baseline and push the pill below the row; centring keeps the pair optically level with
   * it, which is the alignment the eye wants here. */
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 0.55rem;
}

/* `.price-aside` stood here until 2026-09-17: the old price with a percentage pill under it,
 * stacked beside the number. Both are gone with the coupon redesign - the pill because the
 * coupon line states the saving in money and a percentage would be a second unit for one
 * fact, and the stack because it made the row taller the moment a coupon applied, which is
 * the one thing the new row may not do. The struck price is `.price-figures` now. */

.price-note {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 0.4rem;
  /* Reduced from 1.75rem on 2026-09-07: the gap between the price and the first option read
   * as a missing element. **Changed here and on `.price-alone` together** - the two are
   * pinned to each other so the options land in the same place whether or not the delivery
   * line is shown, and changing one alone is the defect that pairing exists to prevent. */
  margin: 0 0 0.9rem;
  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 {
  /* Matches `.price-note` exactly. Change both or neither. */
  margin-bottom: 0.9rem;
}

@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; }
/* **`minmax(0, 1fr)`, never a bare `1fr`.** A grid track's minimum is its content, exactly as
 * a flex item's is - so three chips wider than a third of the column pushed the row past the
 * column and the whole page scrolled sideways. Found at 375x812 on the chocolate pages, where
 * the options panel is a 200px column since the product page was compacted: the swatch row
 * measured 442px against a 375px viewport. Same defect as the workshop tab strip's missing
 * `min-width: 0`, in the other layout system. */
.chips-inline { grid-auto-flow: column; grid-auto-columns: minmax(0, 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); }

/* **The garment colours, 2026-09-18.** Tokens on `:root` like every other colour in this
 * file: `storefront.Color.swatch` carries a token *name* and never a hex value, so the one
 * place that decides what "navy" looks like is here rather than in Python. Same arrangement
 * as `PRINT_FONTS`, which stores a key and lets `.mockup-print[data-font]` own the stack.
 *
 * Natural is a near-white, so it carries a border of its own - a white dot on a white chip is
 * a chip with nothing in it, which is the `.dot` rule's existing problem one colour further
 * along. */
.color-swatch[data-color="natural"] { background: var(--garment-natural); box-shadow: inset 0 0 0 1px var(--border); }
.color-swatch[data-color="black"]   { background: var(--garment-black); }
.color-swatch[data-color="navy"]    { background: var(--garment-navy); }

/* --------------------------------------------------------------- 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` is the line; `--input` is the field's own fill and is `hsl(0 0% 100%)` in this
     palette, so this box drew a white border on a white card and the one control on the
     checkout made of two inputs was the one with no box round it. Found by looking at the
     payment section at 375px on 2026-09-16, not by a test - the same token confusion is
     recorded in CLAUDE.md against the engraving field. */
  border: 1px solid var(--border);
  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;
  /* **The price column keeps its 6rem floor and is allowed to grow**, since 2026-09-18. It was
   * a flat `6rem`, which is enough for one figure and not for two: with a coupon on the line
   * the struck price and the charged one wrapped onto separate rows, which is the stacking
   * both this cell and the product page's price row are written to avoid - a line that gets
   * taller the moment an offer lands. */
  grid-template-columns: minmax(0, 1fr) auto minmax(6rem, auto) 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);
}

/* **A line the workshop cannot start.** Since 2026-09-09 a bespoke figurine can be ordered
 * with no photograph - the customer buys and we ask for the face afterwards - so the screen
 * the order is made from has to say which lines are waiting on one. An order that looks
 * complete and cannot be made is the single way that decision goes wrong.
 *
 * `--destructive`, the same token `.timeline-trouble` reaches for and for the same reason:
 * this is the one thing an operator scans a line for. It is not a third accent - blue
 * navigates and green buys, and neither of those is what this is. */
.order-awaiting {
  display: inline-block;
  margin-top: 0.35rem;
  font-size: 0.8125rem;
  color: var(--destructive);
}

/* 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; }

/* The quantity stepper, 2026-09-18. `cartqty.js` wraps the number input in this and hides
 * the Update button beside it; the measurement that removed that button is in the module.
 *
 * 44px on both steps, unconditionally and not inside a width query - every 44px rule this
 * project scoped by width was missed by a phone on its side, and this control is on the one
 * page where the measured defect is people pressing the wrong thing.
 *
 * `.qty-update` is the button the script hides. It is styled neutral rather than left to the
 * base `button` rule because a stale cache or a blocked module still draws it, and a filled
 * button under the product is the exact shape of the defect being removed. */
.qty-form { display: flex; gap: 0.4rem; align-items: center; }

.qty-stepper {
  display: flex;
  align-items: center;
  gap: 0.15rem;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--card);
}

.qty-step {
  min-width: 44px;
  min-height: 44px;
  border: none;
  background: none;
  border-radius: var(--radius);
  font-size: 1.125rem;
  line-height: 1;
  color: var(--foreground);
  cursor: pointer;
  transition: background var(--duration) var(--ease);
}
.qty-step:hover { background: var(--accent); }
.qty-step:focus-visible { outline: none; box-shadow: var(--focus-ring); }

/* The field inside the stepper loses its own frame - the stepper is the frame now - and its
 * spinner, which is a second pair of up-and-down arrows beside the two real ones. */
.qty-stepper input[type="number"].qty {
  border: none;
  background: none;
  width: 2.5rem;
  -moz-appearance: textfield;
  appearance: textfield;
}
.qty-stepper input[type="number"].qty::-webkit-outer-spin-button,
.qty-stepper input[type="number"].qty::-webkit-inner-spin-button {
  -webkit-appearance: none;
  margin: 0;
}
.qty-stepper input[type="number"].qty:focus-visible {
  outline: none;
  box-shadow: var(--focus-ring);
  border-radius: var(--radius);
}

.qty-update {
  background: none;
  border: 1px solid var(--border);
  color: var(--muted-foreground);
  font-weight: 400;
  box-shadow: none;
}
/* 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;
}

/* The full price beside what is charged, drawn only where a coupon is actually on the line.
 *
 * **Inline rather than stacked**, the arrangement the product page's price row settled on for
 * the same reason: a struck figure above the number makes the cell taller the moment a coupon
 * applies, and a row that changes height when an offer lands is the one thing it may not do.
 *
 * `<s>` carries the line through itself, so nothing here draws one. */
.basket-line-was {
  margin-right: 0.4rem;
  font-size: 0.8125rem;
  font-weight: 500;
  color: var(--muted-foreground);
}
.basket-line-now { font-weight: 600; }

@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; } }

/* ---------------------------------------------------------------- the pinned Checkout bar
 *
 * Added 2026-09-16, off the shop's own logs. Twenty-seven real non-QA sessions put a line in
 * the cart over ninety days and two of them pressed Checkout; twelve presses went to
 * `Update`, the quantity button beside the line, and not one followed a change of quantity.
 * At 375 x 812 with one line in the basket `Update` sits at y=397 and the only Checkout
 * button on the page at y=867 - below the fold, under the line, the delivery picker and the
 * summary panel. The bar is the product page's answer to the identical measurement: pin the
 * way on to the foot of the viewport, where it is reachable at every scroll position.
 *
 * `position: fixed` and not `sticky`, for the reason the buy bar states: `.shop .site-head
 * { position: static }` leaves a sticky child no room to move inside a parent that hugs it,
 * Specification 28.7. A bar at the foot of the viewport has no such parent to fight.
 *
 * The 44px floor is outside the media query on purpose. Every 44px rule this project scoped
 * to a width was missed by a phone on its side, and this is a control on the buying path. */
.checkout-bar {
  display: none;
  align-items: center;
  gap: 0.75rem;
}
/* What `hidden` means for this bar, stated where the display is given. The rule below is a
 * class selector inside a media query and the user agent's is a bare `[hidden]`, so setting
 * the property from the observer would otherwise hide nothing at all - the trap this project
 * has now hit five times. Any rule that gives an element a display takes this on with it. */
.checkout-bar[hidden] { display: none !important; }
.checkout-bar-button {
  flex: 1;
  font-size: 1rem;
}
/* Stated for the bar's button alone, because the inline one is `.btn.lg` and already clears
 * it. `#buy` needed its own line on the product page precisely because it is a bare
 * `button`; this is an `<a class="btn">`, so the `.btn` sizing applies. */
.checkout-bar-button { min-height: 44px; }
.checkout-bar-money {
  font-weight: 650;
  font-size: 1.05rem;
  white-space: nowrap;
  /* A column, so the coupon can sit above the figures. The bar's height is set by its 44px
   * button and these two lines measure under that, so the offer costs the bar nothing - which
   * is what "carries the saving" has to mean for a control pinned over the page. The buy bar's
   * own money column, and if one of the two grows a rule the other wants it. */
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  justify-content: center;
  /* The button beside it gives up nothing; this column elides instead. */
  min-width: 0;
}

/* ------------------------------------------- the coupon on the Checkout bar (2026-09-18)
 *
 * The line strips' offer, summarised: a tick, and what the coupon took off the basket. Not a
 * control, and that is the one place this differs from the buy bar's - every coupon control on
 * the cart is scoped to the line it is drawn across, so there is no basket-wide form for a
 * second submit to bind to. See cart.html.
 *
 * **And it carries no tick**, since 2026-09-18 at the operator's instruction. It had one,
 * borrowed from the buy bar, where the chip really is a submit; here it never could be, and a
 * drawn checkbox nobody can untick is a control that only teaches you it does not work. No
 * 44px floor either, for the same reason the buy bar's chip has none and more so: Apple's
 * minimum is for the controls on the buying path, and this is a sentence. */
.checkout-bar-coupon {
  display: flex;
  align-items: center;
  gap: 0.35rem;
  max-width: 100%;
  min-width: 0;
  font-size: 0.75rem;
  font-weight: 600;
  line-height: 1.2;
  color: var(--commerce);
  overflow: hidden;
  text-overflow: ellipsis;
}
.checkout-bar-figures { display: flex; align-items: baseline; gap: 0.35rem; }
.checkout-bar-was {
  font-size: 0.8125rem;
  font-weight: 500;
  color: var(--muted-foreground);
}

/* **900px, and it is the width this layout actually changes at** - `.basket` goes to one
 * column there and the summary panel, with Checkout inside it, drops below the lines. Not
 * 940: that is the product page's number, for the width `.product` collapses at, and copying
 * it here would key this bar on a layout it has nothing to do with.
 *
 * The height half is not decoration. A phone on its side is 812px wide, so it misses every
 * width-only query while having the least height of any viewport this shop is opened in -
 * which is the one place a Checkout button is most certainly below the fold. */
@media (max-width: 900px), (max-height: 560px) {
  .checkout-bar {
    display: flex;
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 40;
    padding: 0.6rem 1rem calc(0.6rem + env(safe-area-inset-bottom, 0px));
    background: var(--card);
    border-top: 1px solid var(--border);
    /* Large, soft and nearly transparent. A visible drop shadow is a mistake. */
    box-shadow: 0 -12px 28px -18px rgb(15 23 42 / 0.18);
  }
  /* Room for it, so "Continue Shopping" is not permanently underneath the bar. Stated here
   * rather than leant on: the product page's block already gives `.shop main` this padding,
   * but it is keyed on 940px and this bar is keyed on 900, and a rule that works only
   * because another one happens to be wider is a rule nobody can safely change. */
  .shop main { padding-bottom: 5rem; }
}

/* -------------------------------------------------------------------------- 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; }
}

/* ------------------------------------------------------ the checkout's four sections
 *
 * Email, Addresses, Shipping, Payment, answered in order. Every one of these rules
 * describes a state the *script* puts a section into: with no JavaScript nothing carries
 * `is-open`, `is-done`, `is-todo` or `is-locked`, no body is hidden, and what is left is
 * four cards with numbered headings - the page that was here before, better labelled.
 *
 * Hierarchy by weight and ground rather than by rules between things: a shut section is a
 * flat row, the open one is the only card with any depth to it, and a section still out of
 * reach is simply faint. No dividers, no chevrons. */

/* `.card` pads itself and carries a shadow; here the header and the body pad themselves,
 * so the ground can run to the edges of a shut row, and the depth belongs to the open one
 * alone. The margin is beaten off `.checkout-forms .card` by specificity and order rather
 * than by a second number, so there is one distance between sections. */
.checkout-forms .co-step { margin-bottom: 0; }
.co-step { padding: 0; overflow: hidden; box-shadow: none; }
.co-step + .co-step { margin-top: 0.75rem; }

/* The header is a row whatever state it is in, so nothing moves sideways when a section
 * opens. 44px of height unconditionally and outside every media query: on a phone this is
 * the control that reopens an answered section, and every 44px rule this project scoped to a
 * width was missed by a phone on its side. */
.co-step-head {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  min-height: 44px;
  padding: 0.7rem 1.1rem;
}
.co-step.is-done > .co-step-head,
.co-step.is-todo > .co-step-head { cursor: pointer; }

/* A title, not a label - so it opts out of the sitewide `h2`, which is a 0.7rem uppercase
 * micro-label meant to caption a block of content. On a shut row this heading *is* the
 * content, and the question it names is what the reader is scanning for. The micro-label
 * style is kept for the two halves inside the Addresses section, `.co-sub` below, which is
 * exactly the job it is good at. */
.co-step-title {
  margin: 0;
  font-size: 1rem;
  font-weight: 550;
  letter-spacing: -0.01em;
  line-height: 1.2;
  text-transform: none;
  color: var(--foreground);
}

/* The number, and the one place a completed step is marked in colour.
 *
 * `--success` on a tint rather than a third accent: it decorates no control, says nothing
 * about what any button does, and is a state on a row - the exception `--unseen` already
 * earned and documented at the top of this file. Blue still navigates and green still buys;
 * a tick beside a heading is neither. */
.co-step-index {
  flex: none;
  display: grid;
  place-items: center;
  width: 1.625rem;
  height: 1.625rem;
  border-radius: 50%;
  background: var(--muted);
  color: var(--muted-foreground);
  font-size: 0.8125rem;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  transition: background 160ms ease-out, color 160ms ease-out;
}
.co-step.is-open > .co-step-head > .co-step-index {
  background: var(--primary);
  color: var(--primary-foreground);
}
.co-step.is-done > .co-step-head > .co-step-index {
  background: color-mix(in srgb, var(--success) 12%, var(--card));
  color: var(--success);
  /* The glyph replaces the digit rather than sitting beside it: the number says which
   * question this is and is only worth the room while the question is still open. */
  font-size: 0;
  position: relative;
}
/* **Taken out of the grid, not centred inside it.** `font-size: 0` shrinks the digit to
 * nothing but does not remove it: a text node in a grid container is still an anonymous grid
 * item, so this box had *two* rows - measured as `10px 16px` - and the tick was centred in
 * the lower 16px of a 26px circle rather than in the circle. Reported as the check looking
 * broken, and it was: centring it twice in the wrong box cannot be fixed by nudging.
 *
 * So it is positioned against the circle itself, where the only thing that decides where it
 * lands is this rule.
 *
 * The 0.8px lift is optical, and it was chosen by looking rather than by arithmetic. The
 * arithmetic says 1.6: the shape is an L - a 2px left edge and a 2px bottom edge inside a
 * 10 x 6 box - so its centre of area is at (3.86, 4.14) against a box centre of (5, 3), and
 * rotating that offset by -45 degrees leaves it 1.6px low. Drawn at 7x against a crosshair,
 * 1.6 reads distinctly top-heavy: the rotated box's own bounding box is centred on the
 * circle, so lifting it by the full centroid offset moves the whole glyph off the middle.
 * The eye weighs both, and half of it is what actually looks centred. Optical alignment
 * beats mathematical alignment, which is the house rule and is what the measurement said. */
.co-step.is-done > .co-step-head > .co-step-index::after {
  content: "";
  position: absolute;
  left: 50%;
  top: 50%;
  width: 0.625rem;
  height: 0.375rem;
  border-left: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  border-radius: 1px;
  transform: translate(-50%, -50%) translateY(-0.8px) rotate(-45deg);
}

/* The line the section is remembered by, written by the script off the fields themselves.
 * It takes the space left over and gives up first, because a long address must not push the
 * Edit button off the end of the row. */
.co-step-value {
  flex: 1 1 auto;
  min-width: 0;
  text-align: right;
  font-size: 0.8125rem;
  color: var(--muted-foreground);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* A rule that gives an element a display takes `[hidden]` on with it. Four times in this
 * project now. */
.co-step-value[hidden] { display: none; }

/* Quiet: it is a way back, not a thing to do. Shipped `hidden` and revealed by the script,
 * so a browser that never collapses anything is never offered a control that would open
 * something already open. 44px of target under a small label, unconditionally. */
.co-step-edit {
  flex: none;
  margin: 0 0 0 auto;
  padding: 0 0.6rem;
  /* Both dimensions, not just the height. "Edit" plus this padding measures 43px wide at
     375px - one short, and measured rather than assumed. */
  min-height: 44px;
  min-width: 44px;
  background: none;
  border: 0;
  box-shadow: none;
  color: var(--primary);
  font-size: 0.8125rem;
  font-weight: 500;
}
.co-step-edit:hover { background: none; border: 0; color: var(--primary); text-decoration: underline; }
.co-step-edit:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.co-step-value:not([hidden]) + .co-step-edit { margin-left: 0.5rem; }
.co-step-edit[hidden] { display: none; }

.co-step-body { padding: 0 1.1rem 1.1rem; }
.co-step-body[hidden] { display: none; }
/* The first field in an open section sits under the heading, not against it. The heading's
 * own padding is what separates them in a shut one, which is why this is here and not there. */
.co-step-body > :first-child { margin-top: 0.25rem; }
.co-step-lead { margin: 0.25rem 0 0; color: var(--muted-foreground); font-size: 0.9375rem; }

/* Delivery and billing inside one section. Small and muted: they label two halves of a
 * question, where the section's own heading names the question. */
.co-sub {
  margin: 1.5rem 0 0.35rem;
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--muted-foreground);
}
.co-step-body > .co-sub:first-child { margin-top: 0.5rem; }

/* Continue. Blue, because it moves through the form - green on this page means the press
 * that places the order, and there is exactly one of those. 44px unconditionally for the
 * same reason as everything else on this row. */
.co-step-actions {
  display: flex;
  justify-content: flex-end;
  margin-top: 1.25rem;
}
.co-step-actions[hidden] { display: none; }
.co-step-actions > button { min-height: 44px; margin: 0; }
/* The one that spends money takes the width, and keeps the 44px floor outside any query. */
.co-step-place { display: block; }
.co-step-place > button { min-height: 44px; width: 100%; }

/* 16px on every field in these four sections, because Safari zooms the whole page in when a
 * field under 16px takes focus and does not zoom back out. Every field on this checkout was
 * 14px - at every width, not only on a phone - which on the shop's most important form is
 * the worst place in it for that to be true.
 *
 * **Keyed on the pointer, not on a width.** The three fields this was fixed on before - the
 * search box, the engraving field, the notes - are all inside `@media (max-width: 700px)`,
 * and a phone on its side is 812px wide and misses it entirely. That is the recurring shape
 * of defect in this project rather than a one-off, so the condition here is the one that is
 * actually being asked about: a touch screen zooms, a mouse does not. The same test
 * `camera.js` uses to decide whether `capture` is worth offering.
 *
 * Scoped to the sections rather than set on `input`, which would restyle the whole shop. */
@media (pointer: coarse) {
  .co-step input,
  .co-step select,
  .co-step textarea { font-size: 16px; }
}

/* Open: the only card on the page with any depth to it. A whisper - large, soft, nearly
 * transparent, which is the house rule for a shadow. */
.co-step.is-open {
  border-color: var(--border-strong);
  box-shadow: 0 1px 2px hsl(220 20% 12% / 0.04), 0 8px 24px -12px hsl(220 20% 12% / 0.12);
}

/* Ahead of the reader, and never opened. Faint rather than greyed out, and there is no
 * control here that opens it - the way forward is the Continue button in the section being
 * answered, which is the whole point of the arrangement. */
.co-step.is-locked { opacity: 0.5; }
.co-step.is-locked > .co-step-head { cursor: default; }

/* Reached, left unanswered, closed again. It keeps its Edit and its full contrast: the
 * reader has been here and may have typed something, so this is a section with an unfinished
 * answer rather than one they have not got to. */
.co-step.is-todo > .co-step-head > .co-step-index {
  background: color-mix(in srgb, var(--primary) 10%, var(--card));
  color: var(--primary);
}

/* A phone. The value line stops competing with the heading for a 375px row and takes a row
 * of its own, the full width of the card.
 *
 * Under the title and inside the two side columns was the first arrangement and it was
 * measured at 163px, which cut "jane.sleeper@exampl..." and "Expedited shipping - ...". A
 * summary exists so somebody can check what they answered without opening the section, and
 * an address they cannot read is the same as no summary at all - so it spans all three
 * columns and gets 257px. The truncation is still there for a genuinely long address; what
 * went is the truncation caused by the layout. */
@media (max-width: 560px) {
  .co-step-head {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr) auto;
    grid-template-areas: "index title edit" "value value value";
    align-items: center;
    row-gap: 0.15rem;
    padding: 0.55rem 0.85rem;
  }
  .co-step-index { grid-area: index; }
  .co-step-title { grid-area: title; }
  .co-step-value { grid-area: value; text-align: left; }
  .co-step-edit { grid-area: edit; }
  .co-step-value:not([hidden]) + .co-step-edit { margin-left: 0; }
  .co-step-body { padding: 0 0.85rem 0.95rem; }
  .co-step-actions > button { width: 100%; }
  .co-step-actions { display: block; }
}

/* ------------------------------------------------------------------------- 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;
}
/* Reduced from 1.35rem on 2026-09-07: the saving now sits under it, and the pair has to
 * read as one small block against the price rather than as two competing numbers. */
.price .was { font-size: 1.0625rem; margin-right: 0; }
.product-card-price .was { font-size: 0.8125rem; }
.price .now, .product-card-price .now { color: var(--foreground); }
/* No inline offsets: it is a flex child of its price wrapper, not a word after the number. */
.price .pill { margin-left: 0; }

.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; }

/* The studio byline under the masthead logo, added 2026-09-25. Centred under the whole
 * lockup in small tracked capitals, which echo the spaced "PRINT" line inside the artwork,
 * so it reads as part of the sign rather than as a caption beside it. Muted, because the logo
 * is the name over the door and this is a note of whose door it is. The lockup's width is
 * the logo's, so the byline can never widen the brand block. */
.brand-lockup {
  display: flex;
  flex-direction: column;
  align-items: center;
}
.brand-byline {
  margin-top: 5px;
  font-size: 0.5625rem;
  font-weight: 500;
  line-height: 1;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  white-space: nowrap;
  color: var(--muted-foreground);
}

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. */
/* **Sans is a rule like the other seven, not an absence.**
 *
 * It was the base default on `.print-text` and nothing else declared it, so when the picker
 * started wearing `data-font` too, its Sans option set no property and inherited whatever
 * the select was showing - the one option in the menu drawn in the wrong face. Eight faces,
 * eight rules. */
[data-font="sans"] {
  --print-font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto,
    Helvetica, Arial, sans-serif;
}

[data-font="serif"] {
  --print-font: "Iowan Old Style", Georgia, "Times New Roman", Times, serif;
}
[data-font="display"] {
  --print-font: "Haettenschweiler", "Arial Narrow", Impact, "Franklin Gothic Bold", sans-serif;
}
[data-font="mono"] {
  --print-font: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
}
[data-font="script"] {
  --print-font: "Snell Roundhand", "Segoe Script", "Brush Script MT", cursive;
}
/* The three fun faces. Each stack names the Mac face first, then the Linux font the server
 * actually draws with (`figurine.printfonts`), then a generic - so the preview and the print
 * file agree on a machine that has either, and degrade in the same direction on one that has
 * neither. Still system stacks: nothing here may fetch a webfont. */
[data-font="rounded"] {
  --print-font: "Arial Rounded MT Bold", Comfortaa, "Varela Round", ui-rounded, system-ui, sans-serif;
}
[data-font="handwritten"] {
  --print-font: "Bradley Hand", "Humor Sans", "Comic Sans MS", "Segoe Print", cursive;
}
[data-font="brush"] {
  --print-font: "Brush Script MT", Lobster, "Snell Roundhand", 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 six coffee label designs
 *
 * A template is a composition rather than a picture somebody placed, so in this mode the
 * geometry stops being the customer's: the photo is cut to the design's slot and the name
 * sits where the design puts it. What is left to choose is the words, their size and their
 * color, which is what the operator asked for.
 *
 * Everything that moves something is **taken away rather than disabled** - the handles, the
 * size slider and the drag hint are hidden by `printtools.js` in the same `refresh` that
 * draws every other control, and the two grabbable elements give up their pointer events
 * here. A disabled control is a promise about the browser; this shop has said so about card
 * fields and about "Ships next day" and it is the same rule.
 */

/* The decoration, over the photo and under the lettering - the order `figurine.printfile`
 * composites in. Multiplied for the same reason the artwork is: ink on a bag rather than a
 * sticker over one. */
.label-plate {
  position: absolute;
  inset: 0;
  z-index: 2;
  width: 100%;
  height: 100%;
  pointer-events: none;
  mix-blend-mode: multiply;
}
.label-plate[hidden] { display: none; }

/* The photo, cut to the design's slot. `--label-mask` is set by `printtools.js` from
 * /labels/{key}/mask.png, which is the same drawing `figurine.printfile` multiplies into the
 * photograph's alpha - one routine, two sizes, rather than a CSS copy of a Pillow one.
 *
 * `-webkit-mask` alongside the standard property: Safari still wants the prefix, and this
 * shop is opened on a phone four times in five. */
.mockup-print.templated > .art-crop {
  -webkit-mask-image: var(--label-mask);
  mask-image: var(--label-mask);
  -webkit-mask-size: 100% 100%;
  mask-size: 100% 100%;
  -webkit-mask-position: center;
  mask-position: center;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
}

/* **Nothing here disables anything, and that is a reversal.** For one day a design locked
 * its own geometry: the photo could not be dragged, the size slider was withdrawn and the
 * typeface and type toggles were hidden. The operator asked on 2026-09-05 for every element
 * to be movable and restyleable instead, so a design now decides only where things *start*.
 *
 * The class is kept because the plate and the mask above are drawn under it. Removing rules
 * rather than the class: what the mode means changed, not whether it exists. */

/* 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 is a fallback on the `var()`, never a declaration of `--print-font`.**
   *
   * It was a declaration until 2026-09-05, and unscoping the `[data-font=...]` rules above
   * so a picker chip could wear one made both selectors specificity (0,1,0) - so this rule,
   * being later in the file, won every time and *every typeface silently stopped working*.
   * Changing the font did nothing at all, on every product.
   *
   * Declaring it here again would reintroduce that. Only the eight key rules set the
   * property; anything that has not matched one falls back in the shorthand below. */
  --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, -apple-system, BlinkMacSystemFont, "SF Pro Text",
    "Segoe UI", Roboto, Helvetica, Arial, sans-serif);
  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 one line on a product made from words alone, 2026-09-19.
 *
 * The same element the arranged texts use, because it has to look like what the print file
 * will compose - but nothing about it is adjustable, so it takes back the two declarations
 * that say otherwise. A `cursor: grab` over something that cannot be grabbed is a control
 * that only teaches you it does not work, and `pointer-events: auto` on it would take the
 * press away from the picture underneath for no reason.
 *
 * It carries no handles and no rotate arrow, so nothing below needs undoing. */
.print-text.preview {
  cursor: default;
  pointer-events: none;
}

/* 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(100% - 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;
}

/* **Capped by height, not by width, so the two ways in are always on screen.**
 *
 * It was `width: 100%` with the height following, which on a square poster in a 548px body
 * came out 548px tall - the whole scrolling area, with "Upload a photo" and "Camera" below
 * the fold. Asked for on 2026-09-08: shrink the picture rather than lose the buttons.
 *
 * Height first and `width: auto` after it, which is the safe order for an `<img>`: it has an
 * intrinsic ratio, so capping one axis scales the other. Note this is *not* the trap recorded
 * in Specification 28 - that is a `max-height` on a box carrying a CSS `aspect-ratio`, which
 * derives the width back out of the cap. An image is not that box.
 *
 * `svh` and never `vh`: `vh` is the viewport the page would have without the browser's own
 * chrome, so a cap in `vh` is taller than the screen whenever the URL bar is showing.
 */
.pose-advice-art {
  display: block;
  width: auto;
  max-width: 100%;
  height: auto;
  /* **52, not 38, since 2026-09-18 at the operator's request**: the dialog is sized by its
   * content and centred, so the poster stopping short left 119px of nothing above and below
   * it at 574x928 - dead space the picture could be using, on the step whose whole job is to
   * show what a good photo looks like.
   *
   * Raised rather than removed, and the cap's original reason stands: the poster is square,
   * and uncapped it takes the whole scrolling body and puts "Upload a photo" below the fold.
   * That was measured on 2026-09-08 and is why this number exists at all.
   *
   * A phone at 375px is width-bound long before this binds, so what it changes is the narrow
   * *window* the report came from and the tablet widths beside it. */
  max-height: 52svh;
  margin-inline: auto;
  min-height: 0;
  object-fit: contain;
  border-radius: var(--radius);
}

/* A phone on its side has the least height of any viewport this shop is opened in - 375px -
 * and it misses every width-only query, which is the recurring shape of defect here.
 *
 * **Shrinking the poster alone does not get there, which was measured rather than assumed.**
 * At 812x375 the scrolling body is 166px; with the picture down to 98px the buttons still
 * landed at 268-312 against a body ending at 292, so 20px of the thing that has to be
 * pressed was below the fold and 94px of the step was scroll. Taking the poster smaller
 * still would have left a thumbnail nobody can read.
 *
 * So the order changes instead: `.photo-dialog-inner` is already a flex column, and the
 * poster goes last. The two ways in are then at the top of the body and visible at any
 * height, and the advice is under them rather than gone - which matters because the
 * paragraph that used to say the same thing in words is only rendered where the poster is
 * absent. Action first, guidance after, on the one viewport with no room for both. */
@media (max-height: 560px) {
  .pose-advice { order: 2; }
  .pose-advice-art { max-height: 30svh; }
}

.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;
  /* **And the clip goes with them, which is the half this rule forgot.** A `<dialog>` here is
     `overflow: hidden` with a 15px radius, and that survived every other reset above - so with
     the padding at nought the first control inside sat flush in the corner and had a bite
     taken out of it by the curve. Reported 2026-09-07 as the top left of "Re-upload" missing
     its blue outline, and it is the same in every use of this state: the wizard's picture
     step, a product whose images have all been deleted, and `<noscript>`.

     A rounded clip is only ever paid for by a box that draws something in the corner it is
     rounding. This one gives up its border, its background and its shadow one line above. */
  border-radius: 0;
  overflow: visible;
}
.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%);
}

/* **Inside the wizard the height is stated and the ratio cleared, which is the rule rather
 * than a preference.** Specification 28: a `max-height` on a box that also carries an
 * `aspect-ratio` derives the width back out of the cap - it is how `.studio-stage` once
 * computed to 420px inside a 741px column. The pair above is safe on the product page, where
 * the column decides the width and the cap never binds; in a wizard the cap is the whole
 * question, because the stage is competing with a title, a stepper and three buttons for a
 * fixed dialog height.
 *
 * So here the stage takes the room that is left and gives up its shape, and `object-fit:
 * cover` on the photo keeps the picture filling it. Reported 2026-09-08: a tall photo pushed
 * Back, Done and Add to Cart off the bottom, which is the same defect as the pose poster one
 * step along - the control that has to be pressed was below the fold.
 *
 * `svh` and never `vh`, and a floor under it so the stage cannot be squeezed to nothing on a
 * landscape phone - an element sized by leftover space alone is the other trap in that
 * section. */
#studio-figurine-host .studio-stage {
  aspect-ratio: auto;
  height: 46svh;
  min-height: 180px;
  max-height: none;
}

@media (max-height: 560px) {
  #studio-figurine-host .studio-stage {
    height: 40svh;
    min-height: 130px;
  }
}
/* `.studio-preview` is where `.viewer` used to be in every one of these selectors. It is an
 * `<img>` since 2026-09-15 rather than a three.js host, which quietly retires a whole class of
 * defect: an element with no intrinsic size must never be sized by leftover space alone, and
 * an image has one. The explicit heights below stay anyway, because the two panels have to
 * match each other and a picture's own ratio is whatever the model drew. */
/* **`.studio-photo`, not `img`.** Every rule here that means "the photograph" says so by
 * class, and that is not tidiness: the preview became an `<img>` on 2026-09-15 and from that
 * moment `.studio-stage.done > img` matched both of them. The preview quietly picked up the
 * photo chip's `width: auto` and `justify-self: start` on a phone, so it sat auto-width
 * against the left edge of a full-width stage - reported by the operator as the image not
 * being centred. A selector that used to mean one element and now means two is the whole
 * class of fault, and naming the element is the fix. */
.studio-stage > .studio-photo,
.studio-stage > canvas,
.studio-stage > .studio-preview {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
  border-radius: 0;
  max-height: none;
  aspect-ratio: auto;
}
/* **`contain`, so a photograph is never cropped to fill this box.** It was `cover` until
 * 2026-09-16 and a portrait phone photo - which is most of them - lost its top and bottom to
 * the 8/5 frame, so the customer was judging a slice of their own picture. Letterboxing is
 * the right trade here at the operator's instruction: white space costs nothing and a
 * missing head costs the decision. The dimming stays; it is what the scan sweeps over. */
.studio-stage > .studio-photo { object-fit: contain; opacity: 0.82; }
/* The preview is a product photograph on a pale ground, so it is shown whole and at full
 * strength. `cover` here would crop the plinth - which is where the lettering is, and the
 * lettering is the part being checked. The dimmed 0.82 above belongs to the photo *under* the
 * scan overlay and would be wrong on a finished result. */
.studio-stage > .studio-preview { object-fit: contain; opacity: 1; }
.studio-stage > canvas { pointer-events: none; }
/* **No ground behind the preview.** The gradient was the three.js stage's backdrop, and a
 * model rendered on transparency needed one. The preview is a product photograph that brings
 * its own pale studio background with it, so the gradient showed only in the letterboxing
 * `object-fit: contain` leaves - a grey surround behind a warm cream picture, reported by the
 * operator as looking weird. It is the page's own background now, like any other image. */
.studio-stage > .studio-preview { background: none; }
.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 > .studio-photo { grid-column: 1; grid-row: 2; }
.studio-stage.done > .studio-preview { grid-column: 2; grid-row: 2; }
.studio-stage.done > .studio-photo,
.studio-stage.done > .studio-preview {
  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 > .studio-photo { object-fit: contain; }
.studio-stage.done > .studio-photo { opacity: 1; }
.studio-stage.done > canvas { display: none; }

/* **"This is an approximate render."**
 *
 * Off screen until there is something to be approximate *about*, and shown by `.done` rather
 * than by a script. A caption a script reveals is a caption that can be left on screen with
 * nothing under it, or left off the one screen it is required on - the same reasoning the
 * two compare captions live under, two rules below.
 *
 * Full width beneath the pair, in a row of its own, so neither panel loses height to it. It
 * is `position: static` against the absolutely-placed children above because by this point
 * the stage is a grid rather than a stack of overlays. */
.studio-approx { display: none; }
/* **A sibling of the stage, never a row inside it.** `.stage-actions` is pinned to the foot
 * of `.studio-stage`, so anything the grid places in the last row is underneath the engraving
 * field and the two buttons - which is exactly what happened for an hour on 2026-09-16, with
 * the middle of the sentence covered. An overlay's box is not the grid's to allocate.
 *
 * Chosen by `:has()` rather than by a script, so the caption cannot be left on screen with
 * nothing under it or left off the one screen it is required on. */
/* The step's advice, which is about getting a picture, goes once there is one. Withdrawn
 * from the wizard step rather than from the studio, because the studio is what travels. */
#studio-step-figurine:has(.studio-stage.done) .studio-lead { display: none; }

/* **The customer's own photo, on the figurine wizard with no preview** (2026-10-11). It is
 * the only confirmation that arrangement gives that the upload landed, so it is a real
 * picture rather than a filename. Capped by a length on both axes and never by a percentage
 * height, which in an auto-sized box computes to nothing; `svh` so a landscape phone keeps
 * the chooser and Add to Cart on screen beneath it. */
.studio-chosen {
  display: block;
  width: auto;
  height: auto;
  max-width: 100%;
  max-height: min(20rem, 42svh);
  margin: 0;
  border-radius: calc(var(--radius) - 2px);
  box-shadow: var(--shadow-sm);
}
.studio-chosen[hidden] { display: none; }

/* Once a photo is in, the step is that photo: the chooser and the pose poster borrowed into
 * the host are withdrawn, and the question changes with them. Declarative, so the copy and
 * the state cannot disagree. */
#studio-step-photo .ask-after { display: none; }
#studio-step-photo.chosen .ask-before { display: none; }
#studio-step-photo.chosen .ask-after { display: inline; }
#studio-step-photo.chosen #studio-photo-host { display: none; }
/* The photo's frame, centred in the step: a portrait photo narrower than the dialog read as
 * left-aligned and lopsided beside the full-width buttons under it. Reported 2026-10-11. It
 * hugs the picture so the button below can be pinned to the picture's own corner. */
.chosen-frame {
  position: relative;
  width: fit-content;
  max-width: 100%;
  margin: 0 auto 0.75rem;
}

/* **"Use another photo", on the picture**, layout "A" of 2026-10-11. Small because it sits on
 * the thing being confirmed; the 44px target is a transparent `::before`, the arrangement the
 * print handles use, so the drawing is small and the press is not. */
.studio-another {
  position: absolute;
  right: 0.5rem;
  bottom: 0.5rem;
  width: auto;
  min-height: 0;
  padding: 0.3rem 0.7rem;
  font-size: 0.8125rem;
  line-height: 1.2;
  color: var(--foreground);
  background: hsl(0 0% 100% / 0.92);
  border: 1px solid var(--border);
  border-radius: 999px;
  box-shadow: var(--shadow-sm);
}
.studio-another:hover { background: var(--card); }
.studio-another::before {
  content: "";
  position: absolute;
  inset: 50% auto auto 50%;
  width: max(100%, 44px);
  height: 44px;
  transform: translate(-50%, -50%);
}
.studio-another[hidden] { display: none; }
/* A photo narrower than its own label wraps the label rather than hanging the pill off the
 * picture's edge. */
.studio-another { max-width: calc(100% - 1rem); white-space: normal; text-align: center; }

/* **On a short screen the options stand beside the photo**, not under it. A phone on its side
 * has 375px of height and 812 of width, so stacking put the rows below the fold and squeezed
 * the photo to a strip narrower than its own button. Keyed on height, which is the scarce
 * axis there - a width query would miss a landscape phone, as this file records repeatedly. */
@media (max-height: 560px) {
  #studio-step-photo.chosen:not([hidden]) {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    column-gap: 1rem;
    align-items: start;
  }
  #studio-step-photo.chosen > :not(.chosen-frame):not(.quick-specs) { grid-column: 1 / -1; }
  /* Capped at a share of the width as well, or a square photo leaves the options a column
   * too narrow for "Standard 100 mm". */
  #studio-step-photo.chosen .chosen-frame { margin: 0; max-width: 38cqw; }
  #studio-step-photo.chosen .studio-chosen { max-height: 56svh; }
  /* Beside a photo the column is narrow, so each label sits over its control and the
   * segments get the column's whole width rather than eliding "Standard 100 mm". */
  #studio-step-photo.chosen .qs-row { grid-template-columns: minmax(0, 1fr); gap: 0.1rem; }
}

/* **The options under the photo, one line each.** Withdrawn until there is a photo, by the
 * same `.chosen` that withdraws the chooser - the two are one state. */
.quick-specs { display: none; }
#studio-step-photo.chosen .quick-specs {
  display: grid;
  gap: 0.4rem;
}
.qs-row {
  display: grid;
  grid-template-columns: 4.25rem minmax(0, 1fr);
  align-items: center;
  gap: 0.5rem;
  margin: 0;
}
.qs-label {
  font-size: 0.75rem;
  font-weight: 500;
  color: var(--muted-foreground);
}
.qs-seg {
  display: flex;
  min-width: 0;
  border: 1px solid var(--border);
  border-radius: calc(var(--radius) - 2px);
  overflow: hidden;
}
.qs-opt {
  /* Sized by its words and then sharing what is left, so "Standard 100 mm" takes the room it
   * needs beside "Large 150 mm" rather than half of it. */
  flex: 1 1 auto;
  min-width: 0;
  width: auto;
  min-height: 2rem;
  padding: 0.25rem 0.35rem;
  font-size: 0.8125rem;
  font-weight: 400;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  color: var(--foreground);
  background: var(--card);
  border: 0;
  border-left: 1px solid var(--border);
  border-radius: 0;
  box-shadow: none;
}
/* `button, .btn` centres its contents with flex, and a centred box that overflows clips both
 * ends - "andard 100 m". A block keeps the ellipsis on the right, where it reads as one. */
.qs-opt { display: block; text-align: center; }
.qs-opt:first-child { border-left: 0; }
.qs-opt:hover { background: var(--muted); }
.qs-opt[aria-pressed="true"] {
  color: var(--primary);
  background: color-mix(in srgb, var(--primary) 8%, var(--card));
}
.qs-opt:focus-visible { position: relative; z-index: 1; box-shadow: var(--focus-ring); }
.qs-opt .dot {
  display: inline-block;
  width: 0.6rem;
  height: 0.6rem;
  margin-right: 0.3rem;
  vertical-align: -0.05rem;
}
.qs-sub { color: var(--muted-foreground); font-weight: 400; }
.qs-input {
  min-width: 0;
  min-height: 2rem;
  padding: 0.25rem 0.55rem;
  /* 16px, or Safari zooms the whole page when it takes focus and does not zoom back. */
  font-size: 16px;
  border: 1px solid var(--border);
  border-radius: calc(var(--radius) - 2px);
}
/* A touch screen gets Apple's 44px; a mouse keeps the compact row the operator asked for.
 * Keyed on the pointer, never on a width, which a phone on its side would miss. */
@media (pointer: coarse) {
  .qs-opt, .qs-input { min-height: 44px; }
}

/* **The name and the price on one line**, 2026-10-11. The field gives up width to the price
 * row's statement, which is what says the coupon is on and when it ends. */
.qs-name-line {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  min-width: 0;
}
.qs-name-line .qs-input { flex: 1 1 5.5rem; width: 5.5rem; }
.qs-price {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin-left: auto;
  min-width: 0;
}
.qs-coupon {
  display: none;
  min-width: 0;
  line-height: 1.25;
  font-size: 0.6875rem;
}
.qs-price.has-coupon .qs-coupon { display: grid; }
.qs-coupon-taken { color: var(--commerce); font-weight: 500; white-space: nowrap; }
/* The expiry wears `.coupon-expiry` itself, so `--expiring` keeps its one reader: the same
 * coral on the same fact, decided in one rule. */
.qs-figures {
  display: grid;
  justify-items: end;
  line-height: 1.1;
  white-space: nowrap;
}
.qs-was { display: none; font-size: 0.75rem; color: var(--muted-foreground); }
.qs-price.has-coupon .qs-was { display: block; }
.qs-now { font-size: 1.125rem; font-weight: 600; letter-spacing: -0.01em; }
.studio:has(.studio-stage.done) .studio-approx {
  display: block;
  margin: 0.6rem 0 0;
  font-size: 0.78rem;
  line-height: 1.45;
  color: var(--muted-foreground);
  text-wrap: pretty;
}

/* 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. */
/* **`.stage-chip-row` is gone**, and with it the second row of controls. Its one button lives
 * in `.stage-actions` now, which is a single row under the picture - see there. Removed
 * 2026-09-16 with the overlay it was part of.
 *
 * The compare toggle is keyed on the *frame* rather than on the stage, because the button is
 * no longer inside the stage whose state decides whether it is drawn. `:has()` is what lets a
 * sibling ask. */
.compare-toggle { display: none; }
.studio-frame:has(.studio-stage.done) .compare-off { display: none; }
.studio-frame:has(.studio-stage.done.compare) .compare-on { display: none; }
.studio-frame:has(.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;
  }

  .studio:has(.studio-stage.done) .studio-approx { font-size: 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 > .studio-photo {
    grid-column: 1;
    grid-row: 1;
    height: 5.5rem;
    width: auto;
    max-width: 100%;
    object-fit: contain;
    justify-self: start;
  }
  /* The one width where comparing means anything: the stage has stacked and the photo is a
   * 5.5rem chip, so there are two things of very different sizes and a reason to swap them.
   * Keyed on the frame, because the button sits outside the stage now. */
  .studio-frame:has(.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. The picture is drawn with room around the figurine already
   * - see `figurine.cartoon.chocolate.FRAMING`, which asks for the plinth complete and clear
   * space above the head - so `contain` inside this box never puts the plinth behind the
   * controls the way a snugly framed 3D camera did. */
  .studio-stage.done > .studio-preview {
    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 > .studio-photo {
    grid-column: 1 / -1;
    grid-row: 1;
    height: min(52svh, 22rem);
    width: 100%;
  }
  .studio-stage.done.compare > .studio-preview {
    grid-column: 1;
    grid-row: 2;
    height: 5.5rem;
    min-height: 0;
    width: auto;
    aspect-ratio: 4 / 5;
  }

  /* 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. */
  /* The three rules that used to un-pin `.stage-actions` while comparing are gone with the
   * overlay: below 940px the row is an ordinary block under the picture in every state, so
   * there is nothing left to un-pin and nothing for the foot of a 5.5rem chip to catch. */
}

/* 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;
    /* **Cleared here as well as on `.studio-stage` above, and that is the whole fix.**
     *
     * The unconditional `.studio-stage.done` rule sets `aspect-ratio: 8 / 5` at (0,2,0); the
     * clearing rule at the top of this block is `.studio-stage` at (0,1,0) and loses to it.
     * So the finished stage stayed 8/5 in landscape: 272 wide by **170 tall**, with
     * `overflow: hidden`, against rows needing 44 + 215. The figurine was clipped by 97px
     * from the bottom - which is the plinth, where the lettering is cut and therefore the one
     * part the customer is checking.
     *
     * Two rules this project already pays for, met together: **state the height and clear the
     * ratio**, and **a rule that corrects a media query has to be specific enough to win**.
     * Measured in a real 812x375 iframe on 2026-09-16; it was invisible on this machine's own
     * screen, and it predates the preview - the three.js viewer was clipped identically and
     * nobody saw it, because a model you can orbit hides the evidence that part of its box
     * is missing. An image cannot. */
    aspect-ratio: auto;
  }
  .studio-stage.done > .studio-photo,
  .studio-stage.done > .studio-preview {
    height: min(58svh, 15rem);
    min-height: 0;
  }
  .studio-stage.done > .studio-photo { 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%);

  /* How much height the feed may take. It is spent on the *width* below rather than as a
   * `max-height`, for the reason written there.
   *
   * This is the value for a feed on a page that scrolls - the cartoonize bench. Every other
   * place this is drawn is inside a box with a height cap of its own, and takes the second
   * value below.
   *
   * `svh`, never `vh`: `vh` is the height the page would have with the browser's own chrome
   * gone, so a box sized in it is taller than the screen whenever the URL bar is showing. */
  --feed-room: min(52svh, 420px);
}

/* **Inside a dialog the room is what is left after the dialog's own furniture.**
 *
 * Both of these boxes cap themselves at roughly the viewport and scroll inside it, so a feed
 * sized as a share of the viewport does not fit in one - it fits the *screen* and then pushes
 * the shutter out of the scrolling body, which is the one control the panel exists for. The
 * 16rem is the title, the stepper, the question, the actions row and the camera bar under the
 * picture, measured at 812x375 where the whole budget is 352px and the furniture is most of
 * it.
 *
 * So a landscape phone gets a small feed rather than a large one it has to scroll to, and
 * anything with real height is bound by the 420px instead. It was a flat `min(32svh, 300px)`,
 * which is 120px on a landscape phone and still 12px more than the body could hold. */
:is(.wizard, .photo-dialog) .camera {
  --feed-room: min(420px, calc(94svh - 16rem));
}

/* **The box takes the shape of the feed, so nothing is cropped.**
 *
 * It was a flat 4/5 with `object-fit: cover`, and that made the preview lie: a laptop webcam
 * is 16/9, so the middle two thirds of the frame was shown and the whole of it was captured.
 * Reported 2026-09-18 with both pictures - a head-and-shoulders preview, and a print showing
 * most of the room. A preview that is not what the shutter takes is worse than a small one.
 *
 * `camera.js` writes `--feed-ratio` off `videoWidth / videoHeight` once the stream reports
 * its own shape, which is the rule `--stage-ratio` already follows: measure the thing, never
 * type the number. The 0.8 fallback is the 4/5 this used to assert, for the frame or two
 * before the metadata arrives.
 *
 * **The height is capped by capping the width, and that is deliberate rather than clever.**
 * A `max-height` beside an `aspect-ratio` derives the width back out of the cap - the trap
 * that computed `.studio-stage` to 420px inside a 741px column - so this project's standing
 * rule is to state the height and clear the ratio. Here the ratio *is* the answer, so the
 * arithmetic is done the way round that says so: the widest the box may be is the room it
 * has times its own ratio. A tall portrait feed therefore gives up width and sits centred,
 * which is the whole frame rather than a crop of it. */
.camera video {
  display: block;
  width: min(100%, calc(var(--feed-room) * var(--feed-ratio, 0.8)));
  margin-inline: auto;
  aspect-ratio: var(--feed-ratio, 0.8);
  /* Belt for the frames before `--feed-ratio` lands, and for a stream whose shape changes
   * under a box that is already the right one: a letterbox is honest, a crop is not. */
  object-fit: contain;
  /* 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);
}

/* **While a feed is open, the ways of choosing a file are not offered.**
 *
 * Reported 2026-09-18: pressing Camera left "Upload photo", the drop target and the hint
 * above the feed, so the step scrolled and the feed arrived half off the bottom. They answer
 * the question the customer has already answered by pressing Camera, and on a phone they are
 * most of the screen.
 *
 * **The subject is whatever element the open panel is a direct child of**, which is exactly
 * one element per open feed and is a different element in each of the five places this is
 * drawn - the two wizard hosts, the dialog, the coffee step and the bench. Naming them would
 * be a list to keep, and the sixth would be the one nobody remembers; asking the question
 * structurally is the same shape as `.media-stage:has(> .camera:not([hidden]))` withdrawing
 * the stage's own upload trigger, and it costs nothing where the panel's parent holds no
 * chooser at all.
 *
 * On `:has()` rather than by script for the reason that rule gives: a control hidden by a
 * script is a control that can be left hidden. */
*:has(> .camera:not([hidden])) :is(.photo-actions, .dropbox, .hint, .pose-advice) {
  display: none;
}

/* **And the wizard's own row of buttons goes with them, while the feed is up.**
 *
 * Not tidiness: it is what makes a landscape phone work. At 812x375 the dialog gets 352px,
 * its title, stepper, question and actions take 186 of that, and the feed and the shutter
 * then do not fit the 166 that is left - so the step scrolled and "Take photo" went under
 * the fold, which is the defect this whole change is about, one screen smaller.
 *
 * The way out is not inside what this hides, for the fifth time in this project: the panel's
 * own Cancel puts the chooser back, and the title bar's close is still there. Back and Next
 * act on a step whose question is being answered by a gesture, and Next is disabled anyway
 * until a picture exists.
 *
 * Keyed on the step being the visible one, the shape `.wizard:has(.wizard-step:not([hidden])
 * .design-host)` already uses - a feed left running behind a step nobody is on would
 * otherwise take the buttons off the step they are on. */
.wizard-inner:has(.wizard-step:not([hidden]) .camera:not([hidden])) .wizard-actions {
  display: none;
}
.camera-bar {
  display: flex;
  gap: 0.5rem;
  padding: 0.6rem;
  background: var(--card);
  border-top: 1px solid var(--border);
}

/* **44px, unconditionally**, which is Apple's minimum and which these two measured 38px
 * against at 812x375. Outside any media query, for the reason every 44px rule in this
 * stylesheet now is: the older ones were scoped to `max-width: 700px` and a phone on its side
 * is 812px wide, so it missed all of them while having the least height of any screen the
 * shop is opened on. "Take photo" is the one control this panel exists for. */
.camera-bar .btn { min-height: 44px; }

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

   The feed opens over the product picture rather than below the upload buttons. Same box, 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.

   **The overlay is keyed on being in a stage, since 2026-09-18.** Everywhere else this panel
   is borrowed to - the cartoon wizard's photo host, the bespoke wizard's, the chooser dialog -
   used to undo the pinning with a rule of its own, three copies of the same four declarations
   under three different selectors. Two id selectors saying the same thing is the drift a
   shared module exists to stop, and the next host would have been a fourth. So the panel is
   an overlay while it is in the stage it was written to cover and an ordinary `.camera` block
   wherever it has been lent to, which needs no rule at all. */

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

/* `display: flex` here outranks the user-agent rule behind the `hidden` attribute, which is
   a bare `[hidden] { display: none }` and loses to any class selector. Without the line above
   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. */
.camera-in-stage {
  display: flex;
  flex-direction: column;
}

:is(.media-stage, .stage-tile) > .camera-in-stage {
  position: absolute;
  inset: 0;
  z-index: 3;
  margin: 0;
  border: 0;
  border-radius: 0;
}

/* The video takes the room the bar does not, instead of deriving its own box from the feed's
   shape and overflowing a stage that has already been given one. `object-fit` is left at the
   `contain` the base rule states: this box is the stage's shape rather than the feed's, so
   filling it would be the crop that rule exists to stop. */
:is(.media-stage, .stage-tile) > .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.

   Keyed on the stage with the pinning above, since 2026-09-18. A scrim is a thing you lay
   over a picture; borrowed into a wizard step this panel is an ordinary box and the bar under
   it is the plain one the coffee wizard and the bench already draw, so the same two buttons
   no longer read as two different controls depending on which wizard opened them. */
:is(.media-stage, .stage-tile) > .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;
}

/* ============================================================ 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%);
  --tint-roast: hsl(28 30% 92%);

  /* 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); }
.tint-roast { --tint: var(--tint-roast); }

/* ----------------------------------------------------------------- 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; }
}

/* **The utility bar goes on a phone**, 2026-09-18, at the operator's instruction (audit
 * proposal 14). It is 45px at the very top of every page on a shop where 81% of sessions are
 * phones, and below 620px it carries "Sign in or Register" and nothing else - two links that
 * 32 of 1,083 active sessions used, given the most prominent position the site has. In
 * landscape it is 45px of a 375px-tall viewport, on top of a 120px masthead and a 64px
 * checkout bar.
 *
 * **Nothing is lost, which is what made it cheap.** Every link the bar holds is already in
 * the footer: the Policies column lists all of them, "Your account" carries Account, Orders,
 * Addresses, Sign in, Register and the cart, Examples and Guides sit under Deals, and Sign
 * out is in the brandline. `tests/test_chrome_and_disclosure.py` asserts each one.
 *
 * Hidden rather than dropped from the markup, so a wide screen keeps it and a browser with no
 * CSS still gets the links rather than a page whose only account control is a thousand pixels
 * down.
 *
 * Both conditions, because a phone on its side is 812px wide and misses a width query. Fifth
 * rule in this file to need the pair and the first written with it from the start. */
@media (max-width: 700px), (orientation: landscape) and (max-height: 560px) {
  .utility-bar { display: none; }
}

.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 byline under the logo. The folded masthead is what took the chrome from 175px to
   * 120px, and the byline is not worth any of that back. */
  .brand-byline { display: none; }
  /* 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.
 *
 * **The landscape condition was added 2026-09-18, and its absence was the fifth defect of
 * this exact shape in this file.** The fold above carries both conditions because a phone on
 * its side is 812px wide and misses a width query; this rule carried only the width, so at
 * 812x375 the masthead kept its 159px of words, the row would not shrink, and `.site-head`
 * measured **165px of a 375px-tall viewport** — 44% of the screen gone before the page
 * started, and 61% once `.checkout-bar` took its 64px. Measured on the live cart at that
 * size: masthead, breadcrumb, half a heading, pinned bar, and not one product line.
 *
 * Same pair as the fold, and for the same reason: width catches the phone standing up, the
 * short-and-wide pair catches it lying down. */
@media (max-width: 520px), (orientation: landscape) and (max-height: 560px) {
  .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; }

/* --------------------------------------------------------------------- examples strip
 *
 * 2026-09-18. Four tiles between the price and the options, showing one invented portrait
 * drawn four ways. 78% of sessions that opened a product page never uploaded a photo, and
 * these pictures - which already existed - were only visible inside the wizard, after the
 * photo had been committed.
 *
 * **A grid rather than a scrolling row.** A horizontal strip on a phone hides half its own
 * content behind a gesture nothing advertises, on a block whose whole job is to be seen
 * without being asked for. Four across at 375px is 76px a tile, which is the same floor the
 * style picker measured for its own grid and for the same reason.
 *
 * Deliberately quiet: no card, no border, no shadow. It sits between the price and the
 * options on a page that was compacted to keep Add to Cart reachable, so it earns its height
 * by being pictures and one line of type and nothing else. */

.examples { margin: 1.5rem 0 1.75rem; }

.examples-heading {
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted-foreground);
  margin: 0 0 0.6rem;
}

.examples-strip {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 0.5rem;
  margin: 0;
  padding: 0;
  list-style: none;
}

.examples-tile { display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; }

.examples-tile img {
  width: 100%;
  height: auto;
  aspect-ratio: 1;
  object-fit: cover;
  border-radius: var(--radius);
  background: var(--muted);
}

.examples-caption {
  font-size: 0.6875rem;
  color: var(--muted-foreground);
  text-align: center;
  /* One line. A two-word look name wrapping under a 76px tile makes the row of four
   * different heights, which reads as four things rather than one set. */
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.examples-note { margin: 0.6rem 0 0; }

/* ------------------------------------------------------------------- page not found
 *
 * Added 2026-09-18 with the HTML 404 page. Before it, every miss on this site rendered
 * `{"detail":"Not Found"}` as a bare string - no masthead, no way back - and nearly all of
 * this shop's traffic arrives on Meta links written weeks earlier.
 *
 * Left-aligned, not centred: a centred paragraph is the one thing the design language rules
 * out by name, and there is nothing about a miss that makes it an exception.
 *
 * The buttons carry their 44px from `.btn.lg`, which is unconditional - this page is met on
 * a phone as often as anywhere else and it is the only way off it. */

.miss { max-width: 34rem; padding: 2rem 0 4rem; }
.miss h1 { margin: 0.25rem 0 0; }

.miss-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
  margin-top: 1.75rem;
}

/* Scoped to `.miss` to outrank the sitewide `.note`, which sets `margin` as a shorthand and
 * is later in this file - so a bare `.miss-note` lost at equal specificity and the gap
 * measured 0px. Same rule as the buy bar's `right: auto`, and found the same way: by looking
 * at the page rather than at the stylesheet. */
.miss .miss-note { margin-top: 2.5rem; }

.miss-links {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem 1.25rem;
  margin: 0.5rem 0 0;
  padding: 0;
  list-style: none;
}

/* ------------------------------------------------------------------------- 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-credit { margin-left: 0.35rem; }
.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;
}

/* ------------------------------------------------------------------ quick filters
   The five tag filters, beside the window picker because they are the same kind of control:
   one press, no form, and the result is a URL.

   Each is a link, not a checkbox, and that is load-bearing rather than decorative - an
   unticked checkbox is never submitted, so Active, which is on by default, could not be
   switched off through a GET form. See the template.

   The window picker sits directly above, so this takes the picker's negative margin back and
   the pair reads as one bar of controls. */
.tag-filters {
  display: flex;
  flex-wrap: wrap;
  gap: 0.3rem;
  align-items: center;
  margin: -0.9rem 0 1.25rem;
}

.tag-filter {
  font-size: 0.75rem;
  font-weight: 500;
  line-height: 1.5;
  padding: 0.2rem 0.6rem;
  border-radius: var(--radius-sm);
  border: 1px solid var(--border);
  background: transparent;
  color: var(--muted-foreground);
  /* 44px is the rule for a control that spends money; this spends nothing and lives on a
   * dense workshop screen, so it takes the compact end of the scale the admin side is
   * allowed. The target is still comfortably above a stray-press risk at this size. */
  transition: background var(--duration) var(--ease), color var(--duration) var(--ease),
    border-color var(--duration) var(--ease);
}

.tag-filter:hover { color: var(--foreground); background: var(--accent); }
.tag-filter:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }

/* On. Filled rather than coloured: this screen already spends its two accents elsewhere, and
 * a filter that is narrowing what you are looking at should read as pressed, not as a new
 * category of thing. */
.tag-filter.on {
  background: var(--foreground);
  border-color: var(--foreground);
  color: var(--background);
}

.tag-filter-clear {
  font-size: 0.75rem;
  color: var(--muted-foreground);
  padding: 0.2rem 0.4rem;
  margin-left: 0.25rem;
}
.tag-filter-clear:hover { color: var(--foreground); }

/* ---------------------------------------------------------------------- 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. */

/* Margin and padding zeroed on 2026-09-03, and this was the single biggest win in slimming
 * the list. A `.session` is a <details>, so it was picking up the generic disclosure spacing
 * further up this file - 1.1rem of top margin and 0.9rem of top padding - which put 32px of
 * dead space between every pair of rows. The summary itself was 54px inside an 86px pitch, so
 * more than a third of the list was gaps that nothing had asked for. The hairline is what
 * separates two rows here; it does not need help. */
.session {
  border-top: 1px solid var(--border);
  margin: 0;
  padding: 0;
}
.session:first-of-type { border-top: 0; }

/* A row rendered without the bulk selection - customer_detail.html, and the sessions nested
 * inside a Visitors row since 2026-09-15. The checkbox has a column of its own, so leaving the
 * markup out without taking the track out leaves an empty 2.2rem gutter and every column after
 * it a step narrower than it should be. Six columns, not seven. */
.session.no-pick > summary {
  grid-template-columns:
    1.1rem 8.75rem minmax(4.5rem, 0.5fr) 5.25rem minmax(0, 1.35fr) auto;
}

/* New since anybody last looked. The whole row rather than a dot, because the point is to see
 * at a glance how far down the list the new ones stop - a marker in one column makes that a
 * search instead.
 *
 * The left bar carries it as much as the ground does, which matters on a screen somebody may
 * be reading at an angle or with the tint washed out by a bright room: the wash alone is very
 * pale by design, since it sits behind a hundred rows of text that still have to be the thing
 * being read. */
.session.unseen {
  background: var(--unseen);
  box-shadow: inset 3px 0 0 var(--unseen-border);
}

/* Hover has to stay visible on a highlighted row. Without this the summary's own `--accent`
 * hover would paint over the tint and a new row would lose its marking exactly while being
 * pointed at. */
.session.unseen > summary:hover {
  background: color-mix(in oklab, var(--unseen), var(--foreground) 6%);
}

/* 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;
  /* Seven columns since 2026-09-01: the chevron, the bulk checkbox, then the five the row
   * has always had. The checkbox needed a column of its own rather than borrowing one - as an
   * ordinary grid item it took a cell from the badges and pushed the rest of the row onto a
   * second line. 2.75rem is its 44px target. */
  grid-template-columns:
    1.1rem 2.2rem 8.75rem minmax(4.5rem, 0.5fr) 5.25rem minmax(0, 1.35fr) auto;
  gap: 0.65rem;
  align-items: center;
  padding: 0.3rem 0.5rem;
  cursor: pointer;
  border-radius: var(--radius-sm);
  transition: background var(--duration) var(--ease);
  text-transform: none;
  letter-spacing: normal;
  font-size: 0.8125rem;
  font-weight: 400;
  color: var(--foreground);
}

/* Slimmed on 2026-09-03 so more of the list fits on one screen: the padding halved, the row
 * centres rather than sitting on a shared baseline, and the entry path and duration moved
 * into the opened row. `align-items: center` matters more than it looks - the chips are
 * taller than the text beside them, and on a baseline they hung the whole row low. */

/* 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);
}

/* A visit that looks like a fetch rather than a person. Muted and dashed like `.tag-direct`,
 * for the same reason: it is a statement about the absence of evidence, so it must read as
 * quieter than every tag around it that describes something a customer did. It is the only
 * tag on this row that says "do not trust the rest of this line". */
.tag-fetch {
  background: transparent;
  color: var(--muted-foreground);
  font-weight: 400;
  border-style: dashed;
}

/* 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 status tags on a compact row, four added 2026-09-03 and Real on 2026-09-07.

   No new colour. Two accents mean specific things on this site - blue navigates, green spends
   money - and Specification 26.1.2 is explicit that a third has to earn its place by taking a
   job off one of them. These take the same muted chip as the source badge and separate
   themselves by weight and border, which is enough when only the true ones are ever drawn.

   `tag-paid` is the heaviest because it is the one that cost money to produce. */
/* Every one of the four green-run tags is a filled chip with a solid border, which is a reversal of how they
 * were first drawn. `tag-upload` and `tag-made` were transparent with a dashed border, and
 * side by side those two read as **one** broken-up shape rather than as two tags - the
 * operator reported them as a single tag rendered wrongly. Two adjacent chips have to be
 * separated by their own edges, not distinguished from each other by their fill.
 *
 * The labels changed with the styling and were half the fault: abbreviated to "Image" and
 * "Generated", the pair read as the single phrase "Image Generated". They are spelled out now
 * and match the quick-filter chips above the list word for word, which is what lets somebody
 * connect a tag on a row to the control that filters by it. */
.tag-paid { font-weight: 600; }

/* The workshop's own label for a product the visit opened. Neutral but heavy: it is the
 * subject of the row and everything after it is the verdict, so it has to read first without
 * claiming a meaning. Deliberately not blue and not green - on this site blue navigates and
 * green is a judgement, and "which product" is neither. */
.tag-product {
  background: var(--accent);
  border-color: var(--border-strong);
  color: var(--foreground);
  font-weight: 600;
}

/* Green, asked for on 2026-09-04 so the valuable visits carry weight on a list that is
 * otherwise grey. All three say the same kind of thing - this visit did something worth
 * reading - so they share one treatment rather than each getting a shade.
 *
 * `--success` and not `--commerce`. The two are close on screen and mean different things:
 * commerce green is the buying path in the shop, and none of these is a control or a
 * purchase. This is the admin side reporting a judgement about a visit, which is what
 * `--success` already means everywhere else in this stylesheet.
 *
 * `tag-cart` was in this group from 2026-09-04 until 2026-09-15, when the operator asked for
 * it and Checkout to be filled instead. It left the *treatment* and kept the *token*: see
 * `.tag-checkout, .tag-cart` below, which is this same `--success` inverted, not a new colour.
 *
 * Mixed to a pale ground with the saturated token kept for the text, so a row carrying all
 * three is still a row of text rather than three blocks of colour. */
.tag-active.very,
.tag-upload,
.tag-made {
  background: color-mix(in oklab, var(--success), var(--card) 88%);
  border-color: color-mix(in oklab, var(--success), var(--card) 58%);
  color: var(--success);
  font-weight: 600;
}

/* A page in this visit was slow, and this is the only red on the row - the one tag here that
 * reports a fault rather than describing what happened. Same construction as the green above
 * so the two read as one family at opposite ends, and the same `--destructive` the pageload
 * badge inside the opened row already uses. */
.tag-slow {
  background: color-mix(in oklab, var(--destructive), var(--card) 90%);
  border-color: color-mix(in oklab, var(--destructive), var(--card) 60%);
  color: var(--destructive);
  font-weight: 600;
}

/* The row's own tag group. **It wraps since 2026-09-19, reversing the rule below it**, and
 * what reversed it is arithmetic rather than taste: the single Checkout chip became
 * "Checkout - Step 2" and "Checkout - Step 1" that day, which is 179px more on a line that
 * was already 58px over at 1280px. Measured on the worst-case row - every tag at once - the
 * group wants 780px in a 543px column, so four chips were being clipped where one was
 * before, and two of the four were Image uploaded and Asset generated.
 *
 * The old reasoning is worth keeping because it is still true of most rows: a chip dropping
 * to a second line makes that row twice the height of its neighbours, which is the opposite
 * of what slimming the row was for. What decides it is that only the rows that were being
 * clipped grow - a row with three chips still draws on one line - and those are the busiest
 * rows on the screen, which is to say the ones worth reading. **A clipped chip is
 * information the screen silently does not show**, and this project refuses a silently
 * missing tag everywhere else it has met one.
 *
 * `overflow: hidden` stays as the belt: it clips a single chip too wide for the column
 * rather than letting it widen the row.
 *
 * To put it back, this is one declaration. `.visitor > summary .session-flags` and
 * `.visitor-sessions .session-flags` already wrapped and are now saying what this says. */
.session-flags {
  flex-wrap: wrap;
  overflow: hidden;
  align-items: center;
}

/* These five carry fixed, short labels, so they must not inherit `.tag`'s ellipsis - that
 * exists for a campaign name of unknown length. Shrinking them instead produced "Activ..."
 * and "Image uploade..." on a 1280px laptop: every chip clipped by a few pixels, which is
 * the least readable outcome available. `flex: none` means the row clips a whole chip when
 * it runs out of width rather than truncating all of them, and the opened row repeats every
 * one of these facts in full. */
.session-flags .tag {
  flex: none;
  max-width: none;
}

/* The line that opens a session: what the compact row stopped carrying. */
.session-lead {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  align-items: center;
  margin: 0.35rem 0 0;
  padding: 0 0.5rem;
  font-size: 0.8125rem;
}

/* 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.

   Folded away on 2026-09-06: fifteen fields is most of a screen spent on a question the
   chips on `.session-lead` have usually already answered, so the panel is now one small
   control until somebody asks for it. It is a <details>, so the fold costs no script and
   the content stays in the markup for the browser's own find.

   The generic `details` further up this file gives a disclosure a top border, a top margin
   and 0.9rem of padding above the summary - right for a disclosure at the foot of a card and
   wrong for a panel sitting between two blocks in an already dense row. Reset here rather
   than weakened there, the same way `.session > summary` does it. */
.attribution {
  margin: 0.35rem 0 0.9rem;
  padding: 0;
  border-top: 0;
  background: none;
  border-radius: var(--radius-sm);
}

/* Closed, this is the whole block: a quiet control that reads as a way in rather than as a
   heading with nothing under it. The generic summary is already a small uppercase label with
   a `›` in front, which is exactly the right dress for it - so only the box around it is set
   here, and the letterform is left to the rule that dresses every other disclosure. */
.attribution > summary {
  display: inline-block;
  padding: 0.3rem 0.6rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--muted);
  transition: background var(--duration) var(--ease), color var(--duration) var(--ease);
}

.attribution > summary:hover { background: var(--accent); }

/* Open, the panel comes back - the grey ground on the content rather than on the whole
   disclosure, so a closed block is a button and an open one is a panel. */
.attribution[open] > summary {
  border-bottom-left-radius: 0;
  border-bottom-right-radius: 0;
  background: var(--muted);
}

.attribution[open] > :not(summary) {
  background: var(--muted);
  padding: 0.75rem 0.9rem;
  margin: 0;
}

.attribution[open] > :last-child {
  border-bottom-left-radius: var(--radius-sm);
  border-bottom-right-radius: var(--radius-sm);
}

.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; }
/* The two numbers the whole list is scanned by, and every tag beside them is derived from one
 * or the other - so they are the one thing on this row that is heavier than the row, not
 * lighter. They were `muted` at 0.8125rem until 2026-09-03, which put the most-read figures on
 * the line in the same grey as its furniture. */
.session-counts {
  font-size: 0.9375rem;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: var(--foreground);
  text-align: right;
  white-space: nowrap;
}

/* The separator is not one of the numbers. Kept light so the eye lands on the digits rather
 * than on the dot between them. */
.session-counts .count-sep {
  font-weight: 400;
  color: var(--muted-foreground);
  margin: 0 0.1rem;
}

.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 in the log is a way to that page, since 2026-09-06. It keeps the monospace of the
   path it replaced - the operator is reading a column of paths and a proportional one among
   them breaks the shape - and takes its colour from the link rule, because blue navigating is
   the one thing blue means on this site.

   The referring host is the same control saying less: it can only ever reach a site's front
   door, since the referring page is never recorded. So it keeps the colour of the text it
   replaced and earns blue and an underline on hover, rather than advertising itself as a
   destination that will show what somebody was reading. */
/* No font stack of its own: the link carries `.mono` beside this class, the way every other
   path on this screen does. A second copy of that stack is a second place it goes stale. */
.timeline-path { text-decoration: none; }
.timeline-path:hover { text-decoration: underline; }

.timeline-referrer { color: inherit; text-decoration: none; }
.timeline-referrer:hover { color: var(--primary); text-decoration: underline; }
.timeline-path:focus-visible,
.timeline-referrer:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }

/* 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) {
  /* Stacked: the chevron and the checkbox each keep a column of their own down the left, and
   * everything else stacks in the third. Both are placed explicitly rather than auto-flowed,
   * or the checkbox would sit in the middle of the stack of copy. */
  .session > summary { grid-template-columns: 1.1rem 2.75rem 1fr; gap: 0.15rem 0.6rem; }
  .session.no-pick > summary { grid-template-columns: 1.1rem 1fr; }
  /* `span 6`, matching the six children that stack beside these two. It was `span 5` against
   * six children, which left the last of them - the page and action counts - dropping into
   * the chevron's own column. */
  .session > summary::before { grid-row: 1 / span 6; }
  .session-pick { grid-column: 2; grid-row: 1 / span 6; align-self: start; }
  .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;
  /* **Wraps, or it makes the whole page scroll sideways on a phone.** A flex row is as wide as
   * its content unless told otherwise, and From + To + Apply computes to 421px against a 375px
   * viewport - measured 2026-09-15 while checking the ad table, which turned out not to be the
   * cause. Same defect as the workshop's tab strip and the print toolbar, third place now. */
  flex-wrap: wrap;
  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);
}

/* ------------------------------------------------------------------- by product
   One funnel per product, laid side by side where there is room. Columns rather than a
   table because a funnel is five bars and a table of five columns reads as a spreadsheet;
   auto-fit so a shop with two qualifying products does not leave three empty tracks. */

.product-funnels {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: 0 32px;
}

.product-funnel { min-width: 0; }

.product-funnel h3 {
  margin: 0 0 0.35rem;
  font-size: 0.9375rem;
  letter-spacing: -0.01em;
}

.product-funnel h3 a { color: inherit; text-decoration: none; }
.product-funnel h3 a:hover { text-decoration: underline; }

.product-funnel .funnel { margin-bottom: 1.5rem; }

/* The label can be a long step name in a narrow column, and a wrapped label pushes the
   count out of line with the ones above it. Truncate the words, never the number. */
.product-funnel .funnel-label {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* ------------------------------------------------------------------------ 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. */

/* The frame holds the picture and the row of controls under it. `position: relative` is kept
 * for the camera panel, which still pins itself over the stage. */
.studio-frame { position: relative; }

/* **Every control is under the picture, and none is on it.** Rebuilt 2026-09-16 at the
 * operator's instruction to use the t-shirt's arrangement: "all buttons are placed below the
 * image".
 *
 * It was an overlay pinned to the foot of the stage, and it had been paid for twice already -
 * once to stop it overflowing sideways, once to stop it covering 41% of the picture at
 * 620-940px. Both were fixes to a position that was the real problem. `.print-tools` solved
 * this for the t-shirt in August and the reasoning is identical: the row is *beside* the
 * picture, not *on* it, because the picture is the thing being judged.
 *
 * Deliberately the same shape as `.print-tools`, gap and all, so the two pages of this shop
 * that carry a design toolbar carry the same one. */
.stage-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
  margin-top: 0.6rem;
}

/* **Three to a row, then the remainder**, which is `.print-tools`' arithmetic and is there for
 * the same reason: `flex-wrap` packs whatever fits and cannot be told a number, so the count
 * has to come out of the basis. Five controls at most here - re-upload, compare, the engraving
 * field, and two buttons - which gives 3 + 2, the same as the tee's.
 *
 * `min-width: 0` because a flex item's minimum is its content, and without it the longest
 * label refuses to shrink to its share and pushes the row wider than the column. */
.stage-actions > *:not([hidden]) { flex: 1 1 30%; min-width: 0; }
.stage-actions > .engraving-group:not([hidden]) { flex: 1 1 46%; }

/* 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; }

/* **No shadow.** It lifted opaque controls off a photograph, and there is no photograph under
   them now - the same note `.print-tools` carries. The buttons are outlined by the base
   `button, .btn` rule like every other button on this site, and the engraving field takes the
   ordinary input border rather than the overlay's. */
/* `--border`, not `--input`: in this palette `--input` is `hsl(0 0% 100%)` - the field's own
   white fill - so using it as a line colour drew a white border on a white field, which is the
   invisible box again one step along. `--border` is what outlines every button beside it. */
.stage-actions .engraving-group input { border-color: var(--border); }

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

   **One element, in one place since 2026-09-16.** It had a second home in the options panel
   and was moved between the two; the operator asked for that slot to go, because engraving is
   a step behind a button under the finished figurine now and a heading with two lines of hint
   above the fold was paying for a value most customers never set.

   In the row the heading and the hint go: a title and a paragraph among five controls is
   furniture, the placeholder carries the label, and `maxlength` carries the limit. */

/* **16px on the engraving field, keyed on the pointer and never on a width.**
 *
 * Safari zooms the whole page in when a field under 16px takes focus and does not zoom back
 * out. This one measured 15px and was covered by none of the existing rules: the checkout's
 * 16px block is scoped to `.co-step`, and every other one in this file sits inside
 * `@media (max-width: 700px)`, which a phone on its side misses entirely at 812px wide.
 *
 * It became worth fixing on 2026-09-16, when the field stopped being one option among several
 * and became a step of its own behind "Add engraving" - a control somebody is sent to press.
 * A touch screen zooms and a mouse does not, so the pointer is the thing being asked about.
 * At the top level deliberately; a test counts braces to keep it there. */
@media (pointer: coarse) {
  .engraving-group input { font-size: 16px; }
}

/* **It shares the row's arithmetic rather than setting its own width.** `flex: 0 1 18rem`
 * was written for an overlay whose other children were two buttons; in the shared row it beat
 * the `flex: 1 1 46%` above it and the field came out a different width from everything
 * beside it. */
.stage-actions .engraving-group {
  min-width: 0;
  margin: 0;
}

/* The heading and the hint still go - the row is a row of controls and the placeholder
   carries the label - but the error does not. A refusal the customer cannot see is the fault
   this project keeps paying for; there is room for it under the row now that the row is on
   the page rather than over a photograph. */
.stage-actions .engraving-group .option-title,
.stage-actions .engraving-group .hint { display: none; }

/* **A real border, since 2026-09-16.** It was `1px solid transparent` on `--card`, which is a
 * white box that reads as a field *on a photograph* and as nothing at all on a near-white
 * page - the operator reported the field as invisible once the row came off the picture. It
 * takes the same border every other input on this site takes, and the same 44px floor as the
 * buttons beside it so the row is one height. */
.stage-actions .engraving-group input {
  padding: 0.62rem 0.85rem;
  min-height: 44px;
  font-size: 0.9375rem;
  background: var(--card);
  border: 1px solid var(--border);
  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;
}

/* **940px, not 620px, since 2026-09-07.** 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.

   **It was scoped to 620px and the band above it was where the defect was**, which is the
   recurring shape here: a fix written for a phone and keyed on a phone's width. Above 940px the
   stage is a `1fr` column and about 630px wide, so the row fits on one line; from 620 to 940 it
   is a 17rem column - 243px inside the overlay's own inset - against three controls that need
   about 340px. The row does not wrap there, and `justify-content: flex-end` pushes the overflow
   **left, out of the picture**: measured at 900px wide, the engraving field computed to 0px at
   x=-54 and "Re-upload photo" started at -44 against a stage beginning at 28. That is the
   operator's screenshot of three controls sitting on top of each other.

   Keyed on the width `.product` collapses at, like every other rule about this page's columns,
   rather than on a guess about devices. */
@media (max-width: 940px) {
  /* **And it comes off the picture entirely.** Wrapping fixed the overflow and left a
   * different fault: from 620 to 940px the picture is a 17rem column, and three controls on
   * three lines measured **154px of a 340px stage - 41% of the picture covered** at 900px
   * wide, reported by the operator as the buttons covering the image.
   *
   * The same answer the print toolbar got on 2026-08-30 for the mug, and the same argument:
   * the row is *beside* the picture rather than *on* it, and a control that covers the thing
   * being judged is in the wrong place however tidy it looks on a wide screen. The shadows
   * go with the overlay - they exist to lift opaque controls off a photograph, and there is
   * no photograph under them now. */
  /* **One control per row below 940px, because the row stopped having the page's width.**
   * Word for word the reasoning `.print-tools` carries: the picture sits in a column of its
   * own at every width here - 17rem from 620 to 940, 42% below that - and the thirds above
   * were written for a row that had the whole page. */
  .stage-actions > *:not([hidden]),
  .stage-actions > .engraving-group:not([hidden]) { flex: 1 1 100%; }
  .stage-actions { align-items: stretch; }
}


/* ------------------------------------------------------ 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 a floor under it and, since 2026-09-16, no ceiling. 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. That is what `min-height` is for, and it is the half that has to stay.
 *
 * **It was a fixed `height` until the operator reported the plinth missing from a chocolate
 * preview, and the reason is worth writing down because the fix looked already present.**
 * `max-height: 100%` on a grid item resolves against the grid *area*, and the row here is
 * content-sized - so the percentage resolves against an indefinite height, computes to
 * `none`, and has never once applied. `max-width: 100%` did apply. So a 1024x1536 result came
 * out **402x603 inside a 420px frame** and `overflow: hidden` cut off the bottom 183px, which
 * is precisely where the engraving is. The picture looked "scaled horizontally" because that
 * is literally what was happening: fitted to the width, clipped in height.
 *
 * `object-fit: contain` beside it was equally inert and is gone. A box whose width and height
 * are both `auto` always keeps its intrinsic ratio, so there is nothing for `object-fit` to
 * do - a declaration that cannot change the answer is a second place the layout is decided
 * from, the `Category.print_shift` trap in a stylesheet.
 *
 * The cap is `70svh`: a real length, so it resolves where a percentage did not, and it keeps
 * a tall picture inside one screenful instead of making the operator scroll to see a figurine
 * whole. svh rather than vh, because vh is the tall viewport and a vh box overflows whenever
 * the browser's own chrome is showing. Shrinking the *width* out of that cap is the wanted
 * behaviour here and not the trap recorded against `.studio-stage`: that one was a box with a
 * declared `aspect-ratio`, where the width was supposed to stay put. An <img> has an intrinsic
 * ratio and deriving its width back out of a height cap is the whole point. */
.cartoon-frame {
  display: grid;
  place-items: center;
  min-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: 70svh; width: auto; height: auto; }

/* 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);
}

/* **In the row it is an ordinary button.** `studio.js` moves `#stage-upload` out of
 * `.stage-entry` and into `.stage-actions`, and it took the overlay's drop shadow with it -
 * so "Re-upload photo" was a lifted pill sitting beside a flat "Try again", which is the two
 * designs the operator reported. The shadow belongs to being *on* a photograph; in the row
 * nothing is. */
.stage-actions .stage-upload { box-shadow: none; }

/* **44px on every control in the row, outside every media query.** "Try again" measured 38px
 * against the upload button's 44 - `#regenerate` is a bare `.btn` and only `.stage-upload`
 * carried the floor. This stylesheet has paid for the same thing three times: a 44px rule
 * scoped to a phone's width is missed by a landscape phone, which is 812px wide. `.print-tools`
 * carries the identical unconditional list. */
.stage-actions > .btn,
.stage-actions .engraving-group input { min-height: 44px; }
.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 .print-change,
.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;
}

/* **And one per row below 940px, because the row stopped having the page's width.**
 *
 * Reported by the operator 2026-09-07 as the buttons being "all screwed up" on a small screen,
 * and the cause is a layout change rather than this rule: the product page was compacted the
 * same day and the picture now sits in a column of its own beside the options at every width -
 * 17rem from 620px to 940px, and 42% below that, which is **143px on a 375px phone**. The
 * shares above were written when this row had the whole page.
 *
 * Two shares of 143px is 67px a control. Measured in the browser: the labels want 99, 131,
 * 166 and 180, and `.btn` is `white-space: nowrap` - so every one of them hung out of its own
 * box and over the button beside it. That is the overlap in the operator's screenshot.
 *
 * So one control per row here, and three things go with it. **The labels may wrap**, because
 * "Remove background" on one line does not fit 143px minus its padding at any share. **The
 * button wraps too**, so Cartoonize's teaser stacks above its word rather than pushing the row
 * wider than the column - the pictures stay, which the operator asked for on 2026-08-31 and
 * which is not a decision to undo while fixing a layout. And the line height is closed up, or
 * a two-line label inside a 44px minimum makes a 60px button.
 *
 * It costs the column height rather than the page's: the options beside it are taller than the
 * picture and this toolbar put together, so on a phone the stacked row lands in space that was
 * already empty.
 *
 * `min-height: 44px` is untouched and stays outside every query, the rule this stylesheet has
 * paid for twice: a landscape phone is 812px wide and misses every width-only rule. */
@media (max-width: 940px) {
  /* One control per row: see above. */
  .print-tools:has(> .print-size:not([hidden])) > *:not([hidden]) { flex: 1 1 100%; }
  .print-tools > .btn {
    /* The label wraps rather than hanging out of the box, and the teaser stacks above the
       word rather than pushing the row wider than the column. Both are the net under the
       size below, not the arrangement: with the type at 13px every control in this row fits
       on one line at 143px, and they are all the same 44px height because of it. */
    flex-wrap: wrap;
    white-space: normal;
    line-height: 1.2;
    padding-inline: 0.5rem;
    font-size: 0.8125rem;
  }
}

/* "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;
}

/* The typeface picker, drawn here rather than by the browser.
 *
 * Three versions in two days and the browser decided all of it. Chips showed every face and
 * cost three rows of a pane that sits on the design. A `<select>` was one row and showed one
 * face: Chrome and Safari hand the popup to macOS, which draws the whole list in the
 * control's own font, so every option appeared in whichever face was already chosen.
 *
 * A button and a list is the only shape that is both. `data-font` on each feeds the same
 * eight `--print-font` rules the lettering uses, so nothing here knows what "Display" is. */
.fontpick { position: relative; flex: 1 1 auto; min-width: 0; }

.fontpick-open {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.4rem;
  width: 100%;
  min-height: 44px;
  padding: 0.35rem 0.6rem;
  border: 1px solid var(--input);
  border-radius: calc(var(--radius) - 3px);
  background: var(--card);
  /* 16px, or Safari zooms the page in on focus and does not zoom back out. */
  font-size: 1rem;
  font-family: var(--print-font, inherit);
  text-align: left;
  cursor: pointer;
}
.fontpick-open:hover { border-color: var(--muted-foreground); }
.fontpick-open:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.fontpick-current { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fontpick-caret {
  flex: none;
  width: 0;
  height: 0;
  border-left: 4px solid transparent;
  border-right: 4px solid transparent;
  border-top: 5px solid var(--muted-foreground);
}

/* Over the pane rather than inside it: a list that pushed the colours and the slider down
 * would move the controls under the finger that opened it. */
.fontpick-list {
  position: absolute;
  z-index: 5;
  top: calc(100% + 4px);
  left: 0;
  right: 0;
  max-height: 15rem;
  overflow-y: auto;
  padding: 0.25rem;
  border: 1px solid var(--border);
  border-radius: calc(var(--radius) - 2px);
  background: var(--card);
  box-shadow: 0 12px 28px hsl(220 40% 12% / 0.14);
}
.fontpick-list[hidden] { display: none !important; }

.fontpick-option {
  display: block;
  width: 100%;
  min-height: 40px;
  padding: 0.3rem 0.5rem;
  border: 0;
  border-radius: calc(var(--radius) - 4px);
  background: none;
  font-family: var(--print-font, inherit);
  font-size: 1rem;
  text-align: left;
  cursor: pointer;
}
.fontpick-option:hover { background: var(--muted); }
.fontpick-option[aria-selected="true"] { background: var(--muted); font-weight: 600; }
.fontpick-option:focus-visible { outline: 2px solid var(--ring); outline-offset: -2px; }

/* Out of the way while a text is being moved, at the operator's request. 240px of furniture
 * over the design being judged, and it follows the text anyway - so during a drag it is
 * exactly what a customer is trying to see past. Opacity and pointer-events rather than
 * `hidden`, so nothing reflows and it comes back in place when the finger lifts. */
.text-pane.stowed { opacity: 0; pointer-events: none; }
.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(100% - 2rem));
  /* **94, not 88, since 2026-09-07 at the operator's request.** The gap above and below this
     box is space the design step could be using, and that step is the one this shop is for.
     `svh`, never `vh`: `vh` is the height the page would have with the browser's chrome gone,
     so a box sized in it is taller than the screen whenever the URL bar is showing. */
  max-height: 94svh;
  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: 94svh;
  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;
  /* **`overflow-y: auto` makes `overflow-x` `auto` too**, whatever it says - that is the
   * spec, not a browser quirk - so this box clips at its own left and right edges. Every
   * selected control in here wears a 1px ring drawn *outside* its border, and the ones
   * against the edges had a slice taken off both sides. Two pixels of room, pulled back out
   * with a matching negative margin so nothing else moves. */
  padding-inline: 2px;
  margin-inline: -2px;
}
.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) {
  /* **Nearly the full width, with a gutter on both sides**, at the operator's request on
   * 2026-09-18. It was `100vw` with the top corners alone rounded, on the reasoning that a
   * sheet sitting on the edges of a phone wants no gutter - and the box is not anchored to
   * the bottom, which is what that reasoning assumed: a `<dialog>` carries `margin: auto` and
   * this one is centred, so what it actually drew was a panel with its corners cut off by the
   * screen edges.
   *
   * **And `100vw` was wider than the page.** It is the viewport *including* the classic
   * scrollbar gutter, so on a browser that draws one the sheet overflowed to the right by
   * exactly that width and took the close button with it - reported from the operator's
   * Safari as the "X" being cut off. `100%` on a top-layer dialog is the viewport's content
   * box, which is what a box that must not overflow is asking for. The pane this project is
   * driven in uses overlay scrollbars and reports a difference of nought, which is why a
   * screenshot from a real browser is the check.
   *
   * The radius goes back to all four corners with the gutter, and the design step keeps its
   * full bleed below - there the canvas really is the product. */
  .wizard { width: calc(100% - 2rem); max-width: calc(100% - 2rem); }
  .wizard-inner { max-height: 92svh; }
  #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); }
  /* The most cramped viewport this is opened in gets the most of it. */
  .wizard-inner { max-height: 96svh; }
}

/* 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(100% - 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; }

/* **The same defect as `.cartoon-frame` had, and it takes the opposite fix.** A tile keeps its
 * size - a grid of tiles each their own height is not a grid - so here the picture has to
 * letterbox inside the box rather than the box growing to the picture. That means the
 * `max-height: 100%` below has to start working, and what stops it is the row: a percentage
 * against a content-sized track computes to `none`, so a portrait thumbnail overflowed its
 * 130px tile and was cropped by `overflow: hidden`.
 *
 * `minmax(0, 1fr)` makes the row definite against the tile's own height, at which point the
 * picture's rule does exactly what it always claimed. **Nothing about that rule changed** -
 * measured at 87x130 inside a 200x130 tile afterwards, against 200x300 and clipped before. */
.cartoon-thumb {
  display: grid;
  grid-template-rows: minmax(0, 1fr);
  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) {
  /* `min-height` here too, or the landscape block puts the ceiling back that the rule above
   * just removed and crops the picture again on the one viewport with the least height. */
  .cartoon-frame { min-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;
}

/* The bulk QA bar on the sessions screen.
 *
 * The operator's testing arrives in passes rather than singly, so marking a set is the normal
 * case and marking one row is the special one. See figurine.qa.
 *
 * The bar sits above the list and the checkboxes sit in the rows, joined by `form="bulk-qa"`
 * rather than by containment: a form wrapping the list would nest inside each row's own
 * toggle form. Nothing here depends on that arrangement except the note itself. */
.bulk-qa {
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
  padding: 0.75rem 1rem;
}

.bulk-row {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  flex-wrap: wrap;
  /* A flex item's min-width defaults to its content, so without this a long count string
   * makes the whole page wider rather than wrapping inside the card. */
  min-width: 0;
}

/* `.bulk-all` ships `hidden` and `bulkqa.js` reveals it, so the `[hidden]` rule below is not
 * belt and braces: a class selector giving an element a `display` beats the user agent's bare
 * `[hidden]`, so without it the script's own `hidden = false` would be undoing nothing and the
 * control would be on screen from the first paint. The catalogue strip and the countdown both
 * shipped with exactly this bug. Written beside the `display`, which is the rule. */
.bulk-all[hidden] { display: none; }

.bulk-all,
.bulk-scope {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  /* The sitewide `label` rule stacks form fields with an uneven margin, which on a centred
   * flex row moves the box by half the difference. Both of these join rows of controls. */
  margin: 0;
  font-size: 0.85rem;
  color: var(--muted-foreground);
  min-width: 0;
}

.bulk-count {
  font-size: 0.85rem;
  /* Takes the slack so the two buttons stay hard right at every width. */
  margin-right: auto;
}

.bulk-actions {
  display: flex;
  gap: 0.5rem;
  flex: none;
}

/* 44px unconditionally, not inside a width query. Every rule this project scoped to
 * `max-width: 700px` was missed entirely by a landscape phone, which is 812px wide - and
 * these two write to a file that decides what the KPI screens count. */
.bulk-actions .btn { min-height: 44px; }

/* The wide scope. Set apart from the row above because it changes what the buttons do:
 * ticked, they stop acting on the ticks and act on the whole query instead. */
.bulk-scope {
  border-top: 1px solid var(--border);
  padding-top: 0.6rem;
}
.bulk-scope input { accent-color: var(--destructive); }

/* The per-row checkbox, inside the <summary> flex row beside the toggle.
 *
 * `flex: none` so it keeps its size rather than stretching, and the 44px target is on the
 * label rather than the box: a 13px checkbox is under half Apple's minimum, and this screen
 * is read on a phone like every other. */
.session-pick {
  flex: none;
  /* `inline-flex`, not `flex`. A block-level flex container fills its parent's width, and the
   * <summary> this sits in is not a flex row at every width - so `display: flex` measured 234px
   * wide on a 375px phone: a 44px target that is really a fifth of the row, catching presses
   * meant for the disclosure beside it. `min-width` cannot fix that; the box model can. */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  margin: 0;
  /* Compact where there is a mouse. This is the densest screen in the workshop and the box
   * was setting the height of every row in the list - 44px of checkbox around a 16px control,
   * on a row whose text is 20px tall. The admin side is explicitly allowed to be dense.
   *
   * **Keyed on the pointer, never on the width.** Every 44px rule this project scoped to
   * `max-width: 700px` was missed entirely by a landscape phone, which is 812px wide; a touch
   * target is a fact about the input device, so `pointer: coarse` is the honest question and
   * the one the camera button already asks. */
  width: 1.9rem;
  height: 1.9rem;
}

@media (pointer: coarse) {
  .session-pick { width: 44px; height: 44px; }
}
.session-pick input {
  width: 1rem;
  height: 1rem;
  accent-color: var(--destructive);
  cursor: pointer;
}

/* Page speed, on the analytics card and on every pageload row.
 *
 * Two numbers reported beside each other and never pooled: the server times every request,
 * the browser reports only where a script ran. See figurine.pagespeed. */
.speed-table {
  width: 100%;
  border-collapse: collapse;
  /* A flex/grid child's min-width defaults to its content, so without this a wide table makes
   * the page scroll sideways rather than scrolling inside its own .table-scroll. That is the
   * defect this project met on the workshop's tab strip. */
  min-width: 0;
}

.speed-table th,
.speed-table td {
  text-align: left;
  padding: 0.5rem 0.75rem 0.5rem 0;
  border-top: 1px solid var(--border);
  vertical-align: baseline;
  white-space: nowrap;
}
.speed-table thead th {
  border-top: 0;
  font-size: 0.75rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--muted-foreground);
}
.speed-table tbody th { font-weight: 600; }

/* The caveat and the slowest-pages heading both sat hard against the table. Hierarchy through
 * space rather than a rule, which is what the design language asks for. */
.speed-table + p,
.speed-note-after { margin-top: 1.25rem; }
.speed-slow-head { margin-top: 1.75rem; }

/* The median leads and everything qualifying it recedes. Hierarchy through weight and space
 * rather than through a border, which is what the design language asks for. */
.speed-p50 {
  display: block;
  font-variant-numeric: tabular-nums;
  font-size: 1.05rem;
}
.speed-p75,
.speed-n {
  display: block;
  font-size: 0.72rem;
  color: var(--muted-foreground);
  font-variant-numeric: tabular-nums;
}

/* On a pageload row in a session timeline. Quiet: the row is about which page was looked at,
 * and the timing is a fact beside that rather than the point of it. Never drawn where nothing
 * was measured - the template gates on `speed_note`, which is empty rather than zero. */
.timeline-speed {
  font-size: 0.72rem;
  color: var(--muted-foreground);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  flex: none;
}

@media (max-width: 700px) {
  /* The timings drop under the path rather than squeezing it. A path is what the operator is
   * scanning for; a duration is what they check once they have found the row.
   *
   * **`flex-wrap` on the row, not `display` on the chip.** The first version of this set
   * `.timeline-speed { display: block }` and did nothing at all: a flex item is laid out along
   * the main axis whatever its own `display` says, so the row stayed `nowrap`, overflowed its
   * card by ~90px, and pushed the Slow badge off the visible edge. A fix written for a phone
   * that does not engage is the recurring shape of defect here - it is why the visual check at
   * both viewports is a rule rather than a courtesy. */
  .timeline li { flex-wrap: wrap; row-gap: 0.1rem; }
  /* The timestamp keeps the first line to itself with the path, and the numbers wrap beneath.
   * `100%` rather than a flex-basis, so the badge lands on the same line as the timings it
   * qualifies instead of a third line of its own. */
  .timeline-speed { margin-left: auto; }
}

/* "Slow" on a page load in a session timeline.
 *
 * The one thing on that timeline the operator is scanning for, so it is the one thing there
 * that carries a colour - everything else is grey text and a timestamp.
 *
 * `--destructive`, not a third accent. The two accents mean something specific on this site:
 * --primary navigates and --commerce spends money, and neither is true of a warning. This is
 * the same token the QA toggle uses for the other "look at this row" state on these screens.
 * See CLAUDE.md on why a third accent is not introduced. */
.timeline-slow {
  flex: none;
  font-size: 0.68rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--destructive);
  border: 1px solid var(--destructive);
  border-radius: var(--radius-sm);
  padding: 0.05rem 0.3rem;
  white-space: nowrap;
}


/* ============================================================ the coffee wizard
 *
 * Five questions in the shared `.wizard` dialog, so this only has to dress what is inside
 * the steps. Every target here carries its 44px **unconditionally** rather than inside the
 * 700px block: a landscape phone is 812px wide and misses every width-only rule written for
 * "mobile", which is the recurring shape of defect in this stylesheet.
 */

/* The two headings the design step got on 2026-09-06, when nine tiles in one grid became
 * eight artwork styles above eight templates. Quieter than `.wizard-ask`, which is the
 * question itself - these name the two answers to it, so they sit a step down in the
 * hierarchy rather than competing with it. Weight and space, not a rule or a box.
 *
 * The first one loses its top margin: it follows the question directly, and two gaps
 * stacked there reads as a missing element. */
.wizard-group {
  margin: 1.4rem 0 0;
  font-size: 0.8125rem;
  font-weight: 600;
  letter-spacing: 0.01em;
  color: var(--foreground);
}
.wizard-ask + .wizard-group { margin-top: 0.2rem; }

/* One line under each heading saying what that half costs in time. The two differ by
 * minutes, which is the thing a customer most needs to know before choosing and the one
 * thing a picture cannot tell them. */
.coffee-designs-hint { margin: 0.15rem 0 0; }

/* The design tiles. `auto-fit`, not `auto-fill`: auto-fill lays down a track nothing
 * occupies on a wide dialog, leaving the tiles squeezed beside a gap.
 *
 * **92px, raised from 76 on 2026-09-06, and it is the longest name that sets it.** At 76 the
 * dialog fitted four columns of 77.8px on a 375px phone, which leaves a 60px label box - and
 * "Psychedelic" measures 76px at this size, so the word crossed its own tile and took the
 * grid into overflow with it. 92 is that word plus the tile's padding, which gives three
 * columns of about 107px here and a label box that fits every name on both lists.
 *
 * Note this is *not* the number `.style-grid` uses, and the difference is the container
 * rather than a disagreement: that grid sits in a 277px card where an 88px floor gives two
 * columns and four rows, which is the whole screen. This one is in a 340px dialog. Measure
 * the box before copying a floor between them. */
.coffee-designs {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(92px, 1fr));
  gap: 0.6rem;
  margin-top: 0.75rem;
}
.coffee-design { cursor: pointer; margin: 0; }
.coffee-design input { position: absolute; opacity: 0; pointer-events: none; }

.coffee-design-body {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  padding: 0.5rem;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--card);
  transition: border-color 140ms ease-out, box-shadow 140ms ease-out;
}
.coffee-design input:checked + .coffee-design-body {
  border-color: var(--primary);
  box-shadow: 0 0 0 1px var(--primary);
}
.coffee-design input:focus-visible + .coffee-design-body {
  outline: 2px solid var(--ring);
  outline-offset: 2px;
}
.coffee-design:hover .coffee-design-body { border-color: var(--muted-foreground); }

/* The plate itself, on the tint the category uses. A real PNG from /labels/{key}/, never a
 * screenshot of one: a picture of a design is a second copy that goes stale the moment the
 * geometry moves. */
.coffee-design-art {
  display: flex;
  align-items: center;
  justify-content: center;
  aspect-ratio: 1;
  border-radius: calc(var(--radius) - 2px);
  background: var(--tint-roast);
  overflow: hidden;
}
.coffee-design-art img { width: 86%; height: 86%; object-fit: contain; }

/* The seventh tile has no plate, because nothing has been drawn yet. A tinted square with
 * three dots rather than a placeholder promising a picture - the same rule a product card
 * with no photo follows. */
.coffee-design-drawn::after {
  content: "\2026";
  font-size: 1.6rem;
  color: var(--muted-foreground);
  line-height: 1;
}

.coffee-design-label { font-size: 0.82rem; font-weight: 600; }

/* The blends. A stacked list rather than chips: each one carries a sentence about what it
 * tastes like, and a sentence does not fit in a chip. */
.coffee-blends { display: grid; gap: 0.5rem; margin-top: 0.75rem; }
.coffee-blend { cursor: pointer; margin: 0; }
.coffee-blend input { position: absolute; opacity: 0; pointer-events: none; }
.coffee-blend-body {
  display: block;
  min-height: 44px;
  padding: 0.6rem 0.75rem;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--card);
  transition: border-color 140ms ease-out, box-shadow 140ms ease-out;
}
.coffee-blend input:checked + .coffee-blend-body {
  border-color: var(--primary);
  box-shadow: 0 0 0 1px var(--primary);
}
.coffee-blend input:focus-visible + .coffee-blend-body {
  outline: 2px solid var(--ring);
  outline-offset: 2px;
}
.coffee-blend-label { display: block; font-weight: 600; font-size: 0.92rem; }
.coffee-blend-meta {
  display: block;
  margin-top: 0.15rem;
  font-size: 0.78rem;
  line-height: 1.4;
  color: var(--muted-foreground);
}

/* The photo, once there is one. The chooser is withdrawn rather than left underneath it:
 * two ways in at once, one of which throws away what is on screen, is the arrangement the
 * camera panel and the upload trigger already refuse. */
.coffee-preview { margin: 0.75rem 0 0; text-align: center; }
.coffee-preview img {
  max-height: 34svh;
  border-radius: var(--radius);
  object-fit: contain;
}
.coffee-preview figcaption { margin-top: 0.4rem; font-size: 0.8rem; }

/* Draw another, and change this one. Two buttons of equal weight: one asks for a different
 * design and the other for the same design with one change, and both cost a generation. */
.coffee-redraw { display: flex; gap: 0.5rem; margin-top: 0.6rem; }
.coffee-redraw .btn { flex: 1 1 46%; min-width: 0; min-height: 44px; }
#coffee-adjust { font-size: 16px; min-height: 44px; }

.coffee-treatments { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
.coffee-treatments .btn { flex: 1 1 46%; min-width: 0; min-height: 44px; }
.coffee-treatments[hidden] { display: none; }

/* Cartoonize carries the before-and-after pair, as it does in the toolbar on a product page:
 * two pictures say what it does faster than the word. The teaser is laid out by
 * `.looks-teaser` already; this only makes room for it inside a button that also has a word
 * in it, and keeps the whole thing centred. */
.coffee-cartoonize {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.45rem;
}
.coffee-cartoonize .looks-teaser { flex: none; }

/* The blend's own line under the name, on the mockup. Nothing here sizes it - the size comes
 * from `figurine.labels` through the record like every other text - this is only the pane's
 * own listing. */
.coffee-blend input:disabled + .coffee-blend-body { opacity: 0.5; }

/* 16px on the name field, always. Safari zooms the whole page in when a field under 16px
 * takes focus and never zooms back out. */
#coffee-name { font-size: 16px; min-height: 44px; }


/* The wizard's step strip: where you are, and the way back to anywhere you have been.
 *
 * Six questions is enough that "Back" alone is a maze - somebody who wants to change the name
 * is four presses from it with no way of knowing that without trying.
 *
 * It scrolls sideways rather than wrapping, and `min-width: 0` is what lets that engage: a
 * flex item sizes to its content by default, so without it the strip makes the whole dialog
 * wider on a phone instead of scrolling inside itself. That is the defect the workshop's tab
 * strip shipped with, one component along. */
/* **Six have to fit on one row**, so below a certain width only the current step keeps its
 * label and the rest are numbers. A container query rather than a media query: the strip
 * lives in a dialog whose width has nothing to do with the viewport's.
 *
 * **It wraps; it never scrolls.** A strip that scrolls sideways hides the step you have not
 * reached behind an edge with no affordance - "Design" was cut in half at several widths and
 * looked like a rendering fault rather than something to scroll to. Six short labels on two
 * rows is worse typography and better information. */
.wizard-inner { container-type: inline-size; container-name: wizard; }

.wizard-steps {
  display: flex;
  flex-wrap: wrap;
  gap: 0.1rem 0.15rem;
  min-width: 0;
  margin: 0 0 0.9rem;
  padding: 0;
  list-style: none;
}

/* A rule that gives an element a `display` takes `[hidden]` on with it. The figurine wizard
 * hides its stepper when it has one step (2026-10-11), and without this the list stayed. */
.wizard-steps[hidden] { display: none; }

.wizard-stepper { flex: 0 1 auto; min-width: 0; }
.wizard-stepper-body {
  display: flex;
  align-items: center;
  gap: 0.3rem;
  padding: 0.25rem 0.45rem;
  border: 0;
  border-radius: calc(var(--radius) - 4px);
  background: none;
  color: var(--muted-foreground);
  font: inherit;
  font-size: 0.78rem;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
button.wizard-stepper-body { cursor: pointer; }
button.wizard-stepper-body:hover { background: var(--muted); color: var(--foreground); }
button.wizard-stepper-body:focus-visible { outline: 2px solid var(--ring); outline-offset: 1px; }

.wizard-stepper-num {
  display: grid;
  place-items: center;
  flex: none;
  width: 1.15rem;
  height: 1.15rem;
  border: 1px solid var(--border);
  border-radius: 50%;
  font-size: 0.68rem;
  font-variant-numeric: tabular-nums;
}
.wizard-stepper.done .wizard-stepper-num {
  border-color: var(--primary);
  background: var(--primary);
  color: var(--primary-foreground);
}
.wizard-stepper.here .wizard-stepper-body { color: var(--foreground); font-weight: 600; }
.wizard-stepper.here .wizard-stepper-num {
  border-color: var(--primary);
  color: var(--primary);
}

/* A sub-heading inside a step, for a form that asks more than one thing. */
.wizard-sub {
  margin: 0.9rem 0 0.4rem;
  color: var(--foreground);
  font-size: 0.9rem;
  font-weight: 600;
}

/* "or", with a rule either side. Two ways forward of different weights, and a plain gap
 * between them reads as one list. */
.coffee-or {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  margin: 1.1rem 0 0.2rem;
  color: var(--muted-foreground);
  font-size: 0.8rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
}
.coffee-or::before,
.coffee-or::after {
  content: "";
  flex: 1;
  height: 1px;
  background: var(--border);
}

#coffee-instructions,
#coffee-wish,
#coffee-adjust { font-size: 16px; min-height: 44px; width: 100%; }

#coffee-again,
#coffee-adjust-go,
#coffee-cartoon-again { width: 100%; min-height: 44px; margin-top: 0.5rem; }




/* A drawing that came back, fitted to the box rather than shown at its own size.
 *
 * The cartoon arrived at 1024px inside a dialog a third of that, and the step scrolled in
 * both directions - a customer being asked "how is that?" about a picture they could see one
 * eye of. Capped in height as well as width, because the answer is portrait and a landscape
 * cap alone still runs off the bottom on a phone. */
.coffee-drawn {
  display: flex;
  justify-content: center;
  margin: 0.2rem 0 0.6rem;
}
.coffee-drawn img {
  display: block;
  width: auto;
  max-width: 100%;
  max-height: 46svh;
  object-fit: contain;
  border-radius: calc(var(--radius) - 3px);
}


@container wizard (max-width: 560px) {
  /* Numbers alone, except where you are. Six chips of 1.15rem and a single label always fit,
   * where six labels do not - and a step that has wrapped onto its own line reads as a
   * seventh step rather than as the sixth. */
  .wizard-stepper:not(.here) .wizard-stepper-label { display: none; }
  .wizard-stepper-body { padding: 0.25rem 0.3rem; gap: 0.25rem; }
}

/* The photograph, kept as the answer already given, with the cartoons offered under an "or".
 *
 * Small on purpose: a reminder of what was chosen a step ago, not the picture itself. It was
 * a panel as tall as ten style tiles beside it, stretched by whatever width the dialog
 * happened to be, which is neither a reminder nor a picture. */
.coffee-keep { display: block; margin: 0; cursor: pointer; }
.coffee-keep input { position: absolute; opacity: 0; pointer-events: none; }
.coffee-keep-body {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding: 0.6rem;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--card);
  transition: border-color 120ms ease-out, box-shadow 120ms ease-out;
}
.coffee-keep input:checked + .coffee-keep-body {
  border-color: var(--primary);
  box-shadow: 0 0 0 1px var(--primary);
}
.coffee-keep input:focus-visible + .coffee-keep-body {
  outline: 2px solid var(--ring);
  outline-offset: 2px;
}
.coffee-keep-shot {
  flex: none;
  width: 4.5rem;
  height: 4.5rem;
  overflow: hidden;
  border-radius: calc(var(--radius) - 3px);
  background: var(--muted);
}
.coffee-keep-shot img { width: 100%; height: 100%; object-fit: cover; display: block; }
.coffee-keep-words { display: flex; flex-direction: column; gap: 0.15rem; min-width: 0; }
.coffee-keep-title { font-weight: 600; }
.coffee-keep-note { color: var(--muted-foreground); font-size: 0.85rem; }

.coffee-cutout-tick {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin: 0.5rem 0 0;
  min-height: 44px;
  font-size: 0.92rem;
  cursor: pointer;
}
.coffee-cutout-tick[hidden] { display: none !important; }


/* Out of the wizard, from anywhere, in one press. "Back" walks the questions in reverse,
 * which is right for changing an answer and wrong for leaving. */
.wizard-close {
  position: absolute;
  top: 0.5rem;
  right: 0.5rem;
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: none;
  color: var(--muted-foreground);
  font-size: 1.5rem;
  line-height: 1;
  cursor: pointer;
}
/* The `[hidden]` rule beside the `display`, which this stylesheet has had to learn four
 * times: a class selector beats the user agent's bare `[hidden]`, so `node.hidden = true`
 * would set an attribute that changes nothing. */
.wizard-close[hidden] { display: none !important; }
.wizard-close:hover { background: var(--muted); color: var(--foreground); }
.wizard-close:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
/* The title keeps clear of it, or a long one runs under the button. */
.wizard-inner { position: relative; }
.wizard-title { padding-right: 2.5rem; }


/* Placing a photograph in the circle a design will set it into.
 *
 * A round window with the picture behind it, dragged rather than cropped by handles: this is
 * one gesture with one result, and eight dots would say it was eight. `touch-action: none` or
 * a phone scrolls the dialog instead of moving the photograph. */
.framer {
  position: relative;
  width: min(15rem, 68vw);
  aspect-ratio: 1;
  margin: 0.4rem auto 0.2rem;
  overflow: hidden;
  border-radius: 50%;
  background: var(--muted);
  cursor: grab;
  touch-action: none;
  user-select: none;
  /* The rim, so the circle reads as a window onto the picture rather than as the picture. */
  box-shadow: inset 0 0 0 1px hsl(220 20% 12% / 0.12);
}
.framer:active { cursor: grabbing; }
.framer img {
  position: absolute;
  max-width: none;
  pointer-events: none;
}

.framer-zoom { display: flex; align-items: center; gap: 0.6rem; margin: 0.6rem 0 0; }
.framer-zoom input[type="range"] { flex: 1 1 auto; min-width: 0; }

/* ---------------------------------------------------------------- compact option groups
 *
 * Added 2026-09-07. Five size chips carrying a label, a measurement and a price each ran to
 * about 450px on a phone, and the middle group another 270px - most of two screens for two
 * decisions almost nobody changes, with Add to Cart 2.3 screens below them. The dropdown
 * says everything the chips said inside its own options.
 *
 * 16px is not a preference. Safari zooms the whole page in when a form control under 16px
 * takes focus and does not zoom back out, and 44px is Apple's minimum target - both
 * unconditional here rather than inside a width query, because every 44px rule this project
 * scoped to `max-width: 700px` was missed by a phone on its side. */
.option-compact { margin-bottom: 1rem; }

.option-select {
  display: block;
  width: 100%;
  min-height: 44px;
  padding: 0.55rem 2.2rem 0.55rem 0.75rem;
  font: inherit;
  font-size: 16px;
  color: var(--foreground);
  background-color: var(--card);
  border: 1px solid var(--input);
  border-radius: var(--radius);
  /* Drawn rather than left to the platform, so the control matches every other field on the
   * page. The chevron is a background image because a pseudo-element cannot be placed inside
   * a form control. */
  appearance: none;
  background-image: linear-gradient(45deg, transparent 50%, var(--muted-foreground) 50%),
                    linear-gradient(135deg, var(--muted-foreground) 50%, transparent 50%);
  background-position: right 1.15rem center, right 0.85rem center;
  background-size: 0.32rem 0.32rem, 0.32rem 0.32rem;
  background-repeat: no-repeat;
  transition: border-color 140ms ease-out, box-shadow 140ms ease-out;
}
.option-select:hover { border-color: var(--muted-foreground); }
.option-select:focus-visible {
  outline: 2px solid var(--ring);
  outline-offset: 2px;
  border-color: var(--ring);
}

/* ------------------------------------------------------- the instructions field, folded
 *
 * A label, a two-row textarea and a line of help is about 150px standing between the last
 * option and the buy button, for a field almost nobody fills in. A native `<details>` so it
 * needs no script.
 *
 * It has to undo the sitewide `details summary` dressing, which uppercases a summary and puts
 * a chevron in front of it. `text-transform` and `letter-spacing` inherit into the spans
 * inside, so overriding the font-size alone leaves the words in small caps - the trap the
 * `.faq` and `.looks` blocks already document. */
/* Tightened 2026-09-07. It is an optional field almost nobody fills in, standing between the
 * last option and the buy button, so it reads at the weight of a footnote rather than of a
 * heading. The 44px target stays - it is a control, and a control a thumb has to find. */
.notes-fold { margin: 0 0 0.6rem; }
.notes-fold > summary {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  min-height: 44px;
  padding: 0;
  font-size: 0.8125rem;
  font-weight: 500;
  text-transform: none;
  letter-spacing: normal;
  color: var(--primary);
  cursor: pointer;
  list-style: none;
}
.notes-fold > summary::-webkit-details-marker { display: none; }
.notes-fold > summary::before { content: "+"; font-weight: 600; }
.notes-fold[open] > summary::before { content: "\2212"; }
.notes-fold > summary:hover { text-decoration: underline; }
.notes-fold > summary:focus-visible { outline: 2px solid var(--ring); outline-offset: 3px; }
.notes-fold > textarea { margin-top: 0.4rem; }

/* ------------------------------------------- the product picture, on a phone specifically
 *
 * Roughly four customers in five open this shop on a phone, and on one the picture was 469px
 * of a 812px screen - so the name, the price and the buy button were all below the fold on
 * the page paid traffic lands on. Shrunk 2026-09-07.
 *
 * **The height is stated and the ratio is cleared**, never capped with `max-height`. A
 * `max-height` on a box that also carries an `aspect-ratio` derives the *width* back out of
 * the cap: that is what computed `.studio-stage` to 420px inside a 741px column and is
 * written up in CLAUDE.md as its own trap. This rule comes after `.media-stage` in the file,
 * so it beats the inline `--stage-ratio` that `fitStage` sets from the photograph.
 *
 * The showcase photographs are composed on a seamless off-white ground, so `object-fit:
 * contain` in a wider-than-tall box letterboxes into something the eye reads as page rather
 * than as a gap. */
/* Landscape needs this more than portrait, not less: at 812px wide the column is still
 * stacked, so a 4/5 frame would be about 475px tall on a 375px-tall screen. Same conditions
 * as the buy bar, and for the same reason - 940px is where the column stacks. */
@media (max-width: 940px), (max-height: 560px) {
  .media-stage {
    aspect-ratio: auto;
    height: 13rem;
    max-height: none;
  }
}

/* ---------------------------------------------------------- the picture beside the options
 *
 * From 560px to 940px the page was one column with the picture full width, and because the
 * frame is now shorter than it is wide the picture letterboxed - leaving most of two thumbs
 * of empty card either side of it while the options waited underneath. Two columns instead,
 * at the operator's request: the picture in a capped column and everything you choose beside
 * it.
 *
 * **560px is the floor because that is where a second column stops being usable, not because
 * it is a device.** Below it the picture would have to give up more than half the width to
 * leave room for a 16px dropdown, and a 150px-wide select is worse than a stacked one.
 *
 * The frame is square-ish in its own column, so `object-fit: contain` has almost nothing left
 * to letterbox - which is the same problem solved properly rather than painted over. */
@media (min-width: 620px) and (max-width: 940px) {
  .product {
    grid-template-columns: minmax(0, 17rem) minmax(0, 1fr);
    gap: 1.75rem;
    align-items: start;
  }
  .media-stage { height: 15rem; }
  /* The panel carries the name, the price and the options, so it must not inherit the
   * top padding that made sense when it sat under the picture. */
  #product-info { padding-top: 0; }
}

/* ------------------------------------------- and below 620px the picture takes the width
 *
 * **Stacked again since 2026-09-09, at the operator's request and with a prototype.**
 *
 * From 2026-09-07 the picture kept a column of its own at every width - 17rem down to 620px
 * and then 42% of the grid, which is **143px on a 375px phone** - so that the name, the price
 * and the options sat beside it rather than under it. That bought Add to Cart nearer the top
 * of the page. What it cost was the picture, and on a shop four customers in five open on a
 * phone the picture is the product: a 143px catalogue shot with a 138px button across its foot
 * is the screenshot the operator sent.
 *
 * **The reason for the narrow column is now paid for by something else.** The buy bar arrived
 * the same day and carries the price and Add to Cart at the foot of the viewport at every
 * scroll position, which is a stronger version of what the two-column layout was reaching for.
 * So the picture gets the width and everything you choose goes below it.
 *
 * What goes with the column, because each of these was a consequence of it rather than a
 * decision of its own: the swatch row's compaction (written for "a 200px column"), the
 * headline's 24px (written for a 200px panel), the toolbar's one-control-per-row (written for
 * 143px, see `.print-tools` above) and the trigger stretched across the frame's foot (written
 * because the words were wider than the frame). All four are restored to what a full-width
 * column already had.
 *
 * The frame is square here: the showcase photographs are 1024x1024, so a square is the shape
 * that does not letterbox. Its height is *derived from its width* rather than stated, which is
 * safe beside the `max-height: none` above - the trap is a `max-height` beside a ratio, which
 * derives the width back out of the cap.
 *
 * `.product-stage` keeps its `position: sticky` and stops travelling here, which is correct
 * rather than overlooked: in one column each item is its own grid row, so a sticky child has
 * no room to move inside a parent that hugs it. Same arithmetic as the masthead in
 * Specification 28.7. The picture scrolls away and the buy bar is what stays. */
@media (max-width: 619px) {
  .product {
    grid-template-columns: minmax(0, 1fr);
    /* A column gap was 0.9rem; stacked, this is the space between the picture's caption and
       the category line under it, and it is the panel's own vertical rhythm. */
    gap: 1.5rem;
  }
  /* **`.product:has(#studio:not([hidden]))` is gone, and its absence is the point.** It gave
   * the studio the whole width while it was open, because 136px is not something anybody can
   * watch their own figurine turn in. The column is the whole width now whatever is in it, so
   * that rule could not change the answer - and a rule that cannot change the answer is a
   * second place for the layout to be decided from. Same reasoning that removed
   * `Category.print_shift` when the placement it keyed on went.
   *
   * **The swatch row and the headline keep their full-width sizing here**, for the reason
   * above: both compactions named the 200px column as their justification, and there is no
   * 200px column.
   *
   * The toolbar's shares come back with them. `1 1 46%` is two controls to a row and the
   * remainder taking one alone - the 2+2+1 the operator asked for on 2026-08-31 - which the
   * `max-width: 940px` rule above replaced with one per row when this column was 143px wide.
   * The wrap allowance and the 13px type from that block stay: a 166px share still cannot hold
   * "Change picture" on one line at 14px. */
  .print-tools:has(> .print-size:not([hidden])) > *:not([hidden]) { flex: 1 1 46%; }
  .media-stage {
    height: auto;
    aspect-ratio: 1 / 1;
  }
  #product-info { padding-top: 0; }
  .media-thumb { width: 3rem; height: 3rem; }

  .sale-flag.large { top: 0.5rem; left: 0.5rem; font-size: 0.6875rem; padding: 0.2rem 0.45rem; }
}
@media (max-height: 560px) {
  /* Shorter still where height is the scarce thing. **The ratio is cleared with the same
   * statement that sets the height**, and that is not belt and braces: the one-column block
   * above declares `aspect-ratio: 1 / 1`, so a height stated here without this derives the
   * *width* back out of it - measured at 375 x 375 as a 144px picture sitting in a 340px
   * column with the rest of the row empty. This project's own `max-height`-beside-a-ratio
   * trap, in the one arrangement where it had never been reachable: until 2026-09-09 the
   * column was 143px at this width, so a frame that derived 144 looked exactly right. */
  .media-stage {
    height: 9rem;
    aspect-ratio: auto;
  }
}

/* ------------------------------------------------------------------- the mobile buy bar
 *
 * Added 2026-09-07. The brief was Add to Cart above the fold; the masthead and breadcrumb are
 * 241px before any product content, so trimming alone could not reach it. Pinned to the foot
 * of the viewport instead, which is on screen at every scroll position rather than only at
 * the top.
 *
 * `position: fixed`, deliberately, and not `sticky`. The masthead's own stickiness does not
 * work on this shop because `.shop .site-head { position: static }` leaves a sticky child no
 * room to move inside a parent that hugs it - Specification 28.7. A bar at the foot of the
 * viewport has no such parent to fight.
 *
 * The 44px minimum is outside the width query on purpose. Every 44px rule this project scoped
 * to `max-width: 700px` was missed by a phone on its side, and this is the control that
 * spends money. */
.buy-bar {
  display: none;
  align-items: center;
  gap: 0.75rem;
}
/* What `hidden` means for this bar, stated where the display is given. The rule below is a
 * class selector inside a media query and the user agent's is a bare `[hidden]`, so setting
 * the property from the observer would otherwise hide nothing at all - the trap this project
 * has hit three times, on the catalogue thumbnails, the countdown and the bulk-QA checkbox.
 * Any rule that gives an element a display takes this obligation on with it. */
.buy-bar[hidden] { display: none !important; }
.buy-bar-button {
  flex: 1;
  font-size: 1rem;
}

/* **Both controls that spend money carry 44px at every viewport.** Found 2026-09-07 while
 * measuring the mid-tier layout: the inline button was **38px at 900px wide**, which is under
 * Apple's minimum and is the identical defect the cartoonize bench had at 812x375.
 *
 * The cause is worth writing down. `#buy` is `<button class="commerce lg full">` - a bare
 * `button`, not a `.btn` - so the `.btn.lg` rule that was supposed to give it its size never
 * matched it, and its height came from padding alone. It reached 44px on a phone only because
 * a `max-width: 700px` block sizes buttons there, which is exactly the width-scoped 44px this
 * project keeps being bitten by.
 *
 * Unconditional, and stated for both buttons in one rule so the bar and the inline control
 * cannot drift apart. */
#buy,
.buy-bar-button { min-height: 44px; }
.buy-bar-money {
  font-weight: 650;
  font-size: 1.05rem;
  white-space: nowrap;
  /* A column so the coupon can sit above the figures. The bar's own height is set by its
   * 44px button, and these two lines measure under that - so the offer costs the bar nothing,
   * which is what "the most compact version" has to mean for a control pinned over the page. */
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  justify-content: center;
  /* The button beside it gives up nothing; this column elides instead. */
  min-width: 0;
}

/* ------------------------------------------------- the coupon on the buy bar (2026-09-17)
 *
 * The price row's offer, one screen later and with everything the row can afford and the bar
 * cannot taken off it: no expiry, no code, no tooltip. A tick, the saving, and - once it is
 * on - the struck price under it.
 *
 * It is a second submit for `#coupon-form`, so this is only ever a *view* of that control:
 * the state is painted from the `coupon:applied` / `coupon:removed` events the row raises,
 * and nothing here decides anything.
 *
 * **No 44px floor, deliberately, and it is the one control on this bar without one.** Apple's
 * minimum is for the controls on the buying path; this one applies a discount and cannot lose
 * the customer anything, where giving it 44px would take the money column past the height of
 * the button beside it and make the bar taller on the screen it exists to save room on. Its
 * press target is the whole chip, which measures about 120 x 20. */
.buy-bar-coupon {
  display: flex;
  align-items: center;
  gap: 0.35rem;
  /* **No `max-width: 100%`, since 2026-09-18.** A percentage cap here resolves against a
   * column that is itself sized by its content, and the browser settles that circle by
   * capping the chip at what the *figures* under it need - so the caption was elided to fit a
   * price rather than the column widened to fit the caption. It read "Save 2..." at 574px
   * before the wording changed and "20% Coupon App..." after it, and the second is the one
   * that got reported. `min-width: 0` is what still lets it give way on a genuinely narrow
   * bar, and the caption's own ellipsis is what it gives way with. */
  min-width: 0;
  /* Inset and pulled back, exactly as the row's control is and for the same reason: the
   * sitewide `button:hover` fill would otherwise sit flush against the tick and the words.
   * If one of the two grows a rule, the other wants it.
   *
   * **0.3rem rather than the row's 0.55**, measured: this column is sized by the figures under
   * it, the chip is capped to that by `max-width: 100%`, and at 375px the caption came out
   * 0.4px short of "Saving 20%" and elided to "Saving 20...". A plate on a 14px tick wants
   * less room than one beside a price anyway. */
  padding: 0 0.3rem;
  margin: 0 -0.3rem;
  border-radius: var(--radius-sm);
  border: 0;
  background: none;
  font-family: inherit;
  font-size: 0.75rem;
  font-weight: 600;
  line-height: 1.2;
  color: var(--commerce);
  cursor: pointer;
}
.buy-bar-coupon:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
/* Smaller than the row's 18px box, because this one sits beside 12px type rather than beside
 * a price. Same drawing, same two states, same tokens. */
.buy-bar-coupon .coupon-box { width: 14px; height: 14px; border-radius: 3px; }
.buy-bar-coupon .coupon-box svg { width: 10px; height: 10px; }
.buy-bar-coupon.is-applied .coupon-box {
  background: var(--commerce);
  border-color: var(--commerce);
}
.buy-bar-coupon.is-applied .coupon-box svg { opacity: 1; }
/* Both captions ship and the stylesheet chooses - the row's own arrangement, so the bar
 * cannot be left reading "Save" while it is already saving. */
.buy-bar-coupon .coupon-taken { display: none; }
.buy-bar-coupon.is-applied .coupon-offer { display: none; }
.buy-bar-coupon.is-applied .coupon-taken { display: block; }
.buy-bar-coupon .coupon-offer,
.buy-bar-coupon .coupon-taken {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.buy-bar-figures { display: flex; align-items: baseline; gap: 0.35rem; }
/* The struck price, on the same condition the price row draws its own: there is no second
 * base price in the model, so printing one under an offer nobody has taken would claim a
 * discount that is not on the basket. */
.buy-bar:not(.has-coupon) .buy-bar-was { display: none; }
.buy-bar-was {
  font-size: 0.8125rem;
  font-weight: 500;
  color: var(--muted-foreground);
}

/* **Two conditions, and the width is 940px rather than 700px.** 940 is where `.product`
 * collapses to one column, and between 701 and 940 the page was stacked with a full-size
 * picture and no bar - so Add to Cart was further down than on a phone and nothing was
 * pinned. Keyed on the width the layout actually changes at, never on a guess about devices.
 *
 * The second condition is height, because a phone on its side is 812px wide: it misses every
 * width-only "mobile" query while having the least height of any viewport this shop is opened
 * in. That is the recurring shape of defect here rather than a one-off, and is why the
 * cartoonize bench's button measured 38px at 812x375. */
@media (max-width: 940px), (max-height: 560px) {
  .buy-bar {
    display: flex;
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 40;
    padding: 0.6rem 1rem calc(0.6rem + env(safe-area-inset-bottom, 0px));
    background: var(--card);
    border-top: 1px solid var(--border);
    /* Large, soft and nearly transparent. A visible drop shadow is a mistake. */
    box-shadow: 0 -12px 28px -18px rgb(15 23 42 / 0.18);
  }
  /* Room for it, so the last thing on the page is not permanently underneath the bar. */
  .shop main { padding-bottom: 5rem; }

  /* ---- what the compacted page stops saying on a phone
   *
   * Each of these is redundant rather than merely small, which is the only reason to drop it:
   * the caption describes a strip the customer can see, the hint tells them to press a button
   * the frame is already offering by name since it started reading "Create your design", and
   * the breadcrumb repeats the category the masthead has highlighted.
   *
   * The `#artwork-group` title stays. It labels the group for a screen reader and it is where
   * the `<noscript>` chooser lands, and the group itself is never given `display: none` -
   * that would take the photo dialog's top layer with it, which is the trap CLAUDE.md records
   * against `.artwork-done`. */
  #media-caption,
  #artwork-group > .hint,
  .shop .breadcrumbs { display: none; }

  /* **And the group's heading, because the dialog carries its own copy of it.** The three
   * ways in moved behind the button on the picture on 2026-08-29 and took the only visible
   * children of this group with them, so the page-level heading was standing over nothing -
   * conspicuously so once the options moved beside the picture and it was alone in the left
   * column. Nothing is lost: `_photo_dialog.html` titles itself "What to print", and where
   * there is no `showModal` the dialog is drawn inline *with* that title. The group itself is
   * still never given `display: none` - that beats the top layer and would leave the dialog
   * unopenable, the trap recorded against `.artwork-done`. */
  #artwork-group > .option-title { display: none; }

  /* The group's title is kept - it labels the group for a screen reader and it is where the
   * `<noscript>` chooser lands - but with the hint gone it was a heading standing over 64px
   * of nothing. The margin closes rather than the heading disappearing, so the next block
   * comes up to meet it. The group itself is never given `display: none`: that would take
   * the photo dialog's top layer with it, which is the trap CLAUDE.md records against
   * `.artwork-done`. */
  /* **The compacted page is denser between its questions too.** 1.75rem between option groups
   * is right on a page that breathes; on a phone, where the panel is a 200px column and a
   * product like the chocolate trophy asks four questions before its button, it is 28px of
   * nothing repeated four times. `.option-compact` has used 1rem since the dropdowns landed -
   * that number is about this page's density rather than about dropdowns, so it applies to
   * every group here. */
  .product-panel .option-group { margin-bottom: 1rem; }

  #artwork-group { margin-bottom: 0; }
  /* The panel that follows carries its own top space, and with the group emptied to a lone
   * heading the two stacked into about 90px of nothing between the thumbnails and the
   * product name. */
  #artwork-group + * { margin-top: 0; }
  #product-info { padding-top: 0.25rem; }

  /* **The trigger moves off the proof.** It is pinned bottom-right of the frame, and the
   * showcase photograph puts the instant-camera print in exactly that corner - so the one
   * control on the picture was covering the one thing the picture exists to explain. Bottom
   * left is free: the sale flag is top left. Only on a phone, where the frame is small
   * enough for the two to collide. */
  .stage-entry {
    right: auto;
    left: 0.7rem;
    justify-content: flex-start;
  }

  /* Flat, because the frame is now wider than the picture is tall and `object-fit: contain`
   * letterboxes. The showcase photographs are composed on a seamless near-white ground, so a
   * flat card behind them reads as more of the same paper where the radial gradient's far
   * stop read as two grey bands. `cover` was the alternative and is worse: it would crop a
   * fifth off the top and bottom of a square, taking the collar and the hem of the garment
   * the picture exists to show. */
  .media-stage { background: var(--card); }
}

/* ------------------------------------------- the trigger, on a frame narrower than its label
 *
 * **Last in the file deliberately.** The buy-bar block above sets `.stage-entry { right: auto }`
 * so the button sits bottom-left, and an earlier copy of this rule was written before it - so
 * `right` was overridden back to `auto`, the button kept its natural 138px inside a 143px
 * frame and hung over the panel beside it. Same specificity, later wins; that is the whole
 * reason this is here rather than beside the layout it belongs to.
 *
 * `justify-content: stretch` was the first attempt and is not a value a flex container has -
 * it computes to `flex-start`, which is why the button never filled the box. The box is
 * stretched by `left` **and** `right`, and the button fills it with `flex: 1`.
 *
 * The 44px stays at every width: it is a control a thumb has to find. */
@media (max-width: 619px) {
  /* **And back to the lower right corner on 2026-09-09, at the operator's request: "put it
   * to the corner like we do on desktop".**
   *
   * Everything below was true of a 143px frame and none of it is true of a 375px one. The
   * words were wider than the frame, so the box was stretched by `left` **and** `right` and
   * the button filled it with `flex: 1` at 13px - which is how one control came to cover the
   * bottom third of the picture. The picture takes the width in this range now, so the button
   * is 138px of 375 in the corner this page pins every control that acts on the frame to.
   *
   * `right` and `justify-content` are restated rather than unset, because the buy-bar block
   * above sets `right: auto; left: 0.7rem` - the trigger moves off the showcase photograph's
   * instant-camera print on a *small* frame, which is a decision about 272px at 620-940px and
   * not about this range. **That is still why this block is last in the file**: same
   * specificity, later wins. */
  .stage-entry {
    left: auto;
    right: 0.7rem;
    bottom: 0.7rem;
    justify-content: flex-end;
  }
}

/* --------------------------------- the trigger, on a screen the buy bar takes the foot of
 *
 * **Found on 2026-09-09 by looking at 812 x 375, and it was a control nobody could press.**
 * `elementFromPoint` at the centre of "Upload Photo" returned `buy-bar-button`: the frame is
 * 9rem tall and starts at y=221 on a landscape phone, so its foot lands at 365 - and the buy
 * bar is a `position: fixed` band 64px deep at the foot of the viewport, `z-index: 40` against
 * this row's 4. The only way into the design on that page was behind the bar at the top of the
 * page, on the screen four customers in five would be holding sideways.
 *
 * Not a regression from the day's work - the bar has been there since 2026-09-07 and the
 * trigger has not moved in this range - but it is this project's own recurring shape: a phone
 * on its side is 812px wide, so it misses every width-only rule while having the least height
 * of any screen the shop is opened on.
 *
 * **Top right, which is the position the tall-frame rule below already uses**, so this is a
 * fourth home for one button rather than a new idea about where it goes. Top *right* because
 * the sale flag is top left, and the showcase photographs put their subject's own print in the
 * lower right - which is the corner the 940px rule moves off for a small frame and the corner
 * this leaves for the same reason.
 *
 * **Keyed on the height, and after the 619px block on purpose**: at 375 x 375 both apply and
 * this has to win, which at equal specificity means being later in the file. Same reason that
 * block is where it is. */
@media (max-height: 560px) {
  .stage-entry {
    top: 0.7rem;
    bottom: auto;
    left: auto;
    right: 0.7rem;
    justify-content: flex-end;
  }
}

/* ------------------------------------------- the trigger, on a frame that is tall and wide
 *
 * Above 940px the stage is a 4/5 frame in a `1fr` column, so it is the tallest the picture
 * ever gets - and the button pinned to its foot then sits a long way down a short window.
 * Top right instead, at the operator's request on 2026-09-07: it is the first thing in the
 * frame at every window height rather than the last.
 *
 * **Top right and not top left**, because the sale flag is already top left and two things in
 * one corner is a collision that only shows up on a discounted product.
 *
 * Only in this range. Below 940px the frame is short enough that its foot is on screen
 * anyway, and below 620px the button is stretched across that foot because the words are
 * wider than the frame. */
@media (min-width: 941px) {
  /* **The lower right is the default, and that reverses 2026-09-07 at the operator's
   * request.** This used to pin the button to the top here and drop it to the foot once the
   * frame's top had scrolled past. The reasoning was sound - a 630px frame loses its top edge
   * long before its bottom - but it starts the button in the corner that goes away first, and
   * on the bespoke page the script that moved it was never on the page at all, so it simply
   * sat at the top and stayed there.
   *
   * Now it starts where it survives and moves only when it has to; `stageentry.js` watches
   * the foot. Below 940px nothing is needed: the frame is short enough that its whole height
   * is on screen. */
  .media-stage.foot-away .stage-entry {
    top: 0.7rem;
    bottom: auto;
  }
  /* **And back to the foot once the frame's top has scrolled past.**
   *
   * The frame is 630px tall here, so its top leaves the screen long before its bottom does -
   * and a button pinned to the top then scrolls away while most of the picture is still in
   * view. `product.html` watches the frame and sets this class, so the button is on screen
   * for the whole time the picture is.
   *
   * A class rather than two rules keyed on scroll position, because CSS cannot ask where an
   * element is; and a class rather than inline styles, so the two positions stay in the
   * stylesheet where the rest of the frame's furniture lives. */
}

/* ------------------------------------------------- the design, hosted inside the wizard
 *
 * The last step of the design wizard borrows the real frame, its toolbar and the text editor
 * rather than drawing a second set - see `cartoonize.js`. Two things it has to provide.
 *
 * **A positioned ancestor**, because the text editor pane hangs off one and measures against
 * it; on the page that ancestor is `.product-stage`. Without this the pane's coordinates are
 * taken from the body and it lands on top of the design it is avoiding, which is the exact
 * defect recorded against `.product-stage { position: static }`.
 *
 * **A size for the frame**, because every `.media-stage` height rule in this stylesheet is
 * keyed on the viewport, and inside a dialog the viewport says nothing useful about how much
 * room there is. Stated, with the ratio cleared - never a `max-height` beside an
 * `aspect-ratio`, which derives the width back out of the cap. */
.design-host {
  position: relative;
  container-type: inline-size;
}
/* **Square and centred, because the blank is square.** A full-width frame with a stated
 * height letterboxed a 1024x1024 shirt into a wide short box: the picture was as tall as the
 * frame and the rest was empty card either side, which is the "stretched horizontally,
 * leaving very little working space" the operator reported.
 *
 * The ratio drives the height off the width here, which is the safe direction. The trap is a
 * `max-height` *beside* an `aspect-ratio` - that derives the width back out of the cap, and
 * is what computed `.studio-stage` to 420px inside a 741px column. A width and a ratio is
 * fine. `.wizard-body` scrolls, so a frame taller than the dialog is reachable rather than
 * clipped. */
.design-host .media-stage {
  aspect-ratio: 1 / 1;
  height: auto;
  max-height: none;
  /* **30rem since 2026-09-07**, up from 26. The size slider left this step and the dialog
     gave back six percent of the screen, and both of those are room the canvas can have -
     which is what the operator asked the two changes for. `.wizard-body` scrolls, so a frame
     taller than the dialog is reachable rather than clipped. */
  width: min(100%, 30rem);
  margin-inline: auto;
  background: var(--card);
}
/* **Short viewports get less of it, and the cap is derived from the height rather than
 * stated.** The trade is the product page's: a landscape phone is 375px tall and the dialog
 * has to hold a question, a frame, a toolbar and two buttons, so the canvas cannot have all
 * the room it would like.
 *
 * **It was a flat 15rem until 2026-09-09, and a flat number on a height query is a rule about
 * the wrong axis.** A portrait phone in a browser with its own chrome is short *and* narrow -
 * measured at 393x690 in Chrome on iOS, which is where the operator found it - so the frame
 * was clamped to 240px inside a 330px column and gave back 45px of empty card on each side.
 * The screenshot reads exactly as "plenty of space on each of the sides", because that is
 * what it is: width thrown away to pay for a height nobody was short of.
 *
 * `min()` with `100%` in it can never do that. Where the height is the scarce axis the svh
 * term binds and the frame is as small as it was; where the width is, the frame takes the
 * width and the svh term is not reached. 64svh is what 15rem was on the viewport that rule
 * was written for - a 375px-tall landscape phone - so nothing about that case moves.
 *
 * `svh`, never `vh`: `vh` is the height the page would have with the browser's chrome gone,
 * which on the very viewport this rule exists for is the height it does not have. */
@media (max-height: 700px) {
  .design-host .media-stage { width: min(100%, 30rem, 64svh); }
}
/* **What the toolbar keeps in here, and what it does not.**
 *
 * The wizard has already asked the look, the background and the words, and the strip is the
 * way back to any of them - so the controls that repeat a question, or that end a flow this
 * step is still inside, are out. What is left is what the arrangement actually needs: the
 * size slider, another text, and the handles on the design itself.
 *
 * `Cartoonize` in particular would re-open the wizard from inside the wizard, landing the
 * customer back on question two with no sign of what happened.
 *
 * Only ever inside this host: on the page every one of these is the right control, and the
 * nodes are the same nodes - they are borrowed, not copies. */
.design-host #stage-upload,
.design-host #art-looks-good,
.design-host #print-edit,
.design-host #art-cartoonize,
.design-host #art-cutout,
.design-host #print-restart,
.design-host #print-change { display: none; }

/* **"Add text" stays, and it is why the row above names the upload button rather than the row
 * it is in.** The whole `.stage-entry` was withheld here until 2026-09-07, when the wizard's
 * "Words" step was removed at the operator's request and this took its place: the lettering
 * is added in the corner of the frame it lands on, rather than typed into a field one screen
 * earlier and met again with handles on it a screen later.
 *
 * "Upload Image" still goes. It repeats question one, which the numbered strip is already the
 * way back to.
 *
 * **Pinned here rather than left to the page's own rules**, because those are keyed on the
 * page's widths and say the wrong thing in a dialog: below 620px they stretch the row across
 * the foot of the frame from the left, because on the product page the words are wider than a
 * 143px frame - and above 940px they put it at the *top* until the picture has scrolled past.
 * The operator asked for the lower right corner, and in here the frame is wide enough for it
 * at every width. */
.design-host .stage-entry {
  top: auto;
  bottom: 0.7rem;
  left: auto;
  right: 0.7rem;
  justify-content: flex-end;
  /* **Above the handles rather than under them.** They are `z-index: 5` and this row was 4,
   * and the picture's rotate arrow sits off its bottom right corner - which on a print that
   * fills the area is exactly this corner, so the arrow landed over the first letters of the
   * label by default rather than in some unlucky arrangement. A control whose own name is
   * covered is a control nobody presses. What it costs is the near half of the arrow while
   * the picture is selected, which is the smaller of the two: the arrow is one of eight
   * handles on a thing that can also be turned back, and this is the only way to lettering
   * on this step. */
  z-index: 6;
}
.design-host .stage-text { flex: none; }
/* The toolbar is the row of controls under the frame; it needs the room the page gives it. */
.design-host .print-tools { margin-top: 0.6rem; }

/* **And the size slider goes too, since 2026-09-07 at the operator's request.** It was the
 * last control left in that row, and the handles on the picture do the same job in the place
 * the customer is already looking - so the row buys nothing and the height it costs is height
 * the canvas can have instead.
 *
 * The empty `.print-tools` stays in flow deliberately: `place()` reads it as the floor the
 * text editor pane stops above, and `tools.hidden` is the attribute rather than the computed
 * display - a toolbar given `display: none` measures as a zero box at the top of the frame
 * and the floor lands in the wrong place. */
.design-host .print-size { display: none; }

/* **The text editor stays pinned to the text it is editing, here as on the page.**
 *
 * It was made to flow below the frame earlier on 2026-09-07 and that was wrong - the operator
 * reported it as "does not appear next to text, it appears below, making it unreachable in
 * smaller screens", which is exactly right: below the frame *and* below the toolbar it is off
 * the bottom of a scrolling dialog body.
 *
 * **The reason it was moved was an artefact of how it was being tested.** It looked as though
 * it landed on Back and Done, and it did - but only in a state with no real picture, where
 * `.print-tools` is hidden and `place()` therefore has no floor to stop against. With a
 * picture the toolbar is there and the floor works, which is the state every customer is
 * actually in on this step.
 *
 * So the only thing this host provides is what `place()` measures against - see
 * `.design-host { position: relative }` above. Do not give this a `position` here:
 * `place()` writes `left` and `top` inline on every drag frame, and anything other than
 * absolute makes those mean something else. */

/* The shop's sale badge is not part of anybody's design. It belongs on a catalogue picture,
 * which is what this frame is on the product page - in here the frame is a working surface. */
.design-host .sale-flag { display: none; }

/* The chooser, borrowed into the first step. `.photo-dialog.inline` already draws it as an
 * ordinary block - it is what a browser with no `showModal` gets - so this only has to stop
 * it carrying a dialog's own padding twice. */
#wizard-photo-host .photo-dialog.inline { padding: 0; border: 0; background: none; }
#wizard-photo-host .photo-dialog-inner { padding: 0; }
#wizard-photo-host .photo-dialog-inner > h2 { display: none; }

/* **Wider while the design is in it.** 30rem is right for a question and a row of tiles and
 * too narrow for a garment with handles on it: the frame came out about 260px, which is not
 * something anybody can place a print on. Keyed on the step being open with `:has()` rather
 * than on a class the script has to remember to add and remove.
 *
 * **Keyed on `.design-host` rather than on one wizard's step id since 2026-09-07**, when
 * coffee's wizard grew an arrangement of its own. Both dialogs borrow the same three nodes
 * through `designhost.js`; a second selector naming a second id is the copy that drifts. */
.wizard:has(.wizard-step:not([hidden]) .design-host) {
  width: min(38rem, calc(100% - 2rem));
}

/* **And on a phone it keeps the full-bleed the phone rule already gave it.**
 *
 * `@media (max-width: 700px)` above makes every wizard a bottom sheet at `100vw`; the rule
 * just above is later in the file and of the same specificity, so on a phone it *narrowed*
 * the one step that needs width most, back to `100vw - 2rem`. A rule written to widen a
 * dialog was taking 32px off it - the same trap as the buy bar's `right: auto` beating the
 * narrow-frame fix, and the reason that one is last in the file.
 *
 * Restated here rather than folded into the rule above, because `calc(100vw - 2rem)` is
 * right everywhere else: a dialog floating in a window wants a gutter, and a sheet sitting
 * on the edges of a phone does not. The radius stays the sheet's, since nothing overrides
 * it, and the frame is still off the screen edge by `.wizard-inner`'s own padding. */
@media (max-width: 700px) {
  .wizard:has(.wizard-step:not([hidden]) .design-host) {
    width: 100%;
    max-width: 100%;
  }
}

/* **The scrolling body never wears a focus ring.**
 *
 * It is `overflow: auto`, which makes it focusable in Chrome so a keyboard can scroll it -
 * and `showModal` chose it, because on the picture step the chooser's file input is visually
 * hidden and the camera button ships `hidden`. The user agent then drew its ring around the
 * whole scroll box: a blue rounded rectangle around the question with its bottom corners
 * showing below the hint.
 *
 * Suppressed rather than left, and only because `cartoonize.js` now moves focus to the step's
 * own question instead - a heading a screen reader can announce. Taking a ring away without
 * putting focus somewhere better would be the accessibility regression this avoids. */
.wizard-body:focus,
.wizard-body:focus-visible { outline: none; }
/* And the question that now holds it is not a control, so it does not wear one either. */
.wizard-ask:focus-visible { outline: none; }

/* ------------------------------------------------ the studio, hosted inside its wizard
 *
 * The bespoke page's flow moved into a dialog on 2026-09-07, so the studio is borrowed the way
 * the mockup is - see `bespoke.js`. Two things this host has to provide.
 *
 * **Room.** On the page the studio lives in the picture column, which is 42% below 620px and
 * 17rem above it; in here it has the dialog's width. That is the whole reason the flow moved:
 * a customer watching their own photograph become a figurine, and then turning it, was doing
 * so at 136px on a phone.
 *
 * **A positioned ancestor**, because `.stage-actions` is pinned to the foot of the stage. It
 * is not given a `position` here; it keeps the one it has and simply measures against this
 * instead of against the product stage. */
.studio-host {
  position: relative;
  container-type: inline-size;
}
/* The studio's own `hidden` is what `studio.js` toggles; the wizard shows it on the way in. */
.studio-host .studio { display: block; }

/* **Wider while the figurine is in it**, the same rule and the same width the design wizards
 * use. 30rem is right for a question and a chooser and too narrow for a photograph somebody is
 * about to have sculpted. */
.wizard:has(.wizard-step:not([hidden]) .studio-host) {
  width: min(38rem, calc(100% - 2rem));
}

/* The chooser, borrowed into the first step - the same three rules the cartoon wizard's photo
 * host carries, and for the same reasons. `.photo-dialog.inline` already draws it as an
 * ordinary block; this only stops it carrying a dialog's padding twice. The feed borrowed in
 * beside it needs nothing at all: the overlay is keyed on the stage it covers, so anywhere
 * else the panel is already the plain `.camera` box. */
#studio-photo-host .photo-dialog.inline { padding: 0; border: 0; background: none; }
#studio-photo-host .photo-dialog-inner { padding: 0; }
#studio-photo-host .photo-dialog-inner > h2 { display: none; }

/* ------------------------------------------------------------------ Visitors
 *
 * One row per browser. A <details> like a session row and deliberately built from the same
 * pieces - `.tag`, `.session-tags`, the same hairline separator - because the two screens
 * describe the same facts at two altitudes and a second visual vocabulary would make them
 * read as two unrelated products.
 *
 * What is NOT here is a timeline. A browser with nine sessions would be nine timelines on one
 * row; Sessions renders one properly and every row links to it. */
.visitors { margin-top: 1rem; }

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

/* A session with no visitor token is a row this screen cannot speak for, so it is drawn
 * quieter rather than hidden - the same reasoning `.tag-fetch` and `.tag-direct` carry. It is
 * still a real thing that happened. */
.visitor-loose > summary { color: var(--muted-foreground); }

/* The generic `details summary` rule above is a small uppercase workshop label, right inside a
 * card and wrong for a row of data. Reset here rather than weakened there, matching
 * `.session > summary`. */
.visitor > summary {
  display: grid;
  /* The identity column is bounded rather than 1fr: unbounded it took the slack and left a
   * dead gap between a nine-character token and the numbers beside it. The tags take the
   * remainder, which is the column that genuinely wants whatever is going. */
  grid-template-columns: auto 6.5rem minmax(6rem, 16rem) auto minmax(0, 1fr);
  align-items: baseline;
  gap: 0.6rem;
  padding: 0.5rem 0.6rem;
  font-size: 0.85rem;
  font-weight: 400;
  text-transform: none;
  letter-spacing: normal;
  color: inherit;
  cursor: pointer;
}
.visitor > summary:hover { background: var(--accent); }
.visitor > summary:focus-visible { outline: 2px solid var(--ring); outline-offset: -2px; }

.visitor-when { color: var(--muted-foreground); }

/* `min-width: 0` on both, or a long token or an email takes the row wider than the page. A
 * grid track's minimum is its content, exactly as a flex item's is - the workshop tab strip
 * lesson, which cost this project a 1434px body at 812 x 375. */
.visitor-who {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* The three numbers the list is scanned by, and the first of them is the whole point of the
 * screen. Not muted, for the reason `.session-counts` is not: everything beside them is
 * derived from one or another. */
.visitor-counts {
  display: flex;
  gap: 0.5rem;
  min-width: 0;
  font-variant-numeric: tabular-nums;
}
.visitor-span { color: var(--muted-foreground); font-weight: 400; }

/* Wraps. **This said "where `.session-flags` on a session row does not" until 2026-09-19**,
 * when that rule started wrapping too; it is kept because the reasoning here is its own and
 * stronger, so putting the Sessions tab back to `nowrap` cannot silently take this with it.
 * That list caps at fifty browsers, each row is a summary rather than a log line, and the
 * chip that fell off the end was Returned - the one tag this screen has that Sessions does
 * not, and the answer to the question the tab was built for. Measured at 1000px with six
 * chips. */
.visitor > summary .session-flags { flex-wrap: wrap; }

/* Came back. The one tag on this screen that exists nowhere else, so it carries the accent
 * rather than the neutral ground - it is the answer to the question the tab was built for. */
.tag-return {
  background: color-mix(in oklab, var(--primary), var(--card) 88%);
  border-color: color-mix(in oklab, var(--primary), var(--card) 58%);
  color: var(--primary);
  font-weight: 600;
}

/* Sessions this screen keeps and /admin/sessions excludes. Dashed and quiet like `.tag-fetch`,
 * because it is a statement about weak evidence rather than about something a customer did -
 * and the two belong to the same argument, so they should look related. */
.tag-recovered {
  background: transparent;
  color: var(--muted-foreground);
  font-weight: 400;
  border-style: dashed;
}

/* Checkout and Add to cart, filled rather than tinted - dark ground, light text - at the
 * operator's request on 2026-09-15, so the two intent tags carry across a list at a glance.
 *
 * **`--success`, not `--commerce`, and not a new colour at all.** The paragraph above this
 * decides that and it has not been withdrawn: commerce green is the buying path *in the shop*,
 * and these chips are the admin side reporting a judgement about a visit. This is the same
 * token the pale chips beside them use, inverted - a treatment, not a fifth hue, so the third
 * accent Specification 26.1.2 reserves is still unspent.
 *
 * Checkout was deliberately neutral until this, so that an abandoned checkout could not read
 * as a sale at a glance. That reasoning is not withdrawn, it is overridden: the operator wants
 * the funnel's last two steps to stand out, and `Order` above them is the one that is a link.
 * The cost is that Checkout is now louder than Order, which is the more important fact -
 * raised rather than quietly corrected. */
.tag-checkout,
.tag-cart {
  background: color-mix(in oklab, var(--success), black 24%);
  border-color: color-mix(in oklab, var(--success), black 32%);
  color: color-mix(in oklab, var(--success), white 84%);
  font-weight: 600;
}
.tag-cart:hover,
.tag-checkout:hover { background: color-mix(in oklab, var(--success), black 14%); }

/* An order came out of this visit. `--success` like every other judgement on this row, and
 * **not** `--commerce`: it was written with the buying path's token for a day, which the
 * paragraph on the pale group had already ruled out - commerce green means "this is how you
 * buy" to a customer, and nothing on a workshop screen is a control a customer presses.
 *
 * The only chip on either row that is a link, since "which order" is always the next question,
 * so it carries a hover and no underline. Pale rather than filled: the operator asked for
 * Checkout and Add to cart to stand out, which leaves Order quieter than two steps that
 * precede it - a real inversion, raised rather than silently corrected. */
.tag-order {
  background: color-mix(in oklab, var(--success), var(--card) 86%);
  border-color: color-mix(in oklab, var(--success), var(--card) 55%);
  color: var(--success);
  font-weight: 600;
  text-decoration: none;
}
.tag-order:hover { background: color-mix(in oklab, var(--success), var(--card) 74%); }

/* **A wall the customer hit, and the one red chip on either row.**
 *
 * Every other tag on that line is deliberately the same quiet muted chip, because a row of
 * colours is a row that says nothing - this is the exception the colour is being saved for,
 * and it is drawn first. `--destructive`, the same token `.timeline-trouble` reaches for and
 * for the same reason: one fact, one colour, at two altitudes. The individual row is already
 * red below; this says the visit contains one without the operator having to open it.
 *
 * Not a third accent - blue navigates and green buys, and neither of those is what this is.
 * The rule that keeps the palette to two accents is about *those two*; a destructive state has
 * always been its own thing here. */
.tag-error {
  background: color-mix(in oklab, var(--destructive), var(--card) 86%);
  border-color: color-mix(in oklab, var(--destructive), var(--card) 55%);
  color: var(--destructive);
  font-weight: 600;
}

/* The workshop's own testing. Neutral and dashed: it says "not a customer", which is a fact
 * about the row rather than about the visit. */
.tag-qa {
  background: transparent;
  color: var(--muted-foreground);
  font-weight: 500;
  border-style: dashed;
}

.visitor-body {
  padding: 0.2rem 0.6rem 1rem 2rem;
  display: grid;
  gap: 0.9rem;
}

/* Two columns on a wide screen, stacked on a narrow one. A <dl> rather than a table because
 * these are four labelled facts about one thing, not rows of anything. */
.visitor-journey {
  display: grid;
  grid-template-columns: 8rem minmax(0, 1fr);
  gap: 0.35rem 1rem;
  margin: 0;
  font-size: 0.85rem;
}
.visitor-journey dt { color: var(--muted-foreground); font-weight: 500; }
.visitor-journey dd {
  margin: 0;
  display: flex;
  gap: 0.35rem;
  align-items: baseline;
  flex-wrap: wrap;
  min-width: 0;
}

/* The browser's sessions, each one the same `_session_row.html` the Sessions tab draws. It was
 * a list of links until 2026-09-15; the rows are the real thing now, so this only holds them.
 *
 * A hairline above the group, because a <details> inside a <details> otherwise reads as more
 * of the journey above it rather than as a list of its own. */
.visitor-sessions {
  margin: 0;
  padding-top: 0.35rem;
  border-top: 1px solid var(--border);
}

/* Nested rows wrap their tags. **Redundant since 2026-09-19**, when `.session-flags` itself
 * started wrapping, and kept rather than deleted: it is the statement that these rows wrap
 * *for their own reason* - indented inside an opened visitor, in a column narrower than the
 * screen the other rule was measured on, with at most a handful on screen at once - so
 * putting the Sessions tab back to `nowrap` cannot silently take this with it. */
.visitor-sessions .session-flags { flex-wrap: wrap; }

/* A phone, and a phone on its side. 940px is where the summary's five columns stop having
 * room for prose rather than a guess about devices; below it the row becomes two lines and
 * the journey's labels sit above their values.
 *
 * Every link in here is a 44px target from this width down, unconditionally within the block
 * - these are the rows an operator taps at, and the session list inside an opened row is a
 * column of small dates. */
@media (max-width: 940px) {
  .visitor > summary {
    grid-template-columns: auto auto minmax(0, 1fr);
    grid-template-areas:
      "chevron who   who"
      ".       when  counts"
      ".       tags  tags";
    row-gap: 0.3rem;
  }
  /* The date stays, beside the counts rather than in a column of its own. It was dropped
   * here for an hour and the collapsed row then carried no "when" at all - which is one of
   * the two things this list is scanned by, and the one the rows are ordered on. */
  .visitor-when { grid-area: when; }
  .visitor-who { grid-area: who; }
  .visitor-counts { grid-area: counts; }
  .visitor > summary .session-tags { grid-area: tags; }

  .visitor-body { padding-left: 0.6rem; }
  .visitor-journey { grid-template-columns: minmax(0, 1fr); gap: 0.15rem; }
  .visitor-journey dt { margin-top: 0.5rem; }

  /* The row is the tap target. The sessions inside carry `.session > summary`, which has its
   * own sizing on a phone further up this file. */
  .visitor > summary { min-height: 44px; display: grid; align-items: center; }
}

/* --------------------------------------------------------- Conversion per ad
 *
 * One row per advertisement. Built on the sitewide `table` rules rather than a private set,
 * because it is an ordinary table of facts and this screen already has two of those.
 *
 * `min-width: 0` for the reason `.speed-table` states: a flex or grid child's minimum is its
 * content, so without it a seven-column table makes the *page* scroll sideways instead of
 * scrolling inside its own `.table-scroll`. That is the workshop tab-strip defect, third
 * time. */
.ad-table { min-width: 0; }

/* Figures right-aligned and tabular, so a column of counts can be compared down its length
 * rather than read one row at a time. The share sits on the same line in muted type - it is
 * derived from the number beside it and must not compete with it. */
.ad-table .num {
  text-align: right;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}
.ad-table .num .muted { margin-left: 0.4rem; font-size: 0.8125rem; }

/* The ad's own name, as the advertiser wrote it. Monospaced because it is an identifier
 * somebody will compare against their ad account character by character, and allowed to wrap:
 * these names are long and truncating one makes two different creatives look identical. */
.ad-table .mono {
  word-break: break-word;
  min-width: 8rem;
}

/* At phone width the table scrolls inside its own box rather than shrinking its columns to
 * nothing. A seven-column table of figures has no honest stacked form - collapsing it into
 * cards would put seven labels on screen for every row and lose the column-wise comparison
 * that is the whole reason it is a table. */
@media (max-width: 940px) {
  .ad-table { min-width: 34rem; }
}

/* ---------------------------------------------------------------- the engraving controls

   Three changes asked for on 2026-09-16, all of them about a small screen.

   **The whole figurine has to be visible.** Inside the wizard the stage is given a flat
   `46svh` so the scan has a box to sweep, and that height is an id selector - so it went on
   binding after the result arrived, while `.studio-stage.done > .studio-preview` asked for
   `min(58svh, 25rem)`. The stage clips (`overflow: hidden`), so the plinth was cut off the
   bottom of the picture: the lettering is on the plinth, which makes it the one part that
   must not be lost. Once there is a result the stage stops being a fixed box and sizes to
   what is in it, and the picture is capped in `svh` so it fits the dialog whole.

   This is the Specification 28 pair one step along: not a `max-height` beside an
   `aspect-ratio`, but a *fixed* height on a clipping parent whose child asks for more. The
   rule is the same - state the height where the content is, not where the box is. */
#studio-figurine-host .studio-stage.done {
  height: auto;
  min-height: 0;
  max-height: none;
}

#studio-figurine-host .studio-stage.done > .studio-preview {
  height: min(40svh, 22rem);
  min-height: 9rem;
}

/* **"Engrave it" sits inside the field, at its right-hand end.** As a row button it was below
   the fold on a phone, so the field read as a control with nothing to press. `studio.js` moves
   the button into the group while the field is open, because pinning it to the field's right
   edge needs the field's own box as the positioned ancestor - a sibling cannot be. */
.stage-actions .engraving-group { position: relative; }

/* **The field grows so the button inside it can keep its 44px.** That target is
   unconditional for a control that spends money, and a 45px field with the button inset would
   have made it 37px - which is exactly the measurement this project keeps having to redo. So
   the field is sized to carry it rather than the button shrunk to fit the field. */
.stage-actions .engraving-group input {
  min-height: 52px;
  padding-right: 7.75rem;
}

.stage-actions .engraving-group #engrave-confirm {
  position: absolute;
  right: 4px;
  top: 4px;
  bottom: 4px;
  min-height: 44px;
  width: auto;
  flex: none;
  padding-inline: 0.9rem;
  white-space: nowrap;
}

/* **The figurine is what is dimmed while a name is being cut into it.**
 *
 * On an engraving pass the source is the object rather than the photograph, so the preview is
 * what sits under the scan - see `engraveNow` in studio.js. The dimming is not decoration: the
 * scan is drawn in light and needs something to be light against, which is the same reason
 * `.studio-stage > .studio-photo` carries 0.82 while a figurine is being built from it.
 *
 * Scoped to `.engraving` rather than changing the preview's own opacity, because the finished
 * result is shown at full strength and must go on being - a dimmed answer reads as one that is
 * still working. */
.studio-stage.engraving > .studio-preview { opacity: 0.82; }

/* ===================================================================== the coupon line
 *
 * 2026-09-17, rebuilt the same day. The first version was a dashed ticket card under the
 * price - code, terms, expiry and a full-width button, four rows - and the operator's word
 * for it was "bulky". This is Amazon's shape instead: a checkbox, the saving in money, the
 * expiry under it, sitting *in* the price row rather than beneath it.
 *
 * Three rules hold the whole thing together and none of them is decoration:
 *
 * 1. **The row is the same height in every state.** Nothing appears or disappears when a
 *    coupon is applied - both captions ship, the tick ships, and the stylesheet chooses.
 * 2. **The tooltip is absolutely positioned**, so it costs no height and the page cannot
 *    reflow when it shows or hides. That was the operator's stated requirement.
 * 3. **The struck price is inline**, not stacked above the number. Stacking made the block
 *    taller the instant a coupon landed, which breaks (1) in the very row being compacted.
 */

/* `.price` is the row: the coupon beside the figures, both centred against each other.
 * Centred rather than baseline-aligned - the operator's pick of four - so neither side moves
 * when the struck price appears beside the number.
 *
 * **The pair sits at the right end of the row, and the coupon is sized by its words.** It
 * filled the whole line until 2026-09-17 - `space-between` with the coupon growing into
 * everything the price did not take - so on a wide page a two-word offer was stretched across
 * 250px of nothing and read as a panel rather than as a line beside the number. The operator's
 * instruction: next to the price, not extended all the way.
 *
 * It still *shrinks*, which is what `min-width: 0` on the line is for: at 375px the pair is
 * wider than the viewport and the caption elides rather than taking the page wide. */
.price {
  justify-content: flex-end;
  gap: 1rem;
  text-align: left;
}

.price-figures {
  flex: none;
  display: flex;
  align-items: baseline;
  gap: 0.4rem;
}

/* The old price, shown only once a coupon is on. There is no second base price in the model
 * any more, so printing one under an offer nobody has taken would be the page claiming a
 * discount that is not on the basket. */
.price:not(.has-coupon) .was { display: none; }

.coupon-line {
  position: relative;
  /* Sized by its own words, and allowed to give them up before the price does. Growing into
   * the empty half of the row is what made this read as a panel; see `.price` above. */
  flex: 0 1 auto;
  /* A flex item's minimum is its content, so without this a long saving makes the *page*
   * wide instead of the words eliding - the workshop tab strip's defect, which measured
   * 1434px at 812x375. */
  min-width: 0;
}

.coupon-apply {
  display: flex;
  align-items: center;
  /* **Stated, because `button, .btn` centres its contents.** That was invisible for as long as
   * this control only ever sat in the price row, where `.price .coupon-apply` gives it
   * `width: auto` and the box hugs the words - a centred box the width of its content looks
   * exactly like a left-aligned one. The cart's strip is full width, and the tick and the
   * caption arrived 235px in. A rule that is only correct because another rule happens to hide
   * it is one nobody can safely change. */
  justify-content: flex-start;
  gap: 0.6rem;
  width: 100%;
  /* **44px unconditionally, outside every media query.** Every 44px rule this project scoped
   * to `max-width: 700px` was missed by a phone on its side at 812px wide. This one is on the
   * buying path and is what the auto-apply presses. */
  min-height: 44px;
  padding: 0;
  border: 0;
  background: none;
  font-family: inherit;
  text-align: left;
  cursor: pointer;
}
.coupon-apply:focus-visible { outline: 2px solid var(--ring); outline-offset: 3px; }

/* **The hover plate is inset from the words, and the padding is unconditional.**
 *
 * `button:hover { background: var(--accent) }` is the sitewide rule and this control never
 * overrode it - it resets `background` at rest and `border` and `padding` to nothing, so
 * hovering painted a grey rectangle flush against the tick on one side and the expiry on the
 * other. Reported 2026-09-17 as the grey area having no margins; it was never a plate anybody
 * had designed, which is why it took a screenshot to find - clicking the control never shows
 * it, and the stylesheet has no rule with the word "background" anywhere near it.
 *
 * **The padding is on the rest state, not on `:hover`.** Giving it to the hover alone would
 * move the row the moment a cursor arrived, which is the one thing this row may not do; only
 * the colour changes. The negative margin is what keeps the words lined up with the price
 * beside them despite the padding.
 *
 * Horizontal only. Vertical padding would eat into the 44px floor above, and the plate is
 * already as tall as the two lines inside it.
 *
 * **And `width: auto`, or the padding eats the words instead of growing the plate.** The base
 * rule is `width: 100%`, which is right on the cart - the line is a full-width block there and
 * the control fills it - and wrong here now that `.coupon-line` is sized by its content:
 * `border-box` takes the padding out of that 100% rather than adding to it, so the expiry
 * started eliding at desktop width the moment the plate was inset. */
.price .coupon-apply {
  width: auto;
  padding: 0 0.55rem;
  margin: 0 -0.55rem;
  border-radius: var(--radius-sm);
}

/* The box is drawn rather than being an `<input type="checkbox">`, because the control has to
 * be a real submit: a checkbox does not post a form, so an input would need a script to do
 * anything and the offer must work for a browser running none. */
.coupon-box {
  flex: none;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 18px;
  height: 18px;
  box-sizing: border-box;
  border: 1.5px solid var(--border-strong);
  border-radius: 3px;
  background: var(--input);
  color: var(--commerce-foreground);
  transition: background-color 160ms ease-out, border-color 160ms ease-out;
}
.coupon-box svg { width: 12px; height: 12px; opacity: 0; transition: opacity 160ms ease-out; }

.coupon-words {
  min-width: 0;
  display: flex;
  flex-direction: column;
  /* Tight, because these two lines have to fit the height of one price. */
  line-height: 1.3;
}

/* Both captions ship; one shows. A single span rewritten by the script would put the
 * sentence in two places, and the line read "Apply $10.00 coupon" while it was already
 * applied - which is what happened the first time this was built. */
.coupon-offer,
.coupon-taken {
  font-size: 0.875rem;
  font-weight: 600;
  color: var(--commerce);
  /* Elide rather than wrap: a second line here would make the row taller than the price. */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.coupon-taken { display: none; }

/* Coral rather than muted grey, at the operator's instruction on 2026-09-17: the expiry is
 * the half of this offer a customer plans around, and grey filed it with the furniture. See
 * `--expiring`, which exists for this line and nothing else. */
.coupon-expiry {
  font-size: 0.6875rem;
  font-weight: 500;
  color: var(--expiring);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Applied: the box fills and the caption swaps. Nothing is added or removed. */
.coupon-line.is-applied .coupon-box {
  background: var(--commerce);
  border-color: var(--commerce);
}
.coupon-line.is-applied .coupon-box svg { opacity: 1; }
.coupon-line.is-applied .coupon-offer { display: none; }
.coupon-line.is-applied .coupon-taken { display: block; }
/* No longer a control: it is a statement, and pressing it does nothing. */
.coupon-line.is-applied .coupon-apply { cursor: default; }

/* Applying: the tick fades in while the request is in flight. One gesture, one second, at the
 * operator's request - longer than this stylesheet's 120-200ms rule, recorded as their
 * decision rather than quietly shortened. */
.coupon-line.is-applying .coupon-box {
  background: var(--commerce);
  border-color: var(--commerce);
}
.coupon-line.is-applying .coupon-box svg { opacity: 1; }
.coupon-line.is-applying .coupon-apply { pointer-events: none; }

/* Refused, and expired while the page sat open. Both withdraw the offer rather than leaving a
 * control that only teaches the customer it does not work. */
.coupon-line.is-refused .coupon-apply-form,
.coupon-line.is-expired { display: none; }

.coupon-apply-form { margin: 0; }

/* ------------------------------------------------- the coupon across a cart line (2026-09-18)
 *
 * The same control the product page draws beside its price, laid across the line it acts on.
 * It is the last item in `.basket-line`'s grid and spans every column, so the columns beside
 * it keep their widths and a phone - where the line collapses to two columns - does not get a
 * third thing competing for them.
 *
 * Everything about the control itself is `.coupon-line`'s: the 44px floor, the drawn box, the
 * two captions and the four states. What this adds is where it sits and that the expiry moves
 * from under the caption to beside it, because a row across the line has width where a column
 * beside a price has height. */
.coupon-strip {
  grid-column: 1 / -1;
  margin-top: 0.35rem;
  padding: 0 0.6rem;
  border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--commerce) 7%, transparent);
}
/* `.coupon-apply` is `width: 100%` at rest, which is right here: the strip is a full-width
 * block and the control fills it, so the whole row is the press target rather than the words
 * alone. That is also why this one needs no hover plate - the row is already a tinted box. */
.coupon-strip .coupon-words {
  flex-direction: row;
  align-items: baseline;
  gap: 0.5rem;
  /* The expiry drops under the caption on a narrow line rather than eliding: a row across the
   * line has width to give where a column beside a price has not, and the clock is the half of
   * this offer a customer plans around. */
  flex-wrap: wrap;
}

/* **The tooltip takes no space.** Absolutely positioned over the line, so showing and hiding
 * it cannot move anything - the operator's requirement, and the reason it is not simply a
 * third line in `.coupon-words`. Ships `hidden`, so the rule that gives it a display states
 * its own `[hidden]` - the fault this project has now written down four times. */
.coupon-auto-tip {
  position: absolute;
  left: 0;
  bottom: calc(100% + 6px);
  z-index: 3;
  display: block;
  padding: 0.25rem 0.5rem;
  border-radius: var(--radius-sm);
  background: var(--foreground);
  color: var(--background);
  font-size: 0.6875rem;
  font-weight: 500;
  white-space: nowrap;
  box-shadow: 0 6px 18px hsl(220 20% 20% / 0.22);
}
.coupon-auto-tip[hidden] { display: none; }
.coupon-auto-tip::after {
  content: "";
  position: absolute;
  left: 14px;
  bottom: -4px;
  width: 8px;
  height: 8px;
  background: var(--foreground);
  transform: rotate(45deg);
}

/* The coupon's row in a totals list, on the cart, the checkout and a receipt. `--commerce`
 * because it is money coming off on the buying path, and the only coloured row in the list.
 *
 * **Its own name, not `.coupon-line`.** It was that for an hour and they are two different
 * components - the offer's control, and a row in a column of figures - which is exactly the
 * `.swatch` defect, where the ink dots and the home page's material tiles took each other's
 * declarations. */
.totals .totals-coupon { color: var(--commerce); }

/* On the cart the line stands on its own above the totals rather than beside a price, so it
 * gets the tinted plate the price row does not need - and it takes the width, which is why
 * `.coupon-line`'s `flex: 0 1 auto` above does not reach it. */
.basket-summary .coupon-line {
  display: block;
  margin-bottom: 0.85rem;
  padding: 0.35rem 0.6rem;
  border-radius: var(--radius-sm);
  background: color-mix(in oklab, var(--commerce), var(--card) 92%);
}

/* ------------------------------------------------------------------- the row on a phone
 *
 * Keyed on 940px - the width `.product` actually collapses at - and on height, because a
 * phone on its side is 812px wide and misses every width-only query while having the least
 * height of any viewport this shop is opened in. Never a guess about devices.
 *
 * Note what is *not* in here: the 44px floor above, which is unconditional for exactly the
 * reason this block exists. */
@media (max-width: 940px), (max-height: 560px) {
  /* The price gives up size before the coupon does: at 375px the pair is 343px of content,
   * and the saving is the half a customer is reading. */
  .price { font-size: 1.875rem; gap: 0.7rem; }
  .coupon-offer, .coupon-taken { font-size: 0.8125rem; }
}

@media (prefers-reduced-motion: reduce) {
  /* Somebody who has asked their system for less movement has asked for this too. The end
   * state is identical; only the second it takes to get there goes. */
  .coupon-box, .coupon-box svg { transition: none; }
}
